Skip to content

About

A dependency-light, clean-room Rust reimplementation of the Tobii Eye Tracker 5 USB protocol for Linux — gaze/eye-position streaming, display-area setup, calibration, and a GTK4 config app. GPL-3.0.

Resources

Stars

4 stars

Watchers

2 watching

Forks

Latest commit

 

History

665 Commits

Folders and files

Repository files navigation

TobiiLinux

Your Tobii Eye Tracker 5, working on Linux — a native runtime and GTK4 app in Rust, with no Tobii software installed.

CI Latest release License: GPL-3.0-only Linux · Wayland

The TobiiLinux hub with a tracker connected: both eyes as dots in the trackbox with the head drawn around them, a readout of position, distance, yaw, pitch and roll, the infrared sensor view, and the tracking setting cards beneath

Tobii ships no software for Linux: the device will not stream gaze, and it cannot be calibrated or told where your screen is. This project is the missing half — the USB protocol, reimplemented from scratch, plus the app you set the tracker up in.

It is for anyone who owns an ET5 and runs Linux: to track gaze, to calibrate the device properly, or to put head tracking into a game.

Unofficial. Not affiliated with, endorsed by, or supported by Tobii. No Tobii code was copied: the protocol was mapped from this project's own USB captures, cross-checked against the third-party tobiifree project, with op names and enum orderings read from a decompile of Tobii's software. Use at your own risk.

What you get

  • Gaze streaming over USB — 2D gaze point, per-eye validity, eye position in the trackbox, eye origins in millimetres, pupil size.
  • Follow-the-dot calibration — 7 points in three groups, applied on the device and re-applied on every connect. Measured on a 49" 32:9 panel: 12.1 mm mean error inside the device's usable ±28°, about 0.92° at 750 mm.
  • Guided display setup — drag two lines onto the marks at the ends of the tracker, seeded from your monitor's EDID, and the screen geometry is derived.
  • Head tracking for games — 5 DOF straight away; the 6th (pitch) needs an optional model download whose weights are non-commercial-only, so it is not shipped and is fetched only after you have seen the terms and agreed.
  • Output games can actually read — a virtual joystick with nothing else installed, opentrack's UDP protocol, or TrackIR/FreeTrack through a Wine bridge (64-bit games only).
  • The hub — a live instrument panel with the trackbox, your eyes, the head turning as you turn, and the infrared sensor view, over the settings cards.
  • A gaze overlay — a translucent, click-through dot that follows your eyes. It uses Wayland's layer-shell, which has no X11 equivalent and no fallback. Where the session does not offer it — an X11 session never does, and not every Wayland compositor does — the hub greys the switch out and says so on the card rather than presenting a window the compositor has no layer for.
  • A status-area icon when you close the window — on desktops that provide one. It has only ever been seen on KDE Plasma; elsewhere the window minimises instead and Alt-Tab is the way back.
  • The tracker is off unless something is asking for it. The infrared illuminators are lit only while a USB session is open, and one is opened only while a window, an overlay, a flow, a game, the virtual joystick or a program bound to the opentrack address wants data — or, with Keep the tracker awake switched on, for as long as the hub runs.

Try it in three commands

Download the package for your distribution from Releases, then install it, re-plug the tracker, and run it.

Debian, Ubuntu, Mint, Pop!_OS

sudo apt install ./tobii-linux_*.deb
# unplug and re-plug the Eye Tracker 5 — that is what activates the udev rule
tobii-gtk

Fedora, RHEL (gtk4-layer-shell is in EPEL) · openSUSE (sudo zypper install)

sudo dnf install ./tobii-linux-*.rpm
# re-plug the Eye Tracker 5
tobii-gtk

Arch, CachyOS, Manjaro, EndeavourOS — a prebuilt package, nothing to compile. Every release from v0.3.1 on has one; v0.3.0 has only the PKGBUILD. Download it rather than passing the URL to pacman: pacman -U checks a remote file at RemoteFileSigLevel, which Arch's stock config sets to Required, and this package is not signed.

# replace vX.Y.Z / X.Y.Z with the newest tag on the releases page
curl -fLO https://github.com/Tropaion/Tobii_Linux/releases/download/vX.Y.Z/tobii-linux-bin-X.Y.Z-1-x86_64.pkg.tar.zst
curl -fLO https://github.com/Tropaion/Tobii_Linux/releases/download/vX.Y.Z/SHA256SUMS
sha256sum -c --ignore-missing SHA256SUMS   # proves the file arrived intact; not who built it
sudo pacman -U ./tobii-linux-bin-X.Y.Z-1-x86_64.pkg.tar.zst

That checksum says the download arrived intact and nothing about who produced it; What installing an update trusts is the whole story.

Any distribution, from source — see Building from source. On Arch that is four commands.

What to expect the first time

  • Re-plug the tracker after installing. The udev rule only takes effect on the next plug, and without it the device is reachable as root only — the symptom is a tracker that reports no eyes at all. See Device access if that happens.
  • The hub opens on tobii-gtk, or from Tobii Eye Tracker in your application menu.
  • The tracker stays dark until something asks for data, and the hub says Tracker off while nothing does. That is the resting state, not a fault.
  • If the tracker has never been set up on this machine, run Change screen (the Set up display button) and then Improve my calibration, in that order.
The other ways to install a release, and what each one costs

The choice is really one question: do you want the built-in updater, or do you want your system to own this?

Get it Updated by
A release — .tar.gz, .deb, .rpm, an Arch package, or a PKGBUILD Releases the built-in updater (.tar.gz only) or your package manager
From source the steps below git pull and rebuild
# the tar.gz — the only one the built-in updater can update
tar -xzf tobii-linux-*.tar.gz
cd tobii-linux-*/ && ./install.sh          # ~/.local/bin, menu entry, udev rule
sudo ./install.sh --system                 # the same for every user, /usr/local/bin
./uninstall.sh --dry-run                   # removing it again — see Uninstalling

install.sh copies the two binaries, adds the application-menu entry, and asks before using sudo for the udev rule. The packages do all of that as part of installing, into /usr/bin — which is also why the built-in updater will not replace them. It offers to download the file your package manager wants instead; see Updates.

Do not run install.sh with plain sudo: under sudo your home directory is /root, so it would install for the root account. It refuses, and names the two commands that are meant — ./install.sh as yourself, or sudo ./install.sh --system for every user.

On Arch, for a pre-release, drop the dash from the version in the package's name only: v1.0.0-rc1 ships tobii-linux-bin-1.0.0rc1-1-x86_64.pkg.tar.zst, because a pacman version cannot contain one. The tag in the URL keeps it. Once tobii-linux-bin is on the AUR, paru -S tobii-linux-bin (or yay -S) does the same and updates it along with the rest of your system. To build it yourself instead, download PKGBUILD and tobii-linux.install from the release into one directory and run makepkg -si. That compiles the program against your own GTK (it needs rust) and installs a package called tobii-linux. It and tobii-linux-bin conflict, so installing either one offers to remove the other.

Why a prebuilt binary might refuse to install. They are built in a Debian 13 container, which caps the glibc they can need at 2.41. What they actually need is measured from the binaries when they are built — 2.39 as of v0.3.0 — and the .deb, the .rpm and the Arch package declare that measured floor, so your package manager refuses rather than installing something that cannot start. On an older distribution, build from source.

The hub

tobii-gtk                # or: Tobii Eye Tracker, in your application menu
tobii-gtk --background   # resident, no window, tracker off — with a status-area
                         # icon where the desktop has one
tobii-gtk --version

The hub has two tabs, on a strip under the title: Tracker and Games. Ctrl+Page Down goes to Games and Ctrl+Page Up comes back, and the strip is reachable with Tab.

Tracker is the tracker itself. Across the top: the trackbox with a graticule and the tolerance region drawn, your eyes as dots, the head drawn around them turning as you turn, a readout of position, distance, yaw, pitch and roll, and the infrared sensor view. Beneath it, six setting cards in three columns: Improve my calibration, Change screen, Select eyes to detect, Head tracking, Preview my gaze, and Head tracking for games.

Games is one game at a time — see Setting a game up.

The layout has three column counts and drops to two, then one, as the window narrows — though on a floating desktop the window's own minimum width is the three-column width, so reaching the narrower ones takes a tiling compositor or a screen smaller than the hub.

The tab you are on decides whether the tracker is running: the hub asks for it while it has focus and the Tracker tab is showing, so reading about a game does not light the illuminators. See When the tracker is on.

The cogwheel beside the connection status holds what is about the program rather than the tracker: keeping the tracker awake, start at login, check for updates, text size (80–160%, for the whole program), saving or copying the diagnostics report, Help, and Quit.

Help is a window, opened with F1, with the ? beside the cogwheel, or from the cogwheel itself. It explains every card and every control, including the ones that only explain themselves when you hover — the strength presets, the virtual joystick, what the Games tab's buttons run, and why Recentre view is greyed out. That is not a nicety: GTK4 shows a tooltip on pointer hover and on nothing else, so anything that lives only in a tooltip is unreachable by keyboard and by touch, and the help window is where those sentences are reachable. Esc, F1 again or the Close button put it away; the topics are selectable text, so Tab walks them and they can be copied into a bug report.

It is ten topics in a list, with a search box the window opens focused, so you can type the question rather than find it: the search reads every heading and the whole of every topic, and a word off the control in front of you — "joystick", "recentre", "standby", "tray" — is usually enough. Not every caption is in that text, though: if a word off a control finds nothing, type what the control does rather than what it is called. Two known cases: the three strength presets, and games — which is the name of half the program and opens Head tracking for games rather than the Games topic. Every word you type has to appear in the same topic, so a second word narrows rather than widens, and a query nothing answers says so rather than going blank. Down moves from the box into the list and the arrow keys walk it, with the topic beside it changing as you go; Ctrl+F comes back to the box from anywhere in the window. (Those keys are the help window's. The hub's own are Ctrl+Page Up and Ctrl+Page Down, which walk the two tabs, and F1, which opens this window.) Narrow the window and the list folds away behind a Topics button, so the topic you are reading gets the whole width.

Select eyes to detect — both, left only, or right only — is a real device setting and lives on its own card. Choosing one eye adds a line saying what it cannot promise: the tracker stores the selection and, measured on this hardware, goes on reporting both eyes in its gaze stream anyway. The help window carries the measurement and its date.

An accuracy diagnostic is a separate mode: tobii-gtk --accuracy runs a 39-target sweep (13 columns × 3 rows) and reports error per target, per angle band and per eye, with a diagnosis.

Closing the window does not quit — and where the window goes

Pressing X puts the hub in the background and leaves it running. That is deliberate: only one process can claim the tracker over USB, so whatever owns the device is also what feeds a game — and when that is the hub (the route that feeds the joystick, the Wine bridge and opentrack at once), it has to outlive the window you configured it in. A hub that died when you dismissed it would take the game's head tracking with it. Nothing needs it for the tobii headpose route, which owns the device itself.

Where it goes depends on your desktop. If something on your session bus owns org.kde.StatusNotifierWatcher — KDE Plasma, and the panels and bars that implement the same interface — the window is hidden and a tray icon is your way back. With no such host, stock GNOME included, the window is minimised exactly as it was before, and Alt-Tab or the overview is the way back. Either way, launching the app again raises the hub you already have.

The tray icon has no menu on purpose: left-, right- and middle-click all just raise the hub, and Quit lives in the cogwheel inside it. Plasma looks for icons only in folders that existed when it started, so right after a first install the tray and the application menu can show a generic icon. The hub hands the tray its own copy of the picture (IconThemePath) for exactly that case; Plasma reads it, but that it then draws from it has not been confirmed. If the placeholder stays, systemctl --user restart plasma-plasmashell.service, or your next login, fixes both.

The tracker still goes dark. Hiding releases the claim as it hides; minimising drops it within a frame, because the claim is polled from whether the window is active. Measured on a minimised hub: no USB file descriptors and no GPU file descriptors held. If you left Preview my gaze on, that has its own claim and the tracker stays on, which is correct — something is asking for data.

To exit for real, use Quit in the cogwheel menu. From a script, or when the window is somewhere you cannot reach, the same thing is an action on the session bus:

gdbus call --session --dest com.tobiilinux.Configuration \
  --object-path /com/tobiilinux/Configuration \
  --method org.freedesktop.Application.ActivateAction quit '[]' '{}'
Start at login

Start when I log in — behind the cogwheel, with the other settings that are about the program rather than the tracker — writes an XDG autostart entry that runs tobii-gtk --background: no window, and — because nothing is asking for data — no tracker either. Where your desktop has a status area there is an icon in it, which is the way to open the hub; otherwise the application menu is. It exists so the hub is already running when you want it, and so that launching the app hands off to the one process that can claim the tracker rather than starting a second.

The entry runs whichever copy switched it on, by its full path. Switched on from a copy that was replaced while it ran — by install.sh, the updater or a package upgrade — it names the path the new copy is at. From a copy that was removed while it ran, the switch refuses, and its tooltip says why. If that copy is later removed or moved, the entry points at nothing and the next login starts nothing; install.sh repairs it when that is so, and when it points at a different copy that still exists it leaves it alone and tells you, because which copy starts at login is your choice. It reads the entry's Exec line as GLib does, and leaves alone one it cannot read for certain: two Exec lines, an unterminated quote, or a single quote outside double quotes.

To turn it off: the same switch, or delete ~/.config/autostart/com.tobiilinux.Configuration.desktop. Turning it off in GNOME Tweaks or KDE's Autostart page is also honoured — the switch reads their Hidden=true / X-GNOME-Autostart-enabled=false rather than fighting it.

The command line
tobii stream [--json] [--eyes]   # decoded gaze samples
tobii setup                      # interactive display setup
tobii display get|set            # read / re-apply display area
tobii enabled-eye [both|left|right]
tobii update [--install]         # check for a new release
tobii uninstall [--dry-run]      # remove an install made by install.sh
tobii debug                      # the report an issue asks for
tobii record [--calibration]     # re-record a replay fixture
tobii --version                  # what this build reports

These assume tobii is installed and on your PATH; straight out of a build tree it is ./target/release/tobii.

Run tobii with no arguments for the full list, including the protocol diagnostics (streams, log, dump-stream, camera, cal-blob, cal-points, probe-stream, columns).

Head tracking for games

tobii headpose --fetch-model      # optional: adds pitch
tobii headpose --calibrate-pitch  # once, sitting normally
tobii headpose                    # stream to opentrack on :4242

Point opentrack's UDP over network input at 127.0.0.1:4242 and play. That command is the whole route: it opens the tracker, composes the pose and sends the 48-byte datagram, with no hub running and nothing wrapping your game. X-Plane 11/12 reads that datagram through the headtrack plugin and needs no opentrack at all (see docs/wiki/Game-Output.md). The tracker is lit for exactly as long as the command runs. (From a build tree, that is ./target/release/tobii.)

The hub is the other route, and the one to use if you want the virtual joystick, the Wine bridge and opentrack fed at once without turning game output on from the command line: only one process can claim the ET5 over USB, so they all have to come from the same place. With tobii games set enabled true, tobii headpose feeds all three itself — skipping the joystick when a hub already presents one.

Setting a game up

The hub's Games tab — Ctrl+Page Down, or the strip under the title — is one list of every game this machine has: what Steam says is installed, plus anything added by hand. A row is drawn at full strength when the Wine bridge is installed in that game's prefix and drawn back when it is not, so what is left to do is visible without reading. Its second line says which, and tells apart the two ways of not being set up — no bridge yet is something to press Install on, and never launched is a game Proton has not made a prefix for yet. A game added by hand whose folder is not on this machine right now says that instead: with the drive gone, this program cannot see whether the bridge is in there at all.

The button beside the search box, a play triangle with no caption, shows only the games that are set up. On a machine with one set-up game among thirty that is how you find it; pressing it again brings the rest back.

A game Steam has never heard of gets in the same way as any other: press Add a game by folder… under the list, pick the Wine prefix it runs in (the folder holding drive_c), and the bridge installs into it exactly as for a Steam title. The list of them is ~/.config/tobii-linux/custom-games.tsv, and tobii uninstall --purge removes it. Remove from this list takes a row out and touches nothing on disk.

Under the list: the count of what Steam has installed, anything in the profiles directory that is not a profile this program can use, and — if there are any — a count of the profiles here whose game Steam does not list on this machine, from an uninstalled title or a drive that is not plugged in. Those have no row, because there is nothing on this page that could act on one, but the profile is not lost and it applies again the moment Steam lists the title. tobii games profile show, with no app id after it, names every profile on the machine including those.

Pick a game and the page beside it shows the three things that have to be configured for it, numbered:

  1. These settings — this program's own game output. The page reports them and changes nothing: every control for them is global and lives on the Head tracking for games card on the Tracker tab, so a second editor here would be one setting with two of them.
  2. The Wine bridge, inside that game's Proton prefix. The page reports which of the bridge's files are already there; the buttons under it install them, reinstall them, take them back out, or run tobii bridge status and print what it says. They need the command-line tobii on this machine — the hub shells out to it — and when there is nothing to press, the bar says why rather than going blank.
  3. The game's own settings — and this one is read-only in every state. The program reports what those files say and never writes them.

A game added by hand keeps the first two and loses the third, because reading a game's own options needs a profile and a profile is a file named after a Steam app id. The absence is a sentence on the page rather than a gap.

Block 3 needs a profile to know what to look at: which setting, in which file, and what it should say. No profiles ship with this program, so on a fresh install that section is empty for everyone and says so, rather than guessing. It also prints the commands that fill it, with the app id already substituted for the game you picked.

The bridge is three files, of which only freetrackclient64.dll is required — it is the one a game has to load. tobii-bridge.exe and NPClient64.dll are optional, and the installer prints note: <name> not built yet — skipping for one this build was not built with. So a prefix holding one of the three is a working bridge, not a broken install, even though tobii bridge status describes the other two as missing (optional).

Everything the tab can report, the command line can too:

tobii games profile show               # every profile, and where they live
tobii games profile save <app id>      # record this program's settings
tobii games profile check add <app id> …   # record one thing to check
tobii games profile check where <app id>   # where those checks resolve to
tobii games profile check              # what a check is: the full schema

The format, a worked example, and how to author the first profile for a game are in Game-Profiles. In the hub, the same material is under Games in the F1 help window.

Five degrees of freedom, or six

Position in millimetres plus yaw and roll come from the two eye origins, with nothing to download. Pitch cannot: nodding rotates your head about the line through your eyes and leaves both origins where they were. A neural model reads it from the tracker's own infrared camera at ~12 ms a frame. That model is not shipped — its weights are non-commercial-only, so the program shows you the terms and fetches it only if you agree. Without it pitch reads zero and everything else works.

The model reports pitch in its own frame, offset by how your tracker is mounted; --calibrate-pitch measures that once. tobii headpose --check prints the model's yaw and roll beside the geometry's, which is how the sign conventions were confirmed on hardware.

If your in-game view sits off to one side

Hold the posture that should count as straight ahead and take a rotation reference: tobii headpose --recenter (--recentre works too), or Recentre view on the hub's Head tracking for games card. It measures for a second, takes the median of the yaw and roll it saw, and refuses — keeping whatever reference you had, and saying why — if your head moved more than 8° across that second, or if the tracker saw too little of it. Pitch is untouched: that zero comes from --calibrate-pitch. The hub greys the button out wherever it would refuse anyway — no tracker running, a calibration or display setup holding the device, or nothing switched on for the recentre to act on.

What lights the tracker

The hub keeps the infrared illuminators dark unless something is asking for the tracker. Each of the three game-output sinks asks in a different way, and one of them cannot ask at all.

For opentrack, the receiver asks by binding the port, and the hub watches for that whenever game output is on — open opentrack and play, with no launch option at all. Measured on a hub with no window open: 0 USB file descriptors with nothing listening, 1 within four seconds of a socket binding 127.0.0.1:4242, 0 again once it closed. The catch is that an opentrack left open with no game keeps the tracker lit; tobii games set wake_for_opentrack false turns the watch off.

For the virtual joystick there is nothing to watch, so the switch itself is the request: while game output is on and the joystick device has actually been created, the hub holds the tracker on. A game that binds an evdev joystick tells nobody it has done so, and there is no narrower question to ask — the kernel exports no open count for a uinput node, and the processes holding one open are not a signal. Measured on a faithful replica of our device: an idle-wake daemon, a browser probing gamepads on hotplug and winedevice.exe had all opened it within 30 ms, it never had zero openers again for the life of the Wine prefix, and a real reader would have been one more identical row. Reader detection was tried, measured and rejected; this hold is what replaces it. tobii games set wake_for_joystick false turns it off — and then a game reading those axes sees them freeze at centre a few seconds after the hub loses focus, which is what v0.4.0 did to everyone.

The Wine bridge is the one route with nothing to see at all: its only listener is the DLL inside the prefix. Wrap the game instead:

tobii game -- %command%        # Steam: paste this into Launch Options
tobii game -- ./MyGame.x86_64  # or anywhere else

The hub has to be running for this — it is, if you closed its window rather than quitting. The wrapper itself carries no tracking data: it holds a subscription to the hub's socket for as long as the child process lives — measured: 0 USB file descriptors before, 1 while the game runs, 0 again afterwards — so the hub knows exactly how long a game lasts. It is transparent to whatever launched it, exiting with the game's own code and reporting a killed game as 128 + the signal, and it never stops the game starting: with no hub running it prints a note and launches anyway. Any program that connects to that socket and asks for pose wakes the tracker the same way; the wrapper is the one that knows exactly how long a game lasts, and the only one that puts TOBII_BRIDGE_PORT into the game's environment — which is how the Wine-side DLL learns a bridge_port you have changed. Steam's %command%, Lutris's and Heroic's wrapper fields and a plain shell script all work, which is why this is a wrapper rather than a setting.

And when nothing can be detected and nothing can be wrapped — a Wine bridge whose listener is inside the prefix, an opentrack on another machine, a launcher that will not pass a command through — there is a last resort that switches standby off altogether:

tobii games set keep_awake true   # off by default

or Keep the tracker awake in the hub's cogwheel. It holds the tracker on for as long as the hub is running, whatever else is or is not happening, and it is deliberately not tied to game output: it applies with the switch off, with no sink configured, and to the gaze overlay just the same. While it is on the hub shows an ALWAYS ON badge in its header, because that is the only place the state is visible — an infrared illuminator looks identical lit and dark.

These two settings weaken standby, and it is worth being plain about how. With keep_awake on, the illuminators stay lit for as long as the hub runs. With wake_for_joystick on, they stay lit for as long as game output is on — including overnight, if you forget. wake_for_opentrack's hold is bounded by another program's lifetime: opentrack exits, the port closes, the tracker goes dark about three seconds later. These two are bounded only by your memory. That asymmetry is why the hub carries the badge, why the eye-position panel now says tracker off rather than not detected when the session is closed on purpose, and why tobii debug prints which of the three, if any, is allowed to wake the tracker on your machine.

Games that have never heard of head tracking (the virtual joystick)

Most games have never heard of head tracking, but nearly all of them can bind a joystick axis. So game output also presents one:

tobii games set joystick true    # on by default

It appears as Tobii Eye Tracker 5 head tracking, an eight-axis controller:

Axis Carries Full scale
X, Y, Z head displacement from where you sit ±500 mm
RX, RY, RZ yaw, pitch, roll ±180°, ±90°, ±180°
Throttle, Rudder gaze on screen, left→right and top→bottom the whole screen

Nothing else has to be installed — no opentrack, no Wine. Because it is an ordinary evdev joystick it is read by SDL, by the legacy /dev/input/js* interface, and by Wine's winebus, so Proton games see it as a DirectInput joystick too (not XInput — measured under Wine 11.17; XInput's two-stick layout has nowhere to put eight axes). The last two axes are gaze, which no other head tracker offers; bind them to a free-look axis and the camera follows your eyes.

If the axes feel like they barely move, turn up the amplification — this is the first thing to tune with a game in front of you:

tobii games set joystick_yaw_full_deg 45   # head angle for a full swing (default 70)

Two things to know before you bind it, both covered in docs/wiki/Game-Output.md: some games' "look" axis is a rate control rather than a position, and binding a head tracker to one of those makes the view spin away rather than follow your head; and Steam Input can quietly take the device away from a Steam-launched game, which is fixed per-game with Properties → Controller → Disable Steam Input.

This needs write access to /dev/uinput, which the packaged udev rule grants — tobii debug reports whether it is there. The rule is one line and the file says how to remove it if you would rather not grant it; everything else keeps working without it.

Games that speak TrackIR or FreeTrack (Wine)

opentrack is not the only protocol. Windows games under Wine ask for TrackIR or FreeTrack, which means a DLL inside the prefix and a shared-memory block — so there is a small Windows-side bridge:

scripts/build-bridge.sh                  # needs mingw-w64 + the rust win target
tobii bridge games                       # what is installed, and what has a prefix
tobii bridge install --steam elite       # by name, or by app id
tobii bridge install --prefix /path/to/prefix   # anything not Steam
tobii bridge status --steam elite        # what is in that prefix right now

tobii bridge status runs nothing and changes nothing — it reads the prefix's own files and asks the kernel who holds the prefix's wineserver lock, and never starts wine, which matters because starting wine against a prefix initialises or upgrades it. It reports which prefix and which wine it resolved, whether the DLLs are there, what each of the two discovery keys holds and whether it is ours, and whether a wineserver is serving the prefix. It is short on purpose — it is meant to be pasted into an issue, though unlike tobii debug it does not fold your home path away, so read it before you post it. It reports what is registered; it does not predict what a game will do with it.

The registry half is read out of the prefix's own user.reg, so while a game is running its most recent registry writes may not be in the file yet — the report says so when it finds a live wineserver.

Nothing has to be left running. The client DLL the game loads receives the tracking itself, in a background thread inside the game's own process, and publishes it into FT_SharedMem where the game reads it. A second executable would have to run inside the game's own wineserver session. This project long took that to mean reproducing Proton's entire launch environment, which was an assumption and not a measurement; a third-party launcher we have read does it without reproducing anything, by making Steam's own single Proton invocation start both processes. That is written up, with what it does and does not settle, in Game-Output. The DLL is already in there either way, which is why nothing on the FreeTrack route depends on the answer.

64-bit games only. A 32-bit game asks for freetrackclient.dll without the 64 and finds nothing — see docs/wiki/Game-Output.md.

install --steam finds the prefix from Steam's own library files and writes the registry with the Proton build the prefix records, not whatever wine is on your $PATH — a foreign wine would upgrade the prefix out from under the game. When that build cannot be read out of the prefix, install --steam refuses rather than falling back to the host's wine: a Proton prefix was made by a Proton build, so the system wine is by construction not it. Name the build with --wine <Proton>/files/bin/wine, or --force to go ahead knowing it will upgrade the prefix first. Only install refuses; uninstall does not, because refusing there would strand somebody taking our files back out.

TrackIR is pointed at an already-installed client (opentrack's, if you have it) rather than at ours, because games verify NaturalPoint's signature and a clean-room DLL cannot answer it. That is not a guess: the established implementations answer NP_GetSignature from two 200-byte tables XORed together, which is NaturalPoint's own signature data carried in obfuscated halves — which is also why scanning those DLLs for the string "NaturalPoint" finds nothing. Shipping it would mean redistributing their blob, so we do not. Nothing is copied — our provider still supplies the data behind it. --npclient ours overrides that for a game that does not check, and FreeTrack has no signature at all, so a game that speaks FreeTrack works with our own DLL today.

Verified end to end on a real Elite Dangerous Proton prefix with no provider running: the game's own load path (LoadLibrary on the registry-supplied directory, then GetProcAddress) resolved all five FreeTrack exports, and a pose sent from Linux as yaw 7.5°, pitch −3.25°, roll 1.5°, (11, 22, 33) mm read back through FTGetData as yaw=0.1309, pitch=-0.0567, roll=0.0262 radians and pos=(11.0, 22.0, 33.0), with DataID advancing.

install refuses rather than overwriting somebody else's registration. The two discovery keys — HKCU\Software\NaturalPoint\NATURALPOINT\NPClient Location and HKCU\Software\Freetrack\FreeTrackClient — are how any head-tracking client is found in a prefix, not just ours. Install writes down what it put in them, and if a key holds something that record does not account for it stops, names the key and what it holds, and prints the reg delete line to clear it by hand if it is stale. It does not stop over a key that already holds, byte for byte, what this run would write with nothing on record either way — the prefix a v0.4.0 install left behind, where refusing would block a write that changes nothing. A record naming a different value is a different fact and is refused on its own — except for a key that already points at our own C:\tobii-bridge, which counts as ours whatever the record says and is therefore skipped rather than refused over. That exception is the same blindness as the sentence before it — a value-equality test cannot tell our own registration from another program's at the same path — and its cost is written up in Quality and Risks §11.3g.

--force goes ahead, and promises nothing about putting the old value back — the installer records only the values it wrote itself, so what was there is gone. uninstall is the other half of the same rule: it deletes a key only while it still holds our own install directory or exactly the value the record says this installer wrote, and leaves anything else alone and named, so it cannot take opentrack's registration with it on the way out.

A refusal costs the prefix nothing, as long as nothing is serving it. Reading the keys through wine reg query is itself a wine run, so an install that refused — and an uninstall with nothing of ours to take out — used to upgrade the prefix it had just declined to touch. Both now read the prefix's own user.reg first, exactly as status does, and start wine only once the run has decided it is going to write. Measured on a throwaway prefix with a wine that wrecks it if it runs at all: the prefix comes out byte for byte as it went in, and nothing was spawned. That holds while the prefix's wineserver lock is free. With a wineserver alive — the game running, which is exactly when you might try this — user.reg is the registry as last written back and can lag, so the decision is taken through wine instead and a refusal does touch the prefix. Correctness wins there on purpose: a stale file that reported a key empty would let the install overwrite the registration the refusal exists to protect. tobii bridge status never falls through this way, because it only reports.

Every tobii bridge subcommand refuses what it does not read, before it resolves a prefix, creates a directory or writes a key: an unknown flag, -h, --prefix=PATH, a flag with nothing after it — and a bare path. tobii bridge install /games/pfx used to drop the path on the floor and install into $WINEPREFIX or ~/.wine instead, saying so nowhere.

If you start tobii bridge run by hand — which a Steam title set up with tobii game -- %command% does not need, because the launch carries the provider itself, but which is still the way to fill the mapping on a prefix Proton does not serve, where TrackIR is pointed at a third-party client DLL that only reads the shared memory ours create — start the game first. While it runs it is a wine process on that prefix, and Steam runs wineserver -w before it spawns a Proton game, which waits for every wine process on the prefix to exit: a bridge started first leaves the launch sitting there doing nothing. The command says that before it starts, names the lock, and stops itself if something does start waiting behind it. Late is not too late — a client DLL picks up the shared mapping when it appears.

Standing down is not the same as working. In this configuration — and only this one, because our own DLLs fill the mapping themselves — a third-party client merely reads FT_SharedMem and the provider is the only thing that fills it. So from the moment it yields until you start it again, that client is reading a mapping nobody is writing and the game gets no head tracking. That is the whole of what v0.5.0 changed here: the launch goes through instead of hanging.

The way out of that configuration is to let the launch carry the provider, and tobii game -- %command% does it. When the command it is handed is a Proton launch and a bridge that understands it is already in that prefix, it points Proton at our provider with the game after --launch: one waitforexitandrun, so one wineserver -w, and both the provider and the game are born after the lock, inside the session the game is in. The provider exits with the game, so there is nothing to reap and nothing to restart by hand.

An earlier attempt substituted a batch file and did not work — Proton waits on an .exe target and not on a .bat, so the launch reported success a second in — which is why the target is the provider itself.

Every other command is run exactly as Steam wrote it. It declines, and says so, when the prefix has no provider, when the provider predates --launch (run tobii bridge install again for that game), and when STEAM_COMPAT_DATA_PATH is not set to say which prefix a launch uses. Quality-and-Risks §11.3l has the measurements that ruled out the batch and §11.3o those for what replaced it — including what is still unwatched, which is whether a given title then reads the mapping.

Reported, not verified here. One user reports getting head tracking in Microsoft Flight Simulator 2024 by running opentrack's Windows build inside the game's Proton prefix, sequenced with the game in a single Proton launch, by way of a third-party launcher (https://github.com/markx86/opentrack-launcher, GPL-3.0). Nobody on this project has run it. It is recorded because it is the only account anywhere in our notes of that title tracking at all; it is not a recommendation, it is not a supported route, and nothing in this program will set it up for you. What it is good for is written up in Game-Output.

tobii bridge uninstall --prefix PATH removes it again.

When the tracker is on

The ET5's infrared illuminators are lit for as long as a USB session is open, so this program keeps one open only while something actually wants data:

Holds the tracker on For how long
The hub window while it has focus and the Tracker tab is showing. Focus alone was enough until the hub had two tabs; reading about a game does not light the illuminators
Preview my gaze while the overlay is shown
Calibration, display setup, the accuracy diagnostic while the flow is running
Any program connected to the hub's socket asking for pose, gaze or camera — a game started with tobii game is one while it stays connected
A program bound to the address game output sends opentrack to — opentrack's UDP over network input, X-Plane's headtrack plugin while that socket is open, checked about once a second (wake_for_opentrack, default on, with game output turned on)
The virtual joystick, once game output has created it while game output stays on (wake_for_joystick, default on)
Standby switched off by hand — Keep the tracker awake, keep_awake while the hub runs. Not conditional on anything else: not on game output, not on a sink, not on a window
A queued setting (e.g. select eyes) until it has been applied

Three seconds after the last of those lets go, the session closes and the LEDs go out. The linger is not arbitrary: closing the session makes the ET5 reboot, and the next connect has to re-apply the display area, the eye selection and the calibration blob, so alt-tabbing away and back should not pay for that twice.

That table is the whole list. The last two rows before the queued setting are new in v0.4.1, and they weaken standby: with keep_awake the illuminators stay lit for as long as the hub runs, and with wake_for_joystick for as long as game output is on — including overnight if the user forgets. Every other row ends on its own. wake_for_opentrack's hold is bounded by another program's lifetime; these two are bounded only by the user's memory, which is why the hub badges keep_awake in its header and why tobii debug now prints what is allowed to wake the tracker.

What is still not on the list is game output by itself: with wake_for_joystick off, or with no joystick device, the "Head tracking for games" switch decides where frames go and not whether the tracker runs. A hub with the switch on, no joystick, nothing connected and nothing bound to the opentrack address sits dark on purpose — which is why a Wine-bridge-only setup still needs tobii game (or another socket client, or keep_awake), while opentrack and X-Plane are covered by their own row, and the tobii headpose route needs none of it because it holds the device itself — asking the hub to stand down first when there is one.

The hub says which of these it is doing. A session closed by the linger writes one line to the log — tracker off: nothing has asked for it for 3s, with , so game output stopped too when frames were being sent — and the eye-position panel reads tracker off rather than not detected, which is reserved for a tracker that is lit and cannot find a face.

Only one process can have the device at a time, so tobii headpose asks the hub for it before opening it. The hub has honoured that request since it learned to serve clients; since v0.4.1 the CLI is the first thing that sends one. The three answers are each decided rather than defaulted:

  • No hub. Connecting to the socket fails, which is the ordinary state for anyone who has not opened one, so nothing is waited for and the device is opened directly. That is the standalone route and it has to keep working.
  • The hub lets go. It prints the hub has let go of the tracker for this run, drops its USB session, and stays in standby for as long as the command runs — whatever its window is doing, because a lease overrides every demand rather than being weighed against them. While it is out, the hub's eye-position panel reads tracker off, the same word a linger-closed session gets, not an error. The tracker goes back when the command ends — released explicitly, not left to the socket closing, so --calibrate-pitch (the one form that returns on its own rather than running until Ctrl-C) hands it back where it finishes.
  • The hub refuses. It is mid-calibration, mid-display-setup, or another client already holds the lease — all three mean something is in a stateful conversation with the device that handing it over would break. The command fails with the hub's own sentence — and which sentence that is depends on which of the three refused. Mid-calibration and mid-display-setup it is the hub is busy with …, which lists everything the hub is currently holding and not only the flow that refused, so it can name a standing setting beside the calibration you are actually waiting for — in the hub's own words for it, standby turned off in the settings rather than keep_awake. A device already lent to another client refuses with the tracker is leased by <the other client's name>, which names the holder and lists nothing else.

A hub that gives none of those three answers within three seconds is wedged or older than this build; the device is opened anyway, which for the common case of an older hub sitting in standby simply succeeds. Refusing there would make the command less usable than it was before it learned to ask.

That is what makes the standalone route work with the hub open, and it is what makes it work at all now that the joystick and keep_awake can ask for the tracker: both holds are open-ended and wake_for_joystick is on by default, so without the request a user with game output on and a joystick device the hub could actually create has the hub keeping libusb interface 0 for its whole lifetime, and tobii headpose, --check and --calibrate-pitch fail for exactly the users those commands exist for. Measured here with keep_awake on: the hub held one USB file descriptor, tobii headpose --check printed the stand-down line and opened the device, the hub's count went to 0, and it was back to 1 about three seconds after the command exited.

Configuration

Stored under $XDG_CONFIG_HOME/tobii-linux/ (default ~/.config/tobii-linux/): config.toml (display geometry), calibration.bin and calibration.meta.toml, enabled_eye, headpose_pitch_offset, update_check, setup_monitor_id, text_scale (the hub's text size, as a bare number — delete it to get back to 100%), games.toml, accuracy.csv (written by tobii-gtk --accuracy), report_salt (32 random bytes, mode 0600, which is what makes the monitor id in a diagnostics report meaningless to anyone else), and models/ (the fetched head-pose model).

The log is not there: it is at $XDG_STATE_HOME/tobii-linux/tobii.log (default ~/.local/state/…), because the XDG spec puts logs in state. The autostart entry, if enabled, is ~/.config/autostart/com.tobiilinux.Configuration.desktop; the menu entry and icon go under ~/.local/share/.

Note: the ET5 wipes its display area and its calibration every time it reboots — which it does on every session close — so the driver re-applies both on every connect. Without this the tracker reports no eyes at all.

Curved panels: the device is told the EDID arc width — it only accepts a flat plane, and sending the chord makes that plane too narrow and compresses gaze toward the edges — and the per-user calibration absorbs the curve. No runtime gaze correction is applied; one was written and then disproved on hardware.

Device access

Already done if you installed a package or ran scripts/build.sh --udev. By hand:

sudo cp assets/60-tobii.rules /etc/udev/rules.d/
sudo rm -f /etc/udev/rules.d/99-tobii.rules   # if you installed a pre-v0.1.0 copy
sudo udevadm control --reload && sudo udevadm trigger

Then (re-)plug the Eye Tracker 5. Without this the device is only reachable as root, and the tracker reports no eyes at all.

The rule grants access through uaccess: systemd-logind puts an ACL on the device for whoever is logged in at the seat. The 60- in the name is load-bearing — udev reads rule files in lexical order and 73-seat-late.rules is what acts on the uaccess tag, so the pre-v0.1.0 99-tobii.rules set it too late to have any effect and granted access only through a world-readable MODE="0666". A leftover copy still overrides the new rule's mode, which is why the line above deletes it; tobii debug says so if one is present.

If the tracker still needs root (a system without logind)

uaccess needs systemd-logind. Without it there is no ACL and MODE="0660" leaves the device to root. Give a group access instead — pick one your user is already in (id -nG), often plugdev on Debian-family systems:

# Addressed to the tracker lines only — see the warning below.
sudo sed -i '/idVendor/s/MODE="0660"/MODE="0660", GROUP="plugdev"/' \
    /etc/udev/rules.d/60-tobii.rules
sudo udevadm control --reload && sudo udevadm trigger --subsystem-match=usb

Do not drop the /idVendor/ address. The file also carries a MODE="0660" line for /dev/uinput (the virtual joystick). An unaddressed sed would put GROUP="plugdev" on that one too, and every member of the group — logged in or not — could then synthesise keystrokes into any local session. That is a much broader grant than the uaccess one this section is replacing, which is scoped to whoever is actually sitting at the machine.

Without logind the virtual joystick needs its own group grant as well, and a second one: udev's uaccess is also what makes the device it creates readable, so add your group to /dev/uinput and to the created node:

echo 'KERNEL=="uinput", SUBSYSTEM=="misc", MODE="0660", GROUP="plugdev", OPTIONS+="static_node=uinput"
SUBSYSTEM=="input", ENV{ID_INPUT_JOYSTICK}=="?*", MODE="0660", GROUP="plugdev"' \
  | sudo tee /etc/udev/rules.d/61-tobii-nologind.rules
sudo udevadm control --reload

Uninstalling

Installed with install.sh or scripts/build.sh --install: tobii removes itself. Look at the plan first:

tobii uninstall --dry-run     # prints what it would do; changes nothing
tobii uninstall               # asks, then does it

If that tobii is gone already, the release archive runs its own copy for you: ./uninstall.sh --dry-run, then ./uninstall.sh, from the unpacked folder.

--purge also removes settings and calibration, --udev the device rule, and sudo tobii uninstall --system a --system install. A copy your package manager owns is not touched — it prints the manager's own command instead:

sudo pacman -R tobii-linux-bin # Arch (the prebuilt package, or from the AUR)
sudo pacman -R tobii-linux     # Arch (the PKGBUILD)
sudo apt remove tobii-linux    # Debian, Ubuntu, Mint, Pop!_OS
sudo dnf remove tobii-linux    # Fedora, RHEL   (openSUSE: sudo zypper remove tobii-linux)
Exactly where it looks, and what it refuses to touch

It looks in these places, and only these: the install manifest the installer writes (~/.local/share/tobii-linux/installs), the directory named in the menu entry's Exec= and in the start-at-login entry's, ~/.local/bin, the directory of the tobii you run it with, and any --bindir DIR you give it. It also reads the system-wide manifest, /usr/local/share/tobii-linux/installs. A directory listed there is a --system install, which it leaves and prints the command for — unless sudo tobii uninstall --system would refuse that directory too (a ~/bin of yours, say), and then it is checked like any other place. With --system it looks only in that manifest and in --bindir. It does not search your PATH. An install somewhere else that none of these lead to — one made into a directory of your choice by v0.3.0 or earlier, with no menu entry — is found with --bindir DIR.

It asks before stopping a hub that is still running, then removes the binaries, the menu entry, its icon and the start-at-login entry. It deletes only names it knows. The only directories it removes with everything in them are the .tobii-update-<pid> work folders an interrupted update left beside the binaries. Every other directory it touches (the install manifest's, and with --purge the settings, models and log directories) is removed only once it is empty.

What it will not touch, and why. A copy found any way but the manifest is checked before it is run or removed. It is left where it is, never run, and listed with the reason, if:

  • it is not a program. A script called tobii-gtk in ~/.local/bin is a wrapper of yours, not a build of this program.
  • it belongs to a user other than you or root. It is theirs to remove, with their own tobii uninstall. That holds for a copy the manifest lists too. Root's files pass this check, so a copy an older sudo ./install.sh left in a folder of yours is treated as yours.
  • others can write where it is: the file, or a directory that decides which file its name leads to — the one it is in, the one of each symlink on the way, and every directory above those — belongs to a user other than you or root, or can be written by another user or by a group other than your own private group (the group of your own that Fedora and others give each user is fine; so is a sticky directory such as /tmp). Whoever can write there chooses what would run.

A copy that passes must still name itself when asked --version. It also leaves alone:

  • a Cargo build directory, recognised by the CACHEDIR.TAG Cargo writes whatever the directory is called, and an unpacked release archive (install.sh with assets/install-payload.sh beside it). Copies run from there, but no install made them.
  • a copy cargo install put there. Cargo keeps a record beside the directory (.crates.toml, .crates2.json). What that record lists as installed from tobii-cli or tobii-gtk is left, and it prints cargo uninstall for exactly those packages, always with --root naming where they are — Cargo's own default can be moved by CARGO_INSTALL_ROOT or install.root, so a command without it could remove a different copy. Only a directory named bin is Cargo's, because that is the only one cargo install writes. A record that lists only other programs — cargo install --root ~/.local ripgrep writes one beside ~/.local/bin — hides nothing.
  • a directory you cannot write to. If what is there answers as this program and the directory is yours, it prints chmod u+w for it, to run this again after. Otherwise it is a system-wide install, and the command for that is printed (see below).
  • a menu or start-at-login entry that runs a copy which stays — a package's, a build directory's — and one whose program it cannot tell. That includes an entry whose Exec line cannot be read here: there is none, there are two, or it is quoted in a way launchers read differently, as a v0.3.0 menu entry is for a directory whose name holds one of ' \ & | ; $ ~ # * ? < > or a backtick. Such an entry is not used to find an install either; --bindir DIR finds one it would have led to.

--yes goes ahead without asking, which is what a run with no terminal needs. It does not stop for the bridge question below; it goes on. It stops running copies: a hub is first asked to quit over D-Bus (except when a hub from a copy that stays is also running, because D-Bus could reach that hub instead), and whatever is still running 5 seconds later gets SIGTERM. That includes a tobii game wrapper, which is a running game's head tracking and is never asked first. A hub still running from a program that has been deleted (a package removed while it ran) is shown and stopped the same way. Without --yes, stopping it is only offered, and declining leaves it running.

If an icon-theme.cache exists in the hicolor directory the icon is removed from (~/.local/share/icons/hicolor, or /usr/local/share/icons/hicolor with --system), it is refreshed after the icon is removed. When no icons are left under that directory, the cache tool deletes the cache, which puts the directory back as it was before the install. No cache is ever created.

It leaves:

  • your settings, calibration, head-pose model and log. Add --purge to remove those too. Only the files this program writes are deleted, and anything else it finds there — a backup of your own — stays and is listed. calibration.bin is the part that is costly to redo.
  • the udev rule. Add --udev. That step asks first, and uses sudo unless it already runs as root (--system); --yes runs it without asking. With no terminal it only prints the commands, for you to run.
  • the TrackIR/FreeTrack bridge in Wine prefixes. It lists the prefixes it finds the bridge in — Steam's, $WINEPREFIX and ~/.wine — with the command for each. Removing the bridge needs a tobii. When the tobii you run is one this removes, it offers, before removing anything, to stop and let you run those first. With --yes it goes on, and repeats the commands at the end: the tobii in a release archive runs them just as well. Run from the archive's ./uninstall.sh, that tobii stays, so it names it for the commands, which work before or after, and does not stop.

A system-wide install (sudo ./install.sh --system) comes out with sudo tobii uninstall --system. Without --system, tobii uninstall refuses to run as root. Under sudo, HOME is /root, so it would search root's home instead of yours. As root it neither runs nor opens anything in a directory that someone other than root could have changed — a directory the system manifest lists included. One it refuses is left, with the reason and the command for whoever controls it: tobii uninstall --bindir DIR, run as that user. Its line in the system manifest stays until a later sudo tobii uninstall --system finds the directory empty.

By hand — for v0.1.0 to v0.3.0, whose tobii has no uninstall

Every path an install writes, and every path the program writes while it runs. The directory is ~/.local/bin unless you gave install.sh or --install another one; the Exec= line of the menu entry names it. If you set $XDG_DATA_HOME, $XDG_CONFIG_HOME or $XDG_STATE_HOME, those replace ~/.local/share, ~/.config and ~/.local/state below.

# the program
rm -f ~/.local/bin/tobii ~/.local/bin/tobii-gtk
# temporaries an interrupted update can leave beside them
rm -rf ~/.local/bin/.tobii-update-[0-9]*
rm -f ~/.local/bin/.tobii-update-probe-* \
      ~/.local/bin/.tobii.new-* ~/.local/bin/.tobii-gtk.new-* \
      ~/.local/bin/.tobii.old-* ~/.local/bin/.tobii-gtk.old-*

# the menu entry, its icon, and start-at-login
rm -f ~/.local/share/applications/com.tobiilinux.Configuration.desktop
rm -f ~/.local/share/icons/hicolor/scalable/apps/com.tobiilinux.Configuration.svg
rm -f ~/.config/autostart/com.tobiilinux.Configuration.desktop
# only if this file already exists — do not create one:
#   gtk4-update-icon-cache -qtf ~/.local/share/icons/hicolor
#   (~/.local/share/icons/hicolor/icon-theme.cache; with no icons left under
#   hicolor, that command deletes the cache — as it was before the install)

# optional: settings, calibration, models, game profiles and log — the names
# this program writes, so anything of your own in these directories stays. A
# save that was interrupted can also leave one of these names with .tmp added.
rm -f ~/.config/tobii-linux/config.toml ~/.config/tobii-linux/calibration.bin \
      ~/.config/tobii-linux/calibration.meta.toml ~/.config/tobii-linux/enabled_eye \
      ~/.config/tobii-linux/update_check ~/.config/tobii-linux/text_scale \
      ~/.config/tobii-linux/headpose_pitch_offset \
      ~/.config/tobii-linux/setup_monitor_id ~/.config/tobii-linux/games.toml \
      ~/.config/tobii-linux/accuracy.csv ~/.config/tobii-linux/report_salt
rm -f ~/.config/tobii-linux/models/head-pose-0.5-small.onnx \
      ~/.config/tobii-linux/models/head-pose-0.5-small.onnx.partial \
      ~/.config/tobii-linux/models/head-pose-0.5-small.onnx.download \
      ~/.config/tobii-linux/models/head-localizer.onnx \
      ~/.config/tobii-linux/models/head-localizer.onnx.partial \
      ~/.config/tobii-linux/models/head-localizer.onnx.download
# game profiles: one per game, named by Steam app id, so this is a pattern
# rather than a list. A note of your own kept here — a profiles/what-i-measured.md,
# a profiles/359320.toml~ — is not matched and stays, exactly as --purge leaves it.
rm -f ~/.config/tobii-linux/profiles/[1-9]*.toml \
      ~/.config/tobii-linux/profiles/[1-9]*.toml.tmp
rm -f ~/.local/state/tobii-linux/tobii.log ~/.local/state/tobii-linux/diagnostics.txt
# then the directories: rmdir refuses one that still holds something, and says so
rmdir ~/.config/tobii-linux/models ~/.config/tobii-linux/profiles \
      ~/.config/tobii-linux ~/.local/state/tobii-linux

# or instead, the directories with EVERYTHING in them — which also deletes any
# backup of your own kept there, such as a copy of calibration.bin:
#   rm -r ~/.config/tobii-linux ~/.local/state/tobii-linux

# optional: the udev rule
sudo rm -f /etc/udev/rules.d/60-tobii.rules /etc/udev/rules.d/99-tobii.rules
sudo udevadm control --reload
sudo udevadm trigger --subsystem-match=usb
sudo udevadm trigger --subsystem-match=misc

If you ever ran sudo ./install.sh, it installed a second copy into root's home:

sudo rm -f /root/.local/bin/tobii /root/.local/bin/tobii-gtk \
     /root/.local/share/applications/com.tobiilinux.Configuration.desktop \
     /root/.local/share/icons/hicolor/scalable/apps/com.tobiilinux.Configuration.svg

The bridge, if you installed it into a Wine prefix, is removed with tobii bridge uninstall --prefix PATH. Run that before removing tobii.

Updates

The hub asks GitHub for the latest release when its window opens — the one thing this program does on the network without being asked — and shows a banner only if there is something newer. tobii update does the same from the command line. Nothing is downloaded until you choose to update, and the check can be switched off: the switch under Check for updates, or TOBII_NO_UPDATE_CHECK=1 in the environment, which also wins over the switch. An explicit tobii update still works; only the automatic check is affected.

If a package manager owns your copy, the button says Download rather than Update, because overwriting a packaged file behind dpkg/rpm/pacman's back is how a package database comes to describe files that are no longer there. It fetches the artifact that matches your manager — the .deb, the .rpm, the prebuilt Arch package, or for a release without one the PKGBUILD and its install hook together — into a version-named folder inside one you choose, and prints the one command that installs it. It never installs anything itself. This is the hub only: tobii update on the command line still refuses, and tells you why.

What installing an update trusts

The checksums are not a signature. SHA256SUMS is published in the same release as the archive and fetched over the same connection by the same code, so it proves the download arrived intact and nothing about who produced it. Anyone able to publish to this repository's releases could publish binaries that every check in tobii-update accepts.

So installing an update trusts this project's GitHub releases exactly as much as downloading a binary from the releases page and running it by hand would. That is a normal amount of trust for a program you already run, but it is not the guarantee a checksum is often assumed to give, and the UI says so before the button is pressed. Closing that gap needs a signature checked against a key compiled into the binary; there isn't one.

What it does protect against, and the five copies that get a different button
  • Every request is HTTPS to a GitHub host, with the URL passed as an operand so it can never be read as a curl option, redirects pinned to HTTPS, and a timeout and size cap on all of it.
  • A truncated or corrupted download is caught before anything is unpacked.
  • Archive members that are symlinks are ignored, so an archive cannot cause a file it never contained to be installed.
  • The new binaries are run once before they are installed. A release built against newer system libraries than your machine has fails here, while the working binaries are still in place, instead of leaving you with two that don't start.
  • If any part of the swap fails, every binary is rolled back, so an update can't leave a new tobii beside an old tobii-gtk.

Four more cases get something other than Update, all decided before the banner appears, so the button you see fits the copy you have:

  • A copy in a folder only an administrator can change — one installed with sudo ./install.sh --system, or copied into a system folder by hand — also gets Download: the release archive, and the one command that installs it for every user, sudo ./install.sh --system <that folder>. The same is offered for another account's files in a folder that is not yours either, such as one a group can write; there install.sh --system refuses the folder, because it installs for every user and the folder is not root's alone. The way out is a copy of your own: ./install.sh into ~/.local/bin, without sudo.
  • A copy in your own folder whose files are someone else's — what an older sudo ./install.sh ~/.local/bin left behind — gets Download as well, with a plain ./install.sh <that folder>, no sudo. That replaces root's files with your own, which an update in place cannot: its backup step hard-links the old file, and the kernel refuses that for a file you do not own. tobii update --install refuses the same copy and says so.
  • A copy in a folder you cannot write but can fix yourself — your own folder with its write permission off, or a folder in your home that another account owns, which that same older sudo ./install.sh ~/.local/bin made when the folder did not exist yet — gets nothing to download. The banner shows the one command, chmod u+w <that folder> or sudo chown <your uid>:<your gid> <that folder> (that folder alone, not what is in it), selectable so you can copy it. Its button quits the hub, because the banner is worked out once when the app starts and closing the window only hides it. Run the command, start the app again, and it decides afresh. tobii update --install prints the same command and says to run it again.
  • A copy that was replaced or removed while it was running — its package uninstalled, or a newer version installed over it — gets Quit. There is nothing at its path to update, and starting the app again would only hand off to the same old process, so quitting it is the step that helps.

sudo tobii update --install on a copy whose files are an ordinary account's refuses, and says to run it again as that account, without sudo: as root the update would work, and leave root's files behind.

The decision and any failure are written to the log, which tobii debug quotes.

The download folder has to be one nobody else can change, because the command printed at the end is run on those files later, perhaps with sudo: not writable by other users or by a group you share, and the same for every folder above it.

Building from source

Works on any distribution and builds against your own GTK. scripts/build.sh checks the dependencies before it starts, so a missing package is named in the first second rather than as a wall of linker errors five minutes in.

Arch, CachyOS, Manjaro, EndeavourOS
sudo pacman -S --needed base-devel git rustup gtk4 gtk4-layer-shell libusb pkgconf
rustup default stable            # skip if you already have a Rust toolchain
git clone https://github.com/Tropaion/Tobii_Linux.git && cd Tobii_Linux
scripts/build.sh --install --udev
Debian 13+, Ubuntu 24.10+, Pop!_OS, Linux Mint
sudo apt install build-essential git curl pkg-config \
     libgtk-4-dev libgtk4-layer-shell-dev libusb-1.0-0-dev
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh   # if you have no Rust
git clone https://github.com/Tropaion/Tobii_Linux.git && cd Tobii_Linux
scripts/build.sh --install --udev

libgtk4-layer-shell-dev is only in Debian 13 (trixie) and Ubuntu 24.10 or newer. On an older release, either build it from the upstream project, or skip the GUI and build just the command-line tool:

sudo apt install build-essential git pkg-config libusb-1.0-0-dev
scripts/build.sh --lean --install --udev

Ubuntu's rustc is often too old; the rustup line above is the reliable route.

Fedora, RHEL, Rocky, Alma
sudo dnf install gcc git pkgconf-pkg-config \
     gtk4-devel gtk4-layer-shell-devel libusb1-devel
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh   # if you have no Rust
git clone https://github.com/Tropaion/Tobii_Linux.git && cd Tobii_Linux
scripts/build.sh --install --udev

On RHEL and its rebuilds, gtk4-layer-shell-devel comes from EPEL; without it, use scripts/build.sh --lean for the command-line tool only.

openSUSE Tumbleweed / Leap
sudo zypper install gcc git pkg-config \
     gtk4-devel gtk4-layer-shell-devel libusb-1_0-devel
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh   # if you have no Rust
git clone https://github.com/Tropaion/Tobii_Linux.git && cd Tobii_Linux
scripts/build.sh --install --udev
NixOS
nix-shell -p gcc pkg-config gtk4 gtk4-layer-shell libusb1 cargo rustc
git clone https://github.com/Tropaion/Tobii_Linux.git && cd Tobii_Linux
cargo build --release

The udev rule belongs in your configuration rather than being copied into /etc, since NixOS manages that directory:

services.udev.extraRules = builtins.readFile ./assets/60-tobii.rules;
Any other distribution, and what the build flags do

You need a C compiler, pkg-config, a stable Rust toolchain (rustup), and the development packages for GTK 4, gtk4-layer-shell and libusb 1.0. Then:

scripts/build.sh --check     # names anything still missing
scripts/build.sh --install --udev

The full set of options:

scripts/build.sh                    # check dependencies, build everything
scripts/build.sh --check            # only check dependencies, build nothing
scripts/build.sh --lean             # CLI only, without the neural backend
scripts/build.sh --install [DIR]    # build, then install (default ~/.local/bin)
scripts/build.sh --install --system # the same for every user, /usr/local/bin
scripts/build.sh --udev             # also install the udev rule

--install puts tobii and tobii-gtk in ~/.local/bin; pass a directory to choose another (a relative one is taken from where you ran build.sh), or add --system for /usr/local/bin and every user's menu. Every user's menu entry runs what is in that directory, so --system refuses one that another account could change, and names the folder that stops it; Development has the rule. Run it as yourself, not with sudo — it refuses, and asks for sudo itself for the steps that need it. Leave both off to just build into target/release/. --install also adds Tobii Eye Tracker to your application menu.

Plain cargo build --release works too. The neural head-pose backend is on by default and brings a sizeable dependency tree (tract, ~110 crates); the CLI can be built without it — 5 DOF head tracking, everything else unchanged, and a 1.2 MB binary instead of 35 MB:

cargo build --release -p tobii-cli --no-default-features

The GUI currently always includes it; tobii-gtk has no matching feature gate yet.

After installing, re-plug the Eye Tracker 5 so the udev rule takes effect, then run tobii-gtk or pick it from the menu.

Requirements

  • A Tobii Eye Tracker 5 (USB 2104:0313).
  • Rust (stable, edition 2021) — e.g. via rustup — to build from source.
  • System libraries: GTK 4, gtk4-layer-shell (for the gaze overlay), and libusb 1.0 + pkg-config. The GUI needs all of them; the CLI needs only libusb.
  • curl or wget, and tar, for fetching the head-pose model and updates.
  • A Wayland session is recommended: the gaze overlay is a layer-shell surface, and layer-shell has no X11 equivalent.

Architecture and protocol documentation

docs/wiki/ (mirrored to the GitHub project wiki) holds both.

The protocol — TTP framing, the handshake, the full op catalog, the gaze-stream column layout, display-area / calibration / select-eyes, the stream map, head pose, and the reverse-engineering methodology. Every non-obvious claim is tagged CONFIRMED / CODE-VERIFIED / HYPOTHESIS.

The architecture, as arc42: Architecture (§1–5), Runtime-View (§6–7), Architecture-Decisions (§8–9), Quality-and-Risks (§10–12), and Development.

The crates, in one table

A Cargo workspace of focused crates, one acyclic dependency graph:

Crate Responsibility
tobii-protocol Pure protocol codec: TTP framing, handshake, gaze and camera decode (no I/O).
tobii-usb libusb (rusb) transport + connection driver.
tobii-config Display geometry, EDID detection, persistence, SHA-256.
tobii-headpose Head pose: the geometric fallback, the ONNX backend, the model store, opentrack output.
tobii-update Release checking, download integrity, and installation with rollback.
tobii-diagnostics The tobii debug report and the log it quotes.
tobii-cli The tobii command-line tool.
tobii-gtk The GTK4 hub, guided flows and gaze overlay.
tobii-output Game output: the virtual joystick, opentrack/TrackIR encoding, the frame pipeline.
tobii-ipc The local socket other programs read tracking from.
tobii-steam Reads Steam's own files: installed titles, library folders, Proton prefixes.
tobii-gameconf Read-only readers for two game-configuration formats. Never writes.
tobii-recap Decodes a usbmon pcap capture into a readable TTP op catalog.

What is proven, and what is not

Validated on hardware: gaze streaming, display setup, calibration (the 12.1 mm figure above is measured, not estimated), the live views, the gaze overlay, and head tracking. The head-pose model's yaw and roll signs were confirmed against the geometric pose (slope +1.03 and +0.96, r = 0.998 and 0.990).

Implemented, partly proven: the update mechanism. Everything after the download — checksum, unpack, symlink refusal, the runnability probe, the swap and the rollback — is exercised end to end against real tar.gz archives built from real executables (crates/tobii-update/tests/install_end_to_end.rs), and the listing and asset-selection halves have run against two real published releases. What is still unproven is the install half against a release downloaded from GitHub rather than built locally; the Download path for package-managed copies, which nobody has clicked through against a real release; and the new update buttons, which have never been clicked through against a real release. Only the pacman branch of the package-ownership query has ever run against a real package database — dpkg and rpm are unit-tested only, and they are what decides which button a Debian or Fedora user is shown. The trust model is the honest limitation, not a missing test: the checksums are an integrity check, not a signature.

Known limitations:

  • The head-pose model's focal length is assumed (DEFAULT_FOCAL_PX = 355), which feeds a perspective correction worth 12–16° of pitch. focal_from_eye_origins can compute the real value from data already on the wire; it is not yet wired to do so automatically.
  • On a display at fractional scaling, the tops of tall glyphs can appear shaved. CSS, the GSK renderer and widget-label theories have all been ruled out by measurement and it is not reproducible offscreen; the remaining suspect is the compositor's own downscale. Setting the display to an integer scale is the test.
  • 0x501 and 0x50e are the same camera exposed twice, not a stereo pair (199/199 byte-identical frames with a face in view, 166/166 on an empty scene). There is no stereo depth to be had from this device.
  • The tray has only ever been seen on KDE Plasma, and sudo ./install.sh --system has not been run end to end under real sudo.
  • The bridge's registry rules have never met a real third-party registration. install reads both discovery keys before it writes and refuses rather than clobbering, and uninstall removes only what it recorded writing — exercised against a stateful fake wine and against live wine 11.18 in throwaway prefixes, never against a game's Proton prefix with a Windows opentrack registered inside it.
  • Whether any game accepts our own NPClient64.dll is unknown. Two titles have ever been measured against NaturalPoint's signature check, and both stopped at it: on 2026-08-15 Star Citizen called NP_GetSignature, got nothing it recognised from a clean-room DLL, and never asked for data again; on 2026-09-27 Microsoft Flight Simulator 2024 (Steam app id 2537590, Proton Experimental) called it 104 times in 1m45s, called nothing else at all, and went on retrying for as long as it ran. That is why install points TrackIR at an already-installed client instead, and it is the whole of the evidence in either direction — --npclient ours is offered for a game that does not check, and no such game has been measured. Two titles are not a rule about the rest, and nothing here knows which kind a given game is. FreeTrack is the verified route (Elite Dangerous, above); the registry work, the status report and the wineserver yield are all about what is registered and what does not break a launch, and none of them says a game will use the data.
  • Yielding to the wineserver lock fixes the launch freeze and nothing else. A TrackIR title pointed at a third-party client reads FT_SharedMem; our provider is what fills it; and the provider is the process that stands down. So on such a title the v0.5.0 behaviour clears the launch and leaves the mapping empty until the provider is started again by hand once the game is up. Neither half of that has been watched on a real Proton title — the yield was measured on a throwaway prefix — and "the freeze is fixed" must not be read as "the game gets tracking".
  • No per-game profile has ever been verified against a running game. The hub's Games tab can report what a game's own configuration files say, but only where a profile names a setting to look at — and zero profiles ship, so that section is empty for every user of this build. The two readers behind it were read off 30 real Elite Dangerous preset documents; the directory convention was not. The one Elite install here has a populated Proton prefix with no Options or Bindings directory in it at all, so the path a real check would name has never been observed, and the example path the program prints is a shape, not a location. Writing the first verified profile is the next step, not a finished one — see Game-Profiles.
  • The bridge install offered on that tab has never been followed by a real game launch. It reports the prefix correctly and can copy the files in; whether the game then loads them is the open question above about NPClient64.dll, unchanged. The same is true of the uninstall beside it and of Install another client…, which is offered for the one Steam title measured to refuse ours at the signature check.
  • A pitch calibration and a tobii headpose started during it do not cooperate. The measurement holds the hub's device thread for about 13 seconds without declaring itself exclusive, so the hub grants the lease, cannot let go in time, and the command reports the hub as unresponsive before failing the way it did before the lease existed. Two further lease interactions are listed with it, and none of the three is fixed as of v0.5.0.

The full list, per release, is in Quality-and-Risks — §11.3c for v0.3.1, §11.3f and §11.3g for v0.4.1, §11.3h–§11.3j for the help window, the wineserver lock and the prefix reads that no longer run wine, §11.3k for per-game setup and the profile format, §11.3l for the launcher whose ordering answers the lock, and §11.3m for the two-tab hub.

Reporting a problem: tobii debug prints the report an issue asks for, and the hub's cogwheel can copy or save it. Since v0.4.1 it carries a game output section — the sinks, the three wake settings, and a can wake it now line that names every path that could light the tracker at that moment, or says NOTHING and what to turn on. For an install or update problem, say how you installed and paste what tobii uninstall --dry-run prints; it shows where every copy is and who owns it.

Contributing

Pull requests run these checks, and they pass on main:

cargo fmt --all --check
cargo clippy --workspace --all-targets --locked -- -D warnings
cargo test --workspace --locked
bash scripts/test-install-payload.sh   # the install scripts; needs no root

rust-toolchain.toml pins the compiler, and rustup honours it automatically, so your clippy and rustfmt are the ones CI runs. A distribution Rust ignores the pin entirely — the install blocks above use rustup for exactly that reason, and dnf install rust cargo or zypper install rust cargo would give you a compiler these checks do not describe.

Most of what "needs an ET5" does not — tobii record captures a real session and cargo test -p tobii-usb --test replay regression-tests the protocol against it with no tracker attached.

Everything else a contributor needs is in the wiki: Development for the test suite, conventions and the protocol traps; Architecture for how the crates and the four threads of the device path fit together; Quality-and-Risks for the measured numbers and the known defects.

Publishing a release (maintainer)

Four things go in the release commit, and the tag build fails without the first two. release.sh refuses when Cargo.toml disagrees with the tag, and every gate runs --locked, so a bumped manifest with a stale lock fails CI:

# 1. both workspaces' [workspace.package] version -> the new number
# 2. both lock files, which is a separate step:
cargo update -w --offline
cargo update -w --offline --manifest-path bridge/Cargo.toml
# 3. docs/releases/vX.Y.Z.md — this is what the published changelog and the
#    in-app "What's new" show. Without it CI falls back to raw commit subjects.
# 4. docs/wiki/Quality-and-Risks.md — a section for whatever new surface ships.

git commit -am "release: vX.Y.Z"
git push origin main          # the tag must point at a pushed commit
git tag vX.Y.Z && git push origin vX.Y.Z

CI drafts the release; publishing it is a human click.

Releases are built by CI, not on a developer's machine, and that is the point. The machine a binary is compiled on is its compatibility floor: on the maintainer's current system both binaries come out needing GLIBC_2.44, which almost nobody has, so such a release would fail to start for nearly everyone who downloaded it. .github/workflows/release.yml builds in a Debian 13 container instead, fixing the floor at glibc 2.41, and fails the build if the floor ever rises above that rather than shipping a narrower release quietly.

It publishes a draft — the notes are the changelog every user's updater shows them, so a human sees them before anyone does.

scripts/release.sh <version> [triple] is what CI runs and works locally too: it refuses to build if Cargo.toml disagrees with the tag, reports the oldest glibc each binary needs, and checks both answer --version — the same probe the updater runs before installing.

Credits & licence

Protocol reference: the tobiifree project. Head-pose model: the opentrack project (fetched at your request, not bundled — its weights are non-commercial-only). The FreeTrack and TrackIR interfaces are public ABIs; opentrack was read for interface facts, and the files that use them say so and say which fact. No opentrack code was copied. The StatusNotifierItem interface is KDE's published D-Bus specification. No Tobii code was copied: the ET5's USB protocol was mapped from this project's own USB captures, cross-checked against tobiifree, with op names and enum orderings read from a decompile of Tobii's software.

Licensed GPL-3.0-only.

About

A dependency-light, clean-room Rust reimplementation of the Tobii Eye Tracker 5 USB protocol for Linux — gaze/eye-position streaming, display-area setup, calibration, and a GTK4 config app. GPL-3.0.

Resources

Stars

4 stars

Watchers

2 watching

Forks

Releases

Packages

Contributors

Languages