roostField / Lab
Curriculum
Phase 01Lesson 4 of 5
75 min
Instrument the robotWeeks 1–2

ROS 2 essentials: nodes, topics, QoS, and bags

ROS 2 is the lingua franca of robot software: a typed pub/sub bus whose QoS, executor, and recording defaults all fail silently if you treat them as details. Learn the graph, the negotiation rules, and the flight recorder — and when to bypass all of it.

After this lesson you can
  • Sketch the ROS 2 graph for a WidowX AI + RealSense bring-up and justify topic vs service vs action for each interface.
  • Diagnose a silent QoS mismatch in under a minute, and choose deliberate reliability and depth settings for sensor and command topics.
  • Predict worst-case control-timer jitter from executor structure and eliminate it with callback groups or process separation.
  • Record a replayable episode bag with the right topics and QoS handling, and argue when a single-process logger is the better choice for latency research.

You have built systems where dozens of services coordinate through a message bus, so the honest way to introduce ROS 2 is this: it is a typed pub/sub middleware with a service mesh's discovery, a real-time system's delivery knobs, and no broker. Nodes publish typed messages on named topics; everything else — services, actions, parameters, the transform tree, bag recording — is built on that substrate. The previous lesson gave you clock discipline; this one gives you the transport that carries those timestamps between processes. You need it even if your capstone ends up single-process: every arm driver, camera driver, and visualization tool you will touch speaks ROS — and its defaults fail in ways that corrupt experiments silently.

Foundations: QoS Compatibility and Buffer Dynamics

Before configuring endpoints, we must correct two common misconceptions that lead to silent failures. First, QoS (Quality of Service) is not a property of a topic, but of the endpoint (publisher or subscriber). Second, the compatibility rules are not a simple 'stronger wins' hierarchy; they are a specific matrix of logical constraints. A subscriber requesting RELIABLE guarantees that every message is delivered. A publisher offering BEST_EFFORT promises only to send once, without retransmission. If a subscriber demands a guarantee the publisher cannot provide, the connection is refused. However, if a subscriber requests BEST_EFFORT (accepting potential loss) and the publisher offers RELIABLE (providing guarantees), the match succeeds because the publisher's offer exceeds the subscriber's request. This asymmetry explains why command topics (RELIABLE pub, RELIABLE sub) and sensor topics (BEST_EFFORT pub, BEST_EFFORT sub) behave differently, and why a default rclpy subscriber (RELIABLE) fails to connect to a RealSense driver (BEST_EFFORT).

Subscriber RequestPublisher OfferMatch?Reason
RELIABLERELIABLEYesOffer meets request
RELIABLEBEST_EFFORTNoOffer cannot satisfy request
BEST_EFFORTRELIABLEYesOffer exceeds request
BEST_EFFORTBEST_EFFORTYesOffer meets request
QoS Reliability Compatibility Matrix

The second misconception concerns buffer dynamics. The previous section introduced a utilization ratio ρ=fpubtcb\rho = f_{\text{pub}} \cdot t_{\text{cb}}. While useful for intuition, this model is imprecise for KEEP_LAST buffers. A KEEP_LAST buffer with depth dd does not drop a random fraction of messages; it drops the oldest message when a new one arrives and the buffer is full. The critical variable is not the ratio, but the comparison between the arrival interval Tarr=1/fpubT_{\text{arr}} = 1/f_{\text{pub}} and the callback service time tcbt_{\text{cb}}. If tcb>Tarrt_{\text{cb}} > T_{\text{arr}}, the buffer fills permanently. The age of the message being processed is determined by the buffer depth and the arrival rate, not by a utilization fraction. Specifically, if the buffer is full, the message at the head of the queue is approximately dTarrd \cdot T_{\text{arr}} old. This 'staleness' is the primary cost of backpressure in real-time systems.

Tarr=1fpub,StalenessmaxdTarrif tcb>TarrT_{\text{arr}} = \frac{1}{f_{\text{pub}}}, \quad \text{Staleness}_{\max} \approx d \cdot T_{\text{arr}} \quad \text{if } t_{\text{cb}} > T_{\text{arr}}
Buffer staleness depends on depth and arrival rate, not utilization ratio.

Worked Example: Camera Buffer Analysis

Consider a 30 Hz camera publishing images. The subscriber callback takes 40 ms to process. The QoS is KEEP_LAST with depth 5. We analyze the buffer behavior and the resulting data staleness.

  1. Arrival Interval: Tarr=1/30 Hz33.3 msT_{\text{arr}} = 1/30 \text{ Hz} \approx 33.3 \text{ ms}.
  2. Service Time: tcb=40 mst_{\text{cb}} = 40 \text{ ms}.
  3. Buffer Behavior: Since tcb(40 ms)>Tarr(33.3 ms)t_{\text{cb}} (40 \text{ ms}) > T_{\text{arr}} (33.3 \text{ ms}), the buffer will always be full.
  4. Staleness: The subscriber processes the oldest message in the buffer. With depth 5, the maximum age of a processed message is 5×33.3 ms166.5 ms5 \times 33.3 \text{ ms} \approx 166.5 \text{ ms}.
  5. Drop Rate: The subscriber processes at 1/40 ms=25 Hz1/40 \text{ ms} = 25 \text{ Hz}. The publisher sends at 30 Hz30 \text{ Hz}. The drop rate is 3025=5 messages/sec30 - 25 = 5 \text{ messages/sec}, or 1/61/6 of all messages.
  6. Error: If the robot moves at 0.25 m/s0.25 \text{ m/s}, the position error due to staleness is 0.25 m/s×0.1665 s4.16 cm0.25 \text{ m/s} \times 0.1665 \text{ s} \approx 4.16 \text{ cm}.
Checkpoint 01

A subscriber requests RELIABLE QoS. A publisher offers BEST_EFFORT QoS. What happens?

The computation graph: four primitives and a discovery protocol

A running robot is a graph of nodes — independently schedulable units, usually one per process — connected by topics carrying typed messages. The types are compiled IDL: sensor_msgs/Image is a stamped header plus encoding, dimensions, and a byte buffer; sensor_msgs/JointState is a header plus parallel arrays of names, positions, velocities, efforts. Your minimal bring-up graph is four nodes: the RealSense driver publishing /camera/color/image_raw at 30 Hz (0.92 MB per 640×480 RGB frame, 27.6 MB/s), the Trossen arm driver exchanging /joint_states and command topics with the WidowX AI's iNerve controller over UDP (joint states at up to ~500 Hz, a few hundred bytes each), robot_state_publisher converting joint states into transforms, and your own code. The official concepts docs (opens in a new tab) cover vocabulary; what follows is the part that bites.

  • Topic — continuous unidirectional streams: images, joint states, motor commands. No reply, no handshake; the newest message is usually the only one that matters. This is 90% of a manipulation stack.
  • Service — a synchronous request/response pair: enable torque, switch a control mode, read a register. Configuration, never control — a blocked service call inside a control path is a stalled robot.
  • Action — goal, periodic feedback, result, cancellation. The trajectory controller's FollowJointTrajectory interface is the canonical example. Use one wherever you would design a job API with progress reporting.
  • Parameter — typed runtime configuration attached to a node, declared and validated at startup.

There is no master and no broker. Each node participates in DDS discovery — multicast announcements on the local network — and publishers match subscribers by topic name, type, and QoS compatibility. Nodes can therefore start in any order; restrict discovery to localhost on a single-workstation rig (ROS_LOCALHOST_ONLY=1 on Humble, ROS_AUTOMATIC_DISCOVERY_RANGE=LOCALHOST on newer distros). Brokerlessness has a darker consequence: no central place records that two endpoints which should have connected did not. That failure mode — and how to see it — is the next section.

QoS: the per-connection SLA that can silently disconnect you

Every publisher offers a QoS profile and every subscriber requests one; DDS matches them with a request-versus-offered rule — a connection forms only if the offer is at least as strong as the request. Reliability and durability participate in matching; history depth does not, being a local buffering decision. The defaults encode a worldview: rclpy endpoints default to RELIABLE with a keep-last queue of 10 — right for commands, wrong for sensors. The built-in sensor-data profile chooses BEST_EFFORT with depth 5, on the theory that a 33 ms-old frame worth retransmitting is worth less than the fresh frame behind it.

PolicySensor-data profilerclpy defaultMismatch consequence
ReliabilityBEST_EFFORT — send once, never retransmitRELIABLE — retransmit until acknowledgedSub requests RELIABLE, pub offers BEST_EFFORT → no match, zero messages
DurabilityVOLATILE — late joiners get nothingVOLATILESub requests TRANSIENT_LOCAL, pub offers VOLATILE → no match
History / depthKEEP_LAST 5KEEP_LAST 10Never blocks matching — purely local queue sizing
Deadlinenot setnot setSub requests a tighter deadline than pub offers → no match; runtime misses fire QoS events
The QoS policies you will actually set, and what happens when they disagree

Here is the failure you will hit in week one. The RealSense driver offers BEST_EFFORT on its image topics. Your subscriber, built with the default profile, requests RELIABLE — a stronger request than the offer, so DDS declines the match and your callback simply never fires. No exception, no log line, and ros2 topic echo works fine from the shell, because the CLI adapts its request to whatever the publisher offers. The debug ritual: ros2 topic info /camera/color/image_raw --verbose prints every endpoint's offered and requested QoS, and the mismatch is obvious in ten seconds. You can also register an incompatible-QoS event callback so this class of bug logs itself.

History depth is the other half of QoS, and it is really a backpressure policy. Derive what it does to your data. Messages arrive at rate fpubf_{\text{pub}}; your callback takes tcbt_{\text{cb}} seconds, so the executor drains at most 1/tcb1/t_{\text{cb}} per second. The ratio of arrival to drain rate is a utilization:

ρ  =  fpub1/tcb  =  fpubtcb\rho \;=\; \frac{f_{\text{pub}}}{1 / t_{\text{cb}}} \;=\; f_{\text{pub}} \cdot t_{\text{cb}}
Utilization of a subscription: arrival rate times callback service time.

If ρ>1\rho > 1, each second fpubf_{\text{pub}} messages arrive and only 1/tcb1/t_{\text{cb}} leave, so the depth-dd keep-last buffer stays permanently full and sheds the difference. The dropped fraction is the excess over arrivals, and every message you do process waited behind up to dd predecessors:

drop fraction  =  fpub1/tcbfpub  =  11ρ,queue-added age    dtcb\text{drop fraction} \;=\; \frac{f_{\text{pub}} - 1/t_{\text{cb}}}{f_{\text{pub}}} \;=\; 1 - \frac{1}{\rho}, \qquad \text{queue-added age} \;\le\; d \cdot t_{\text{cb}}
A 30 Hz camera with a 40 ms callback gives ρ = 1.2: one frame in six dropped forever, and at the default depth of 10 the frames you do process are up to 400 ms old.

Seen through this math, the sensor-data profile stops looking arbitrary: its depth of 5 is not saving frames, it is bounding staleness, and for a latest-value control consumer the correct depth is usually 1. This is the timing lesson wearing middleware clothes — a queued frame is a stale frame, and ε=vΔt\varepsilon = v\,\Delta t prices it: 400 ms of queue age during a 0.25 m/s reach is a 10 cm error in where the policy believes the gripper is.

Checkpoint 02

Your subscriber uses rclpy's default QoS on the RealSense image topic, which the driver publishes as BEST_EFFORT. ros2 topic echo prints frames from the shell. What does your callback see?

Executors and callback groups: the event loop you must not block

rclpy.spin(node) runs a single-threaded executor: one thread pulls ready work — subscription callbacks, timers, service handlers — and runs each to completion, with no preemption. Every intuition you have about blocking an event loop applies, with a robotics twist: the thing being delayed is the timer that commands your arm. Non-preemption gives a clean worst-case bound. A timer becomes ready every TT seconds, but if another callback started running just before the tick, the timer waits — and in the worst case that callback just began:

timer release jitter    maxitcb,iε  =  vmaxitcb,i\text{timer release jitter} \;\le\; \max_i \, t_{\text{cb},i} \qquad\Rightarrow\qquad \varepsilon \;=\; v \cdot \max_i \, t_{\text{cb},i}
Under a non-preemptive single-threaded executor, any callback's worst-case delay is the longest runtime among the callbacks sharing its thread — and the staleness formula converts that delay into millimeters.

Concretely: a 100 Hz control timer sharing an executor with a 25 ms image-preprocessing callback fires up to 25 ms late — two and a half command periods — every time preprocessing runs. At 0.25 m/s end-effector speed that is 6 mm of unmodeled world motion, injected not by the network or the GPU but by your own process architecture. Measure it as always: log time.monotonic() at the top of the timer callback and plot inter-tick intervals. Starvation is a bimodal histogram, one lobe at TT and a second near T+tcbT + t_{\text{cb}}.

Three escapes, in increasing order of isolation. Callback groups: assign the control timer its own mutually-exclusive group under a multi-threaded executor, so the image callback and the timer proceed on different threads. The Python caveat: CPU-bound callbacks still contend on the GIL, so numpy-heavy preprocessing can starve the timer anyway unless the hot loop releases the GIL inside C extensions. Process separation is the honest fix: sensing in one process, control in another, and the OS scheduler preempts where the executor cannot. On your rig, run the camera driver, the arm driver, and your policy client as three processes and let DDS do the job it was built for.

Checkpoint 03

A 100 Hz control timer shares a default single-threaded executor with an image callback that takes 25 ms. What do you observe?

Here is the pattern in runnable form: a sensor node that stamps at read time, and a control node whose subscription and command publisher use deliberately different profiles, with the control timer isolated in its own callback group. Explicit QoS, capture-time stamps, a staleness guard — this skeleton is the honest core of every ROS 2 control stack you will read.

minimal_stack.py — one sensor node, one control node, explicit QoSpython
import rclpy
from rclpy.callback_groups import MutuallyExclusiveCallbackGroup
from rclpy.executors import MultiThreadedExecutor
from rclpy.node import Node
from rclpy.qos import (DurabilityPolicy, HistoryPolicy, QoSProfile,
                       ReliabilityPolicy)
from sensor_msgs.msg import JointState

SENSOR_QOS = QoSProfile(          # matches the built-in sensor-data profile
    reliability=ReliabilityPolicy.BEST_EFFORT,
    durability=DurabilityPolicy.VOLATILE,
    history=HistoryPolicy.KEEP_LAST,
    depth=5,
)

COMMAND_QOS = QoSProfile(         # losing a command is bad; a stale one is worse
    reliability=ReliabilityPolicy.RELIABLE,
    history=HistoryPolicy.KEEP_LAST,
    depth=1,
)


class JointStateSensor(Node):
    """Reads encoders (mocked here) and publishes stamped states at 100 Hz."""

    NAMES = ["joint_0", "joint_1", "joint_2", "joint_3",
             "joint_4", "joint_5", "gripper"]

    def __init__(self):
        super().__init__("joint_state_sensor")
        self.pub = self.create_publisher(JointState, "joint_states", SENSOR_QOS)
        self.timer = self.create_timer(0.010, self.tick)   # 100 Hz

    def tick(self):
        positions = self.read_encoders()
        msg = JointState()
        # Stamp when the encoders were read, not when publish() runs.
        msg.header.stamp = self.get_clock().now().to_msg()
        msg.name = list(self.NAMES)
        msg.position = positions
        self.pub.publish(msg)

    def read_encoders(self):
        # Real driver: latest ~500 Hz UDP joint-state update
        # from the arm's iNerve controller.
        return [0.0] * 7


class Controller(Node):
    """Consumes joint states, emits position commands at 50 Hz."""

    def __init__(self):
        super().__init__("controller")
        self.latest = None
        # The control timer gets its own group so a slow subscription
        # callback can never delay the command tick.
        self.ctrl_group = MutuallyExclusiveCallbackGroup()
        self.sub = self.create_subscription(
            JointState, "joint_states", self.on_state, SENSOR_QOS)
        self.cmd_pub = self.create_publisher(
            JointState, "joint_commands", COMMAND_QOS)
        self.timer = self.create_timer(
            0.020, self.tick, callback_group=self.ctrl_group)

    def on_state(self, msg):
        self.latest = msg

    def tick(self):
        if self.latest is None:
            return
        now = self.get_clock().now().to_msg()
        stamp = self.latest.header.stamp
        age_s = (now.sec - stamp.sec) + (now.nanosec - stamp.nanosec) * 1e-9
        if age_s > 0.100:
            self.get_logger().warning("state is stale, holding position")
            return
        cmd = JointState()
        cmd.header.stamp = now
        cmd.name = list(self.latest.name)
        cmd.position = list(self.latest.position)   # placeholder policy
        self.cmd_pub.publish(cmd)


def main():
    rclpy.init()
    sensor = JointStateSensor()
    controller = Controller()
    executor = MultiThreadedExecutor(num_threads=2)
    executor.add_node(sensor)
    executor.add_node(controller)
    try:
        executor.spin()
    finally:
        rclpy.shutdown()


if __name__ == "__main__":
    main()

Notice the command topic: RELIABLE with depth 1, because a queued stale command is worse than none. And the controller checks observation age before acting — middleware delivers messages, but only your code enforces freshness.

tf2: the transform tree, live and time-indexed

The frames lesson gave you the kinematic tree as math; tf2 is that tree as infrastructure. Every node that knows a transform broadcasts it: robot_state_publisher consumes /joint_states and publishes the base-to-gripper chain on /tf at the joint-state rate, while fixed mounts — camera bracket, table — go on /tf_static, published as TRANSIENT_LOCAL so a late-starting subscriber still receives the last static tree (compacted-topic semantics, and durability's one indispensable use). Every listener maintains a local buffer holding, by default, 10 seconds of transform history — and the killer feature is the time-indexed query. Ask for camera-to-base at the timestamp of a specific image, and the buffer interpolates between the two samples that bracket it:

α=tt1t2t1,p(t)=(1α)p1+αp2,q(t)=slerp(q1,q2,α)\alpha = \frac{t - t_1}{t_2 - t_1}, \qquad \mathbf{p}(t) = (1-\alpha)\,\mathbf{p}_1 + \alpha\,\mathbf{p}_2, \qquad \mathbf{q}(t) = \operatorname{slerp}(\mathbf{q}_1, \mathbf{q}_2, \alpha)
Linear interpolation for translation, spherical linear interpolation for rotation — valid only for 0 ≤ α ≤ 1. Outside that range the buffer refuses and raises an extrapolation error, by design.

Interpolation is accurate when samples are dense; extrapolating a 6-DOF arm mid-motion invents geometry, so tf2 refuses to do it. The exception you will actually meet in week one is extrapolation into the future: an image stamped at capture arrives at your node a few milliseconds before robot_state_publisher has pushed transforms for that instant, and a naive immediate lookup at the image stamp fails with a lookup-would-require-extrapolation error. It is a race between two topics, and the refusal is correct. Two fixes: pass a timeout so the lookup blocks briefly (50 ms is generous on one host), or knowingly query the latest available transform and log the accepted time gap as measured staleness — never swallow the exception and retry blindly.

Recording: rosbag2, and the case for not using it

rosbag2 is the flight recorder: it subscribes to a topic list, writes the raw serialized bytes with receipt timestamps into an MCAP (opens in a new tab) or SQLite file, and stores each topic's type plus the publisher's offered QoS in metadata. Two properties matter. The recorder adapts its subscription QoS to what publishers offer, so it captures BEST_EFFORT sensor topics without the mismatch trap you just learned (a QoS override file handles exotic cases). And playback re-offers the recorded QoS and can republish the clock, so nodes running on sim time re-experience the original timeline — what makes a bag a debuggable artifact rather than a pile of bytes. Budget the disk before recording images raw. Two RealSense-class cameras at 640×480, RGB plus 16-bit depth, 30 fps:

B  =  ifisi  =  2×30×0.92RGB  +  2×30×0.61depth    92 MB/s    16.6 GB per 3-minute episodeB \;=\; \sum_i f_i\, s_i \;=\; \underbrace{2 \times 30 \times 0.92}_{\text{RGB}} \;+\; \underbrace{2 \times 30 \times 0.61}_{\text{depth}} \;\approx\; 92\ \text{MB/s} \;\approx\; 16.6\ \text{GB per 3-minute episode}
Raw RGB-D from two cameras saturates a SATA SSD and fills an NVMe drive fast — which is why compressed image transports (a few MB/s per camera) are the recording default.

What goes in an episode bag: the image topics and their camera_info (intrinsics must travel with the data, or your calibration claims become unfalsifiable), /joint_states, every command topic, and /tf plus /tf_static. The timing lesson's rule generalizes: record inputs, not derivations — anything you can recompute, recompute from raw at analysis time, so a bug in derived code never poisons a dataset.

Episode recording and QoS forensics from the shellbash
# Is the publisher actually offering what you assume?
ros2 topic info /camera/color/image_raw --verbose

# Measure rates and bandwidth instead of trusting launch files
ros2 topic hz /joint_states
ros2 topic bw /camera/color/image_raw

# Record an episode: sensors, commands, calibration, and the transform tree
ros2 bag record -o episode_001 /camera/color/image_raw /camera/color/camera_info /joint_states /joint_commands /tf /tf_static

# Inspect what was actually captured
ros2 bag info episode_001

# Replay on the recorded timeline for nodes running with use_sim_time
ros2 bag play episode_001 --clock

One honesty note on determinism. Replay reproduces message content and approximate timing, but not microsecond-level delivery order — DDS is not a serialized log, and best-effort topics were recorded with whatever loss occurred live. Treat header stamps as ground truth and receipt times as diagnostics, and replay is trustworthy for analysis and policy-input reconstruction, though never for bitwise regression tests.

ApproachWhat you getWhat it costsReach for it when
rosbag2 (MCAP)Every serialized message with types and offered QoS; replay into rviz and Foxglove; captures topics from code you did not writeSerialization and DDS in the loop; ~92 MB/s raw for two RGB-D cameras; a conversion step before trainingMulti-node debugging, driver bring-up, anything involving drivers you do not control
LeRobot-style dataset loggerEpisodes born in training layout — video files plus tabular states and actions; dataloaders work immediatelyYou write the driver glue; no graph-wide capture; useless for debugging other nodesSteady-state data collection for policy training
Single-process Python loggerOne process, one monotonic clock, no middleware jitter; the whole loop is measurable end to endYou own drivers, schema, and visualization yourself; nothing off-the-shelf plugs inLatency-critical experiments — including the capstone's evaluation runs
Three ways to record a robot, and when each wins

Now the judgment call the ecosystem will not make for you. For a one-arm lab whose research question is latency, a full ROS stack inserts serialization, DDS scheduling, and executor jitter between every pair of components — each a confound in your measurements. Trossen's libtrossen_arm driver (C++ with Python bindings) and librealsense expose clean Python APIs, and LeRobot (opens in a new tab)-style single-process stacks — camera read, policy step, command write, log append, one loop on one monotonic clock — exist precisely because, for collection and evaluation on a single robot, they are simpler and more measurable. The mature posture: use ROS 2 for bring-up, driver verification, teleoperation, and visualization (rviz and Foxglove alone justify it), and a single-process stack for the capstone's latency-critical experiments, with next lesson's episode schema as the common interface. It is the same decision as bypassing a service mesh for a hot inference path: knowing the middleware is what earns you the right to bypass it, because you can name exactly which costs you removed — and measure the difference.

Studio exercise 01

Sabotage your own graph, three ways

Stand up the two-node pipeline from this lesson plus an image source (the RealSense driver, or a mock publisher sending 0.9 MB messages at 30 Hz). Break it three ways, one at a time, recording each observed symptom and the fastest diagnostic that reveals the cause: (1) set the state subscriber's reliability to RELIABLE against the BEST_EFFORT publisher; (2) move the image callback onto the control node's executor thread with a 40 ms busy-wait inside it; (3) on each image arrival, immediately look up camera-to-base at exactly the image's header stamp with zero timeout. Deliver a three-row table: sabotage, symptom, detection method, fix.

Need a hint?

For (1), compare ros2 topic hz against your node's own callback counter, then run ros2 topic info --verbose. For (2), log time.monotonic() in the control timer and compare inter-tick histograms — look for a second lobe. For (3), catch the exception and print the requested stamp against the newest time in the tf buffer; that gap is your joint-state-to-tf latency, worth keeping.

Where this goes next: Time is a sensor: clocks, timestamps, and latency gave you clock discipline; this lesson gave you the middleware that moves stamped data between processes — and the silent ways it fails. In Lab 0: bring-up, safety, and the episode logger, both go to work on the physical arm: a safe bring-up over the Trossen ROS 2 driver, QoS-verified sensor streams, and the episode logger — shaped by the bags-versus-single-process tradeoff you just weighed — that records every dataset for the rest of the course.