fleetlessfleetlessdocs
Concepts/Exposing a Robot

🔓 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.

See Manage Users and Roles.

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.

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-ms header, 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.packages mapping silently 404s. prepareUrdfScene registers the literal package:// name and the loader’s default rewrite, so not setting packages works; 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, immutable exists to prevent.
  • A 403 is not “no URDF”. A role without assets is refused exactly as a request for something absent, so check urdf.present first.
  • Re-fetching identical meshes per robot. Dedup by sha256 is a storage optimization, not a caching one — your app still issues one authenticated request per URL. Cache parsed geometry client-side, keyed by sha256.