A local control plane for the Pollen Robotics Microduck's daemons: one page in a browser on the home network — a phone, a laptop — that shows the map the duck built, live, and drives it. It runs on the duck beside the daemons it manages and talks to each one over its own unix sockets, through an adapter per kind: quack-nav now, quacksat and others later. Italian: README.it.md.
The page on the MuJoCo twin (casa_grande, 2026-10-01): the duck on its way to a tapped point — the route ahead in green, the path walked in pink, the drops it booked in red, two named places — and its camera in the corner.
Independent project, not affiliated with Pollen Robotics or Hugging Face. Status: 0.1.0-rc2, the second release candidate (release notes, earlier: 0.1.0-rc1, releases); tested against quack-nav's MuJoCo twin (map, go_to, places, stop, knobs and restart, camera), not yet on a duck.
| quack-control | quack-nav | quacksat |
|---|---|---|
| 0.1.0-rc2 | ≥ v0.2.0-rc2 (the 3D house: quack-nav ≥ v0.3.0-rc2's scripts/map3d) |
no control socket yet |
| 0.1.0-rc1 | ≥ v0.2.0-rc2 | no control socket yet |
With an older quack-navd the page degrades: what it lacks (nav.knobs,
nav.restart, a place named at a tapped point, the rest / untrusted /
self-started badges) answers "update quack-nav" or stays off. The camera
needs Pollen's mediad, daemon-v0.14.4 or later (media.frame), or the
twin's viewer (The camera).
Map (the default view):
- the live map (walls, floor, unknown) and, from quack-nav v0.3.0-rc2, its band: what the depth sensor found in the way at the duck's height (brown) and table tops or bed edges with floor under them (tan), which the navigation goes round as walls; the duck's pose and heading — red when it is lost or seated — and its uncertainty (one sigma), the path it walked on its last two runs (a run ends where it stood still 20 s), the route of the job it runs, the goal, the drops and obstacles on its books, the named places;
- pan and zoom (drag, pinch, wheel), fit, follow the duck; turn the map (⟲ ⟳ by 90°, two fingers freely, ⊞ squares the walls to the screen, N back to the map as drawn) — kept per map in the browser, since a map starts wherever the duck stood, turned as it was facing;
- tap the map → Go here (asks first) or Name the point; a big STOP that stops whatever the duck is doing;
- places: the list with their distance, Go, Forget, and Teach the spot the duck stands on;
- exploring: Start, Stop, Exploration complete (asks first);
- the status line: mapping mode and map, localized or searching, the job (exploring, going somewhere, its outcome), how much of the house is mapped and in how many sessions, the daemon's own hint, and badges for what the duck does of its own accord — moving on its own (and why), may have been moved (it finds itself before the next job), resting, stopped by you; places say whether they are usable, waiting for the duck to find itself, on another map, or stale; a quack-navd that refuses says why, one that is unreachable says so, and the page reconnects by itself;
- the camera: a small window over the map (the camera button, top right), with what the duck's head camera sees — drag it by its title to any corner, close it, full screen (the browser's own where it has one; on the iPhone, whose Safari has it for videos only, the window covers the page). It shows the frame rate and how old the frame is, or "camera unavailable" and why; open/closed and the corner are remembered in the browser. Frames are asked for only while the window is open, the map view shown and the page visible — none at all otherwise.
3D (experimental; shown when a house3d service is configured, as by
default): the house in 3D with the camera's own colours, built off board
by quack-nav's scripts/map3d from what the duck saw while exploring
(The house in 3D). Drag to turn it, two fingers (or
the right button, or shift) to move it, pinch or the wheel to zoom;
isometric, from above (squared like the map tab), a height to cut it at
so the rooms show, the duck's way through the house, and the duck where
it is now when the house was built on the map it is on.
Services: each managed daemon and whether it answers. quacksat shows as not available: it has no control socket yet (docs/todo.md).
Advanced (collapsed, behind a warning):
- All tools: every tool quack-navd lists in
nav.catalog, with a form built from its JSON Schema (or raw JSON), the raw reply shown; a tool that acts asks for confirmation, and the server refuses it without; - Knobs: quack-navd's environment knobs (
QK_*,MAPLOC_*) grouped, with their default, what the code says, the value saved and the value running; edit, reset one, reset all, and Apply, which restarts quack-navd so it reads them (it stops what the duck is doing, saves the map, and the homecoming runs as at boot).
Rust 1.89 or newer.
cargo build --release
target/release/quack-control /etc/robot/quack-control.tomlquack-control.example.toml:
bind = "0.0.0.0:8090"
# token = "a-long-random-string" # better: QC_TOKEN in the environment
[[service]]
name = "quack-nav"
kind = "quack-nav"
nav_socket = "/run/quack-nav/nav.sock"
map_socket = "/run/quack-nav/map.sock"
[[service]]
name = "camera"
kind = "mediad"
media_socket = "/run/mediad/media.sock"
[[service]]
name = "quacksat"
kind = "quacksat"No file: these defaults. Then open http://<the duck's address>:8090/.
quack-nav's MuJoCo twin serves quack-navd's sockets under its STATE
directory (/tmp/quack-twin by default). A config that points there, on
this machine only:
bind = "127.0.0.1:8090"
[[service]]
name = "quack-nav"
kind = "quack-nav"
nav_socket = "/tmp/quack-twin/nav.sock"
map_socket = "/tmp/quack-twin/map.sock"
[[service]]
name = "camera"
kind = "mediad"
media_socket = "/tmp/quack-twin/media.sock"target/release/quack-control twin.toml
open http://127.0.0.1:8090/
# without a browser: the live stream, and the services
curl -N http://127.0.0.1:8090/api/quack-nav/events
curl http://127.0.0.1:8090/api/servicesThe camera on the twin: there is no mediad, so quack-nav's twin viewer
(scripts/twin/viewer/eye.py, VIEWER=on) renders the duck's head camera
in MuJoCo and answers mediad's call on $STATE/media.sock — the same
adapter for the twin and the duck. With VIEWER=off, no camera.
The knobs on the twin: quack-navd writes $STATE/knobs.env, and Apply
answers that it is not under systemd — scripts/twin/twin.sh restart-navd
in quack-nav restarts it with them.
Plain HTTP, for the home network only. Whoever reaches the port can open the page, and the page drives the duck. Never forward it to the internet; remote access is planned over Pollen's channel instead (docs/todo.md).
- The token (
token, orQC_TOKEN— the unit reads/etc/robot/quack-control.env, root-only, mode 600): when set, every call and the live stream need it, as theX-Tokenheader or?token=; the page asks for it once and keeps it in the browser. It travels in clear on the network, like everything else here: it keeps out a guest's phone, not someone sniffing the Wi-Fi. - Another site cannot drive the duck through the browser of someone at
home: every call is a
POSTwith a JSON body, which a page from another origin cannot send without a preflight that quack-control never answers. - What reaches the duck: the map view reaches seven tools in the
shapes it uses (
robot.go_toa point, a place or stop; explore start, stop, complete — neverfresh; teach and forget a place; three reads). The advanced view reaches every catalog tool, and one that acts only with"confirmed": true; the knobs only through quack-navd'snav.knobs, which checks every value against its type. - On the duck quack-control runs as
quackctl, in therobotgroup (that is what quack-navd's sockets ask), unprivileged and fenced (systemd/quack-control.service).
The mediad adapter (src/adapters/mediad.rs) reads Pollen's mediad
(microduck, daemon-v0.14.4 and daemon-v0.15.0): media.frame on its local
socket /run/mediad/media.sock (0660, group robot, which quackctl is
in). One JSON-RPC line asks; one line answers with a header (width,
height, UYVY, bytes, capture time, rotate) and the raw frame follows —
1280x720, 1.84 MB at the default rung. mediad copies a frame only when
asked and waits for the next capture to answer (at most 500 ms).
GET /api/<service>/snapshot answers image/jpeg (token-protected like
every call): the frame turned upright (rotate, 90 on every duck and on
the twin), shrunk to at most 640 px on its long edge, JPEG quality 70 —
360x640, 5–40 kB — with X-Frame-Age-Ms, X-Frame-Captured-Us and
X-Frame-Size. One fetch at a time, at most five a second for all the
pages together: a frame younger than 200 ms is served from memory, and a
failure answers for itself (503) for a second before the camera is asked
again. Video (WebRTC, mediad's own stream) is for later
(docs/todo.md).
Port 8090: mediad's own console listens on 0.0.0.0:8080
(--web-port, daemon-v0.15.0), so quack-control's default is 8090
(since 2026-10-01; it was 8080, and the second to start could not listen).
The house3d adapter (src/adapters/house3d.rs) serves one file,
/var/lib/quack-control/house3d.bin (house_file): GET info its
header, GET model the file, for the page to draw with WebGL (no
library, nothing from the network). Nothing is built on the duck — its
board is too small for the depth model and keeps its cores for walking:
quack-nav's scripts/map3d/record.py records beside an exploring duck,
build.py builds the house on a Mac or a PC and writes house3d.bin
(a few hundred kB at 2 cm), and scripts/map3d/send.sh (or
build.py --send) copies it to the duck over ssh:
scripts/map3d/send.sh microduck@duck out/house3d.binBy hand, the same:
scp house3d.bin microduck@duck:/tmp/ && ssh microduck@duck \
sudo install -D -m 644 /tmp/house3d.bin /var/lib/quack-control/house3d.binThe file is read again when it changes; the page's ↻ loads it again. The
format: QH3D, a u16 version (1), a u16 header length, the header (JSON:
points, voxel_m, map, frames, built, path) padded to a
multiple of 4, then 12 bytes a point — i16 x, y, z in mm on the map's
frame, u8 r, g, b, i8 normal x, y, z. On another map than the one it was
built on, the house still shows but the duck is not drawn on it.
After quack-nav (quack-navd and the robot group).
No checkout and no build: every release carries an install package,
quack-control-<version>-aarch64-linux.tar.gz, with its .sha256 — the
board's binary, the unit, the service account, the example config,
install-on-duck.sh and a step-by-step README-install.md (Italian:
README-install.it.md). The bare binary, quack-control-aarch64-linux,
is attached too, with its .sha256. Your computer needs ssh, scp, tar and
shasum.
V=0.1.0-rc1 # the release's tag without its v
gh release download "v$V" --repo andreagenovese/quack-control \
--pattern "quack-control-$V-aarch64-linux.tar.gz*"
# or: curl -LO https://github.com/andreagenovese/quack-control/releases/download/v$V/quack-control-$V-aarch64-linux.tar.gz
# (and the same URL with .sha256)
shasum -a 256 -c "quack-control-$V-aarch64-linux.tar.gz.sha256" # prints OK
tar xzf "quack-control-$V-aarch64-linux.tar.gz" && cd "quack-control-$V"
./install-on-duck.sh --dry-run microduck@192.168.1.42 # optional: prints every command, connects to nothing
./install-on-duck.sh microduck@192.168.1.42The script finds its files next to itself (bin/quack-control,
systemd/, quack-control.example.toml) and installs them as in the
table below. Then, on the duck: the services' sockets in
/etc/robot/quack-control.toml if they differ from quack-navd's and
mediad's defaults (Running it), and a token in
/etc/robot/quack-control.env (Security):
sudo sh -c 'echo "QC_TOKEN=$(tr -dc a-z0-9 </dev/urandom | head -c 32)" > /etc/robot/quack-control.env'
sudo systemctl restart quack-controlUpgrading: the newer release's package, its ./install-on-duck.sh
the same way; the config and the token file stay. scripts/package.sh <version> <binary> <outdir> packs the same package from a checkout, as
CI does.
scripts/cross-build.sh # aarch64, glibc >= 2.31
scripts/install-on-duck.sh microduck@192.168.1.42 # binary, unit, account, configEither way, --dry-run prints every command, the script it would run on
the duck included, and connects to nothing; a second argument installs
another binary.
| on the duck | from this repo |
|---|---|
/usr/local/bin/quack-control |
the cross-built binary (1.7 MB) |
/etc/systemd/system/quack-control.service |
systemd/quack-control.service |
/etc/sysusers.d/quack-control.conf (user quackctl) |
systemd/sysusers.d/quack-control.conf |
/etc/robot/quack-control.toml |
quack-control.example.toml, only when absent |
/etc/robot/quack-control.env |
created empty (QC_TOKEN commented), root, 600, only when absent |
systemctl status quack-control, journalctl -u quack-control -f.
Uninstalling: sudo systemctl disable --now quack-control, then remove the
binary, the unit and the sysusers file.
One binary, std threads, no async runtime and no HTTP crate: a few hundred
lines of HTTP/1.1 (src/http.rs), one thread per connection, server-sent
events for the live data and POST JSON for commands. Dependencies:
serde, serde_json, toml, tracing, anyhow, jpeg-encoder (pure Rust, for the
camera's frames). The page is one HTML file with
vanilla JavaScript and a canvas, embedded in the binary — no build step,
nothing loaded from the network, it works offline.
| file | what it does |
|---|---|
src/server.rs |
the routes, the token, the JSON-only rule, the event streams |
src/adapters/mod.rs |
the Adapter trait and the kinds |
src/adapters/quack_nav/ |
quack-navd: the map stream and the status poll (hub.rs), the checks on every call (bridge.rs) |
src/adapters/mediad.rs |
the camera: media.frame, the shared cache, UYVY → upright JPEG |
src/adapters/quacksat.rs |
a placeholder, and what quacksat would need to expose |
src/page.html |
the page |
The quack-nav adapter uses quack-nav's control contract
(quack-nav's docs/control-contract.md): nav.catalog/nav.call on the
nav socket, robot.map on the map socket, nav.knobs and nav.restart.
Why a repository of its own, the adapter model and the road to Pollen's
channel: docs/adr/0001-a-control-plane-of-its-own.md.
cargo test --release # 40 tests: HTTP, token, routes, every check on the calls, the camera