Vehicle manifest¶
The vehicle manifest, robot.yaml, describes one vehicle for the whole stack: what the model is
(frames, drive, sensors, their topics, rates, frames and QoS) and what runs on it (the container
image or how to build it, the entrypoint launch, the parameter files, the driver-swap map, the
readiness conditions, the resource budget). Task P1-T01 wrote the first manifest for the reference
UGV (robots.md), P1-T03's bridge generator reads it
(bridge.md), and P1-T04 formalised the format as schema v0 with the runtime
section (ADR-0004,
ADR-0007). The schema is the Pydantic model
fieldproof.manifest.RobotManifest in the engine; engine/schemas/robot.schema.json is its JSON
Schema export, used by the check-jsonschema pre-commit hook on every robot.yaml
(tooling.md).
uv run --project engine fieldproof manifest validate robots/fieldproof_robots/models/ugv_ref/robot.yaml \
examples/vehicles/ugv_nav2/robot.yaml
uv run --project engine fieldproof manifest schema > engine/schemas/robot.schema.json # make manifest-schema
validate resolves extends, validates every section, checks that the model sections a stack
needs are present (--partial for a manifest meant only to be extended) and prints one line per
manifest with its sensors, interfaces, runtime tier and image and, if any, the interfaces no driver
of the swap map provides. Exit code 2 and one line per problem (runtime.drivers[lidar].provides:
'points' is not an interface of this manifest) otherwise.
Document¶
apiVersion: fieldproof.dev/v0alpha1
kind: Robot
extends: ../../../robots/fieldproof_robots/models/ugv_ref/robot.yaml # optional
metadata: {...}
description: {...} # model sections, from the reference model when extended
frames: {...}
dimensions: {...}
drive: {...}
sensors: [...]
runtime: {...} # what runs on the vehicle (P1-T04)
Unknown keys are errors in every section, so a typo never passes silently. Topic names are
relative to the vehicle namespace unless they start with a slash. The TF topics follow the
drive's tf interface: relative, as in the reference vehicles, every vehicle of a fleet has its
own /<namespace>/tf and /<namespace>/tf_static with the usual frame names (Nav2's
multi-robot convention, task P2-T01); /tf keeps one tree shared by every vehicle, which then
tell their frames apart with a frame_prefix (scenarios.md).
extends¶
A manifest may inherit another one, named by a path relative to its own directory. The parent is
read first (recursively; a cycle is an error), then the child's sections are merged over it:
mappings merge key by key, anything else (scalars, lists) replaces. The relative paths of the
inherited description section are rewritten so they still point at the parent's files from the
child's directory. The example vehicle examples/vehicles/ugv_nav2/robot.yaml extends the
reference UGV and adds only metadata and runtime; the bridge launch
(bridge.launch.py manifest:=examples/vehicles/ugv_nav2/robot.yaml) resolves the same chain and
produces the same contract as for the reference model, under the vehicle's name.
Model sections¶
| Section | Fields |
|---|---|
metadata |
name (lower case, digits, underscores), description, domain (ground, aerial, surface), licence (SPDX) |
description |
xacro (required), urdf, sdf, model_uri, launch, meshes, xacro_args (defaults the spawn and the bridge render with); paths relative to the manifest that declares them |
frames |
base, footprint, odom |
dimensions |
length_m, width_m, height_m, ground_clearance_m, mass_kg |
drive |
type, wheel_radius_m, wheel_separation_m, joints, limits (max_linear_velocity_mps, max_angular_velocity_radps, max_linear_acceleration_mps2, max_angular_acceleration_radps2) and the four interfaces command, odometry (rate_hz, frame_id, child_frame_id), joint_states (rate_hz, gz_rate: physics or a rate the bridge throttles from), tf (rate_hz) |
sensors[] |
name, type, frame_id, rate_hz, gz_topic, optional, resolution, horizontal_fov_rad, noise (standard deviations, SI units), outputs[] |
power |
battery (optional, task P2-T03): the battery as the bridge reports it on ros_topic (battery/state) as sensor_msgs/msg/BatteryState at rate_hz (1): full_voltage_v, empty_voltage_v, capacity_ah, frame_id, qos. The reference vehicles simulate no power system: the bridge's battery node publishes the state from these figures, full until a scenario's battery.drain event drains it (bridge.md, events.md) |
Every interface (drive.* and sensors[].outputs[]) carries gz_topic, gz_type, ros_topic,
ros_type and an optional qos block (reliability: reliable or best_effort; durability:
volatile or transient_local; depth; defaults reliable, volatile, 10). A sensor output adds
ros_frame_id (the frame the ROS messages carry, relabelled by the bridge when it differs from
the sensor's frame_id) and gz_header_frame_id: false where Gazebo leaves the header frame
empty. ROS topics are unique across the manifest; sensor names are unique.
runtime¶
| Field | Type | Meaning |
|---|---|---|
tier |
T1 (default), T2, T3 |
fidelity tier of ADR-0007; images are generated for T1 (SIL, the stack rebuilt for x86) until Phase 6 |
image |
string | image reference to build (fieldproof vehicle build) or to run as supplied |
build |
mapping, optional | how the image is built from the vehicle's sources (below); absent when image is prebuilt |
entrypoint |
launch or command |
launch: {package, file, args, namespace_arg, frame_prefix_arg} becomes ros2 launch <package> <file> key:=value ... (booleans as true/false), the default command of the image; namespace_arg and frame_prefix_arg name the launch arguments that take the vehicle's namespace and frame prefix in a fleet (the orchestrator appends <namespace_arg>:=ugv1; a namespaced vehicle whose launch names none is refused at validation); command: [...] runs as given and reads FIELDPROOF_NAMESPACE and FIELDPROOF_FRAME_PREFIX from the environment |
params |
list of paths | parameter files the launch reads unchanged from the field, relative to the manifest; checked to exist, recorded for reports |
drivers |
list | the driver-swap map (below) |
ready |
mapping, optional | timeout_s (default 120) and conditions[]: {lifecycle: <node>, state: active} (default state), {topic, type}, {service, type}, {action, type}, {node}, {tf, child}; evaluated inside the container by the readiness probe (below), which is the image's HEALTHCHECK and what fieldproof vehicle wait runs; the orchestrator starts the scenario clock once every condition holds. Relative names (bt_navigator, map) resolve under the vehicle's namespace, so one manifest serves every vehicle of a fleet |
resources |
mapping, optional | the container budget in Docker's units, applied as cgroup limits: cpus (the CPU quota, --cpus), cpuset (the cores it may run on, --cpuset-cpus: 0-1, 0,2), memory (2g, 512m), pids; a scenario may replace any field per vehicle (vehicles[].resources) |
dds_profile |
path, optional | the vehicle's own DDS XML profile, as deployed in the field, relative to and inside the manifest's directory (the example's src/ugv_nav2_bringup/config/fastdds.xml); bind-mounted read-only at /opt/fieldproof/dds/profile.xml by fieldproof run and fieldproof vehicle run and named to the RMW of env through FASTRTPS_DEFAULT_PROFILES_FILE (Fast DDS, the default), CYCLONEDDS_URI (file://...) or NDDS_QOS_PROFILES; another RMW is refused |
env |
mapping | extra environment of the container (ROS_DOMAIN_ID, FIELDPROOF_NAMESPACE, FIELDPROOF_FRAME_PREFIX and, with a discovery server, ROS_DISCOVERY_SERVER are set per run by the orchestrator) |
link |
mapping, optional | the radio link to the ground station (below) |
runtime.link¶
What the vehicle expects from its ground control station over the radio, and what it does without it (task P2-T02, network.md). In a scenario with radio links, Fieldproof's ground station sends the heartbeat over the vehicle's link, so a dropped or congested link delays or stops it, and the scenario's assertions check the documented behaviour.
| Field | Meaning |
|---|---|
heartbeat.topic |
the topic the vehicle listens on for the heartbeat (std_msgs/msg/Header, best effort, depth 1; relative names resolve under the vehicle's namespace: gcs/heartbeat is /ugv1/gcs/heartbeat in a fleet) |
heartbeat.rate_hz |
how often the ground station sends it (2 by default) |
loss_behaviour |
what the vehicle does when the link is lost and when it comes back: the behaviour a loss-of-link scenario asserts |
Without heartbeat the ground station sends the vehicle nothing; its link is still shaped and
measured.
runtime.build¶
| Field | Default | Meaning |
|---|---|---|
base |
ghcr.io/dimitrios-kafetzis/fieldproof-base-ros2:local |
base of both stages: ROS 2 Jazzy ros-base with snapshot-pinned apt sources, non-root user, fieldproof-apt-install (containers.md) |
ros_distro |
jazzy |
ROS 2 distribution of the base |
rosdistro_commit |
8e9a99d2… (2026-10-03) |
commit of ros/rosdistro rosdep resolves keys against, so a rebuild resolves the same packages |
repos[] |
repositories vcs import clones into src/<name>: name, url, version (a 40-digit commit SHA: branches and tags are not pins), type: git |
|
sources[] |
local directories, relative to and inside the manifest's directory, copied into src/; the example vehicle lists src |
|
apt[] |
run-time apt packages beyond what rosdep resolves | |
rosdep_skip_keys[] |
rosdep keys to ignore | |
colcon_args[] |
extra colcon build arguments (--packages-skip, ...) |
At least one of repos and sources is required.
The driver-swap map¶
Each entry of runtime.drivers names one hardware driver the vehicle's launch starts in the
field and the topics of the model's interface that the bridge provides instead
(bridge.md):
drivers:
- name: lidar
package: rplidar_ros
executable: rplidar_composition
provides: [scan]
- name: description
package: robot_state_publisher
provides: [/tf_static, /robot_description]
provides must name ROS topics of this manifest's interfaces (plus the bridge's own /tf_static,
/robot_description and /clock); a topic is provided for one driver at most. The swap itself is
the vehicle's business: the example's field launch takes drivers:=false and does not include its
drivers.launch.py, and its package.xml declares the driver packages as dependencies only when
built for the vehicle (<exec_depend condition="$UGV_TARGET == field">), so the simulation image
carries neither the drivers nor their serial-port parameters. fieldproof manifest validate
reports the interfaces no driver provides, which is fine for a sensor the stack does not use.
Vehicle images¶
uv run --project engine fieldproof vehicle dockerfile examples/vehicles/ugv_nav2/robot.yaml -o /tmp/ugv_nav2_image
uv run --project engine fieldproof vehicle build examples/vehicles/ugv_nav2/robot.yaml # make vehicle-image
uv run --project engine fieldproof vehicle run examples/vehicles/ugv_nav2/robot.yaml --domain-id 7 --detach
uv run --project engine fieldproof vehicle wait examples/vehicles/ugv_nav2/robot.yaml
| Command | Effect |
|---|---|
vehicle dockerfile MANIFEST [-o DIR] |
write the build context: Dockerfile, entrypoint.sh, the resolved robot.yaml the image carries at /opt/fieldproof/robot.yaml, and vehicle.repos when there are repositories (default directory ./<name>_image) |
vehicle build MANIFEST [--context DIR] [-t TAG] [--base-image REF] [--no-cache] [--progress plain] [--builder NAME] [--docker-arg ARG] [--dry-run] |
write the context (a temporary directory by default) and run docker buildx build --load on the daemon's own builder (default, which sees a locally built base image; a docker-container builder does not) with the labels' build arguments (GIT_SHA, BUILD_DATE, VERSION) and the manifest's directory as the vehicle-source build context when there are local sources; the command line is printed first |
vehicle run MANIFEST [--name N] [--network fieldproof] [--domain-id N] [--namespace NS] [--frame-prefix P] [-d] [--keep] [--image REF] [-e K=V] [--docker-arg ARG] [--dry-run] [-- CMD...] |
docker run --rm of the image with the manifest's resources (--cpus, --cpuset-cpus, --memory, --pids-limit), its env and its dds_profile mounted, on the fieldproof network of the compose stack by default; --namespace and --frame-prefix go into the environment and, through the launch's namespace_arg and frame_prefix_arg, onto its command line; extra arguments replace the entrypoint |
vehicle wait MANIFEST [--name N] [--timeout S] [--interval S] [--quiet] [--dry-run] |
docker exec of the readiness probe in the running container (named after the vehicle by default): exit code 0 once every runtime.ready condition holds, 1 when the timeout (the manifest's ready.timeout_s by default) passes first, with the pending conditions listed |
The generated Dockerfile¶
Two stages on the manifest's base image:
build: the ROS build tools (build-essential,cmake,git, colcon, rosdep, vcstool) from the base image's apt snapshots; rosdep's source list andROSDISTRO_INDEX_URLpointed at the pinnedros/rosdistrocommit,rosdep update;vcs importofvehicle.reposandCOPY --from=vehicle-sourceof the local sources into/opt/vehicle_ws/src;rosdep install --simulateresolves the keys of every package insrc/(a key that needs an installer other than apt fails the build: vendor it or skip it) andfieldproof-apt-installfetches the apt packages in parallel; the exec-only resolution is kept as a list;colcon build --merge-install --install-base /opt/vehiclein release mode with the manifest'scolcon_args.runtime: the base image,/opt/vehiclecopied from the build stage, the exec dependencies of that list and the manifest'saptextras installed, the entry point (sources/opt/ros/<distro>/setup.bashand/opt/vehicle/setup.bash, thenexec "$@"), the resolved manifest,FIELDPROOF_VEHICLEandFIELDPROOF_MANIFESTin the environment plusruntime.env, the non-root userfieldproof,tinias PID 1, the entrypoint's command line asCMD, and the OCI labels (titlefieldproof-vehicle-<name>,source,revision,created,version,base.name) withio.fieldproof.vehicle,io.fieldproof.tier,io.fieldproof.manifest-apiandio.fieldproof.rosdistro-commit.
No compiler, colcon or rosdep is left in the runtime stage. The Dockerfile passes hadolint with
the repository's configuration (tests/vehicles/test_vehicles.sh checks it).
The readiness probe¶
fieldproof-vehicle-ready (engine/src/fieldproof/vehicle_ready.py, copied unchanged into every
image at /usr/local/bin) evaluates the manifest's runtime.ready conditions against the ROS
graph as seen from inside the container, with rclpy from ros-base and nothing else: it polls
every --interval (1 s) until every condition holds (exit 0) or --timeout passes (exit 1; the
manifest's ready.timeout_s by default), printing a line each time the set of pending conditions
changes and a final ready or not ready line (--json adds the evaluation as JSON, --quiet
drops the progress):
waiting (1/10 ready): /bt_navigator lifecycle inactive, /controller_server lifecycle unresponsive, ...
waiting (8/10 ready): map->base_link tf no frames yet, /navigate_to_pose action no server
ready: 10 conditions hold after 42.3 s
| Condition | Holds when | Pending detail |
|---|---|---|
lifecycle: <node> (state: active by default) |
the node's get_state service answers with the state |
the state it reported; unavailable (no service: the node is not in the graph); unresponsive (no answer in 2 s, what a Nav2 server blocked in its activation looks like while it waits for TF) |
topic: <name> (type optional) |
a publisher exists, of the type when given | no publisher, or the types published |
service: <name> (type optional) |
the service is offered, of the type when given | no server, or the types offered |
action: <name> (type optional) |
an action server is offered (its _action/send_goal service), of the type when given |
as for services |
node: <name> |
the node is in the graph | absent |
tf: <parent> with child: <frame> |
a chain of transforms seen on /tf or /tf_static (0.5 s of listening per evaluation) links the two frames, in either direction; tf: map, child: base_link holds once SLAM publishes map → odom on top of the bridge's odom → base_link, which is when Nav2 can plan (a goal sent before it is aborted) |
no frames yet, or no transform (N frames seen) |
Relative names resolve against the probe's namespace: --namespace, or FIELDPROOF_NAMESPACE,
which the orchestrator sets to the vehicle's namespace in a fleet, so lifecycle: bt_navigator
means /ugv2/bt_navigator inside ugv2; the tf condition listens on the namespace's tf
and tf_static and on the global ones. With runtime.ready the generated
Dockerfile also declares the probe as the image's HEALTHCHECK (--interval=15s --timeout=30s
--start-period=60s --retries=3, each check a 10 s wait), so docker inspect reports starting,
then healthy as soon as one check passes or unhealthy after three failed ones past the start
period, and docker compose up --wait or the orchestrator can gate on it. fieldproof vehicle
wait runs the same probe through docker exec with the manifest's timeout and shows what is
still pending, which is the readable form of the same answer. A manifest without ready carries
the probe (it reports ready at once) and no HEALTHCHECK.
The example vehicle ugv_nav2¶
examples/vehicles/ugv_nav2/ is the stand-in for a real vehicle codebase: the reference UGV
running Nav2 (navigation2 1.3.13) with SLAM Toolbox (2.8.5) as installed from the Jazzy apt
snapshot, driven by the bringup package src/ugv_nav2_bringup:
| File | Contents |
|---|---|
robot.yaml |
extends the reference UGV's manifest; runtime: T1, image fieldproof-vehicle-ugv-nav2:local built from src, entrypoint bringup.launch.py use_sim_time:=true drivers:=false with namespace_arg: namespace, the two parameter files, six drivers in the swap map (base through ros2_control, description, lidar, IMU, camera, GNSS), readiness on Nav2's and SLAM Toolbox's lifecycle nodes, the map topic, the map → base_link transform and the navigate_to_pose action (relative names, resolved under the vehicle's namespace), the Fast DDS profile config/fastdds.xml, a budget of 2 CPUs and 2 GiB, and the radio link (runtime.link: the heartbeat on gcs/heartbeat at 2 Hz and the loss-of-link behaviour of link_monitor) |
launch/bringup.launch.py |
the field bringup: drivers.launch.py when drivers:=true (the field default), SLAM Toolbox's online_async_launch.py with config/slam.yaml, the package's navigation.launch.py with config/nav2.yaml; use_sim_time defaults to false and is passed to every node; namespace puts every node under it and remaps /tf and /tf_static to the namespace's (Nav2's multi-robot bringup does the same; SLAM Toolbox's own launch knows no namespace, so this file wraps it); nothing else differs between the vehicle and the simulation |
config/fastdds.xml |
the vehicle's DDS profile as deployed: one UDPv4 transport with 8 MB socket buffers for the camera's images and no shared-memory transport; mounted into the container unchanged (runtime.dds_profile) |
launch/navigation.launch.py |
the Nav2 servers (controller, smoother, planner, behaviors, bt_navigator, waypoint follower, velocity smoother, collision monitor) under nav2_lifecycle_manager, one process each, with Nav2's cmd_vel_nav → cmd_vel_smoothed → cmd_vel_safe remaps, then the loss-of-link failsafe link_monitor (cmd_vel_safe → cmd_vel), the bringup watchdog bringup_watchdog and, under a namespace, config/nav2.yaml rewritten with the namespace as its root key (nav2_common's RewrittenYaml, what nav2_bringup does: a parameter file names its nodes, and controller_server: reaches /controller_server only); the vehicle's own launch rather than nav2_bringup's, whose package pulls Gazebo, the TurtleBot models and the rviz plugins into the image |
launch/drivers.launch.py |
the hardware drivers: robot_state_publisher, ros2_control (controller_manager with diff_drive_controller and joint_state_broadcaster), rplidar_ros, microstrain_inertial_driver, nmea_navsat_driver, realsense2_camera; config/drivers.yaml holds their ports and frames; not started and not installed in the simulation image |
config/nav2.yaml, config/slam.yaml |
the vehicle's parameters, from the Jazzy defaults of nav2_bringup (without the route and docking servers) and slam_toolbox adapted to the platform: 0.70 m × 0.44 m footprint with base_link at the axle, scan from the 25 m lidar, odom from the drive, MPPI controller limited to 0.8 m/s and 1.5 rad/s, NavFn planner, SLAM Toolbox mapping with base_link and 0.2 m / 0.2 rad travel thresholds, its map on the relative map (/ugv1/map in a fleet, /map alone; slam_toolbox's default is the absolute /map); both costmaps wait 0.5 s for a transform, SLAM stamps map → odom 0.5 s ahead and the behaviour tree waits 1 s for a server to acknowledge a goal, so a CPU-starved simulation (software rendering in CI) does not make Nav2 abort a goal on a stale map → odom or a slow follow_path acknowledgement |
scripts/link_monitor |
the loss-of-link failsafe (rclpy, installed to lib/ugv_nav2_bringup): armed by the ground station's first heartbeat on gcs/heartbeat; after loss_timeout_s (2 s) without one it publishes link_state false and holds the vehicle with zero cmd_vel at 20 Hz, dropping Nav2's commands; at the next heartbeat it publishes link_state true and passes them through again (parameters in config/nav2.yaml under link_monitor) |
scripts/bringup_watchdog |
the bringup retry (rclpy, installed next to it): nav2_lifecycle_manager brings the servers up once and, when one transition call is lost under load, logs Aborting bringup and leaves them inactive and unconfigured for good (issue #32); the watchdog sees that line on /rosout (or, as a fallback, servers not active and unchanged for 30 s after a 90 s grace), takes every server back to unconfigured through its change_state service and asks the manager for STARTUP again, at most three times; a stack whose servers are all active (the manager lost only the answer of its last get_state) is left alone |
package.xml |
exec_depend on the Nav2 servers it launches, rclpy, std_msgs, geometry_msgs (the failsafe), nav2_mppi_controller, nav2_navfn_planner, nav2_msgs, nav2_common and slam_toolbox; the driver packages under condition="$UGV_TARGET == field" |
make test-vehicles runs tests/vehicles/test_vehicles.sh: every robot.yaml validates
(engine and check-jsonschema), the committed schema is current, the generated Dockerfile and
entry point lint, the image builds (--no-build to use a present one, --static to skip the
image), runs as fieldproof with the manifest, the install space, the readiness probe and its
HEALTHCHECK, Nav2 and SLAM Toolbox but no compiler, no nav2_bringup, no rviz and no driver
package, declares the launch arguments, carries the labels and the entrypoint command, and,
started with fieldproof vehicle run on the fieldproof network with the manifest's budget,
joins the ROS graph: from a second container on the same network its nodes are listed,
slam_toolbox is active and Nav2's servers answer their lifecycle services. Without the bridge
they stay inactive: the controller server's costmap waits in its activation for the odom →
base_link transform, so fieldproof vehicle wait exits 1 listing them and Docker reports the
container unhealthy, which is why the manifest's ready conditions need the whole stack.
The integration test¶
make test-integration runs tests/integration/test_ugv_nav2.py on the host from the engine's
environment, driving Docker (the P1-T04 verification; also the image job's last step in CI):
- the compose service
simwith the depot world (containers.md); - the reference UGV spawned at the depot's
spawn_dockframe by a one-shot container of the bridge image (docker compose run bridge ros2 launch fieldproof_robots spawn_ugv_ref.launch.py ... robot_state_publisher:=false); - the compose service
bridgeon the example vehicle's manifest (bridge.md); fieldproof vehicle runof the example image, thenfieldproof vehicle waituntil Nav2's servers and SLAM Toolbox areactive,/maphas a publisher andnavigate_to_posea server, and Docker reports the containerhealthy;ros2 action send_goal /navigate_to_posefrom inside the vehicle container to a point 4 m ahead in themapframe: the goalSUCCEEDED, and the ground-truth pose the simulator publishes is within 0.5 m of that point in the world (the map frame starts at the spawn pose);- no node of the vehicle or the bridge died.
The three containers share ROS_DOMAIN_ID (default 0, as with make up); the test stops what it
started and prints their logs on a failure (FIELDPROOF_IT_KEEP=1 leaves them running for a
look). It needs make images and make vehicle-image first. fieldproof run performs the same
steps from a scenario file (scenarios.md), and tests/integration/test_run.py
checks that it does.
Known limits¶
- Only tier T1 images are generated; T2 (ARM64 under QEMU) and T3 (HIL) are Phase 6.
rosdepkeys that resolve to pip are refused rather than installed, so the image stays reproducible from the apt snapshots.extendsrewrites the relative paths ofdescriptiononly; other relative paths (sources,params,dds_profile) are the extending manifest's own.- One manifest describes one kind of vehicle; a fleet runs it several times under different
namespaces from a scenario (scenarios.md). A launch without a
namespace_argcan only run at the root, hence alone. dds_profileknows the profile variable of Fast DDS, Cyclone DDS and Connext; another RMW needs its variable inenvand the file mounted by hand (--docker-arg).