Skip to content

Robot Install & Drivers

Status: draft. The customer-facing side of ADR-0020 and ADR-0021: the core carries no driver; this document is how the right drivers get onto a robot without the customer becoming a packaging expert.

  1. Install the core in one line, drivers only when a use case needs them. A robot that streams a USB webcam and a remote desktop never sees a stereo-camera SDK; a robot with a depth camera gets exactly that vendor’s package and its prerequisites.
  2. The tool knows the catalog; the customer states the use case. Fjarr ships a machine-readable driver catalog; fjarr-agent setup detects what is attached, proposes a config, installs what it can, and prints precisely what it cannot (vendor EULAs, kernel modules, reboot).
  3. Missing is a state, not a crash. A configured track whose driver is absent is unavailable with the package to install — in the doctor, the manifest label, the introspection endpoint and the dashboard — and everything else runs.
  4. The same answer everywhere. Robot-side --check, the dashboard’s robot page and (later) the fleet view all render the same source status from the same data.
ChannelContentsFor
apt repository (https://apt.fjarr.io, suites stable and testing, component main)the packages belowrobots on Ubuntu 26.04 LTS (the supported platform, docs/04, ADR-0022)
Container imagesghcr.io/fjarrio/fjarr-agent:<ver> (core) and per-vendor variants …:<ver>-zed, …:<ver>-realsense, plus -desktop-x11/-wayland; built from the same .debs, multi-arch, cosign-signed with an SBOM. The core image exists from slice 5b (docker/agent/Dockerfile, built and smoke-tested in CI, amd64, unpublished — docs/12); the variants, arm64 and publishing are this milestonecontainerized robot stacks (below), the demo, CI
Embeddinglibfjarr as a CMake package (find_package(fjarr)), headers = docs/09; the customer’s app links the core and installs the driver packages it wantsrobot companies embedding the library in their own daemon
Install scriptwget -qO- https://get.fjarr.io | sudo sh (wget rather than curl: a fresh Ubuntu desktop has only the former; the script falls back to whichever is present) — adds the repository, installs fjarr-agent, runs fjarr-agent setupfirst contact

The fjarr-agent package installs the introspection viewer’s static files under /usr/share/fjarr/viewer and points introspect.viewer_dir there in its shipped fjarr.toml (docs/24); the container image carries the same directory. The fjarr-agent package ships the systemd unit (Type=notify, WatchdogSec=30, Restart=on-failure, running as the unprivileged fjarr user per docs/10) and enables it; the daemon requires systemd on robots (ADR-0019).

Pixi (conda-forge/RoboStack), Nix and Yocto follow customer by customer (ADR-0031). Each renders the same system profile its own way (a conda package, a NixOS module, a meta-fjarr layer), and each takes on its own GStreamer version as a supported, CI-tested row in docs/04.

Packages install; setup switches on; --check verifies. Nothing invasive happens as a side effect of apt install.

PackageInstallsSwitched on by
libfjarr-devthe static library, headers and the CMake package (find_package(fjarr))the customer, for embedding
fjarr-setupthe setup tool (/usr/lib/fjarr/fjarr-setup, fjarr-agent setup … hands over to it), also as the command fjarr-setup for a host with no agent: a containerized robot’s desktop hostnothing; it is what setup runs
fjarr-agentthe daemon, linked statically against libfjarr (depends on fjarr-setup); fjarr-agent.service; the fjarr user (sysusers) in video and render; /run/fjarr and /var/lib/fjarr (tmpfiles); /usr/share/fjarr/fjarr.toml.example; the viewer, the driver catalog and the system profile under /usr/share/fjarr/; the boot unit that creates the tunnel device before the robot’s software starts, inert unless fjarr.net is configuredthe package enables the service, which starts only once /etc/fjarr/fjarr.toml exists (ConditionPathExists); setup writes that file
fjarr-desktop-sessionthe desktop’s host side, with no agent: fjarr-desktop-session, the session helper, and its user unit (ADR-0028); the fjarr-desktop group (sysusers); the GDM watchdog’s timer and service; depends on fjarr-setupsetup desktop (or, on a container host, fjarr-setup setup desktop --container). Ghost screens’ EDIDs are generated by display add-ghost, not shipped
fjarr-desktop-waylandthe Wayland module (/usr/lib/fjarr/desktop); the agent’s fjarr account into fjarr-desktop (sysusers); recommends fjarr-desktop-session, so apt install on a robot brings both and the agent image only the modulesetup desktop
fjarr-desktop-x11the X11 module (/usr/lib/fjarr/desktop); fjarr-x11-session, the kiosk session’s program that grants the agent’s account (xhost +si:localuser:fjarr, through the X API), lays out the outputs and re-lays them on every RandR change, and hands the agent the desktop’s sound (ADR-0034); its XDG autostart entry, installed inert under /usr/share/fjarr/setup desktop (links the autostart entry into /etc/xdg/autostart/)
fjarr-toolsfjarr-connect (given cap_net_admin at install)nothing
fjarr-gst-<vendor>camera drivers, per vendor and architecturedrivers install (with the design partner’s hardware)

fjarr-inputd is not packaged: none of the chosen desktop backends needs it (ADR-0006).

Three choices were corrected when the packaging was built (2026-09-28):

  • No shared libfjarr until the ABI is stable (M6). A C++ shared library would change its SONAME, and so its package name, with every 0.x release. Embedders link the static library from libfjarr-dev, and so does fjarr-agent.
  • /etc/fjarr/fjarr.toml is written by setup, not shipped as a conffile. An enabled service with a placeholder config would restart in a loop. The unit waits for the file instead.
  • fjarr-opsim is not shipped. It is a lab tool whose receive side decodes with libav, which is dev and CI only (docs/14). Probing a source is fjarr-agent --probe-source.

A data file in the repository (packaging/profile.toml), installed as /usr/share/fjarr/profile.toml. Per feature (core, net, desktop-wayland, desktop-x11, headless), it lists the users, groups, units, udev rules, kernel arguments and GDM settings that feature needs. It is used three ways. The package scripts and setup apply it where the OS allows. --check verifies it on every robot, however it was installed, and names each missing piece. It is run as root (sudo fjarr-agent --check): a path an ordinary user cannot look at is reported as permission denied, check as root, never as missing. Units are the exception in a container: with no systemd (/run/systemd/system absent) a unit row is satisfied by what does its job there — the image’s entrypoint for fjarr-net.service, the container runtime for fjarr-agent.service — and says so rather than reporting it missing (containerized devices). Later channels (a NixOS module, a Yocto layer) render the same data declaratively.

Every package declares its architecture and its vendor prerequisites (Depends/Recommends) so apt does the dependency work where the vendor publishes packages; where a vendor SDK is an installer behind a EULA (some stereo-camera SDKs, CUDA), the package declares a virtual prerequisite and the catalog carries the human steps.

packaging/catalog.toml in the repository, installed as /usr/share/fjarr/catalog.toml (also published at https://fjarr.io/drivers/ later, and versioned with the agent), is the single source the tools read. Shipped 2026-09-28 with the built-in entries only: test, v4l2 and rtsp, each builtin = true with source naming its type in fjarr.toml and element what the doctor checks. Vendor entries and their packages are M3, chosen by the design partner’s hardware; the desktop entries come with the desktop packages (M3). Vendor entries arrive with the design partner’s hardware. The shape, with the vendor entries as they will look:

[v4l2]
title = "V4L2 cameras (USB webcams, UVC, CSI through the kernel)"
builtin = true
source = "v4l2"
element = "v4l2src"
matches = [{ api = "v4l2" }] # gst-device-monitor's device.api
[realsense]
title = "Intel RealSense (D4xx, L5xx)"
package = "fjarr-gst-realsense"
element = "realsensesrc"
arch = ["amd64", "arm64"]
matches = [{ usb = "8086:0b*" }, { usb = "8086:0a*" }] # udev-visible ids
prereqs = ["librealsense2 (from Intel's apt repo, added by the package)", "udev rules: 99-realsense-libusb.rules (installed by the package)"]
post_install = "replug the camera or reboot for the udev rules to apply"
docs = "https://fjarr.io/docs/drivers/realsense"
[zed]
title = "Stereolabs ZED (2, 2i, X, Mini)"
package = "fjarr-gst-zed"
element = "zedsrc"
arch = ["arm64"] # SDK availability decides
requires_manual = "ZED SDK ≥ 4.x from stereolabs.com (EULA; CUDA on Jetson)"
matches = [{ usb = "2b03:*" }]
docs = "https://fjarr.io/docs/drivers/zed"
[desktop-x11]
title = "Remote desktop on X11 (Xorg)"
package = "fjarr-desktop-x11"
matches = [{ display = "x11" }]
[desktop-wayland]
title = "Remote desktop on Wayland (PipeWire + portal)"
package = "fjarr-desktop-wayland"
matches = [{ display = "wayland" }]

The matches rules are what setup uses to detect hardware; element is what the doctor checks; arch is why the tool can say “not available on this machine” instead of failing an install. A usb rule is a vvvv:pppp id with an optional trailing *; it beats an api rule for the same device (a RealSense is a v4l2 device too, and the vendor entry is the one that brings its depth stream). The tool reads the file’s order and nothing else: adding a vendor is an entry plus a plugin package.

CommandDoes
fjarr-agent setupinteractive first run: enrolls (or takes a dev token), detects the display server, lists attached devices (gst-device-monitor-1.0 for v4l2/PipeWire plus udev ids matched against the catalog), proposes fjarr.toml with one track per detected camera, installs the matching packages (apt, with the repository already configured), prints the manual steps for anything behind a EULA, and ends with --check
fjarr-agent drivers listthe catalog, with per-entry status on this machine: installed / available / not for this architecture / needs manual step; --json for agents
fjarr-agent drivers install <name>installs one entry and its prerequisites, prints the post-install steps
fjarr-agent drivers detecthardware currently attached, matched against the catalog, with the package each needs
fjarr-agent --checkthe doctor: every configured source and backend with element availability, plus the one-line fix for each missing one (install: sudo fjarr-agent drivers install realsense). Always a desktop row (ADR-0021, slice 2c): available with the backend serving it, or unavailable naming the package to install — and never a failure, because a robot with no desktop is the normal case
fjarr-agent --probe-source …bring up one source standalone (docs/09)
fjarr-agent net setupthe tunnel’s one-time setup: address, fjarr-net.service recreating the device at every boot, optional ROS ordering and DDS file (below, docs/27)

| fjarr-agent setup desktop | makes a desktop robot reachable unattended (ADR-0006): chooses or creates the auto-login account (no password, no remote login), enables auto-login, the GDM watchdog and the session helper’s user unit; for X11 kiosks the output layout; for headless robots, on request, a forced connector with an EDID on the kernel command line | | fjarr-agent display list\|add-ghost\|remove-ghost | the robot’s connectors and monitors, and ghost screens for a headless robot, on free root connectors, beside any real monitors (ADR-0032); M3 | | fjarr-agent setup --undo [<feature>] | reverses every change setup recorded for that feature (setup, net; later desktop: GDM, GRUB, accounts, units); bare, every feature, newest change first |

setup never guesses silently: every proposed track and every install is shown and confirmed (--yes for provisioning scripts), and the result is a plain fjarr.toml the customer can read and edit. It applies system changes only where the OS allows it (Ubuntu with apt). On a system whose configuration is generated or read-only, it prints the options to set instead. It never replaces the ecosystem’s package manager. Every change it makes is recorded, so --undo can reverse it.

The tunnel is the one piece of setup that must run before the robot’s own software starts, and the one that needs root. The interface is created once, here; afterwards the agent attaches to it unprivileged, and upgrading the agent does not disturb anything bound to it (docs/27). fjarr-agent --check reports the interface, its address, whether the agent is attached, and whether the configured range overlaps a route that already exists on the machine.

Decided 2026-09-28. setup, setup desktop, net setup and drivers are one small Rust program, fjarr-setup, shipped in the fjarr-agent package at /usr/lib/fjarr/fjarr-setup. fjarr-agent hands those subcommands over to it unchanged, so the commands above keep their names. Why a separate tool:

  • It is installer UX and system configuration, not device runtime. An embedder of libfjarr never needs it, so it does not grow the C++ daemon.
  • It reuses tested code. The boot-time tunnel device is created over netlink by the same module fjarr-connect uses for its own persistent device (docs/27).
  • One prompt library across the operator’s machine and the device: cliclack. fjarr-connect moves from dialoguer to cliclack in the same slice, after its filter mode is confirmed to serve the device picker as well as dialoguer’s fuzzy select does. The repository then carries one prompt library (docs/14).

Rules for every command:

  • Say “device”, not “robot”. The tool is for any connected machine. (The configuration and protocol field robot_id keeps its name; renaming it would be a protocol change, taken separately if ever.)
  • Every prompt has a flag (--yes, --server, --device-id, --ros-units, --dds none|cyclone|fastdds, …), so a fleet provisions with a script and the prompts are only the friendly face.
  • Every change is recorded in /var/lib/fjarr/setup-changes.json, and fjarr-agent setup --undo <feature> reverses exactly those changes. Every change is attempted. One that cannot be reversed is reported and kept in the record for the next --undo, and the command exits non-zero, but the rest are still reversed. The record is saved after each change, so an undo stopped half-way (Ctrl-C) leaves only what is still to do. An account setup desktop created is removed only after its session has ended; when it does not end (a display manager’s automatic login logs it straight back in), the undo says so and keeps the account in the record.
  • A replaced file’s previous contents are kept under /var/lib/fjarr/setup-backups/, and a rerun keeps the first run’s copy. A copy can be gone while the record still names it: an undo before 2026-10-05 that was stopped between restoring a file and saving the record left the mini-PC’s /etc/gdm3/custom.conf that way (M3 gate review, finding 18). Then:
    • --undo names the file and the missing copy, and what to do. It leaves the file as it is and keeps the entry, like any change it cannot reverse, and exits non-zero. Its last line lists every change it did not undo. The entry is kept so that the record still says the file is not the original. Dropped, the entry would be forgotten, and the next setup would copy its own earlier output and let --undo “restore” that without a word.

    • The next setup of that feature heals the entry before it changes the file. It keeps a new copy of the file as it is at that moment and says so, so --undo puts that version back.

    • When the file holds setup’s own output, a copy would let --undo “restore” setup’s output, so setup takes none. It says the original is lost and leaves the entry as it is. Setup’s own output is what this run would write, or what an earlier run wrote with other settings, which each file is told by:

      FileAn earlier setup’s output when
      /etc/gdm3/custom.confits automatic login names an account the record says setup desktop created
      the dconf database 00-fjarr-desktopit starts with setup’s header
      the dconf profile /etc/dconf/profile/userit has system-db:local (setup writes the profile only when that line is missing)
      /etc/default/grub.d/fjarr-ghosts.cfgit has a # fjarr-ghosts: line
      net setup’s drop-ins and Cyclone DDS filethey say Written by `fjarr-agent net setup`
      /etc/tmpfiles.d/fjarr-container.confit starts with setup’s header
      /etc/fjarr/fjarr.tomlnever: it is the customer’s file, edited in place, so only this run’s output counts

      The way out is the person’s, never setup’s guess. Either put the original back by hand (for a package’s file, the version it shipped) and run that setup again, which copies it; or, when the file as it is now is its original (an automatic login set up by hand exactly as setup writes it, say), copy it to the kept copy’s name with the command both setup and --undo print, sudo cp -p <file> <kept copy>. Either way, --undo restores it afterwards. No flag does this, so --yes never can.

  • The configuration file stays the customer’s. Setup adds and removes only its own keys, prunes the tables they leave empty, and takes a table written inline ("fjarr.desktop" = {}) like any other.
  • System changes are applied only on Ubuntu with apt. Elsewhere the tool prints the options to set. It never replaces the ecosystem’s package manager.
  • Each command ends with fjarr-agent --check, whose profile rows verify what it did.

Built 2026-09-28: net setup, net up and setup --undo net (signaling/crates/fjarr-setup), shipped in fjarr-agent with fjarr-net.service, and proven on the spike machine; then, the same day, setup (the first run, below), drivers list|detect|install with the built-in catalog (below) and the bare setup --undo, proven on the spike machine against the lab server and in the package install test. setup desktop and display followed in M3 slice 3.3 (2026-10-01, below). fjarr-connect still prompts with dialoguer; its move to cliclack is owed (docs/14).

The first run, which the install script calls. It detects the machine (OS, architecture, hardware H.264 encode, display server, cameras via gst-device-monitor-1.0 matched against the catalog), then asks:

┌ fjarr setup
│
◇ This device: Ubuntu 26.04 · amd64 · VA-API H.264 ✔ · GNOME on Wayland
│
◆ Where does it connect?
│ ● Your own fjarr-server ○ Fjarr Cloud
◇ Server: wss://fleet.acme.com/ws
◇ Device id: dev-024 (from the hostname)
◆ Device token (from your dashboard): ••••••••
◇ Token accepted by the server ✔ stored in /etc/fjarr/fjarr.toml (0640, root:fjarr)
│
◆ Cameras found. Which should stream?
│ ◼ Front Logitech C920 (usb-046d_C920…) mjpeg 1280×720@30
│ ◻ Depth Intel RealSense D435 → needs the realsense driver
◆ Install the realsense driver? (adds Intel's apt repository) Yes
│
◆ Also set up:
│ ◼ Terminal → as which account? operator
│ ◼ Tunnel → runs `net setup` next
│ ◻ Remote desktop → runs `setup desktop` next
│
◇ /etc/fjarr/fjarr.toml written · fjarr-agent started · online ✔
└ fjarr-agent --check: all rows ok · undo: fjarr-agent setup --undo

Until M5 builds enrollment (docs/17, docs/10), setup asks for the device token the server accepts today and writes it to the configuration. The prompt becomes a one-time enrollment token, redeemed for the per-device key, when M5 lands. The flow around it does not change. The “Fjarr Cloud” choice joins the server prompt with M7; today the prompt is the server URL. Installing a vendor driver from this flow arrives with the first vendor entry (with the design partner’s hardware): a detected camera that needs one is shown with its entry and left unchecked.

What it does, in order, as built 2026-09-28 (fjarr-setup’s setup.rs):

  1. Detects: OS and architecture (os-release), hardware H.264 encode (the line fjarr-agent --check prints, so the encoder the agent will probe is the one reported), the graphical session (loginctl), and the cameras (gst-device-monitor-1.0 Video/Source, matched against the catalog by udev usb id and device.api).
  2. Asks the server URL, the device id (default: the hostname, or the existing config’s), and the device token (a password prompt; an existing token is offered to keep). Before anything is written it opens a TCP connection to the server’s host and port: a typo or a closed port fails here, with nothing changed, and --offline skips the check for a device provisioned before its uplink exists (offline is a normal state, ADR-0019 addendum; a first run that cannot be checked is not a finished setup, so it is said, not assumed).
  3. Decides the encoder with the agent’s answer: auto with hardware encode; otherwise software, confirmed, because the agent has no silent fallback and would refuse to start (docs/23).
  4. Proposes one track per camera that a built-in source serves, pre-selected; the id is a slug of the camera’s name (unique among the file’s tracks), the label its name, the source { type = "v4l2", device = <by-id name> } with the mode picked as MJPEG 1280×720@30 when the camera has it, else the largest MJPEG mode up to 1080p at ≥ 15 fps, else the largest raw mode (docs/06’s format; editable).
  5. Asks the terminal’s account, with no default (docs/06): the agent’s own account works now; another account is written as chosen, with the warning that the terminal reports unavailable until the agent runs as it. --yes without --terminal configures none.
  6. Writes /etc/fjarr/fjarr.toml, 0640 root:fjarr: created plain when absent, edited in place when present (comments, order and the customer’s other keys kept; a track with the same id is replaced). The whole file is recorded, with its previous contents beside the record, so --undo puts back exactly what was there.
  7. Starts the agent (systemctl restart fjarr-agent.service; enabled by the package, enabled here if it was not) and waits for its STATUS=online (ADR-0019, second addendum; --timeout, 30 s) — the server’s acceptance of the token is the agent’s own word, not a second handshake in the tool. A failed or restarted unit ends the wait with the journal’s reason; a timeout says the configuration stays and the agent keeps trying. Without systemd (a container) the agent is not started and the tool says how to run it.
  8. Ends with --check, or hands over to net setup when the tunnel was chosen (--net yes, with --ros, --ros-units, --dds passed through).

The flags, one per prompt: --yes, --server, --device-id, --token (or FJARR_DEVICE_TOKEN, for scripts that keep it off the command line), --offline, --encoder auto|vaapi|software, --cameras all|none|<device,…> (by-id names or /dev paths, as drivers detect lists them), --terminal none|<account>, --net yes|no, --timeout. What it leaves on the device, recorded for --undo:

WhereWhat
/etc/fjarr/fjarr.tomlagent.robot_id, agent.server_url, agent.dev_token, media.encoder, one capabilities."fjarr.camera".tracks.<id> per chosen camera, capabilities."fjarr.terminal" when an account was chosen; 0640 root:fjarr
/var/lib/fjarrcreated 0700 fjarr when the service has not run yet
fjarr-agent.servicestarted; recorded as enabled only when the package had not already enabled it, as started only when it was not running
/var/lib/fjarr/setup-changes.jsonthe record, feature setup; the file’s previous contents under setup-backups/

The package install test (packaging/install-test.sh) runs the scripted form on a clean Ubuntu 26.04 without systemd and a server: the first run fails before writing anything and names --offline; --offline writes the file the agent’s --check then passes on; --undo removes it and the check names setup again.

The tunnel’s one hard rule is that its device exists before any software that should use it starts (docs/27#lifecycle), because DDS picks its interfaces when a participant is created. net setup derives or takes the address, checks the range against existing routes, enables fjarr.net in the config, and installs fjarr-net.service: a oneshot that recreates the device at every boot (owned by fjarr, at the chunk MTU, before fjarr-agent), because a tun device does not survive a reboot.

ROS and DDS are an explicit question, never detected. ROS often runs in a container, invisible from the host, so detection would answer “no” on exactly the machines that need the ordering. “No” skips the ordering and DDS steps (--ros no); “yes” asks which units start it and which DDS it uses:

┌ fjarr net setup
│
◇ Device id: dev-024 → tunnel address 100.70.118.224 (derived)
◇ Range 100.64.0.0/10 is free on this machine ✔
│
◆ Does software on this machine use ROS 2 / DDS over the tunnel?
│ ● Yes ○ No
│
◆ Which services start it? They must start after the tunnel.
│ ◼ bringup.service
│ ◼ docker.service (ROS in containers: they start with the runtime)
│
◆ Which DDS?
│ ● Cyclone DDS (writes /etc/fjarr/cyclonedds.xml, LAN interface enp3s0)
│ ○ Fast DDS (no file needed)
│ ○ None
│
◇ fjarr-net.service installed · 1 ordering drop-in · config updated
└ fjarr-agent --check: all net rows ok · undo: fjarr-agent setup --undo net

The ordering is a systemd drop-in per chosen unit — After= and Wants= of both fjarr-net.service (the device exists) and fjarr-agent.service (the agent has attached it, which is when it has carrier: the condition a Fast DDS participant needs, docs/27#lifecycle) — which is the ordering rule applied to the customer’s own services. The wait is bounded by the robot, not the WAN: the agent’s READY comes once its capabilities are configured and its loop runs, before any server is reached (ADR-0019, second addendum). When ROS runs in containers, the unit to order is the container runtime (docker.service) or the unit that starts the compose stack, and the containers need host networking to see fjarr0 (as in containerized devices).

What it leaves on the device, each recorded for --undo net:

WhereWhat
/etc/fjarr/fjarr.tomlcapabilities."fjarr.net".enabled = true, and address when --address pinned it; only those keys, comments and order kept
fjarr0created now, owned by the agent’s account (the profile’s [net] owner), <address> peer 100.64.0.1, at the chunk MTU
fjarr-net.serviceenabled and started; at every boot it runs fjarr-setup net up, which reads the config, asks fjarr-agent --net-address and recreates the same device (a no-op when it is already there)
/etc/systemd/system/<unit>.d/50-fjarr-net.confthe ordering drop-in, one per --ros-units entry: after fjarr-net.service and fjarr-agent.service
/etc/fjarr/cyclonedds.xmlwith --dds cyclone: docs/27’s file with the tunnel and the LAN interface (the default route’s, or --lan-interface); the ROS 2 processes take it through CYCLONEDDS_URI
/var/lib/fjarr/setup-changes.jsonthe record, with a replaced file’s previous contents beside it under setup-backups/

The flags: --yes, --ros yes|no (required without a terminal: it is never detected), --ros-units a.service,b.service, --dds cyclone|fastdds|none, --address, --lan-interface. A running agent is restarted at the end so it attaches now; a stopped one is left alone. It ends with fjarr-agent --check, whose net rows verify the unit and the device.

Before 2026-09-28 the drop-in ordered after the device alone, because READY waited for the first hello-ack and ordering after the agent would have held a robot’s bringup on the WAN; that was #32, closed by the ADR-0019 addendum above.

Built in M3, with the desktop packages (moved from M2.5 on 2026-09-28): it configures the session helper and backend M3 builds. The appliance pieces the M2 spikes showed a desktop device needs (ADR-0006, ADR-0028):

┌ fjarr setup desktop
│
◇ Session: GNOME 50 on Wayland under GDM → backend: mutter
◇ Monitors: 1 (DELL U2422H on HDMI-1)
│
◆ Which account should the desktop run as? It logs in by itself at boot.
│ ● Create "desktop" (no password, no remote login) ○ Use existing: operator
│
◇ Auto-login enabled for desktop in GDM
◇ GDM watchdog enabled (restarts the login if the session dies)
◇ Session helper enabled for desktop · group fjarr-desktop
│
◆ Ghost screens for when no monitor is attached? (free: DP-2, HDMI-A-2)
│ ○ None ● 1 ○ 2 --ghost-screens N · change later: fjarr-agent display
│
▲ desktop must log in once for its group to apply. Reboot now? Yes / Later
└ Undo: fjarr-agent setup --undo desktop

What it writes, each change recorded for setup --undo desktop (specified 2026-10-01 for M3 slice 3.3):

WhereWhat
the accountcreated when asked for: useradd --create-home --user-group, password locked (no login but the automatic one), added to fjarr-desktop. An existing account is only added to the group
/etc/gdm3/custom.conf[daemon] AutomaticLoginEnable=true, AutomaticLogin=<account>; the file’s other keys are kept
/etc/dconf/db/local.d/00-fjarr-desktop and /etc/dconf/profile/userno screen lock and no idle blanking (org/gnome/desktop/screensaver lock-enabled=false, org/gnome/desktop/session idle-delay=0), then dconf update. An auto-login account has no password, so a lock screen would strand the robot. This applies to every account on the machine, which suits an appliance and is said in the prompt
~<account>/.config/systemd/user/graphical-session.target.wants/fjarr-desktop-session.servicethe helper’s user unit, enabled for that account only. It starts with the graphical session, restarts on failure, and connects to /run/fjarr/desktop.sock
~<account>/.config/systemd/user/gnome-initial-setup-first-login.service and …/gnome-initial-setup-upgrade-login.servicelinks to /dev/null: GNOME’s “Welcome to Ubuntu” wizard is masked for the account. Unmasked, it starts in the middle of the robot’s screen, over whatever the robot shows, and stays until someone at the machine clicks through it (found by the M3 gate review, 2026-10-11). Ubuntu starts it from gnome-session.target, one unit per mode: --existing-user while the account has no ~/.config/gnome-initial-setup-done, and --upgrade-user once it has one, until ~/.config/gnome-initial-setup/upgrade-<release>-done exists. Masking both keeps it off whatever the done file says, and after a release upgrade, where writing this release’s marker would not. The masks apply from the account’s next login. A file already at either path, the account’s own mask or override, is left alone and not recorded, and setup names an override, as the wizard can start through it
~<account>/.config/gnome-initial-setup-doneyes, as GNOME’s wizard leaves it when finished. On its own (2026-10-11 to 10-17) it only traded the first-login mode for the upgrade mode, which then started at every boot of the mini-PC’s account, recreated each night (found 2026-10-17). Kept beside the masks because GNOME’s copy worker (gnome-initial-setup-copy-worker.service) runs at every login while it is missing, with nothing to copy into a robot’s account. Left alone when the file exists
fjarr-desktop-watchdog.timerenabled. Every 30 s it runs fjarr-setup desktop watchdog. When GDM is active and two checks in a row find no user session at all on seat0, only GDM’s own greeter, the watchdog restarts GDM, which logs the account in again, and says so in the journal (ADR-0006). A person logged in at the machine holds the seat and is never logged out by it (found on the mini-PC, 2026-10-01: the first version watched for the account’s own session, and would have ended the session of whoever was at the machine)
/etc/fjarr/fjarr.toml[capabilities."fjarr.desktop"] helper.user = "<account>", helper.group = "fjarr-desktop"; then the agent restarts

Built 2026-10-01 (fjarr-setup’s desktop.rs): the package install test runs setup desktop and --undo desktop on a clean Ubuntu, and checks each file, the account and the keys; since 2026-10-17 also for an existing account, whose home keeps everything setup did not add. The automatic login, the watchdog and the reboot are proven on the mini-PC in 3.L.

The wizard did not cause the nightly’s input-to-photon 0/10 (corrected 2026-10-17 from “over the nightly’s test window”). The first-login wizard also ran on 2026-10-02 and 10-04, nights with ten real samples, and on 10-13 and 10-14, with the upgrade-mode wizard running, the clicks and the closing Shift release still reached the test window. The 10-14 frame dump is the full-screen test window, not the wizard (M3 gate review, finding 2).

The group applies at the account’s next login, so the command ends by offering a reboot. --check adds rows for the module, the account and its group, the auto-login, the watchdog and each ghost screen.

On an X11 kiosk it switches on fjarr-x11-session instead (its autostart entry, linked into /etc/xdg/autostart/) and writes display = ":0" (or --display) under [capabilities."fjarr.desktop"]. It does not touch the display manager or the automatic login: the kiosk session is the robot maker’s. For the desktop’s sound (ADR-0034) it needs the kiosk account: --account, else LightDM’s autologin-user. It adds that account to fjarr-desktop and records it as helper.user and the group as helper.group, as on GNOME (all undone by --undo desktop); with no account found it says the sound is off and how to turn it on. Advice, from ADR-0006’s spikes: LightDM with an automatic login is the easy X11 kiosk; under GDM the X session loses the screen to a Wayland greeter about 11 s after an automatic login, and needs a boot-time loginctl activate. --check adds rows for the X11 module and the kiosk session’s autostart entry ([desktop-x11] in the profile); a session that did not grant the agent shows in the agent’s log (“cannot open X display”) and to every operator as desktop: absent (docs/08). With no monitor, or with --ghost-screens N, it offers ghost screens on the free connectors, and says a reboot is needed.

A headless robot often wants screens its applications lay windows out on, with nobody’s monitor attached. A ghost screen is a forced DRM connector carrying a Fjarr-generated EDID (“Fjarr Ghost N”, its own serial, the requested mode). It exists from boot, whether or not anyone is connected, and GNOME and the capture backends treat it like any monitor (ADR-0032; measured on the mini-PC with three at once). Real monitors can be plugged in beside ghosts, and the two never hide each other:

  • Free root connectors only. A ghost goes on a connector that is disconnected when it is added, never on one a real monitor uses. Forcing a used connector would replace that monitor’s EDID with the ghost’s.
  • Never a DisplayPort MST branch. A daisy-chain’s DP-1-1, DP-1-2… are created at runtime, so a boot-time kernel parameter cannot name them. Real monitors on a chain are fully supported; the root connector the chain hangs off counts as in use while the chain is plugged in.
  • A distinct identity. A ghost’s monitor id is its EDID slug, unique by construction, so a ghost and a real monitor never share a track_id (docs/09).

fjarr-agent display lists every connector and manages ghosts, as root, and setup desktop --ghost-screens N uses the same code:

$ sudo fjarr-agent display list
CONNECTOR STATE MONITOR GHOST
HDMI-A-1 connected DELL U2422H (real) —
DP-1 connected MST chain: 2 monitors — (not forceable: chain)
DP-2 free — can be a ghost
HDMI-A-2 ghost Fjarr Ghost 1 1920×1080 active
$ sudo fjarr-agent display add-ghost [--connector DP-2] [--mode 1920x1080@60]
DP-2 → Fjarr Ghost 2 (1920×1080@60) · takes effect after a reboot
$ sudo fjarr-agent display remove-ghost <connector|all>

What a change writes, each recorded for setup --undo desktop:

WhereWhat
/etc/default/grub.d/fjarr-ghosts.cfgvideo=<connector>:<mode>e drm.edid_firmware=<connector>:edid/fjarr-ghost-N.bin per ghost, then update-grub
/usr/lib/firmware/edid/fjarr-ghost-N.binthe generated EDID

Built 2026-10-01 (M3 slice 3.3, fjarr-setup’s display.rs): connectors are read through DRM ioctls, so a DisplayPort chain’s root is recognised by its members’ PATH property (mst:<root>-<port>) even while it reads as disconnected. The EDID is generated as EDID 1.4 with the mode’s CVT reduced-blanking timing, and di-edid-decode reports it conforming. The GRUB snippet’s # fjarr-ghosts: line is the one record of which ghosts exist. Proving it on hardware (a reboot into the ghosts, a chain beside them) is 3.L’s, as was open question #37: telling that a real monitor is on a ghost connector (answered the same evening: over DDC, above). On the mini-PC (amdgpu, 2026-10-01) the ghost came up from the first reboot with the EDID only in /usr/lib/firmware/edid, because amdgpu is not in that machine’s initramfs and loads after the root filesystem is mounted. A machine whose initramfs carries its GPU driver would need the EDID inside it too. add-ghost should check with lsinitramfs and add an initramfs hook when it does (owed).

add-ghost without --connector takes the next free root connector, and when none is left it says how many ghosts this machine can have instead of failing after a reboot. --check adds rows for each configured ghost: active when the running kernel booted with it, reboot pending when it did not, and a failure when a real monitor is plugged into a ghost connector. That monitor shows up as the ghost, because the kernel forces the ghost’s EDID on that port, so both read the monitor’s own EDID over the connector’s DDC bus instead (i2c-dev, which fjarr-desktop-wayland loads at boot). A sink that answers there is a real monitor (#37, measured on the mini-PC).

A ghost is never the primary monitor while a real one is connected. On a desktop account with no saved layout, mutter picked one of the ghosts as primary (the mini-PC, 2026-10-01, with a three-monitor chain beside it). The robot’s own applications open on the primary monitor, and an operator’s view follows it. So fjarr-desktop-session checks every layout change: when the primary is a ghost (manufacturer FJR) and a real monitor is connected, it makes the leftmost real monitor primary through mutter’s ApplyMonitorsConfig, with every position, mode and scale kept, persistently, so the account’s monitors.xml remembers it. With only ghosts connected, a ghost is primary, as it must be. It asks at most once per layout, and not at all when a real monitor is primary already; it sends the agent the current monitors whether or not it asked; and when mutter reports more than one primary, the agent hears exactly one (a real monitor first, then the leftmost). The M3 gate review found why (the mini-PC, 2026-10-11): after a Dell came back under a new connector, mutter reported a ghost and the Dell both primary, the helper asked again on every change, eight times a second, and each new layout reallocated every framebuffer until the out-of-memory killer ended the session.

Ghosts and real monitors share the GPU’s display pipes. The mini-PC’s GPU drives four; with two ghosts and the three-monitor chain (five), mutter lit one monitor, a ghost, and left the Dells dark. Count the real monitors a robot may have before adding ghosts.

A virtual monitor (mutter’s RecordVirtual) is the other kind: an extra screen for one session, created when the operator asks and gone when they leave. It needs no reboot and no connector, and promises no persistence (docs/22).

Mostly output, for people and scripts alike (--json):

$ fjarr-agent drivers list
NAME STATUS PACKAGE NOTE
v4l2 built in — source = { type = "v4l2", … } in fjarr.toml (docs/06)
realsense available fjarr-gst-realsense needs Intel's apt repository (added for you)
zed needs manual fjarr-gst-zed SDK behind a EULA: see the link
jetson-csi not for amd64 fjarr-gst-argus available on arm64
$ fjarr-agent drivers detect
/dev/video0 Logitech C920 → v4l2 (built in) mjpeg 1280×720@30
usb 8086:0b07 Intel RealSense D435 → realsense (available)

The status per entry is built in, installed (dpkg’s view of the package), available, not for <arch> or needs manual, in that precedence; --json gives { "arch", "catalog", "drivers": [ { name, title, status, package, element, note, docs } ] } for list and { "arch", "devices": [ { name, path, api, usb_id, serial, modes, stable_name, driver, status } ] } for detect, where stable_name is the by-id name a v4l2 source takes and modes the caps the monitor reported. Neither needs root. detect without gst-device-monitor-1.0 is an error naming gstreamer1.0-plugins-base-apps, which the fjarr-agent package depends on for exactly this.

fjarr-agent drivers install v4l2 says the entry is built in and exits 0. sudo fjarr-agent drivers install realsense will show the prerequisites, ask before adding a vendor repository, install, reload udev, and finish with a --probe-source of the device it found — with the first vendor entry (with the design partner’s hardware); until then it says so and exits non-zero.

Containerized robots {#containerized-robots}

Section titled “Containerized robots {#containerized-robots}”

A reference compose file ships with each release and states what the agent needs from the host:

NeedIn composeWhy
WebRTCnetwork_mode: hostbridged networking hides the robot’s addresses from ICE and adds a NAT hop
Encoder, camerasdevices: /dev/dri, /dev/video*; group_add with the host’s render GIDgroup ids differ per host
Config and device keyvolumes for /etc/fjarr and /var/lib/fjarr (0700)the key survives image upgrades
Tunneluser: "0", cap_add: NET_ADMIN, /dev/net/tun, host networkingthe container’s entrypoint does what fjarr-net.service does on an apt device — fjarr-setup net up creates fjarr0 owned by fjarr — then drops to fjarr with no capabilities before the agent starts, so the agent itself never holds CAP_NET_ADMIN (docs/27, rule 1); it runs on every container start, which is every boot. ROS containers wait for carrier on fjarr0 before starting ROS (rule 2): compose’s depends_on orders only docker compose up, not the daemon restarting containers after a reboot
Desktopthe image …:<ver>-desktop-wayland; the host’s /run/fjarr bind-mounted, where the agent listens on desktop.sock; group_add with the host’s fjarr-desktop gid; the host side from the fjarr-desktop-session package (below)the helper has to run inside the desktop user’s session; descriptors cross a bind-mounted socket unchanged

The reference file is packaging/compose/docker-compose.yml, published with each stable release at https://apt.fjarr.io/docker-compose.yml (its image tag is latest unless FJARR_VERSION pins one). It is written for one job — the agent next to the customer’s own containers — and the first run is the setup tool inside the image, which writes the configuration into the volume and, having no systemd, says so: it starts no agent, and net setup installs no fjarr-net.service (the entrypoint below does its job) and writes no drop-ins:

Terminal window
docker compose run --rm fjarr-agent setup --server wss://… --device-id dev-024 --net yes --ros no
docker compose up -d
docker compose exec fjarr-agent fjarr-agent --check

Tunnel only, on a machine with no GPU for the agent or no /dev/dri (a Jetson, whose encoder is not VA-API): delete the /dev/dri device and group_add. Docker refuses to start a container whose listed device is missing, and a tunnel needs neither. Keep user: "0", cap_add, /dev/net/tun and host networking. The host needs no Fjarr package, only a Docker that knows the clone3 syscall (20.10.10 or later), because the image’s glibc uses it.

The image’s entrypoint runs the setup tool as root (setup, net, drivers); for the agent it creates the tunnel device when fjarr.net is enabled and then setprivs to fjarr — no capabilities, keeping the supplementary groups compose added (group_add render) along with fjarr’s own. Started as fjarr (the image’s default user, as without the tunnel) it runs the agent directly. The agent reads /etc/fjarr/fjarr.toml whenever it exists and no --config is given — on an apt device too, so fjarr-agent --check checks what setup wrote — and the file sets FJARR_AGENT_ALLOW_UNSUPERVISED=1 beside restart: unless-stopped: the runtime is the supervisor ADR-0019 asks for. A ROS container keeps its image’s entrypoint behind a wait for carrier:

entrypoint: ["/bin/sh", "-c", "until [ \"$$(cat /sys/class/net/fjarr0/carrier 2>/dev/null)\" = 1 ]; do sleep 0.5; done; exec /ros_entrypoint.sh \"$$@\"", "wait-for-fjarr0"]

Carrier is up exactly while the agent is attached, which is the condition a participant needs — so the wait holds however the container was started, including by the daemon after a reboot in whatever order it restarts them. make compose-gate proves the file: setup in the image, --check, a ROS container started before the agent that waits and then finds the tunnel address, an agent restart the device survives, and real IP over the tunnel from an operator.

The image runs as fjarr with a fixed, published uid: 10001. The Dockerfile creates the user before installing the package, and the package’s sysusers entry then only adds video and render; on an apt robot the uid is dynamic. The image’s health check is live: the running agent’s introspection endpoint answers. That says more about a running container than a static check of its files (corrected 2026-09-28 from a --check --health that was never built). The agent checks the helper’s uid (ADR-0028; corrected 2026-10-05 from “the helper checks the agent’s”), and the helper’s socket belongs to the host’s group, so user-namespace remapping (rootless Docker, userns-remap) is either off or its mapped uid is configured. On an X11 kiosk, /tmp/.X11-unix is bind-mounted, the container shares the host’s IPC namespace (ipc: host: ximagesrc reads through MIT-SHM and captures stale frames without it), and the package creates a host account with the image’s uid so the xhost grant can name it.

A desktop in a container {#a-desktop-in-a-container}

Section titled “A desktop in a container {#a-desktop-in-a-container}”

GNOME (backend E) with the agent in a container (M3 slice 3.8). The desktop stays the host’s: its account, GDM’s automatic login and the session helper are host things, and the agent reaches the helper through a socket in a bind-mounted /run/fjarr, exactly as on an apt robot. The host has no agent, so it installs only the host side:

Terminal window
sudo apt install fjarr-desktop-session
sudo fjarr-setup setup desktop --container [--account desktop] [--ghost-screens N]

--container does what setup desktop does on the host (the account, the automatic login, no screen lock, the helper for that account only, the watchdog, ghost screens), writes no agent configuration (there is none on the host), and makes /run/fjarr the container agent’s (uid 10001, a tmpfiles drop-in, recorded for --undo). It ends by printing the two numbers the container needs, which the host owns and the image cannot know: the desktop account’s uid and the fjarr-desktop group’s gid. The container side:

# docker-compose.yml, the fjarr-agent service
image: ghcr.io/fjarrio/fjarr-agent:${FJARR_VERSION:-latest}-desktop-wayland
volumes:
- /run/fjarr:/run/fjarr # the helper's socket
group_add: ["${RENDER_GID:-109}", "${FJARR_DESKTOP_GID}"]
Terminal window
docker compose run --rm fjarr-agent setup desktop --helper-uid <uid> --helper-gid <gid>

which writes capabilities."fjarr.desktop".helper.uid and .gid (numbers, not names: the image has neither the account nor the group). The agent accepts a helper only from that uid (SO_PEERCRED), and gives its socket to that gid, 0660. An X11 kiosk with the agent in a container is described in the table above and is not built yet.

Repository, versions and releases {#releases}

Section titled “Repository, versions and releases {#releases}”
  • The repository is static files on Cloudflare R2 at apt.fjarr.io (bucket fjarr-apt), built from debhelper packages made inside Ubuntu 26.04 containers on native amd64 and arm64 runners (dpkg-shlibdeps derives the dependencies). Its shape (corrected 2026-09-28 from “components”, and from reprepro/aptly, when it was built):

    PathWhat
    pool/main/f/fjarr/*.debevery released package, shared by both channels; nothing is removed
    dists/testing/, dists/stable/one suite per channel: Release, InRelease, Release.gpg, and main/binary-{amd64,arm64}/Packages{,.gz}
    fjarr-archive-keyring.ascthe public key, beside the repository it signs
    install.shthe install script (get.fjarr.io redirects here)
    docker-compose.ymlthe reference compose file of the stable release

    The indexes are generated with apt-ftparchive (packaging/repo/) from the pool, each suite listing exactly the packages of the version it carries. So promotion to stable is regenerating stable’s index over files already in the pool, which cannot be a rebuild. There is no repository database to keep in step with the bucket. A robot’s source (/etc/apt/sources.list.d/fjarr.sources, deb822) names the suite and the key: Types: deb, URIs: https://apt.fjarr.io, Suites: stable, Components: main, Signed-By: /etc/apt/keyrings/fjarr.asc.

  • The signing key is held in the release environments’ secrets. The key’s custody and yearly rotation are documented beside the release runbook, and the install script carries its fingerprint. The current key: Ed25519, fingerprint B376164F0985CFDB0DE7C26C839E1ECA17D3B8F1, created 2026-09-28, expiring 2028-09-27 (replaced yearly with a year of overlap), public half at packaging/fjarr-archive-keyring.asc. The private half exists only in the apt-testing and apt-stable GitHub environments and offline; its revocation certificate is kept offline with it.

  • One version for every artifact, from one tag vX.Y.Z: packages, images, fjarr-connect, the fjarr-server image, crates and npm packages. The wire protocol keeps its own major (docs/08). 0.x until the extension API is stable (M6). make set-version V=X.Y.Z sets it everywhere it is declared (CMake, which the agent reports; the Cargo workspace and its lockfile; the npm packages; debian/changelog). That is committed, then tagged, and the release workflow refuses a tag that does not match every one of them.

  • A release is: tag → the tagged commit’s CI run, waited for and required green → its packages (never rebuilt, docs/30) and images built from them to testing → a manual, protected promotion to stable that copies the same artifacts and never rebuilds them (.github/workflows/release.yml; environments apt-testing and apt-stable, the second requiring approval, both deployable only from v* tags). Images go to ghcr.io/fjarrio/fjarr-agent as one multi-arch manifest per version, tagged X.Y.Z and testing, with latest moved on promotion; each is signed with cosign (keyless, GitHub’s identity) and carries an SBOM. install.sh and the reference docker-compose.yml are uploaded on promotion. The crates and npm packages join the promotion once their registries are set up; no token for either exists yet. Release notes come from the conventional commits since the last tag.

  • Building locally: make deb builds the packages for the host’s architecture in a throwaway builder container (docker/deb-builder) into dist/deb/<arch>/. make deb-install-test installs them on a clean Ubuntu 26.04 and checks every promise in the table above (packaging/install-test.sh). make deb-embed-test builds demo-robot out of tree against the installed libfjarr-dev alone (find_package(fjarr)), which is what a customer’s CMake project does. CI runs all three on native amd64 and arm64 runners.

  • The install script (get.fjarr.io, packaging/install.sh) only does what apt cannot do by itself. It detects Ubuntu 26.04 and the architecture, fetches the key and refuses it unless its fingerprint is the one the script carries, writes the source above, installs fjarr-agent and runs setup (reading the terminal, since its own stdin is the script). On anything else it refuses, naming the supported systems and the container route. --dry-run prints every step without doing it; --channel testing picks the other suite.

The introspection endpoint’s /sources (docs/24) carries the same status per source (available, missing: fjarr-gst-realsense, not-for-arch, needs-manual: ZED SDK), so the demo dashboard’s robot page shows a “driver missing” badge with the install command instead of a black tile, and Fjarr Cloud (M7) shows it fleet-wide — “3 robots configured for RealSense without the driver” — and, once OTA campaigns exist (M8), can push a driver package to a robot group like any other update.

  • Fjarr does not redistribute SDKs it is not licensed to redistribute; the catalog says so and links the vendor’s download, and the tool verifies the SDK is present before installing the plugin package.
  • A driver that fails after install (kernel module, permissions) shows up as a source error with the bus message, not as a generic media failure — the --probe-source output is the support ticket.
  • The catalog is data: adding a vendor is a catalog entry plus a plugin package, never a change to fjarr-agent.

Packaging is the M2.5 milestone (docs/17): the repository, the packages and their units, the system profile, the images and the reference compose file, the install script, the release pipeline, setup (with --undo), drivers and the catalog with the built-in entries; setup desktop and the desktop packages are M3 (ADR-0031). The first vendor packages come with the design partner’s hardware. Slice 3 already ships the runtime half: unavailable with reason, --check, --probe-source, /sources.