FleetlessFleetlessdocs
Reference/fleetless.yaml

fleetless.yaml

Everything configurable about one robot lives in one file. Which datapoints it exposes, which actions and services it offers, which topics clients may publish to, which cameras stream, which alerts watch its values — and the credentials its cameras need.

The file does not describe the robot’s identity. No name, no id, no organisation. That is deliberate: a file you download from one robot uploads unchanged onto another.

Comments and key order survive. What you write to explain a decision is still there next time you open it. That is the reason this format exists rather than four forms.

“Refused” on this page means refused at publish, not refused when you save. A draft is stored whenever it is valid YAML — text that breaks any rule below is kept, and so is text that is not this format at all, so a half-written file is never lost. The rules here are what the format will let you publish; the editor marks them as you type, and the publish is what enforces them. The one exception is text that is not YAML at all, which the draft itself refuses with the line and column. See the API reference.

The header

yaml
fleetless: 1

Required, an integer, on the first line. It says what the file is and by which rules to read it. A file without it is refused rather than guessed at; so is an unknown version, rather than being half understood.

The key is deliberately not version. The console counts published states as “v12 → v13”, and two numbers both called version would be the likeliest confusion the whole format could offer.

Names

Slugs, parameter names and message names all share one grammar:

^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$

Lowercase, underscores, starting with a letter, 2 to 63 characters. Valid: battery_soc, dock, linear_speed. Invalid: Battery, 1st_cam, battery-soc, battery__soc, dock_.

Slugs are one namespace across all five exposure sections. A datapoint and a camera cannot share a slug. That is what lets a role grant say {robot, slug} without ever naming a kind.

Three slugs are taken and exist on every robot without appearing in the file: bridge_state, robot_details, bridge_pressure.

A slug is stable and decoupled from ROS names. Renaming a topic on the robot changes one line here and breaks no client. The reverse also holds: changing a slug breaks every client, every role grant and every MCP call that named it.

ROS names, ROS types and field paths keep ROS’s own spelling — they are not ours to restyle.

Two rules that apply everywhere

Omission is the only way to say “not set”. An explicit null is refused. One spelling, not two.

An unknown key is an error. A typo in a field name that is silently ignored is the most expensive kind of silence.

datapoints

A datapoint is one field of one topic — or the whole topic. Never several topics.

yaml
datapoints:
  battery_soc:
    topic: /battery
    type: sensor_msgs/msg/BatteryState
    field: percentage
    rate_throttle_hz: 1
    description: |
      Main battery charge.
      Below 15 % the robot returns to its dock on its own.

    numeric:
      scale: 100
      unit: "%"
      decimals: 1

    retention:
      enabled: true
      interval_seconds: 60
      max_buffer_values: 5000

    chart:
      y_min: 0
      y_max: 100
      style: line
      default_window_minutes: 1440

    alerts:
      low_battery:
        name: Battery low
        severity: warning
        condition: { fire_at: 15, resolve_at: 18 }

topic and type are required. field is optional, and leaving it out means the whole message — the value is then an object rather than a scalar. There is no whole_message: true; omission already says it.

Without field, the numeric, chart and alerts groups are refused. All three need a single comparable value: a scale factor on a whole message means nothing, nor does a y-axis, and an alert on an object would be an object comparison that tells you nothing useful.

field — a path into the message

Dot-separated field names, each name optionally followed by an array index. All four of these are valid values for field:

percentage
pose.position.x
ranges[0]
points[0].x

Names keep ROS’s spelling — lowercase letters, digits and underscores, starting with a letter or an underscore — and the whole path is at most 255 characters.

An index only works on a field the type declares as an array. points[0].x is a path; percentage[0] on a float64 is not, and is refused. One index per name: ROS has float64[], float64[3] and float64[<=10] and no multi-dimensional arrays at all, so points[0][1] could name nothing on any message. The format refuses it, and the editor marks it as you type — long before the robot would have seen it.

Index bounds are not checked, and cannot be — an array’s length is not part of a ROS type. ranges[999] publishes cleanly; on a robot whose message is shorter the datapoint just produces no values, one logged and dropped sample per message.

The path is checked against the message type as this robot’s introspection reported it. Until the robot has connected and reported the type, there is nothing to check against: you get a warning saying the type is unknown, and the path is not checked at all.

rate_throttle_hz

An upper bound, 0 to 20. Omitted or 0 means no throttling.

Three properties define it:

  • It is a ceiling, not a clock. If the topic publishes more slowly, you get it more slowly. That is correct.
  • A value is never repeated to manufacture a rate. A silent topic produces no traffic.
  • Within a window the newest value wins. What arrives faster is dropped, not queued — a subscriber always sees the current state, never a delayed backlog.

The bridge enforces this, not the cloud. That way every realtime subscriber sees the same rate and the robot’s bandwidth is genuinely saved; throttling in the cloud would arrive too late to save anything.

The 20 Hz limit is a product decision. For an app’s interface it is plenty; if you need a control loop or millisecond debugging, use a tool like Foxglove that reads directly on the robot.

numeric — needs a numeric field

scale and offset are applied on the robot, before sending: value * scale + offset. That is why REST, realtime and history all carry identical numbers — there is no second place where arithmetic happens.

unit is the unit after conversion. It appears beside the value and is carried into the datasheet robot_describe answers an MCP client with, so a model does not have to guess whether 15 means percent, volts or minutes.

decimals (0–6) is how many fraction digits the console shows the value with — the value tile, the tree row and the live rail, the chart’s y-axis ticks and its crosshair, the frames tail and the datapoint’s own detail page. It is an exact count, not a ceiling: decimals: 1 renders 18.0 as well as 18.4, which is what makes a column of readings line up. robot_describe reports it as a field of its own beside unit, so a model formats the value the way the console does.

Leaving it out is not the same as decimals: 0. Absent means the console’s own default — at most three fraction digits, trailing zeros dropped — while 0 is a request for whole numbers.

It is presentation only. The stored value keeps the precision it arrived with, and rewriting decimals changes nothing the robot does, so a publish touching only it pushes no configuration.

retention — what outlives the moment

enabled (default off) decides whether values are written to the time series and become queryable. Without it the value is live only: if you are not watching when it arrives, you never see it. That is often right — not every value deserves a past.

interval_seconds (1–3600, default 300) is how often a value is written to history. Not how often it is sent — that is rate_throttle_hz. Two consequences worth knowing:

  • Short-lived things disappear. A bumper that is true for 200 ms does not appear if no write falls inside it.
  • Stored points are billed. This interval is the direct lever on what a robot costs.

max_buffer_values (1–100 000) acts on the robot: how many values it holds while the bridge is disconnected, pushed after reconnect — after live telemetry and job results, at a limited rate, so closing a gap never delays the present. Without it the series simply has a gap, which is an honest answer.

chart — display only

y_min and y_max are fixed axis bounds; omitted means auto-scaling. y_min: 0 is a real axis floor and is read as 0, not as “unset”.

style is line or step, and it is not a matter of taste. line claims the value moved evenly between two points — roughly true for temperature or charge. step holds the value and then jumps, which for a mode, a switch or a counter is the only honest drawing: a straight line between false and true shows values that never existed.

default_window_minutes (1–43 200, default 60) is only the starting zoom.

alerts

An alert is a state machine (ok ⇄ firing), nested under the datapoint it watches — which makes an orphaned alert unrepresentable.

yaml
    alerts:
      low_battery:
        name: Battery low
        severity: warning      # warning | error, default warning
        enabled: true          # default true
        condition: { fire_at: 15, resolve_at: 18 }

condition has no discriminator. Whether it is an upper threshold, a lower one or an equality follows from the two values:

written means
{ fire_at: 80, resolve_at: 75 } fires at 80, ok at 75 — an upper threshold
{ fire_at: 15, resolve_at: 18 } fires below 15, ok at 18 — a lower threshold
{ fire_at: true } fires while the value is true
{ fire_at: 3 } fires while the value is exactly 3

Without resolve_at it is an equality — fires while the value equals fire_at, ok as soon as it differs. With resolve_at it is a threshold, and the gap between them is the hysteresis. That gap is mandatory for thresholds: without it a value sitting on the line oscillates and emits an event on every crossing. resolve_at equal to fire_at is an error, and resolve_at on a non-numeric fire_at is an error — a boolean has only two values, and text has no ordering to take a direction from.

Where alerts go: the robot overview, the org overview and the activity log, as an org event on every transition. No mail is sent. Webhooks will come later and will be how an alert reaches another system.

The key is the identity. Renaming an alert is delete plus create: runtime state is lost, and an alert that is still true fires again.

Messages and parameters

Actions, services and publishers all send a message to the robot. All three describe it the same way: as a template with placeholders.

yaml
    message:
      linear:
        x: ${linear_speed}
        y: 0.0
        z: 0.0
      angular:
        z: ${angular_speed}
    parameters:
      linear_speed:
        type: float64
        min_value: -0.5
        max_value: 0.5

message is the complete message as it will be sent. Fixed values stand as literals, variable ones as ${name}. So the document shows what actually reaches the robot — and what a caller cannot change. A field written 0.0 here is 0.0, and no client can set it.

${name} is the only spelling of a placeholder. Anything without the braces is a literal, even if it happens to look like a parameter name. Resolving by “is this name declared?” would be a trap: a parameter added later would silently reinterpret a literal somewhere else.

Careful with flow style. In { x: ${linear_speed} } the placeholder's closing brace ends the YAML mapping. Inside braces a placeholder must be quoted: { x: "${linear_speed}" }. In the indented form above there is nothing to watch out for, which is why it is the recommended one.

parameters is a mapping keyed by parameter name — not by a field path. Same decoupling as a slug: linear_speed stays linear_speed even if the field moves in the message tree, and a caller sends a name that means something.

The type decides what you can constrain

type is required, one of:

bool · byte · char · int8 · uint8 · int16 · uint16 · int32 · uint32 · int64 · uint64 · float32 · float64 · string · wstring

ROS’s own spelling, not C++'s — introspection reports float64, not double.

The type is required and never inferred, even though introspection often knows it. The reason is the robot that has never connected: there is nothing to infer from, and that is exactly where the editor has to help most.

type allowed not allowed
integers (int8uint64, byte, char) min_value, max_value, enum regex
float32, float64 min_value, max_value enum, regex
bool all
string, wstring enum, regex min_value, max_value

A constraint on the wrong type is an error, not silently ineffective. Floats deliberately have no enumeration: equality on floating point is unreliable, and a list of allowed float64 values would be a trap that only shows up in production.

default must match the type. Without a default the parameter is required — the message cannot be built without it. There is no separate required field; it would be a second spelling of the same fact.

min_value and max_value are enforced, in the cloud, before anything reaches the robot. This is where a speed limit actually holds.

A declared parameter that appears in no message is an error. A placeholder with no declaration is an error.

Shared messages

Messages used more than once go at the top of the file and are inserted by name:

yaml
messages:
  stop_twist:
    linear: { x: 0.0, y: 0.0, z: 0.0 }
    angular: { x: 0.0, y: 0.0, z: 0.0 }
yaml
    message: ${stop_twist}

Position decides what a ${name} means: directly after message: it is a message name; inside a message body it is a parameter.

A shared message may contain placeholders; whoever inserts it declares the parameters. So limits stay where they belong — two publishers can use the same message with different bounds.

Nesting is not allowed. A shared message may not insert another. That rules out cycles and lets every check look at exactly one message.

actions

Something the robot does on request, that takes time. At most one job runs per action slug; a second call is refused with busy, and every observer of that slug sees the same job.

yaml
actions:
  navigate_to:
    ros_name: /navigate_to_pose
    type: nav2_msgs/action/NavigateToPose
    description: Drives to a target pose on the map.
    message:
      pose:
        header: { frame_id: map }
        pose:
          position: { x: ${target_x}, y: ${target_y}, z: 0.0 }
    parameters:
      target_x: { type: float64, min_value: -50.0, max_value: 50.0 }
      target_y: { type: float64, min_value: -50.0, max_value: 50.0 }

ros_name and type (with the action segment) are required. message is optional — leaving it out sends an empty goal.

A description is recommended and no longer required. Every exposure the caller’s role grants is listed by robot_describe; one without a description is listed with description: null, and the tool works. What is lost is the only thing that tells a model what the action means — so writing one is the difference between a tool a model uses correctly and one it guesses at. The same holds for datapoints, services, publishers and cameras.

services

A ROS service call — unlike an action, short and without progress.

yaml
services:
  reset_odom:
    ros_name: /reset_odometry
    type: std_srvs/srv/Trigger
    description: Resets odometry to the origin.
    # Trigger has no fields, so neither message nor parameters

publishers

A topic clients may send to. This is where the format’s whole safety story lives.

yaml
publishers:
  drive:
    topic: /cmd_vel
    type: geometry_msgs/msg/Twist
    description: Velocity command. If sending stops, the robot stops.
    message:
      linear: { x: "${linear_speed}", y: 0.0, z: 0.0 }
      angular: { x: 0.0, y: 0.0, z: "${angular_speed}" }
    parameters:
      linear_speed: { type: float64, min_value: -0.5, max_value: 0.5 }
      angular_speed: { type: float64, min_value: -0.25, max_value: 0.25 }
    failsafe:
      timeout_ms: 500
      message: ${stop_twist}
    quiet_timeout_ms: 2000

message is required here — a publisher with nothing to send is not a publisher.

failsafe is required, a group of two fields. The deadline and the message sit together because the deadline exists only to trigger the message.

  • timeout_ms (1–60 000): how long the bridge waits for the client’s next send before stepping in.
  • message: what the bridge itself sends when that time runs out. For a drive command, a zero twist. It must be safe in every state, because it is sent precisely when nobody is watching any more.

That is the core of it: a client that crashes, loses its connection, or whose operator closes the window does not leave a robot driving.

The failsafe message may contain no placeholder — directly or through a shared message. It is sent with no caller to fill it.

quiet_timeout_ms (0–600 000) answers a different question: how long a publisher must be silent before another user may send. Whoever sends holds the publisher implicitly exclusive — no session, no lock. Too short and two operators fight over one robot; too long and a crash blocks it for everyone for minutes.

cameras

yaml
cameras:
  front:
    source:
      kind: ros
      topic: /camera/image_raw
      type: sensor_msgs/msg/Image
    width: 1280
    height: 720
    fps: 15
    bitrate_kbps: 2000
    snapshot_interval_seconds: 5

width, height, fps and bitrate_kbps are the resolution and load the bridge produces before sending — not the camera’s own. Together they are your control over the robot’s bandwidth, which is why they live in the configuration and not in a viewer’s request: a viewer must never be able to make a robot send more.

snapshot_interval_seconds (1–3600): how often a still frame is captured. Snapshot always runs, whether or not anyone is watching; the cloud holds the one frame and serves every reader from it, so a hundred clients cost the robot one image per interval. Live streaming runs on demand and is counted in the cloud — first viewer starts it, last one ends it.

The four sources

  • kind: rostopic and type (sensor_msgs/msg/Image or .../CompressedImage).
  • kind: rtspurl (must start rtsp:// or rtsps://), optional transport (tcp default, because udp loses frames on a busy link and does so silently), optional credentials.
  • kind: mjpegurl (http:// or https://), optional credentials.
  • kind: v4l2device, a path under /dev/, resolved on the robot.

The scheme restrictions and the /dev/ requirement are not a formality. The bridge opens these values with libraries that also serve file: and ftp:; without the restriction a configuration file would be arbitrary file access on the robot. The bridge re-checks the same thing itself — a robot must not become a file server because a validator changed.

Credentials

yaml
    source:
      kind: rtsp
      url: rtsp://cam-1.plant.local/stream1
      credentials:
        username: ops
        password: "..."

Credentials may also go in the URL (rtsp://user:pass@host/stream). If both are present, the explicit block wins.

Be clear about what this means. A password in the document is in every published version, and those are immutable. It cannot be removed from history and cannot be rotated without republishing. Anyone who can read a robot’s configuration history can read every camera password that was ever in it.

One rule bounds that: the publish audit event and the org event stream do not carry the document body.

Publishing

The file carries both what the robot acts on and what only the cloud acts on. Both are versioned together, but they do not take effect in the same place.

The robot is sent the whole file either way. A configuration frame carries the document unmutated — every key, including every one in the right column below. The bridge even parses numeric.unit; it simply has nothing to do with it. So the table is about where a field has its effect, never about what is transmitted.

the robot acts on it only the cloud acts on it
topic, type, field, rate_throttle_hz retention.enabled, retention.interval_seconds
numeric.scale, numeric.offset numeric.unit, numeric.decimals, description
retention.max_buffer_values chart, alerts
actions, services, publishers, messages
cameras, credentials included

The split inside numeric is the one worth reading twice. scale and offset are arithmetic the robot performs before sending, so a change to them changes what the robot does. unit and decimals are labels the cloud puts on the result, so a change to them changes nothing the robot does — although the robot is sent all four.

A new configuration reaches the robot only when something in the left column changed. Otherwise every corrected alert threshold would push a configuration to a robot — churn for nothing.

Alerts and charts therefore take effect only on publish. That is the price of one door; in exchange every change to them is versioned, comparable and revertible.

Runtime state across a publish, per alert: condition unchanged keeps the state; condition changed resets to ok; a new alert starts ok; a disappeared key deletes the alert with its state.

Limits

section maximum
datapoints, actions, services, publishers, messages 200
cameras 50
parameters per entry 50