Repository navigation
daemon
Install, run, configure, and troubleshoot the AprilCam daemon — the single process that owns every camera and does all vision.
The daemon is the single long-running process that owns every camera, runs
AprilTag/ArUco detection, calibration homography, and frame capture, and
answers all clients over gRPC — the MCP server, the
robot direct API, and the aprilcam camera view
window. Clients never touch hardware or do pixel work themselves.
This page is about running the daemon. For the gRPC/stream contract see Daemon Wire Protocol; for detection tuning see Tag Detection Under Variable Lighting.
Install with the daemon extra on the host the cameras are plugged into —
it carries the capture/enumeration stack (cv2-enumerate-cameras, mss, …):
pipx install 'aprilcam[daemon]'Thin clients elsewhere use the base install (see install tiers).
A daemon without the extra starts but sees no cameras. The failure is silent:
daemon statussays "running",camera listshows every cameraabsent/unusable. This bites especially when a pipx install and a repo checkout coexist —aprilcam daemon startspawns the daemon with the same interpreter the CLI itself runs under (sys.executable -m aprilcam.daemon), so whicheveraprilcamis onPATHdecides which venv the daemon gets. From a checkout,uv run aprilcam daemon startguarantees the repo venv. See this knowledge entry.
aprilcam daemon start # spawn the daemon in the background
aprilcam daemon status # running/stopped, cameras, resolved dirs
aprilcam daemon stop # SIGTERM; quiet no-op if not running
aprilcam daemon restart # stop (if running), then start
aprilcam daemon list # every discovered daemon, local and remote
python -m aprilcam.daemon # run in the foreground (logs on stderr)You start the daemon explicitly. Clients never auto-spawn one: a client
that can't find a daemon fails fast (no local daemon answered) instead of
launching one. Key behaviors, all from
cli/daemon.py,
client/lifecycle.py, and
daemon/lifecycle.py:
-
Singleton. The daemon holds an exclusive
flockon<socket_dir>/daemon.lock. A secondstartis refused witha daemon is already running: pid <N>— checked before spawning, without disturbing the running daemon. -
Ready means ready. The control socket is not created until the initial
camera enumeration has finished, so the moment
daemon startreturns (it polls the socket, up to 10 s),camera listanswers correctly. A start that fails reports the daemon's stderr tail:daemon process exited before becoming ready: …. -
start/stop/restartare local-only by design. They accept-d HOSTsyntactically but refuse any value with a message pointing at SSH — process lifecycle never crosses the network.daemon status -d HOSTis allowed: it returns the remote daemon's RPC-answered view. -
Bare
statusalso scans for orphans. If daemon-shaped processes exist on this machine besides the lock holder,statusprints anorphan processes (local scan, …)line — report only, never auto-killed. -
Restart re-reads disk, drops the rest. Configuration comes back from
config_dir(installed playfields and camera links survive), but anything only ever pushed over RPC — livecamera configoverrides, client-registered tag mounts — is gone, and open views/streams die and must be reopened. - Empty is success. Starting with zero cameras and zero configuration is normal, not an error.
The daemon listens on three things at once
(daemon/__main__.py):
- a Unix socket at
<socket_dir>/control.sock(local clients), -
TCP
0.0.0.0:5280(remote clients; unauthenticated, LAN-scoped), and - an mDNS advertisement of that TCP port (below).
If port 5280 is already taken, the TCP bind failure is logged at WARNING and the daemon continues unix-socket-only — it never fails to start over the network, it just isn't advertised.
Every daemon-talking CLI command takes -d/--daemon HOST (or HOST:PORT;
a bare host implies 5280). Target resolution, highest wins:
- an explicit
-d HOSTflag; -
APRILCAM_DAEMON_HOSTin the environment; - the local daemon on the default Unix socket.
aprilcam camera list # the local daemon
aprilcam camera list -d vidar.local # a specific remote daemon
aprilcam daemon list # fan out: every daemon on the LAN
aprilcam camera list --all # fan out a read-only commanddaemon list and --all merge a local lock-probe (instant, no network) with
a bounded ~2 s mDNS browse; an unreachable daemon is listed and flagged, never
silently dropped, and "found nothing" is an empty result, not an error.
Resolution lives in
config/directories.py:
a two-directory model plus a runtime socket dir, each resolved once at
startup with cascade flag > env > system default > per-user fallback.
| Root | System default | Fallback | Env override | Owner |
|---|---|---|---|---|
config_dir |
/etc/aprilcam (if it exists and is readable) |
~/.config/aprilcam |
APRILCAM_CONFIG_DIR |
You. Hand-authored; the daemon only reads it. |
state_dir |
/var/lib/aprilcam (if writable/creatable) |
~/.local/share/aprilcam |
APRILCAM_STATE_DIR |
The daemon. Regenerable state. |
socket_dir |
/run/aprilcam |
$XDG_RUNTIME_DIR/aprilcam, else $TMPDIR/aprilcam-<uid>
|
(none — deliberately not configurable) | Runtime plumbing: control.sock, daemon.lock, stream sockets. |
What lives where:
config_dir/
playfields/<name>.json # playfield definitions (surveyed features)
cameras/<slug>/config.json # per-camera settings, playfield link, position
state_dir/
cameras/registry.json # persistent camera identity -> number map
cameras/<slug>/calibration.json # daemon-written homography (regenerable)
Which roots were chosen is never a mystery: every resolved root and its cascade tier is logged at INFO on startup, and
aprilcam configprints the resolved roots, every environment variable the cascade consults
(set/unset), every config/calibration file location (exists/missing), and —
most valuable — every config-file load error. At boot the daemon loads
everything under config_dir skip-and-warn: one malformed file is logged and
skipped, never refuses startup. The flip side is that a bad file (classic
case: a v1-shaped config.json with no "name" field) makes its camera
silently never appear; aprilcam config turns that into a five-second
diagnosis.
Environment variables actually read by v2 code:
| Variable | Read by | Effect |
|---|---|---|
APRILCAM_CONFIG_DIR |
daemon + clients | Overrides config_dir. |
APRILCAM_STATE_DIR |
daemon + clients | Overrides state_dir. |
APRILCAM_DAEMON_HOST |
clients | Default -d target for every command. |
APRILCAM_LOG_LEVEL |
daemon |
DEBUG/INFO/WARNING/ERROR, default INFO. |
XDG_RUNTIME_DIR |
both | Socket-dir fallback when /run/aprilcam is unavailable. |
(APRILCAM_DAEMON_PORT appears in the design docs but no v2 code path reads
it — the port is the fixed constant 5280.)
The daemon and its clients must resolve the same socket_dir to find each
other; since it is derived, not configured, that just means launching both
from environments with the same uid and XDG_RUNTIME_DIR. The spawned daemon
inherits the CLI's environment, so the APRILCAM_* variables always agree
across a daemon start.
On first sight of a camera the daemon assigns it a persistent number and a
readable slug (the slugified device name, e.g.
arducam-ov9782-usb-camera), stored in state_dir/cameras/registry.json.
The number — not the volatile OS device index — is the stable handle
aprilcam camera list shows and every command accepts. Identity across
re-plugs is resolved by a fallback chain (hardware unique ID / USB serial,
then VID:PID+port, then name+resolution) in
camera/identity.py;
cameras with a real serial survive port moves, identical serial-less models
are told apart by port.
The daemon enumerates actively once at startup, then watches passively
(a ~2 s enumeration poll that never opens a device), so a hotplugged camera
shows up in camera list within a few seconds. aprilcam camera probe
forces a deep re-probe on demand; it never opens a camera that is currently
streaming, and anything it couldn't interrogate is listed under skipped:.
Once the TCP port is bound, the daemon registers aprilcam-<hostname> under
_aprilcam._tcp.local. with TXT properties version and host
(daemon/mdns.py).
The advertised address is the host's primary routable IPv4 (found via a UDP
route probe — deliberately not gethostname() resolution, which on Ubuntu
often maps to the useless loopback alias 127.0.1.1). Advertising is never
load-bearing: any mDNS failure is logged at WARNING and the daemon runs on;
clients can always reach it explicitly with -d HOST. The advertisement is
withdrawn during shutdown, before the listener stops accepting RPCs.
The daemon logs to stderr only — there is no log directory and no log file in v2 (that's deliberate: under systemd stderr is journald; by hand it is your terminal). Consequences:
-
aprilcam daemon startbackgrounds the daemon and its output is not captured anywhere (the stderr tail is only reported if startup fails). - For interactive debugging run it in the foreground:
aprilcam daemon stop
APRILCAM_LOG_LEVEL=DEBUG python -m aprilcam.daemonThe repo ships no unit files yet, but the daemon is designed to run as a
system service: as root, the cascade resolves the FHS roots
(/etc/aprilcam, /var/lib/aprilcam, /run/aprilcam) automatically, and
logs land in journald. A minimal unit is just:
[Service]
ExecStart=/path/to/venv/bin/python -m aprilcam.daemon
Restart=on-failurewhere the venv has the daemon extra installed. Do not wrap
aprilcam daemon start in a unit — that command backgrounds a child and
returns, which is the opposite of what systemd wants.
-
Every camera
absent/unusableafter a start/restart — the daemon is running from a venv without thedaemonextra (see Install). Checkpgrep -fl "python.*-m aprilcam.daemon"to see which interpreter it got; stop and restart from the right environment. -
A camera you configured never appears — its
config.jsonwas rejected at boot (skip-and-warn). Runaprilcam configand read theload errorssection; the classic cause is a v1-shaped document with no"name"field. -
no frame availablefrom one camera, device still on USB — the daemon's in-process capture session for that device is wedged;camera probemay flip it back tousablebut reads still fail. Only a daemon restart recovers it; reopen any views afterwards. -
Image nearly black but tags still detect (
darkflag incamera list) — UVC controls drifted under the daemon (another writer, or a lighting change made the tuned exposure wrong). Compareaprilcam camera config <n> --dumpagainst hardware readback, thenaprilcam camera config --reset <n>re-applies the on-disk config live, no restart. Exposure is ambient-dependent — see lighting and detection and the UVC-drift entry. -
A client errors
Method not found!after upgrading — the running daemon predates the code answering it.aprilcam daemon restart(any long-lived process holds imported code in memory; restart to pick up changes). -
daemon startfails witha daemon is already running: pid N— one is already up (userestart), or an orphan holds the lock: checkaprilcam daemon status's orphan-process line and kill the stray pid yourself — the CLI reports, never kills. -
daemon listfinds nothing across the network — the remote daemon may have lost the 5280 bind (port taken → no advertisement; grep its stderr formdns:/TCPwarnings), or multicast is blocked.-d HOSTstill works regardless of mDNS.