ROS 2 bridge for Comma 3/X and Comma 4 devices running openpilot.
To run on a vehicle PC whose ROS 2 stack uses CycloneDDS, including SSH setup for the Comma, see QUICKSTART.md.
Nothing to build. The launch file runs straight from this checkout, and the published image already contains the bridge, its Python packages, and CycloneDDS.
The host only needs Docker, ROS 2 (Humble or newer) to run ros2 launch, and an openpilot checkout:
source /opt/ros/jazzy/setup.bash
ros2 launch /path/to/openpilot_ros2_bridge/launch/openpilot_bridge_docker.launch.py \
host_openpilot_root:=/path/to/openpilot \
comma_ip:=<comma_device_ip>
- If the openpilot checkout isn't built, add
prepare_openpilot:=true. The launch then checks it out at the Comma's commit and builds the two parts the bridge uses, msgq and the cereal bridge, inside the container. This needs SSH access to the Comma; see QUICKSTART.md. - If the image can't be pulled, for example offline in the car, the launch uses the copy pulled last time.
- To use an image built from your local source instead, add
image_name:=openpilot_ros2_bridge:local image_source:=build.
Build openpilot natively (instructions) and create the bridge venv once with ./scripts/setup_bridge_venv.sh. Then build this package in a ROS 2 Jazzy workspace and launch it:
cd /path/to/ros2_ws
colcon build --symlink-install --packages-select openpilot_ros2_bridge
source /opt/ros/jazzy/setup.bash
source install/setup.bash
export OPENPILOT_ROS2_BRIDGE_VENV=/path/to/openpilot_ros2_bridge/.venv
ros2 launch openpilot_ros2_bridge openpilot_bridge.launch.py \
openpilot_root:=/path/to/openpilot \
comma_ip:=<comma_device_ip>
In both cases, the Comma device must also run its side of the bridge (cd openpilot/cereal/messaging && ./bridge). Alternatively, add start_remote_bridge:=true remote_bridge_target:=comma@<comma_device_ip> to start it over SSH. The sections below cover the details.
Driver monitoring topics are published by default. To also stream the cameras, add start_camera:=true cameras:=road,wide_road,driver (see Cameras).
The bridge now exposes:
modelV2predictions, lane lines, road edges, leads, temporal pose, disengage predictions, and desired curvaturelongitudinalPlanas ROS 2 topics for speeds, accelerations, jerks, and planner statelateralPlanas ROS 2 topics for path points, curvature, solver state, and lane-change statecarStateandcarControlsummaries as ROS 2 topics- RViz marker topics for plan, lane lines, road edges, and leads
- TF frames for openpilot's calibrated frame and the Comma device, driven by openpilot calibration
- driver monitoring model output (
driverStateV2) and policy state (driverMonitoringState) - optional road, wide road, and driver camera streams as raw and JPEG-compressed images
All topics are published under the openpilot namespace (for example /openpilot/lane_lines). Change it with namespace:=<name>.
Fields that a given openpilot version no longer has are published as NaN, so array layouts stay the same across versions.
-
Install ROS 2 Jazzy.
-
Have an openpilot checkout available locally.
-
Build the openpilot messaging bridge in that checkout by following the official openpilot setup instructions: https://github.com/commaai/openpilot/tree/master/tools#native-setup-on-ubuntu-2404-and-macos
-
Create the bridge virtual environment:
cd /path/to/openpilot_ros2_bridge ./scripts/setup_bridge_venv.sh -
Build this repository in a ROS 2 workspace:
cd /path/to/ros2_ws colcon build --symlink-install --packages-select openpilot_ros2_bridge
If you want the Comma device to start its side of the cereal bridge through SSH, enable start_remote_bridge.
Otherwise start it manually on the device:
cd openpilot/cereal/messaging
./bridge
Then launch the ROS 2 bridge stack:
source /opt/ros/jazzy/setup.bash
source /path/to/openpilot_ros2_bridge/.venv/bin/activate
source /path/to/ros2_ws/install/setup.bash
export OPENPILOT_ROS2_BRIDGE_VENV=/path/to/openpilot_ros2_bridge/.venv
ros2 launch openpilot_ros2_bridge openpilot_bridge.launch.py \
openpilot_root:=/path/to/openpilot \
comma_ip:=<comma_device_ip> \
start_bridge:=true \
start_camera:=false
Useful optional arguments:
start_remote_bridge:=trueremote_bridge_target:=comma@<comma_device_ip>bridge_topics:=modelV2,carControl,carState,longitudinalPlan,lateralPlan,liveCalibration,extrinsicsCalibration,driverStateV2,driverMonitoringStatenamespace:=openpilotstart_driver_monitoring:=falsestart_camera:=truecameras:=road,wide_road,drivercamera_ip:=<comma_device_ip>(defaults tocomma_ip)camera_scale:=0.5camera_jpeg_quality:=80
With start_tf:=true (default), openpilot_tf_publisher broadcasts:
base_frame→openpilot: openpilot's calibrated frame (forward, right, down), with its origin at the Comma device. Model outputs and markers live in this frame. The translation is the device's mount position inbase_frame. Whenuse_calibration_height:=true, the height comes from openpilot calibration minusbase_frame_height, the height ofbase_frameabove the road.openpilot→openpilot_device: the physical device orientation, the inverse of calibrationrpyCalib, updated as calibration changes.
Calibration is read from liveCalibration, or from extrinsicsCalibration on openpilot versions that use that name. Example for a car whose base_link is 0.37 m above the road, with the Comma 1.6 m ahead of it:
ros2 launch openpilot_ros2_bridge openpilot_bridge.launch.py \
openpilot_root:=/path/to/openpilot comma_ip:=<comma_device_ip> \
base_frame:=base_link device_x:=1.6 device_y:=0.0 base_frame_height:=0.37
After launch, verify that the lane and plan topics are active:
ros2 topic list | grep /openpilot/
Useful topics include:
lane_lineslane_line_probspositionlongitudinal_planlongitudinal_plan_statelateral_planlateral_plan_statecar_state_motioncar_control_actuators
For RViz visualization:
ros2 topic echo /openpilot/plan_markers
ros2 topic echo /openpilot/lanes_markers
With start_camera:=true, the launch file starts one camera node per entry in cameras.
Each node connects directly to the cereal bridge on the device (camera_ip, which defaults to comma_ip), so the camera streams do not need to be in bridge_topics.
It decodes the video with PyAV and publishes:
/openpilot/<camera>_camera/image_raw(sensor_msgs/Image,bgr8)/openpilot/<camera>_camera/image_raw/compressed(sensor_msgs/CompressedImage, JPEG)
cameras entry |
openpilot stream | Video |
|---|---|---|
road |
narrowRoadEncodeData (roadEncodeData on older openpilot) |
full resolution HEVC, 5-10 Mbit/s |
wide_road |
wideRoadEncodeData |
full resolution HEVC, 5-10 Mbit/s |
driver |
cabinEncodeData (driverEncodeData on older openpilot) |
full resolution HEVC, 5-10 Mbit/s |
q_road |
qNarrowRoadEncodeData (qRoadEncodeData on older openpilot) |
526x330 H.264, 256 kbit/s |
ros2 launch openpilot_ros2_bridge openpilot_bridge.launch.py \
openpilot_root:=/path/to/openpilot \
comma_ip:=<comma_device_ip> \
start_camera:=true \
cameras:=road,wide_road,driver \
camera_scale:=0.5
Notes:
- openpilot only runs its video encoder while the device is onroad, so the camera topics stay silent offroad.
camera_scaleresizes the images before publishing, andcamera_jpeg_quality(1-100) sets the JPEG quality.- A node always decodes its stream, but only converts and compresses images while something subscribes to that topic.
- Over a weak Wi-Fi link, stream fewer cameras or use
q_road. - If you created the bridge venv before camera support was added, rerun
./scripts/setup_bridge_venv.shto install PyAV.
The driver monitoring node is on by default (start_driver_monitoring:=true).
It reads driverStateV2 and driverMonitoringState through the local bridge, so keep both in bridge_topics if you override it.
All topics are std_msgs/Float32MultiArray, in the same style as the other topics.
Model output (driverStateV2). For the face topics, row 0 is the left-hand drive seat and row 1 is the right-hand drive seat:
| Topic | Shape | Contents |
|---|---|---|
driver_state |
4 | frame id, model execution time, GPU execution time, wheel on right probability |
driver_face_orientation |
2x3 | pitch, yaw, roll (rad) |
driver_face_orientation_std |
2x3 | standard deviation of the above |
driver_face_position |
2x2 | x, y of the face in the driver camera image, relative to the image center |
driver_face_position_std |
2x2 | standard deviation of the above |
driver_face_probs |
2x8 | face, left eye, right eye, left blink, right blink, sunglasses, phone, sleep probabilities (NaN when the openpilot version does not provide one) |
Policy state (driverMonitoringState):
| Topic | Shape | Contents |
|---|---|---|
driver_monitoring_state |
13 | alert level, active policy (0 wheeltouch, 1 vision), is RHD, lockout, lockout count, lockout minutes remaining, alert 3 count, no response count, no response force decel, always on, always on lockout, RHD calibrated percent, RHD offset |
driver_monitoring_vision |
18 | awareness percent, awareness step, is distracted, face detected, distracted by pose / eye / phone / sleep, pose pitch, pose yaw, pose calibrated, pose uncertainty, pitch calibrated percent, pitch offset, yaw calibrated percent, yaw offset, wheeltouch fallback percent, uncertain offroad alert percent |
driver_monitoring_wheeltouch |
3 | awareness percent, awareness step, driver interacting |
driver_monitoring_legacy_state |
15 | Only for older openpilot versions: face detected, is distracted, distracted type, awareness status, awareness active, awareness passive, step change, pose pitch offset, pose pitch valid count, pose yaw offset, pose yaw valid count, is low std, hi std count, is active mode, is RHD |
The docker/Dockerfile provides a ROS 2 Jazzy environment for the bridge.
It copies the local repository into /workspace/ros2_ws/src/openpilot_ros2_bridge, creates a dedicated bridge .venv, installs the required Python packages there, builds the ROS 2 package with colcon, and expects only an openpilot source checkout to be bind-mounted at runtime.
Build the image manually:
cd /path/to/openpilot_ros2_bridge
docker build -t openpilot_ros2_bridge:local -f docker/Dockerfile .
Validate ROS 2 and openpilot inside the container:
docker run --rm --network host --ipc host --user "$(id -u):$(id -g)" -e HOME=/tmp \
-v /path/to/openpilot:/workspace/openpilot \
ghcr.io/jkk-research/openpilot_ros2_bridge:latest \
bash -lc 'source /opt/ros/jazzy/setup.bash && \
source /workspace/ros2_ws/src/openpilot_ros2_bridge/.venv/bin/activate && \
source /workspace/ros2_ws/install/setup.bash && \
export PYTHONPATH=/workspace/openpilot:/workspace/openpilot/openpilot:/workspace/openpilot/opendbc_repo:/workspace/openpilot/msgq_repo:${PYTHONPATH} && \
python3 -c "import rclpy, av, cereal.messaging; print(\"ROS 2 and openpilot imports OK\")" && \
ros2 pkg prefix openpilot_ros2_bridge && \
{ test -x /workspace/openpilot/openpilot/cereal/messaging/bridge || test -x /workspace/openpilot/cereal/messaging/bridge; } && \
echo "cereal bridge found"'
Launch the containerized bridge from the host. No colcon build is needed when you run the launch file by path:
source /opt/ros/jazzy/setup.bash
ros2 launch /path/to/openpilot_ros2_bridge/launch/openpilot_bridge_docker.launch.py \
host_openpilot_root:=/path/to/openpilot \
comma_ip:=<comma_device_ip> \
ros_domain_id:=0
For local development from source, build the image from this checkout instead of pulling it:
ros2 launch /path/to/openpilot_ros2_bridge/launch/openpilot_bridge_docker.launch.py \
host_openpilot_root:=/path/to/openpilot \
image_name:=openpilot_ros2_bridge:local \
image_source:=build
The Docker launch file pulls (or builds) the image, starts the container with --network host and --ipc host, forwards ROS_DOMAIN_ID, RMW_IMPLEMENTATION and CYCLONEDDS_URI, and then runs the in-container openpilot_bridge.launch.py.
The container runs as your user. A root container can't share Fast DDS shared memory with your host nodes, so they would see topics but receive no data.
Inside the container, msgq uses its own OPENPILOT_PREFIX, so it never touches the queues of an openpilot running natively on the host.
For a quick runtime smoke test without a Comma device connection:
docker run --rm --network host --ipc host --user "$(id -u):$(id -g)" -e HOME=/tmp \
-v /path/to/openpilot:/workspace/openpilot \
ghcr.io/jkk-research/openpilot_ros2_bridge:latest \
timeout 15 bash /workspace/ros2_ws/src/openpilot_ros2_bridge/docker/run_openpilot_bridge_container.sh \
start_bridge:=false start_camera:=false
.github/workflows/publish-docker.yml publishes the Docker image to GHCR on pushes to main, version tags, and manual runs.
The image includes CycloneDDS and the tools prepare_openpilot needs, so no local image build is required.
To pull it by hand:
docker pull ghcr.io/jkk-research/openpilot_ros2_bridge:latest
@tambetm Thanks for the original ROS1 version.