Robot Direct API
The Python client library for robot control loops — discover and connect to the daemon, read tag positions at loop rate, subscribe to streams, and draw shared annotations.
AprilCam Robot Direct API
This guide is for robot programs that need high-frequency access to tag positions. If you are an AI agent working interactively, use the MCP tools instead (mcp-server.md). A control loop running at 5–50 Hz uses the Python client library described here — it talks directly to the daemon over gRPC and stream sockets, bypassing the MCP layer entirely.
The client library
(src/aprilcam/client/)
is the only sanctioned way to talk to a daemon — the CLI, the MCP
server, and robot programs all sit on it — and it contains no
OpenCV: the daemon is the sole vision authority. The wire-level
contract underneath is daemon-interface.md;
running the daemon itself is daemon.md.
Two conventions are load-bearing everywhere below (from
src/aprilcam/types.py):
the world frame is A1-centred, +x east, +y north, units cm;
angles are radians, 0 = east, counter-clockwise positive. yaw_rad
is where a tag faces; heading_rad is where it is moving (atan2
of velocity) — a robot driving backwards has them 180° apart, which is
signal, not error.
Quick Start
from aprilcam.client.discovery import Discovery
daemon = Discovery().connect() # local daemon, over its unix socket
frame = daemon.get_tags("arducam-ov9782-usb-camera")
for record in frame.tags:
print(record.tag.family.value, record.tag.number, record.world)
connect() returns a Daemon — the control-plane interface defined in
src/aprilcam/daemon/interfaces.py,
implemented client-side by GrpcDaemon
(src/aprilcam/client/daemon_client.py).
Cameras are addressed by name (from daemon.list_cameras() or
aprilcam camera list); there is no default camera.
Connecting and Discovery
Discovery
(src/aprilcam/client/discovery.py)
has two jobs: finding daemons and connecting to one.
from aprilcam.client.discovery import Discovery
discovery = Discovery()
daemon = discovery.connect() # local: unix socket
daemon = discovery.connect("pi-field.local") # remote: TCP, default port 5280
daemon = discovery.connect("192.168.1.20:5280") # remote: explicit port
connect(host) is a direct connect — no discovery probe first. An
explicit host argument beats the APRILCAM_DAEMON_HOST environment
variable, which beats the local default (the daemon’s unix socket). It
never spawns a daemon; if nothing answers within the connect timeout it
raises aprilcam.errors.ConnectionFailed.
find_all() is the network view — the local daemon (probed via its
lock file and socket, no network needed) plus every daemon advertising
_aprilcam._tcp.local. over mDNS:
for info in discovery.find_all(): # tuple[DaemonInfo, ...]
print(info.host,
"local" if info.is_local else "remote",
"ok" if info.reachable else "advertising but not answering")
Finding nothing is an empty tuple, not an error; discovery
failing (multicast blocked) raises DiscoveryFailed instead. A
remote DaemonInfo.host embeds its port as "ip:port" and can be
passed straight to connect(). If something answers but speaks the old
v1 protocol, calls raise DaemonVersionMismatch — “something answered,
but not as a compatible daemon.”
Reading Tags
Three camera-addressed queries, defined on Daemon:
from aprilcam.types import TagFamily, TagId
CAM = "arducam-ov9782-usb-camera"
# One tag. Family + number, never a bare id — AprilTag 1 and ArUco 1 differ.
record = daemon.get_tag(CAM, TagId(TagFamily.APRILTAG, 7))
if record is None:
... # not currently detected — never a fake record at (0, 0)
elif record.world is None:
... # camera not calibrated — record.pixel is all you get
else:
x, y = record.world.x, record.world.y # cm, A1-centred
facing = record.yaw_rad # orientation (0 = east, CCW+)
# Every currently detected tag, no count or limit:
frame = daemon.get_tags(CAM) # -> TagFrame
# Recent full-rate history from the daemon's ring buffer (~10 s),
# optionally filtered to one tag:
history = daemon.get_tag_history(CAM, frames=60) # -> tuple[TagFrame, ...]
The models
All returned values are frozen dataclasses from
src/aprilcam/types.py
(built from the wire messages by
src/aprilcam/client/models.py):
TagRecord field |
type | meaning |
|---|---|---|
tag |
TagId |
family + number |
world |
WorldPoint \| None |
position in cm; None on an uncalibrated camera |
yaw_rad |
float \| None |
facing direction (mount-corrected) |
heading_rad |
float \| None |
motion direction from velocity |
world_velocity, speed |
optional | velocity components and magnitude, cm/s |
pixel |
PixelPoint \| None |
diagnostic pixel position |
world_corners, pixel_corners |
4-tuples | observed tag outline, for drawing |
timestamp |
float |
capture time |
A TagFrame bundles tags, timestamp, frame_index, and
calibration_stale — the in-band flag that positions have become
suspect. Mount corrections are already applied: with a registered
mount (next section), world and yaw_rad report the robot, not the
tag. world_corners/pixel_corners stay uncorrected — they outline
the tag as physically seen, for drawing.
Tag Mounted on a Robot? Register Its Mount — Every Session
from aprilcam.types import MountParameters, TagFamily, TagId
warning = daemon.register_tag(
TagId(TagFamily.APRILTAG, 7),
MountParameters(
mount_x=12.0, # cm, robot frame: +x forward, +y left of your reference point
mount_z=18.0, # cm above the field plane — drives parallax correction
mount_yaw_rad=0.0, # tag facing relative to the robot's forward
size_cm=8.0, # optional tag edge length
),
)
if warning:
print(warning) # e.g. mount_z accepted but inert — no camera located yet
daemon.list_tag_parameters() # dict[TagId, MountRegistration]
daemon.unregister_tag(TagId(TagFamily.APRILTAG, 7))
Registrations are runtime-only — in memory, gone on daemon restart.
Register at the start of every session, or after a restart you
silently read the raw tag’s position instead of your robot’s. The
parallax correction (mount_z) additionally needs the observing
camera’s own solved position (aprilcam camera locate); until then
register_tag returns a warning and list_tag_parameters reports
mount_z_applied=False. Shell equivalent:
aprilcam tags mount register apriltag 7 --x 12 --z 18 --size 8.
Streams
For control loops, subscribe rather than poll. stream_tags /
stream_images return TagStream / ImageStream subscriptions
(src/aprilcam/client/streams.py)
— a length-prefixed protobuf socket: unix locally, TCP to a remote
daemon, chosen automatically.
stream = daemon.stream_tags(CAM)
try:
for frame in stream: # frame is aprilcam.types.TagFrame
if frame.calibration_stale:
... # told in-band, not silently wrong
for record in frame.tags:
... # control logic at stream rate
finally:
stream.close()
daemon.stream_images(CAM, deskewed=True) is the same shape, yielding
ImageFrames. Semantics worth knowing:
- Change-driven, rate-capped, with a heartbeat — a quiet field stays distinguishable from a dead stream. Backpressure is silent drop: a slow consumer loses frames, never stalls the pipeline.
close()unsubscribes. When the last subscriber leaves, the daemon idles that product — holding a stream open costs it real work. A clean daemon-side close simply ends iteration; genuine socket errors propagate.- Iterate with
for frame in stream:. A throwawaynext(iter(stream))closes the socket after one frame — keep the iterator (it = iter(stream)) if you callnext()manually.
One-shot get_tags/get_tag polling remains fine for low-frequency
callers; polling faster than the camera’s frame rate gains nothing.
Drawing on the Playfield — Annotations
Annotations are world-coordinate drawings held by the daemon,
visible to every client watching the same playfield (viewers, agents,
other robots): playfield-addressed, keyed (layer, id),
last-write-wins, and — like mount registrations — runtime-only,
gone on daemon restart and re-drawn by clients on reconnect.
from aprilcam.types import Annotation, Marker, Style, WorldPoint
PF = "main-playfield"
daemon.put_annotation(PF, Annotation(
id="goal", layer="strategy",
shape=Marker(at=WorldPoint(x=60.0, y=30.0), label="goal"),
style=Style(color="#00C850", line_width=3),
ttl_seconds=None, # optional expiry for crash-proof overlays
))
daemon.replace_layer(PF, "robot-7", (...,)) # atomically swap a whole layer
daemon.get_annotations(PF) # -> AnnotationSet
daemon.remove_annotation(PF, "strategy", "goal")
daemon.clear_annotations(PF, "robot-7") # omit layer to clear every layer
Shapes are Marker, PathShape (open polyline), Polygon (closed),
Circle, and Text — all geometry in world cm. Style is
presentation only: color is any Pillow colour string (default
"yellow"), line_width is pixels on the rendered image.
A control-loop pattern
from aprilcam.client.discovery import Discovery
from aprilcam.types import Annotation, Circle, MountParameters, Style, TagFamily, TagId
CAM, PF = "arducam-ov9782-usb-camera", "main-playfield"
ROBOT = TagId(TagFamily.APRILTAG, 7)
daemon = Discovery().connect()
daemon.register_tag(ROBOT, MountParameters(mount_z=18.0, size_cm=8.0)) # every session
stream = daemon.stream_tags(CAM)
try:
for frame in stream:
record = next((r for r in frame.tags if r.tag == ROBOT), None)
if record is None or record.world is None:
continue
# ... control logic on record.world / record.yaw_rad ...
daemon.replace_layer(PF, "robot-7", (
Annotation(id="body", layer="robot-7", ttl_seconds=1.0,
shape=Circle(center=record.world, radius_cm=10.0),
style=Style(color="#3C78FF")),
))
finally:
daemon.clear_annotations(PF, "robot-7")
stream.close()
Frames and Client-Side Rendering
get_frame returns one encoded frame plus everything needed to
interpret it:
frame = daemon.get_frame(CAM, deskewed=True) # encoding="png" for lossless
frame.data # encoded image bytes
frame.px_per_cm, frame.world_extent # deskewed frames only
frame.tags # embedded TagFrame (detection running)
frame.annotations # embedded AnnotationSet (deskewed only)
A deskewed frame spans exactly the field rectangle — px_per_cm plus
that anchor is the whole world→pixel conversion. A deskewed request on
an uncalibrated camera fails explicitly, never silently substituting a
raw frame.
Renderer
(src/aprilcam/client/renderer.py)
rasterizes a frame’s embedded overlays cv2-free (Pillow): tag boundary
quads with the green front “hat”, id labels, a crosshair at each
corrected world position, and the embedded annotations.
from aprilcam.client.renderer import Renderer
png = Renderer().render(frame, layers=("strategy",)) # layers=None draws all
extra= accepts local Annotations to draw without sending them to
the daemon — ad-hoc debug overlays.
Daemon Lifecycle
DaemonLifecycle
(src/aprilcam/client/lifecycle.py)
starts, stops, and checks on the local daemon (a remote daemon is
started over SSH, not by this API):
from aprilcam.client.lifecycle import DaemonLifecycle
lifecycle = DaemonLifecycle()
status = lifecycle.status() # -> DaemonStatus
if not status.running:
lifecycle.start() # spawns python -m aprilcam.daemon, waits until ready
start() pre-probes the daemon’s singleton lock and raises
AlreadyRunning naming the holder; when it returns, the daemon is
genuinely ready (cameras enumerated, control plane answering).
DaemonStatus reports running, the camera list, and the resolved
config_dir/state_dir; daemon.status() returns the same shape over
RPC.
Errors
Every call raises a typed exception from
src/aprilcam/errors.py
— reconstructed from the wire, never a bare string to parse:
UnknownCamera, UnknownPlayfield, CameraNotPresent,
NotCalibrated, UnsupportedSetting, AlreadyRunning,
ConnectionFailed, DiscoveryFailed, and friends. Catch the base
aprilcam.errors.AprilCamError when your loop only needs “did that
call succeed.”
Packaged Guides
The same conventions ship inside the package, readable with no
daemon running: Guides().get("robot") (from
aprilcam.client.guides) returns the condensed in-package version of
this page, and Guides().available() lists ("agent", "robot") — the
agent guide covers the MCP workflow
(mcp-server.md). daemon.get_guide("robot") serves
byte-identical text over the wire; aprilcam guide prints it from a
shell.