Design
This section describes EasyFleet’s architecture: how a robot’s capabilities are structured and hosted, how a mission-control process discovers and drives them, and how the Navigation Manager coordinates several robots sharing the same space.
What’s a “capability”?
A capability is the atomic unit EasyFleet builds everything around: one
rclcpp_lifecycle::LifecycleNode (easyfleet_core::Capability<ActionServerT>)
wrapping exactly one ROS 2 action. Once activated, it:
Publishes an
easyfleet_interfaces/CapabilityDescriptionon the reliable, transient-local/capabilitiestopic — so anyone subscribing late still gets it. It carriesrobot,capabilityandaction_name(all resolved from the node’s actual ROS namespace at runtime, not configured by hand) and a raw JSON description (what it does, its requirements, effects, parameters).Publishes a 1 Hz
easyfleet_interfaces/CapabilityStatusheartbeat on/capabilities_statusfor as long as it stays active — how a mission controller knows a capability is not just registered but actually alive, and whether it currently has a goal executing (busy) or is free to be called, without sending it a goal just to find out.Exposes an
allow_preemptionparameter controlling whether a new goal can replace one that’s currently running, or must be rejected until the current one finishes or is canceled.Can always be told to stop early — canceling the goal stops whatever the capability was doing: the robot stops moving, the perception stream stops reporting, the manipulator stops executing its trajectory.
Because a capability resolves its own identity from its ROS namespace,
the exact same binary announces itself correctly whether it’s launched as
/robot_1/navigation or /robot_2/navigation — nothing about a
capability’s own code needs to know which robot it’s running on.
![digraph capability {
rankdir=LR;
node [shape=box, style=rounded, fontname="verdana"];
"Mission control\n(CapabilityClient)" -> "Capability<ActionServerT>\n(one ROS 2 action)" [label="goal"];
"Capability<ActionServerT>\n(one ROS 2 action)" -> "Mission control\n(CapabilityClient)" [label="feedback / result"];
"Capability<ActionServerT>\n(one ROS 2 action)" -> "/capabilities\n(transient-local)" [style=dashed];
"Capability<ActionServerT>\n(one ROS 2 action)" -> "/capabilities_status\n(1 Hz heartbeat)" [style=dashed];
}](../_images/graphviz-0c9ce28c97e4db00569b9005d6f05977d970b63e.png)
Any integrator adds a new backend for a capability domain by subclassing
one of easyfleet_core’s three domain base classes
(NavigationActionServerBase, ManipulationActionServerBase,
PerceptionActionServerBase) and implementing on_goal_received()/
on_execute() — everything else (announcing itself, the heartbeat,
preemption bookkeeping, cancellation) is handled by Capability<T> and
needs no changes. EasyFleet ships one real backend this way, on top of
EasyNavigation’s navigation stack
— see Examples.
Note
NavigationActionServerBase/ManipulationActionServerBase/
PerceptionActionServerBase are convenience subclasses for the three
domains easyfleet_interfaces ships today — not a hard limit on what
a capability can be. Capability<ActionServerT>/
ActionServerBase<ActionT> are templated on any ROS 2 action
type: a brand-new capability domain (say, Inspection or
Charging) is your own .action file plus a direct
ActionServerBase<YourActionT> subclass — announcing itself on
/capabilities, the heartbeat, preemption bookkeeping, and discovery
all keep working unchanged, with no framework code to modify.
Interfaces
easyfleet_interfaces defines three actions general enough that any
robot’s implementation of a capability — any navigation stack, any
manipulator, any perception modality — can express itself through the same
interface, with a parameters_json escape hatch for stack-specific
tuning:
Navigation: go to
target_pose, optionally via orderedwaypoints.Manipulation: reach a
joint_target, apose_target, or run anamed_task, selected bymode.Perception: detect/report
object_classes, once orcontinuously.
All three share a result-code convention (SUCCESS=0, REJECTED=1,
ABORTED=2, CANCELED=3, TIMEOUT=4, with capability-specific
codes starting at 10) — documented, not enforced by the type system,
since .action files can’t share constants across packages.
Note
Because Navigation is a plain, generic ROS 2 action — not
nav2_msgs/action/NavigateToPose, not anything Nav2-specific —
EasyFleet itself is navigation-stack-agnostic: a
NavigationActionServerBase subclass could just as well wrap
Nav2, a custom driver, or any other
stack — EasyNav is simply the one backend this project ships today
(see Examples). easyfleet_navigation_manager (see
Navigation Manager) is currently the one place that assumes
EasyNav specifically, for the fleet-wide map/routes/pause-resume
plumbing — isolating that coupling so a Nav2-backed fleet could use it
too is planned, not done yet.
The robot side: Deployment and CapabilityFactory
One process per robot, one easyfleet_core::Deployment per process —
matching reality: a real fleet’s robots each have their own onboard
computer, so a deployment abstraction spanning several robots in one
process would be modeling something that doesn’t exist outside a
single-machine simulation.
Deployment replaces the configure → check → activate → check → spin →
deactivate → cleanup → shutdown dance every robot process would otherwise
repeat by hand, plus the “which concrete class does the string
'navigation' mean” lookup: add_capability(name) loads a
pluginlib-registered CapabilityFactory for that name and constructs the
matching capability. A concrete backend becomes pluginlib-loadable with one
PLUGINLIB_EXPORT_CLASS line, no boilerplate factory class of its own to
write (CapabilityFactoryFor<YourActionServerT> covers every case). The
robot’s identity comes entirely from the ROS namespace the process itself
was launched under — Deployment needs no namespace of its own.
easyfleet::init(argc, argv);
easyfleet::Deployment deployment;
deployment.add_capabilities_from_parameters("my_deployment_package");
deployment.start();
deployment.run();
deployment.shutdown();
The mission-control side
A separate process discovers a fleet’s capabilities and drives them —
never linked against any capability’s implementation, only against
easyfleet_interfaces’ action types. Two client libraries speak the
exact same wire protocol (/capabilities, /capabilities_status, the
easyfleet_interfaces actions), so a controller written against either
is interchangeable against the same fleet, no matter which one hosted the
capabilities it talks to:
C++ —
easyfleet_mission_manager’sFleetSession/RobotHandle/SimpleController.Python —
easyfleet_mission_manager_py, a 1:1 port of the same three types.
See Mission Manager for the full API. A minimal controller looks like this in either language — this is genuinely all a manual, hand-written mission needs:
easyfleet::init(argc, argv);
easyfleet::SimpleController controller;
easyfleet::RobotHandle robot_1("robot_1");
controller.add_robot(robot_1);
controller.discover_capabilities();
robot_1.run_capability<easyfleet_mission_manager::Navigation>(
"navigation", easyfleet_mission_manager::make_navigation_goal("kitchen"));
while (robot_1.is_capability_running("navigation")) {
controller.spin_some();
}
controller.shutdown();
rclpy.init()
controller = SimpleController()
robot_1 = RobotHandle("robot_1")
controller.add_robot(robot_1)
controller.discover_capabilities()
robot_1.run_capability("navigation", Navigation, make_navigation_goal("kitchen"))
while robot_1.is_capability_running("navigation"):
controller.spin_some()
controller.shutdown()
Both snippets above are what SimpleController is: a mission script
decides everything by hand, in order, with no plumbing beyond “send a
goal, wait, check the result”. Writing your own controller (C++ or
Python) and your own deployment (the robot-side launch/config that
goes with it) is exactly what most EasyFleet users will actually spend
their time on — easyfleet_core/easyfleet_mission_manager are
meant to fade into the background once a fleet is up and running. The
example mission scripts this project ships (a hand-rolled one and its
RobotHandle-based sibling, each available in C++ and Python) are
templates to start from, not the point of the exercise.
Nothing about FleetSession/RobotHandle assumes a linear script,
either — see Extensibility: building other controllers on FleetSession below for building a
controller with real decision-making on top of the exact same primitives:
one that asks an LLM what to do next from inside its own
spin_some()-driven loop, or one whose PDDL planner (PlanSys2, for
example) action implementations reach into the fleet through
robots()/find_robot() instead of a fixed phase-by-phase script.
Robot vs. mission control
Every EasyFleet deployment is split into two kinds of process that never link against each other’s implementation, only ever talk over ROS actions/topics:
![digraph split {
rankdir=LR;
node [shape=box, style=rounded, fontname="verdana"];
subgraph cluster_robot1 {
label="robot_1 process";
style=dashed;
"Deployment (robot_1)" -> "navigation" -> "perception";
}
subgraph cluster_robot2 {
label="robot_2 process";
style=dashed;
"Deployment (robot_2)" -> "navigation (robot_2)" -> "manipulation";
}
"Mission control\n(FleetSession)" -> "navigation" [label="/capabilities,\n/capabilities_status,\nactions"];
"Mission control\n(FleetSession)" -> "navigation (robot_2)";
"Navigation Manager" -> "navigation" [label="/global_map,\n/global_routes,\npause/resume"];
"Navigation Manager" -> "navigation (robot_2)";
}](../_images/graphviz-c05b6cc7ffc1b7403c389615eacb47d04dac033d.png)
This split is what makes discovery meaningful in the first place: a
mission-control process has no compile-time knowledge of what robots exist
or which capabilities they carry — it finds out by listening on
/capabilities//capabilities_status, the same way any other
operator or control-computer program would.