🔓 Exposing a Robot
A robot’s ROS graph is private by default. A developer decides which part of it becomes an API.
Five kinds, one slug each
| Kind | Direction | What it is |
|---|---|---|
| Datapoint | Robot → clients | One field of a message, or the whole message; never several topics. |
| Publisher | Clients → robot | A topic client apps send to, repeatedly and continuously. |
| Service | Request → response | A ROS service call, validated, returning with its result on the job. |
| Action | A job with a lifecycle | A ROS action with feedback, progress, result and cancel. |
| Camera | Robot → clients | An image topic, an RTSP or MJPEG URL, or a V4L2 device. |
All five share one slug namespace, which lets a role grant say
{robot, slug} without naming a kind. A slug is decoupled from the ROS name:
rename the topic and no app notices.
Bounds and defaults live in the
fleetless.yaml reference:
datapoints,
publishers,
actions and services,
cameras.
Built-in datapoints
Two datapoints exist on every robot, configured by nobody and granted like any slug.
| Slug | Value |
|---|---|
bridge_state |
online, latency_ms (null while offline) and low_bandwidth. online and latency_ms are cloud-observed; low_bandwidth is what the bridge reports — false while offline, and false on a bridge older than 4.0.0. |
robot_details |
Static metadata a developer keeps in the console: model name, serial number, whatever is not from ROS. |
bridge_state has history; robot_details is a document, not a series.
Configure, then publish
A robot that has never connected can be configured in full: the format declares message types rather than discovering them. A connected bridge adds introspection — a browsable view of the live ROS graph, each topic a template to adopt.
Every change goes through draft → publish, versioned and restorable, and the bridge only runs the published version.
A slug can be configured before the topic exists: ros2 topic pub from any
machine on the graph is enough to see it arrive.
Grant it to a role
Exposing a slug does not make it reachable. Each app carries Roles, whose
grants are a list of {robot_id, slugs}; a new role starts empty and grants
nothing.
Two refusals, not the same one:
- A slug the caller’s role does not grant is
403 forbidden:'slug' is not granted to your role, or it does not exist on this robot.One sentence covers both, so it is no oracle for what a robot has. - A robot the app does not list is
404 not_found, whatever a role grants.
Validation lives in the cloud
Every parameter of an action, service or publisher carries a type and
optional constraints: min_value, max_value, enum, regex, and a
default whose absence makes it required. They are enforced in the cloud,
before anything reaches the bridge — which is where a speed limit holds,
rather than in the app supposed to respect it.
A rejection is a validation_error naming the field and the rule it broke.
The bridge validates structurally too, since it builds the message anyway.
Custom message, service and action types are scoped to one robot — two
may define custom_msgs/Speed differently — while standard ROS types are
known globally.
When the link drops
The robot is on an ordinary internet link, so it will disconnect. What follows is a decision, not an accident.
Jobs keep running. A disconnect does not abort a running action or
service call — the bridge does not cancel a goal just because the link
dropped, and a robot with no safety layer of its own keeps driving through
an ordinary dead zone. Watch bridge_state.online for the dead zone itself;
a job says nothing about the link on its own. Feedback, progress and result
arrive on reconnect, and only the newest state for each job — what
happened while disconnected is not replayed frame by frame.
Offline for more than five minutes turns a running job unknown
(bridge_disconnected) — not lost. The slug stays occupied, and only the
bridge’s own word resolves it: it says the job is still running, it says how
the job ended, or it says it does not know the job at all, and nothing else
is running on that job’s action either — the last one settles the job
lost, final, job_unknown_to_bridge. A cloud restart does the same to
every Fleetless job in flight: unknown, bridge_disconnected, until the
robot’s bridge reconnects and answers.
A bridge restart is different, but no longer a guess either: the bridge
persists which goal belongs to which job, so a job it recognises again after
restarting is reported running (then its real outcome) rather than staying
unaccounted for. Only a goal the restarted bridge truly cannot place — its
mapping lost too — leaves the old job unknown until that action is free,
and it then ends lost the same way. That mapping is a file in
FLEETLESS_STATE_DIR, so the
recognition only works if that directory survives the restart: a bridge in a
container needs it on a mounted volume, not in the container’s own
filesystem.
Both rules need bridge 6.0.0 or later, which speaks protocol 5 and tracks
every goal on a published action by id, not only its own — including a goal
nobody at Fleetless started, reported as a live-only job with origin: external, cancellable the same way as any other. There is no grace window
for this floor, unlike the usual 90-day protocol sunset: an older
bridge is refused at connect (hello_error, bridge_too_old), a deliberate
one-time cut while Fleetless is still building up.
There is no queue. A command to an offline robot is refused at once,
robot_offline — or busy first, when a job still holds its slug, an
unknown or external one included. Build the UI on bridge_state.
timestamp_ms is capture time — when the bridge read the value, never
when your app received it. Action feedback carries the same field, so age
computed from your own clock is a guess.
Buffering rides on retention. max_buffer_values says how many values
the robot holds while the bridge is away, but it sits inside a datapoint’s
retention block, whose enabled is off unless written: a datapoint
without retention buffers nothing and has a gap. On reconnect, live
telemetry and job results go first; the backfill follows at a bounded rate.
A publisher's failsafe is required. Once timeout_ms passes with no
client publish — a dropped connection included — the bridge publishes the
failsafe itself. The standard shape is a cmd_vel publisher with a zero
twist: a lost link stops the robot instead of leaving it committed.
quiet_timeout_ms is the other half: how long a publisher must stay silent
before a different user may take it over. Whoever sends holds it, with no
session and no lock.
Cameras
Four sources: a ROS image topic, RTSP, MJPEG, a V4L2 device. width,
height, fps and bitrate_kbps are what the bridge produces before
sending — they live in the configuration, not in a viewer’s request, so no
viewer can make a robot send more.
Bridge to cloud there is exactly one stream per camera, however many clients watch. The cloud fans it out, so uplink cost is independent of audience size: the robot never finds out how popular it is.
- Snapshot runs always, at
snapshot_interval_seconds, live viewers or none. The cloud caches the one frame and serves every reader from it, so a hundred pollers cost the robot one image per interval. - Live runs on demand over WebRTC through LiveKit, whose room
participation is the refcount: the first viewer starts it, the last one
leaving ends it. Non-WebRTC sources are transcoded into that stream. In
low-bandwidth mode a running
stream is reduced or ended and a new one is refused with the code
low_bandwidth.
Fleetless does not record; snapshot storage or a live-stream pipeline is yours. Two things that cost an afternoon otherwise:
- Take a snapshot’s age from the
x-fleetless-age-msheader, not from when your request returned. A robot without NTP would report every frame as new. - Leave a live session cleanly. Refcounting is server-side, so a viewer who walks away keeps the stream up for nobody.
URDF & meshes
An asset is an immutable file belonging to one robot, addressed by UUID.
It is not a service kind and not covered by a role’s slug grants: a separate
assets capability gates the store, because a mesh set gives away the
machine’s build.
Assets are read with the same bearer token as every other API call and answer
Cache-Control: private, immutable, never public. A shared cache must not
store a response to an Authorization-carrying request, so the browser’s
cache works and an edge cache does not. This is not a CDN.
MCP is the exception: a session token there is refused on REST, so the asset tools mint a signed, bearer-free link good for fifteen minutes, and a linked URDF’s own references expire with it.
One way in today. A connected bridge reports only that a URDF exists
(default source /robot_description); a developer starts the sync, which
uploads it and the meshes its package:// URIs resolve to. It is Owner-tier,
because it spends the organisation’s asset storage — the robots times the
plan’s allowance per robot (Plans and Limits),
at most one gigabyte for a single file — a larger one, the URDF included, is
refused 413 file_too_large — with the URDF exempt from the storage and never
counted against it. Storage
is content-addressed by sha256, so two robots sharing a mesh cost one copy,
and the listing reports urdf.present plus every unresolved reference.
A full store is never a dead end. A sync that runs out of room answers
plan_limit naming the storage, what is used and what did not fit, and
moves on to the next file; reconcile after every sync then frees whatever
the new URDF stopped referencing. An Owner can also clear a robot’s store
outright, and the next sync fills it again.
The served URDF has every filename rewritten, not only the resolvable
package:// ones — a mesh loader would otherwise attach the caller’s bearer
token to an absolute URL that arrived from the ROS graph. Anything with no
stored asset points at a placeholder answering asset_missing. Only those
attribute spans change, so comments, attribute and element order and
whitespace survive byte for byte — and fetching the asset by its own UUID
returns the stored document untouched; only this route rewrites.
Rendering is the app’s job. Neither an image tag nor urdf-loader’s default
loaders can set an Authorization header, so the SDK ships
assets.prepareUrdfScene and assets.createMeshLoader — see the
SDK assets reference. Joint states are an ordinary
datapoint.
The app starter’s 3D tab is a working example of this,
in ModelView.client.vue.
The console is the one exception: its URDF page renders the model from
these same assets and drives the joints from a whole-message
sensor_msgs/msg/JointState datapoint you choose, cleared automatically
when a publish removes or retypes it.
Four traps:
- A custom
URDFLoader.packagesmapping silently 404s.prepareUrdfSceneregisters the literalpackage://name and the loader’s default rewrite, so not settingpackagesworks; your own mapping must produce a path it registered. - A cache in front of your app. A proxy or CDN on these URLs brings back
the wrong-robot mesh that
private, immutableexists to prevent. - A
403is not “no URDF”. A role withoutassetsis refused exactly as a request for something absent, so checkurdf.presentfirst. - Re-fetching identical meshes per robot. Dedup by
sha256is a storage optimization, not a caching one — your app still issues one authenticated request per URL. Cache parsed geometry client-side, keyed bysha256.