Docs Architecture Workspace repo
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.

ROS 2 topic graph of the whole workspace Bringup drivers read the hardware: joy_node the F710 gamepad, urg_node the Hokuyo LiDAR and vesc_driver_node the VESC. vesc_to_odom_node turns /sensors/core into /odom. On car 1, bno_i2c_bridge reads the BNO085 over I2C. imu_bridge_node calibrates the VESC IMU and the BNO085 separately, and vehicle_state_node fuses them with /odom into /odom/fused, /steering/measured and gyro odometry. slam_toolbox builds the map from /scan and the odom transform; the particle filter localizes on a saved map. The control layer, one at a time, publishes /teleop or /drive; auto_map_race_node forwards gap_follow while mapping and pure_pursuit while racing. ackermann_mux forwards one command as /ackermann_cmd, ackermann_to_vesc converts it to motor and servo commands, and vesc_driver_node sends them to the VESC. neural_racer_node reaches /drive only with shadow:=false. The dashboard, run recorder and doctor only watch. HARDWARE F710 gamepad Hokuyo LiDAR BNO085 CAR 1 ONLY VESC 6 MOTOR, SERVO, IMU DRIVERS joy_node urg_node bno_i2c_bridge vesc_driver_node ODOMETRY AND IMU CALIBRATION imu_bridge_node EACH IMU ALONE vesc_to_odom_node WHEEL ODOMETRY MAPPING, LOCALIZATION AND IMU FUSION slam_toolbox BUILDS THE MAP particle_filter ON A SAVED MAP vehicle_state_node FUSES THE IMUS CONTROL LAYER, ONE AT A TIME joy_teleop /joy gap_follow_node /scan /odom /joy auto_map_race_node /joy pure_pursuit_node /scan /odom /joy neural_racer_node /scan /odom /joy COMMAND CHAIN ackermann_mux TELEOP 100, DRIVE 10 ackermann_to_vesc TOOLS THAT WATCH web_dashboard BROWSER VIEW record_run BAG AND DIAGNOSTICS racerbot_doctor HARDWARE CHECK Bringup Started by the launch that needs it Control layer Only with the launch flag shown USB USB OR ETHERNET I2C BUS 7 USB /tmp/bno085 /sensors/imu/raw /sensors/core /odom /imu/bno/data /imu/vesc/data /odom /scan /odom odom → base_link on /tf odom_imu → odom, with gyro_odom:=true /pf/viz/inferred_pose map → base_link, /map /odom/fused, with fused_speed:=true /imu/data, /imu/status /slam_pose → shadow:=false /drive /teleop /ackermann_cmd /commands/motor/speed, /commands/servo/position
How to read it: boxes are nodes and arrows are topics, each labelled with the topic’s name; a capitalized label on an arrow from the hardware names the physical connection. The rows run from the hardware at the top to the command chain at the bottom, and the line up the right edge carries the motor and servo commands back to 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

  1. joy_node reads the F710 and publishes /joy.
  2. The control layer publishes a steering angle and speed: joy_teleop on /teleop, or an autonomy node on /drive.
  3. ackermann_mux chooses one input and publishes it as /ackermann_cmd.
  4. ackermann_to_vesc_node converts the speed to motor ERPM and the steering angle to a servo position, using the car’s calibration from its calibration/vesc.yaml, and publishes /commands/motor/speed and /commands/servo/position.
  5. vesc_driver_node sends 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);
calibration/vesc.yaml, command conversion and limits
Car 1Car 2
speed_to_erpm_gain36504614
steering_angle_to_servo_gain−0.83−0.78
steering_angle_to_servo_offset0.3660.5904
servo_min, servo_max0.05, 0.750.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_node publishes the VESC’s state on /sensors/core, from which vesc_to_odom_node estimates how far the car has moved and publishes /odom and the odom → base_link transform. See Wheel odometry.
  • IMU. imu_bridge_node calibrates the VESC’s IMU (/sensors/imu/raw) and the BNO085 read by bno_i2c_bridge, each on its own. vehicle_state_node is the node that fuses the two IMUs, with each other and with /odom, into /odom/fused, /steering/measured and gyro odometry. See IMU fusion.
  • Mapping and localization. slam_toolbox builds the map from /scan and the odom → base_link transform, and publishes /map and the map → odom transform. On a saved map, the particle filter localizes the car from /scan and /odom instead, 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.