ROS 2 talker, listener and a moving tf2 frame
A ROS 2 Jazzy package with a talker, a listener, a tf2 broadcaster that moves a frame in a circle and a node that looks the frame up, run in the official Docker image on macOS, Linux or Windows.
| Runs on | Any machine with Docker (Docker Desktop or OrbStack on macOS), ros:jazzy-ros-base image, under 10 minutes |
|---|---|
| Time | about 2 hours |
| Stack | python, ros2-python |
What you will build
This project builds a small ROS 2 package that shows the two things almost every robot program does: pass messages between nodes, and keep track of where coordinate frames are. It targets ROS 2 Jazzy Jalisco, the long-term support release from May 2024, which REP 2000 lists as supported until May 2029. Everything runs inside the official ros:jazzy-ros-base Docker image, so it works the same on a Mac, a Windows PC or a Linux laptop.
The package has four nodes:
talkerpublisheshello 0,hello 1and so on, asstd_msgs/Stringon the topic/chatter, at 10 Hz.listenersubscribes to/chatterand logs each message.circle_broadcasterpublishes a tf2 transform fromworldto a frame calledorbiter, which moves anticlockwise round a 1 m circle once every 4 s, at 50 Hz.frame_watcherlooks upworldtoorbitertwice a second and logs its position, distance from the origin and heading.
You will run the nodes two ways. First, all four in one Python process for 3 s, which prints a short summary and exits. That is the quickest check that your Docker set-up works, and it is the version our build pipeline runs. Second, as a proper ament_python package built with colcon and started with a launch file, one process per node, which is how you would structure it on a robot.
The lesson Hello, ROS 2 introduces nodes and topics. The lesson on frames and rotations covers the maths behind a transform. The ROS 2 CLI cheat sheet lists the commands used in the Run section.
What you need
- Docker. On macOS use Docker Desktop or OrbStack. On Windows use Docker Desktop. On Linux use Docker Engine. According to Docker Hub, the official
rosimage is published foramd64andarm64v8, so it runs natively on Apple Silicon. - The image:
docker pull ros:jazzy-ros-base. On our Mac it used 1.3 GB of disk. - No ROS install on the host. If you already run Ubuntu 24.04 with Jazzy installed natively, skip the Docker commands and run the same
colconandros2commands directly.
We tested everything on an Apple Silicon Mac mini with OrbStack, using the ros:jazzy-ros-base image as pulled in November 2026 (package ros-jazzy-ros-base 0.11.0, Python 3.12.3).
Design
Topics and QoS
The talker creates a publisher with a queue depth of 10 and a 0.1 s timer. The listener creates a subscription with the same depth. Both use the default QoS profile for this case: reliable delivery, volatile durability, keep last 10. Nodes find each other through DDS discovery, which is why a listener started after the talker still receives the messages published from then on.
tf2
tf2 keeps a tree of coordinate frames. Each edge is a transform from a parent frame to a child frame, stamped with a time. A broadcaster publishes edges on /tf. A listener subscribes to /tf and /tf_static and fills a Buffer, which keeps 10 s of history by default and can interpolate between stamps.
The broadcaster fills a geometry_msgs/TransformStamped with parent world, child orbiter, the current time, a translation and a rotation. Frames follow REP 103: right-handed, x forward, y left, z up. The rotation is a pure yaw that points the orbiter’s x axis along its direction of travel, which is 90 degrees ahead of its angle on the circle. ROS messages store quaternions in (x, y, z, w) order. A yaw ψ about z is (0, 0, sin(ψ/2), cos(ψ/2)), and the watcher recovers it with atan2(2wz, 1 - 2z²).
The watcher asks for lookup_transform("world", "orbiter", Time()). A zero time means “the latest transform you have”, which avoids errors when the newest stamp is a few milliseconds old. Asking for an exact time newer than the last message raises an ExtrapolationException. The first lookups may also fail with a LookupException, because the frames do not exist until the first broadcast arrives. The code catches all three tf2 exceptions and tries again on the next tick.
Executors
A node does nothing until an executor runs its callbacks. In the demo, one SingleThreadedExecutor holds all four nodes, and the loop calls spin_once(timeout_sec=0.05) until 3 s have passed. Each call waits up to 50 ms for a timer or message and runs one ready callback. Bounding the loop by time makes the program end by itself, and it then removes and destroys every node and calls rclpy.shutdown(). In the package each entry point instead calls rclpy.spin(node), which runs until Ctrl-C or until launch asks the process to stop.
Code
The nodes
This file is both the package module circle_demo/nodes.py and a standalone script. Run as a script, it starts all four nodes in one process for 3 s. The first line marks it for our checker, which runs it in the Jazzy container. To Python it is a comment.
# ros2
# circle_demo/nodes.py: talker, listener, tf2 broadcaster and tf2 listener (tested on ROS 2 Jazzy).
import math
import signal
import time
import rclpy
from geometry_msgs.msg import TransformStamped
from rclpy.executors import ExternalShutdownException, SingleThreadedExecutor
from rclpy.node import Node
from rclpy.time import Time
from std_msgs.msg import String
from tf2_ros import Buffer, TransformBroadcaster, TransformListener
from tf2_ros import LookupException, ConnectivityException, ExtrapolationException
RADIUS = 1.0 # m, radius of the circle traced by the orbiter frame
PERIOD = 4.0 # s, time for one lap
class Talker(Node):
def __init__(self) -> None:
super().__init__("talker")
self.pub = self.create_publisher(String, "chatter", 10)
self.count = 0
self.create_timer(0.1, self.tick) # 10 Hz
def tick(self) -> None:
msg = String()
msg.data = f"hello {self.count}"
self.pub.publish(msg)
self.count += 1
class Listener(Node):
def __init__(self, verbose: bool = True) -> None:
super().__init__("listener")
self.received: list[str] = []
self.verbose = verbose
self.create_subscription(String, "chatter", self.on_msg, 10)
def on_msg(self, msg: String) -> None:
self.received.append(msg.data)
if self.verbose:
self.get_logger().info(f"heard '{msg.data}'")
class CircleBroadcaster(Node):
"""Broadcasts world -> orbiter, a frame moving anticlockwise on a circle in the x-y plane."""
def __init__(self) -> None:
super().__init__("circle_broadcaster")
self.br = TransformBroadcaster(self)
self.t0 = self.get_clock().now()
self.create_timer(0.02, self.tick) # 50 Hz
def tick(self) -> None:
now = self.get_clock().now()
angle = 2 * math.pi * ((now - self.t0).nanoseconds * 1e-9) / PERIOD
t = TransformStamped()
t.header.stamp = now.to_msg()
t.header.frame_id = "world"
t.child_frame_id = "orbiter"
t.transform.translation.x = RADIUS * math.cos(angle)
t.transform.translation.y = RADIUS * math.sin(angle)
t.transform.translation.z = 0.0
# Yaw so the frame's x axis points along the direction of travel. ROS quaternions are (x, y, z, w).
yaw = angle + math.pi / 2
t.transform.rotation.z = math.sin(yaw / 2)
t.transform.rotation.w = math.cos(yaw / 2)
self.br.sendTransform(t)
class FrameWatcher(Node):
"""Looks up world -> orbiter twice a second and checks it lies on the circle."""
def __init__(self) -> None:
super().__init__("frame_watcher")
self.buffer = Buffer()
self.tf_listener = TransformListener(self.buffer, self)
self.samples: list[tuple[float, float, float]] = []
self.create_timer(0.5, self.tick)
def tick(self) -> None:
try:
tf = self.buffer.lookup_transform("world", "orbiter", Time()) # Time() = latest available
except (LookupException, ConnectivityException, ExtrapolationException) as e:
self.get_logger().info(f"no transform yet: {type(e).__name__}")
return
x, y = tf.transform.translation.x, tf.transform.translation.y
qz, qw = tf.transform.rotation.z, tf.transform.rotation.w
yaw = math.atan2(2 * qw * qz, 1 - 2 * qz * qz) # yaw of a pure z rotation
self.samples.append((x, y, yaw))
self.get_logger().info(f"orbiter at x={x:+.3f} y={y:+.3f} m, r={math.hypot(x, y):.3f} m, "
f"yaw={math.degrees(yaw):+.1f} deg")
def _spin_single(node_cls) -> None:
"""Entry point body used by the installed package: one node per process."""
rclpy.init()
node = node_cls()
try:
rclpy.spin(node)
except (KeyboardInterrupt, ExternalShutdownException):
pass # Ctrl-C or a shutdown request from launch
signal.signal(signal.SIGINT, signal.SIG_IGN) # a second Ctrl-C must not interrupt the clean-up
node.destroy_node()
rclpy.try_shutdown()
def talker_main() -> None:
_spin_single(Talker)
def listener_main() -> None:
_spin_single(Listener)
def broadcaster_main() -> None:
_spin_single(CircleBroadcaster)
def watcher_main() -> None:
_spin_single(FrameWatcher)
def demo(duration: float = 3.0) -> None:
"""All four nodes in one process for a fixed time, then a summary and a clean shutdown."""
rclpy.init()
nodes = [Talker(), Listener(verbose=False), CircleBroadcaster(), FrameWatcher()]
talker, listener, _, watcher = nodes
executor = SingleThreadedExecutor()
for n in nodes:
executor.add_node(n)
end = time.monotonic() + duration
while time.monotonic() < end:
executor.spin_once(timeout_sec=0.05)
print(f"talker sent {talker.count}, listener received {len(listener.received)}")
print(f"first: {listener.received[0]!r}, last: {listener.received[-1]!r}")
radii = [math.hypot(x, y) for x, y, _ in watcher.samples]
print(f"tf lookups: {len(watcher.samples)}, radius min {min(radii):.4f} m, max {max(radii):.4f} m")
for n in nodes:
executor.remove_node(n)
n.destroy_node()
executor.shutdown()
rclpy.shutdown()
print("shut down cleanly")
if __name__ == "__main__":
demo()
The _spin_single function handles a detail that bites most first ROS 2 packages. When you press Ctrl-C on ros2 launch in a terminal, the operating system sends SIGINT to every process in the foreground group, and launch then forwards another SIGINT to each node. The second signal can arrive while the node is being destroyed and print a KeyboardInterrupt traceback. Ignoring SIGINT once clean-up has started makes the nodes exit cleanly, as the launch output in the Test section shows.
The package
Lay the workspace out like this. resource/circle_demo is an empty marker file that lets the ament index find the package.
ros2_ws/
src/
circle_demo/
package.xml
setup.py
setup.cfg
resource/
circle_demo (empty file)
circle_demo/
__init__.py (empty file)
nodes.py (the file above)
launch/
circle_demo.launch.py
package.xml declares the package and what it needs at run time:
<?xml version="1.0"?>
<package format="3">
<name>circle_demo</name>
<version>0.1.0</version>
<description>Talker, listener and a tf2 frame moving in a circle.</description>
<maintainer email="you@example.com">Your Name</maintainer>
<license>Apache-2.0</license>
<exec_depend>rclpy</exec_depend>
<exec_depend>std_msgs</exec_depend>
<exec_depend>geometry_msgs</exec_depend>
<exec_depend>tf2_ros</exec_depend>
<exec_depend>launch</exec_depend>
<exec_depend>launch_ros</exec_depend>
<export>
<build_type>ament_python</build_type>
</export>
</package>
setup.py installs the module, the marker file, package.xml and the launch file, and creates one console script per node:
# snippet
from glob import glob
from setuptools import find_packages, setup
package_name = "circle_demo"
setup(
name=package_name,
version="0.1.0",
packages=find_packages(exclude=["test"]),
data_files=[
("share/ament_index/resource_index/packages", ["resource/" + package_name]),
("share/" + package_name, ["package.xml"]),
("share/" + package_name + "/launch", glob("launch/*.launch.py")),
],
install_requires=["setuptools"],
zip_safe=True,
maintainer="Your Name",
maintainer_email="you@example.com",
description="Talker, listener and a tf2 frame moving in a circle.",
license="Apache-2.0",
entry_points={
"console_scripts": [
"talker = circle_demo.nodes:talker_main",
"listener = circle_demo.nodes:listener_main",
"circle_broadcaster = circle_demo.nodes:broadcaster_main",
"frame_watcher = circle_demo.nodes:watcher_main",
],
},
)
setup.cfg tells setuptools to put those scripts where ros2 run looks for them:
[develop]
script_dir=$base/lib/circle_demo
[install]
install_scripts=$base/lib/circle_demo
The launch file starts the four nodes as four processes. output="screen" sends the listener and watcher logs to the terminal:
# snippet
from launch import LaunchDescription
from launch_ros.actions import Node
def generate_launch_description() -> LaunchDescription:
return LaunchDescription([
Node(package="circle_demo", executable="talker", name="talker"),
Node(package="circle_demo", executable="listener", name="listener", output="screen"),
Node(package="circle_demo", executable="circle_broadcaster", name="circle_broadcaster"),
Node(package="circle_demo", executable="frame_watcher", name="frame_watcher", output="screen"),
])
Run
All commands below run on the host (macOS, Windows or Linux) unless they say otherwise. Pull the image once:
docker pull ros:jazzy-ros-base
Quick check: one process, 3 seconds
Save the nodes file as nodes.py and pipe it into Python inside the container:
docker run --rm -i ros:jazzy-ros-base \
bash -lc 'source /opt/ros/jazzy/setup.bash && python3 -' < nodes.py
The package
Create the workspace with the layout above, then start a container with the workspace mounted at /ws. The --name lets you open a second shell in the same container later.
cd ~/ros2_ws
docker run -it --rm --name jazzy -v "$PWD":/ws -w /ws ros:jazzy-ros-base bash
Inside the container, build and launch:
source /opt/ros/jazzy/setup.bash
colcon build --symlink-install
source install/setup.bash
ros2 launch circle_demo circle_demo.launch.py
With --symlink-install, edits to nodes.py on your Mac take effect at the next launch without a rebuild. Press Ctrl-C to stop.
To look inside the running system, open a second terminal on the host:
docker exec -it jazzy bash
and inside that shell:
source /opt/ros/jazzy/setup.bash
source /ws/install/setup.bash
ros2 node list
ros2 topic echo --once /chatter
ros2 topic hz /tf
ros2 run tf2_ros tf2_echo world orbiter
All the nodes here run in one container, so DDS discovery stays inside it. We did not test nodes split across several containers or between a container and the macOS host. That needs extra Docker networking set-up, which the ROS 2 documentation covers in its guide to running ROS 2 nodes in Docker.
Test
The quick check prints the watcher’s lookups, then a summary, and exits after about 3 s. You will see five or six lookups, depending on whether the sixth 0.5 s timer tick lands before the cut-off. Timestamps will differ, and the angles shift by a degree or two between runs because the timers do not start at exactly the same moment. The rest should match:
[INFO] [1794674607.289850288] [frame_watcher]: orbiter at x=+0.728 y=+0.686 m, r=1.000 m, yaw=+133.3 deg
[INFO] [1794674607.770268149] [frame_watcher]: orbiter at x=-0.001 y=+1.000 m, r=1.000 m, yaw=-180.0 deg
[INFO] [1794674608.271134201] [frame_watcher]: orbiter at x=-0.688 y=+0.725 m, r=1.000 m, yaw=-136.5 deg
[INFO] [1794674608.770265570] [frame_watcher]: orbiter at x=-1.000 y=+0.029 m, r=1.000 m, yaw=-91.7 deg
[INFO] [1794674609.271573417] [frame_watcher]: orbiter at x=-0.728 y=-0.686 m, r=1.000 m, yaw=-46.7 deg
[INFO] [1794674609.770458076] [frame_watcher]: orbiter at x=+0.000 y=-1.000 m, r=1.000 m, yaw=+0.0 deg
talker sent 30, listener received 30
first: 'hello 0', last: 'hello 29'
tf lookups: 6, radius min 1.0000 m, max 1.0000 m
shut down cleanly
The talker sent 30 messages in 3 s (10 Hz) and the listener received all 30. Every lookup is 1.000 m from the origin. Successive lookups are 0.5 s apart, an eighth of a lap, so the position steps round the circle 45 degrees at a time and the heading leads the position angle by 90 degrees. The heading is printed in the range -180 to +180 degrees, so a heading slightly past +180 degrees appears as a value near -180 degrees.
The build and the launch file give this (first lines, then the shutdown after Ctrl-C):
Starting >>> circle_demo
Finished <<< circle_demo [0.59s]
Summary: 1 package finished [0.65s]
[INFO] [launch]: All log files can be found below /root/.ros/log/2026-11-14-16-37-58-913561-12fd9347ea43-40
[INFO] [launch]: Default logging verbosity is set to INFO
[INFO] [talker-1]: process started with pid [45]
[INFO] [listener-2]: process started with pid [46]
[INFO] [circle_broadcaster-3]: process started with pid [47]
[INFO] [frame_watcher-4]: process started with pid [48]
[listener-2] [INFO] [1794674279.294878908] [listener]: heard 'hello 0'
[listener-2] [INFO] [1794674279.387673320] [listener]: heard 'hello 1'
[listener-2] [INFO] [1794674279.490933375] [listener]: heard 'hello 2'
[listener-2] [INFO] [1794674279.592702464] [listener]: heard 'hello 3'
[listener-2] [INFO] [1794674279.688700685] [listener]: heard 'hello 4'
[frame_watcher-4] [INFO] [1794674279.705474615] [frame_watcher]: orbiter at x=+0.725 y=+0.689 m, r=1.000 m, yaw=+133.6 deg
...
[WARNING] [launch]: user interrupted with ctrl-c (SIGINT)
[INFO] [frame_watcher-4]: sending signal 'SIGINT' to process[frame_watcher-4]
[INFO] [circle_broadcaster-3]: sending signal 'SIGINT' to process[circle_broadcaster-3]
[INFO] [listener-2]: sending signal 'SIGINT' to process[listener-2]
[INFO] [talker-1]: sending signal 'SIGINT' to process[talker-1]
[INFO] [circle_broadcaster-3]: process has finished cleanly [pid 47]
[INFO] [frame_watcher-4]: process has finished cleanly [pid 48]
[INFO] [talker-1]: process has finished cleanly [pid 45]
[INFO] [listener-2]: process has finished cleanly [pid 46]
Each node reports “process has finished cleanly”, with no traceback. If you see a KeyboardInterrupt traceback on Ctrl-C, check that _spin_single ignores SIGINT before it destroys the node.
From the second shell, ros2 node list showed the four nodes. ros2 topic hz /tf reported an average rate of 49.7 Hz, close to the 50 Hz timer. tf2_echo printed the transform once a second:
At time 1794673755.364677983
- Translation: [0.660, 0.751, 0.000]
- Rotation: in Quaternion (xyzw) [0.000, 0.000, 0.936, 0.353]
- Rotation: in RPY (radian) [0.000, -0.000, 2.421]
- Rotation: in RPY (degree) [0.000, -0.000, 138.692]
- Matrix:
-0.751 -0.660 0.000 0.660
0.660 -0.751 0.000 0.751
0.000 0.000 1.000 0.000
0.000 0.000 0.000 1.000
tf2_echo prints the quaternion in (x, y, z, w) order, the same as the message. A rotation about z only has its x and y parts at zero.
On a real robot
On a robot the same patterns carry real data, and a few things change.
- Real frames. The
orbiterframe stands in for a moving part of the robot. A mobile base publishesodomtobase_linkfrom wheel odometry, and a localisation node publishesmaptoodom, following REP 105. Fixed sensor mounts go on/tf_staticonce, usually from the URDF throughrobot_state_publisher. - Clocks. tf2 lookups compare timestamps from different machines. Synchronise clocks with NTP or PTP, or lookups at a specific time will fail or return stale data. In simulation, set
use_sim_timeso that every node uses the simulator’s clock. - QoS for sensors. High-rate sensor topics usually use best-effort QoS. A reliable subscriber cannot receive from a best-effort publisher, and this mismatch is a common reason why a subscriber hears nothing.
- More than one machine. Nodes on different computers find each other through DDS discovery on the network. Give each robot its own
ROS_DOMAIN_IDif several share a network. Check firewall rules before suspecting your code. - Executors. A node that blocks in a callback stalls every other callback in a single-threaded executor. Use a
MultiThreadedExecutorwith callback groups, or move slow work out of callbacks.
The code in this project has no hardware-specific part, so nothing changes in the files themselves when the frames come from a real robot.
Things to try
- Static transform. Add a
StaticTransformBroadcasterthat publishesorbitertosensor, 0.1 m ahead on the orbiter’s x axis. Look upworldtosensorand check that it lies on a circle of radius √(1 + 0.01) m, about 1.005 m. - Time travel. Look up the transform at
now - 1 swithlookup_transform("world", "orbiter", t, timeout=Duration(seconds=0.1)). Check that the position is a quarter of a lap behind. - Break discovery. Start the launch file with
ROS_DOMAIN_ID=5set in one shell and runros2 topic echo /chatterwith the default domain in another. Nothing arrives. - QoS mismatch. Make the listener’s subscription use
qos_profile_sensor_data(best effort) while the talker stays reliable, then swap them round. One combination works and one does not. - A C++ node. Rewrite the frame watcher with
rclcppandtf2_ros::Buffer. It runs alongside the Python nodes unchanged.
Summary
- A ROS 2 Python package needs
package.xml,setup.py,setup.cfg, aresource/marker and console-script entry points. A launch file starts the nodes. - tf2 broadcasters publish stamped parent-to-child transforms on
/tf. ABufferwith aTransformListenercollects them and answers lookups.Time()asks for the latest. - ROS messages store quaternions as (x, y, z, w). A pure yaw is (0, 0, sin(ψ/2), cos(ψ/2)).
- A bounded
spin_onceloop with an explicit executor makes a ROS 2 program that ends by itself, which suits tests and CI. - The official
ros:jazzy-ros-baseimage runs natively on Apple Silicon. Mount your workspace into it and work as you would on Ubuntu.
The ROS 2 documentation has the matching tutorials: writing a simple publisher and subscriber (Python), writing a tf2 broadcaster (Python) and creating a launch file. The ROS and middleware topic hub and the ROS platform page put them in context.