Docs menu
System architecture
Every node in the workspace and how they connect, from the sensors through odometry, IMU fusion, SLAM and the control layer to the motor and servo, with the launch layers, topics and packages.
Every component of the stack is a ROS 2 node, and nodes communicate only through topics. The topic graph is therefore the whole system: nothing calls another package’s functions, and nothing shares memory. A drive command flows one way, through ackermann_mux, to the motor.
vesc_driver_node. Navy nodes start with bringup. Grey nodes start with the launch that needs them: the IMU nodes with vehicle_state_launch.py or map_course, slam_toolbox with map_course or slam_launch.py, and the particle filter with race_launch.py. Blue nodes are the control layer. The small line in each blue box lists the shared topics it reads, which are not drawn as arrows. A dashed arrow exists only with the launch flag next to it. The unlabelled arrows into auto_map_race_node are /auto_map/drive from gap_follow_node and /auto_race/drive from pure_pursuit_node.The drive chain
joy_nodereads the F710 and publishes/joy.- The control layer publishes a steering angle and speed:
joy_teleopon/teleop, or an autonomy node on/drive. ackermann_muxchooses one input and publishes it as/ackermann_cmd.ackermann_to_vesc_nodeconverts the speed to motor ERPM and the steering angle to a servo position, using the car’s calibration from itscalibration/vesc.yaml, and publishes/commands/motor/speedand/commands/servo/position.vesc_driver_nodesends those over USB to the VESC, which drives the motor and the steering servo.
How the mux chooses
ackermann_mux has two inputs, each with a priority and a timeout set in mux.yaml:
| Input | Topic | Priority | Timeout |
|---|---|---|---|
joystick |
/teleop |
100 | 0.2 s |
navigation |
/drive |
10 | 0.2 s |
An input is active while its last message is less than 0.2 s old. Each time a message arrives, the mux forwards it to /ackermann_cmd only if no active input has a higher priority. So /drive reaches the motor only while /teleop has been silent for 0.2 s. When both inputs go silent, the mux publishes nothing; it does not send a zero.
joy_teleop repeats its last command at 20 Hz even with LB released, a message every 50 ms. /teleop therefore never times out while teleop_launch.py runs, which is why teleop and autonomy must never run together.
From speed and angle to motor and servo
ackermann_to_vesc_node converts each /ackermann_cmd with two straight lines, using the calibration in the /** block of the car’s calibration/vesc.yaml, which bringup loads on top of the shared vesc.yaml:
erpm_msg.data = speed_to_erpm_gain_ * cmd->drive.speed + speed_to_erpm_offset_;
servo_msg.data = steering_to_servo_gain_ * cmd->drive.steering_angle + steering_to_servo_offset_;On car 1, a command of 2.0 m/s and 0.20 rad (left) gives and .
vesc_driver_node then clips each command to its limits before sending it to the VESC: the servo to servo_min and servo_max, the speed to ±23,250 ERPM. It publishes the clipped servo value on /sensors/servo_position_command, so the difference between /commands/servo/position and that topic shows when the steering is being clipped.
double servo_clipped(servo_limit_.clip(servo->data));
vesc_.setServo(servo_clipped);
servo_sensor_msg.data = servo_clipped;
servo_sensor_pub_->publish(servo_sensor_msg);| Car 1 | Car 2 | |
|---|---|---|
speed_to_erpm_gain | 3650 | 4614 |
steering_angle_to_servo_gain | −0.83 | −0.78 |
steering_angle_to_servo_offset | 0.366 | 0.5904 |
servo_min, servo_max | 0.05, 0.75 | 0.15, 0.95 |
Every steering limit in the workspace must fit inside the servo range, or the servo clips it without any warning in the driving node’s log. test_steering_limits_fit_servo.py checks this for each car.
Beside the chain
Everything else in the figure produces the inputs the control layer decides from, and none of it can move the car.
- Wheel odometry.
vesc_driver_nodepublishes the VESC’s state on/sensors/core, from whichvesc_to_odom_nodeestimates how far the car has moved and publishes/odomand theodom→base_linktransform. See Wheel odometry. - IMU.
imu_bridge_nodecalibrates the VESC’s IMU (/sensors/imu/raw) and the BNO085 read bybno_i2c_bridge, each on its own.vehicle_state_nodeis the node that fuses the two IMUs, with each other and with/odom, into/odom/fused,/steering/measuredand gyro odometry. See IMU fusion. - Mapping and localization.
slam_toolboxbuilds the map from/scanand theodom→base_linktransform, and publishes/mapand themap→odomtransform. On a saved map, the particle filter localizes the car from/scanand/odominstead, and publishes/pf/viz/inferred_pose. See SLAM and localization. - Watching. The dashboard, the run recorder and the doctor read topics and publish no drive command.
urg_node publishes the LiDAR’s /scan independently of everything else, and /scan, /odom and /joy reach the driving nodes directly, as the line in each blue box lists.
Two launch layers
Every mode starts with two launches in separate terminals.
| Layer | Launch | Starts | Moves the car |
|---|---|---|---|
| Bringup | f1tenth_stack bringup_launch.py |
joy_node, urg_node, vesc_driver_node, ackermann_to_vesc_node, vesc_to_odom_node, ackermann_mux, the base_link to laser transform |
Never by itself |
| Control layer, exactly one | teleop_launch.py, gap_follow_launch.py, race_launch.py, wiimote_teleop_launch.py, or map_course |
The node that decides what the car does | Only while LB is held |
The split has two consequences. Bringup can stay running while control layers are swapped, so the VESC and LiDAR connections never close. And two control layers must never run together: teleop would always win at the mux, and two autonomy nodes would fight over /drive. auto_map_race_launch.py is the one launch that starts bringup itself.
Bringup is defined in bringup_launch.pyGitHub, with its shared parameters in f1tenth_stack/config/: vesc.yaml, sensors.yaml, mux.yaml and joy_teleop.yaml. For vesc.yaml, sensors.yaml and joy_teleop.yaml it loads the car’s file of the same name from its profile’s calibration/ folder on top, so the servo map, speed gains, LiDAR connection and full-stick steering are each car’s own. Bringup stops before starting any node if the car cannot be identified, or if a USB device that belongs to the other car is plugged in.
Automatic mapping and racing
During auto_map_race_launch.py, gap_follow and pure_pursuit both run, but neither publishes on /drive. A supervisor, auto_map_race_node, forwards one of them:
| Topic | Publisher | Read by |
|---|---|---|
/auto_map/drive |
gap_follow, while mapping |
auto_map_race_node |
/auto_race/drive |
pure_pursuit, while racing |
auto_map_race_node |
/drive |
auto_map_race_node |
ackermann_mux |
/slam_pose |
auto_map_race_node, from SLAM’s transform |
pure_pursuit |
The supervisor, gap_follow and pure_pursuit each keep their own LB check.
Topics
These are the topics with bringup and one control layer running.
| Topic | Type | Publisher | Subscribers |
|---|---|---|---|
/joy |
sensor_msgs/Joy |
joy_node |
joy_teleop, every autonomy node’s LB check |
/teleop |
ackermann_msgs/AckermannDriveStamped |
joy_teleop |
ackermann_mux |
/drive |
ackermann_msgs/AckermannDriveStamped |
The autonomy node | ackermann_mux |
/ackermann_cmd |
ackermann_msgs/AckermannDriveStamped |
ackermann_mux |
ackermann_to_vesc_node |
/commands/motor/speed |
std_msgs/Float64 |
ackermann_to_vesc_node |
vesc_driver_node |
/commands/servo/position |
std_msgs/Float64 |
ackermann_to_vesc_node |
vesc_driver_node |
/sensors/core |
vesc_msgs/VescStateStamped |
vesc_driver_node |
vesc_to_odom_node, the doctor |
/sensors/servo_position_command |
std_msgs/Float64 |
vesc_driver_node |
vesc_to_odom_node |
/sensors/imu/raw |
sensor_msgs/Imu |
vesc_driver_node |
imu_bridge_node |
/odom |
nav_msgs/Odometry |
vesc_to_odom_node |
gap_follow (TTC), pure_pursuit (lookahead), neural_racer, particle_filter, imu_bridge_node, vehicle_state_node |
/imu/vesc/data, /imu/bno/data |
sensor_msgs/Imu |
imu_bridge_node, one topic per IMU |
vehicle_state_node |
/imu/data, /imu/status |
sensor_msgs/Imu, std_msgs/String (JSON) |
imu_bridge_node, a copy of the primary IMU and its health |
neural_racer |
/odom/fused |
nav_msgs/Odometry |
vehicle_state_node |
gap_follow, with map_course fused_speed:=true |
/scan |
sensor_msgs/LaserScan |
urg_node |
gap_follow, pure_pursuit, slam_toolbox, particle_filter |
/map |
nav_msgs/OccupancyGrid |
slam_toolbox, or map_server with a saved map |
pure_pursuit, auto_map_race_node, the dashboard. The particle filter fetches the saved map from map_server’s /map_server/map service instead. |
/pf/viz/inferred_pose |
geometry_msgs/PoseStamped |
particle_filter |
pure_pursuit, the waypoint recorder |
/slam_pose |
geometry_msgs/PoseStamped |
auto_map_race_node, from SLAM’s transform |
pure_pursuit during automatic racing, neural_racer in map mode |
/drive_intent |
std_msgs/String (JSON) |
gap_follow, pure_pursuit |
The dashboard. See Drive intent. |
/tf, /tf_static |
tf2_msgs/TFMessage |
The static transform, vesc_to_odom_node, slam_toolbox, the particle filter, and vehicle_state_node with publish_odom_tf |
Everything that converts between frames |
vehicle_state_node also publishes /steering/measured and /odom/imu, which only record_run records. See IMU fusion.
To see why a car is not doing what the code says, compare /drive, /teleop and /ackermann_cmd. The first two are what each source wants, and the third is what won. Each topic’s delivery settings, rate and timeout are on QoS and timing.
Packages
The local packages are this workspace’s own code. Vendored and submodule packages come from upstream projects.
| Package | Source | Role |
|---|---|---|
f1tenth_systemGitHub |
Vendored from F1TENTH, with local fixes | f1tenth_stack launch files and configuration, the VESC driver, ackermann_mux, joy_teleop |
gap_followGitHub |
Local | Reactive autonomy. See Follow the gap. |
pure_pursuitGitHub |
Local | The race controller, racing-line tools and the map-to-race supervisor. See Racing on a map. |
racerbot_carGitHub |
Local | The car profiles: which car this is, each car’s hardware values, and the guards on per-car overlays. See The cars. |
racerbot_launchGitHub |
Local | Launch files that combine packages: SLAM, mapping, racing, the dashboard, the camera |
drive_intentGitHub |
Local | The /drive_intent schema and trajectory prediction, without ROS |
imu_bridgeGitHub |
Local | Calibrated IMU topics, fused speed, measured steering and gyro odometry |
race_diagnosticsGitHub |
Local | Read-only run recorder and analyzer. See Record a run. |
racerbot_doctorGitHub |
Local | Read-only hardware self-check |
racerbot_simGitHub |
Local | F1TENTH Gym behind the car’s own topics. See Simulators. |
odom_calibrationGitHub |
Local | Browser wizard for odometry and steering calibration |
racerbot_wiimoteGitHub |
Vendored from ros2_wiimote_driver |
Wiimote driver and its teleop layer |
neural_racerGitHub |
Local | Learned racing policy, experimental. See Neural racer. |
web_dashboardsGitHub |
Submodule: sfu-racerbot/web-dashboards |
The dashboard’s car-side server and usb_cam_stream |
particle_filterGitHub, range_libcGitHub |
Submodules: F1TENTH, humble-devel |
Monte Carlo localization and its GPU ray caster |
transport_driversGitHub |
Submodule: ros-drivers, humble |
Serial port library for the VESC driver |
realsense-rosGitHub |
Submodule: Intel, ros2-master, built on car 2 only |
Car 2’s RealSense camera driver |
racerbot_aGitHub, racerbot_bGitHub |
Submodules: the two team repositories | Each team’s own algorithms |
slam_toolbox, urg_node, joy |
apt | SLAM, the Hokuyo driver and the gamepad driver |
The upstream F1TENTH repositories have no Jazzy branch yet. Everything is built from their humble and humble-devel sources against Jazzy.
Checked against
car 1 commit 0265263
and
car 2 commit b81b291, before the two workspaces merged.