Build a ROS2 WebSocket Topic Bridge
Bridge two isolated ROS2 domains over WebSocket using raw CDR serialization.
Introduction
30 Second Summary
Robots on separate networks can't talk to each other by default. If you have two ROS2 systems in different rooms, buildings, or even different continents, their discovery protocol simply doesn't reach across network boundaries.
In this project, you will build a lightweight WebSocket topic bridge that connects two physically isolated ROS2 systems through a relay server. Messages travel as raw CDR bytes so no deserialization happens in transit.
What You'll Build
A ROS2 WebSocket topic bridge that forwards raw CDR-serialized messages between two isolated DDS domains through a central relay server, configured entirely through YAML files.
By the end of this project, you'll have:
- A standalone WebSocket relay server that forwards binary messages between connected clients.
- A ROS2 bridge node with YAML-based topic filtering that subscribes to local topics, sends CDR-serialized data over WebSocket, and republishes remote data locally.
- A custom binary wire protocol for framing topic names, types, and payloads into a single message.
- Secret Mission: add connection diagnostics with a /bridge_status topic that reports real-time connection state and message throughput.
Are there any prerequisites?
This project assumes you have a ROS2 Humble workspace already set up on your machine and that you're familiar with creating basic ROS2 publishers and subscribers in Python.
Before We Start
The WebSocket bridge you're building needs a Python networking library and a clean project structure.
In this step, you'll install the websockets library, create your project directory, and verify everything is ready to go.
In this step, get ready to:
- Install the websockets Python library.
- Create a project directory with four starter files.
- Verify the library installed correctly.
Install the websockets library
The websockets library provides the networking layer your bridge will use to send and receive binary data between ROS2 machines.
- Open a terminal by pressing Ctrl+Alt+T.
- Install version 16.0 of the websockets library by running this command:
pip install websockets==16.0
Why pin to version 16.0?
Pinning a specific version ensures every learner gets the same behavior.
Version 16.0 uses the modern asyncio-based API with from websockets.asyncio.server import serve and from websockets.asyncio.client import connect. Older versions use a different import path.
Seeing a permission error?
If pip reports a permission denied error, try running pip install --user websockets==16.0 instead.
If the pip command is not found, try pip3 install websockets==16.0.
help me fix my pip install error
Create the project directory and files
Your project needs four files. Two Python scripts (the relay server and the bridge node) and two YAML configs (one per robot).
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir ros2-ws-bridge
cd ros2-ws-bridge
- Create the four project files by running this command:
touch relay_server.py bridge_node.py config_robot_a.yaml config_robot_b.yaml
- Confirm the files exist by running:
ls
You should see relay_server.py, bridge_node.py, config_robot_a.yaml, and config_robot_b.yaml in the output.
Don't see all four files?
Make sure you ran the touch command from inside the ros2-ws-bridge folder. Run pwd to check your current location.
If the touch command is not recognized, you may be on a system without it. Create the files manually in your text editor instead.
help me create my project files
Verify the websockets installation
- Confirm that Python can import the library by running this command:
python3 -c "import websockets; print(websockets.__version__)"
You should see 16.0 printed in the terminal.
Seeing ModuleNotFoundError?
This means pip installed the library for a different Python than the one python3 points to. Try running python3 -m pip install websockets==16.0 to ensure the install targets the correct Python.
help me fix my websockets import error
Your workspace is ready. Next up, you'll write the relay server that shuttles binary messages between connected clients.
Build the WebSocket Relay Server
Your environment is ready with the websockets library installed and your project files created. Now it's time to build the first piece of the bridge: the relay server.
The relay server is a standalone Python script with no ROS2 dependency. It sits between your robots, accepts WebSocket connections, and blindly forwards any message from one client to every other connected client.
In this step, get ready to:
- Write an asyncio WebSocket server that tracks connected clients.
- Forward incoming binary messages to all other clients.
- Test that the server starts and logs its address.
Write the connection handler
Every time a client connects to the relay server, a handler function runs for that connection. The handler needs to track the client, listen for messages, and forward each message to every other connected client.
- Open relay_server.py in your editor and paste the following code:
import asyncio
from websockets.asyncio.server import serve
# Track all connected WebSocket clients
CLIENTS = set()
async def handler(websocket):
"""Handle a new WebSocket client connection."""
# Register this client
CLIENTS.add(websocket)
print(f"Client connected. Total clients: {len(CLIENTS)}")
try:
# Listen for incoming messages from this client
async for message in websocket:
# Forward to every other connected client
for client in CLIENTS:
if client != websocket:
try:
await client.send(message)
except Exception:
pass
finally:
# Clean up when client disconnects
CLIENTS.remove(websocket)
print(f"Client disconnected. Total clients: {len(CLIENTS)}")
What does this code do?
The CLIENTS set keeps track of every active WebSocket connection. When a new client connects, the handler adds it to the set.
The async for message in websocket loop waits for incoming messages. Each message gets forwarded to every client except the sender. The finally block removes the client from the set when it disconnects, even if an error occurs.
- Save relay_server.py.
You won't see a change yet. The next substep adds the entry point that lets you actually run the server.
Add the server entry point
The handler defines what happens per client, but the server needs a main() function to bind to a port and start listening for connections.
- In relay_server.py, add this code below the handler function:
async def main():
print("Relay server starting on ws://0.0.0.0:8765")
# Create the WebSocket server and run it forever
async with serve(handler, "0.0.0.0", 8765) as server:
await server.serve_forever()
if __name__ == "__main__":
asyncio.run(main())
What is the asyncio pattern here?
The async with serve(handler, host, port) call creates a WebSocket server as an async context manager. It calls your handler function once per new connection.
The serve_forever() call keeps the server running until you press Ctrl+C. The asyncio.run(main()) at the bottom starts the entire asyncio event loop.
- Save relay_server.py.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on to the test.
ⓧ I'd like to double check the full code
Here is the complete relay_server.py for comparison:
import asyncio
from websockets.asyncio.server import serve
CLIENTS = set()
async def handler(websocket):
"""Handle a new WebSocket client connection."""
CLIENTS.add(websocket)
print(f"Client connected. Total clients: {len(CLIENTS)}")
try:
async for message in websocket:
for client in CLIENTS:
if client != websocket:
try:
await client.send(message)
except Exception:
pass
finally:
CLIENTS.remove(websocket)
print(f"Client disconnected. Total clients: {len(CLIENTS)}")
async def main():
print("Relay server starting on ws://0.0.0.0:8765")
async with serve(handler, "0.0.0.0", 8765) as server:
await server.serve_forever()
if __name__ == "__main__":
asyncio.run(main())
Test the relay server
- Run the relay server by executing this command in your terminal:
python3 relay_server.py
You should see this output printed to the terminal:
Relay server starting on ws://0.0.0.0:8765
The server is now running and waiting for WebSocket connections. It will stay running until you stop it.
- Press Ctrl+C to stop the server.
Don't see the log message?
- Make sure you saved relay_server.py after adding the main() function. Unsaved changes won't run.
- Check that your if __name__ == "__main__": line is at the bottom of the file with no extra indentation.
- If you see Address already in use, another process is using port 8765. Close any other terminal running the relay server and try again.
Why isn't my relay server starting?
Your relay server is working. Next up, you'll build the bridge node that connects ROS2 topics to this server using a custom binary wire protocol.
Build the Bridge Node - Configuration and Wire Protocol
Your relay server is done and ready to forward bytes between clients. Now you need the code that lives on each robot and decides what to send and how to frame it.
In this step, you will write the utility functions that handle message framing and dynamic type resolution. These are the foundation of the bridge node. They let you pack a ROS2 topic name, message type, and raw CDR bytes into a single binary frame that travels over the WebSocket connection.
In this step, get ready to:
- Set up the bridge node file with all required imports.
- Write a dynamic message class loader for runtime type resolution.
- Implement binary wire protocol helpers that frame ROS2 messages for WebSocket transport.
Set up the bridge node imports
The bridge node will combine ROS2 subscriptions, WebSocket networking, threading, and binary packing. You need imports from all of these domains up front so the file is ready to grow in the next steps.
- Open bridge_node.py in your editor and add all the imports by pasting this code:
import asyncio
import queue
import struct
import sys
import threading
import yaml
import rclpy
from rclpy.node import Node
from rclpy.qos import QoSProfile, ReliabilityPolicy, HistoryPolicy
from websockets.asyncio.client import connect
What do these imports do?
- asyncio and threading let the WebSocket client run alongside the ROS2 executor in separate threads.
- queue provides thread-safe communication between the ROS2 thread and the WebSocket thread.
- struct handles binary packing and unpacking for the wire protocol header.
- yaml reads the YAML configuration files that define which topics to bridge. It is already installed as a ROS2 Humble dependency.
- websockets.asyncio.client.connect is the async WebSocket client from websockets 16.0.
How will the YAML configuration work?
Each robot will have a YAML config file with three keys:
- server_url points to the relay server address.
- publish_topics lists local topics to forward to the remote machine.
- subscribe_topics lists remote topics to republish locally.
You will write the actual config files and the class that reads them in later steps. For now, having yaml imported means the bridge node is ready to load them.
- Save bridge_node.py.
You won't see a change yet. The next chunk adds the first function that uses these imports.
Write the dynamic message class loader
ROS2 message types are specified as strings in the config (like std_msgs/msg/String). The bridge needs to convert that string into an actual Python class at runtime so it can create subscriptions and publishers.
- In bridge_node.py, add the following function below the imports:
def get_message_class(type_string):
"""Import a ROS2 message class from a type string like 'std_msgs/msg/String'."""
package, subfolder, message_name = type_string.split('/')
module = __import__(f'{package}.{subfolder}', fromlist=[message_name])
return getattr(module, message_name)
What does this code do?
- type_string.split('/') breaks "std_msgs/msg/String" into three parts: the package name, the subfolder (always msg), and the class name.
- __import__ dynamically imports the module at runtime. For std_msgs/msg/String, it imports std_msgs.msg.
- getattr retrieves the String class from that module so you can use it to create subscriptions and publishers.
- Save bridge_node.py.
You won't see output yet. The next function uses this loader when setting up subscriptions.
Implement the binary wire protocol
When a ROS2 message travels over the WebSocket, the relay server needs zero knowledge of ROS2. It just forwards raw bytes. But the receiving bridge needs to know which topic the bytes belong to and what type they are.
You will solve this by packing a small header in front of the CDR payload. The frame format is: 2 bytes for topic name length, the topic name, 2 bytes for type string length, the type string, then the raw CDR bytes.
- In bridge_node.py, add the following two functions below get_message_class():
def pack_message(topic_name, type_string, cdr_data):
"""Pack topic name, type string, and CDR data into a binary frame."""
topic_bytes = topic_name.encode('utf-8')
type_bytes = type_string.encode('utf-8')
header = struct.pack('>HH', len(topic_bytes), len(type_bytes))
return header + topic_bytes + type_bytes + cdr_data
def unpack_message(data):
"""Unpack a binary frame into topic name, type string, and CDR data."""
topic_len, type_len = struct.unpack('>HH', data[:4])
offset = 4
topic_name = data[offset:offset + topic_len].decode('utf-8')
offset += topic_len
type_string = data[offset:offset + type_len].decode('utf-8')
offset += type_len
cdr_data = data[offset:]
return topic_name, type_string, cdr_data
What does this wire protocol do?
pack_message() encodes a single frame that the relay server can blindly forward:
- struct.pack('>HH', ...) writes two unsigned 16-bit integers in big-endian byte order. These are the lengths of the topic name and type string.
- The topic name and type string follow as UTF-8 bytes.
- The CDR payload (the raw serialized ROS2 message) is appended at the end with no modification.
unpack_message() reverses this process. It reads the header to find where each section starts, then slices the frame apart.
Why big-endian byte order?
The > prefix in the struct format string forces big-endian (network byte order). This guarantees both sides of the bridge interpret the header identically regardless of CPU architecture.
- Save bridge_node.py.
- Verify the wire protocol works by running this round-trip test:
python3 -c "from bridge_node import pack_message, unpack_message; frame = pack_message('/chatter', 'std_msgs/msg/String', b'\x00\x01hello'); print(unpack_message(frame))"
You should see a tuple containing the topic name, type string, and the original bytes restored.
Seeing a ModuleNotFoundError?
- Make sure you are running the command from inside the project folder that contains bridge_node.py.
- Check that you saved the file after adding all three functions.
- If you see an error about yaml or rclpy, confirm your ROS2 environment is sourced by running echo $ROS_DISTRO. It should print humble.
help me debug a ModuleNotFoundError when importing from bridge_node.py
✔️ Awesome, I've got everything!
Great. Double check you have saved bridge_node.py with all three functions in place.
ⓧ I'd like to double check the full code
Here is the complete bridge_node.py at the end of this step:
import asyncio
import queue
import struct
import sys
import threading
import yaml
import rclpy
from rclpy.node import Node
from rclpy.qos import QoSProfile, ReliabilityPolicy, HistoryPolicy
from websockets.asyncio.client import connect
def get_message_class(type_string):
"""Import a ROS2 message class from a type string like 'std_msgs/msg/String'."""
package, subfolder, message_name = type_string.split('/')
module = __import__(f'{package}.{subfolder}', fromlist=[message_name])
return getattr(module, message_name)
def pack_message(topic_name, type_string, cdr_data):
"""Pack topic name, type string, and CDR data into a binary frame."""
topic_bytes = topic_name.encode('utf-8')
type_bytes = type_string.encode('utf-8')
header = struct.pack('>HH', len(topic_bytes), len(type_bytes))
return header + topic_bytes + type_bytes + cdr_data
def unpack_message(data):
"""Unpack a binary frame into topic name, type string, and CDR data."""
topic_len, type_len = struct.unpack('>HH', data[:4])
offset = 4
topic_name = data[offset:offset + topic_len].decode('utf-8')
offset += topic_len
type_string = data[offset:offset + type_len].decode('utf-8')
offset += type_len
cdr_data = data[offset:]
return topic_name, type_string, cdr_data
Your utility functions are locked in. Next up, you will build the BridgeNode class that uses these helpers to subscribe to local ROS2 topics and queue their CDR bytes for WebSocket delivery.
Build the Bridge Node - ROS2 Subscriptions and Publishers
Your helper functions can pack and unpack binary frames, and they can dynamically import any ROS2 message class. Now it's time to build the actual ROS2 node that uses them.
The bridge node's job is to subscribe to local topics, capture their raw CDR bytes without deserializing, and queue them for sending. It also needs to receive incoming frames from the other side and republish them locally. This step builds all of that plumbing.
In this step, get ready to:
- Create the BridgeNode class with YAML config loading and thread-safe queues.
- Build raw subscriptions with a closure-based callback factory.
- Add publishers and a timer that drains the incoming message queue.
Set up the bridge node class with configuration
The bridge node needs to read a YAML config file to know which topics to forward and which to receive. It also needs two thread-safe queues to communicate with the WebSocket client that you will build in the next step.
- In bridge_node.py, find the closing line of the unpack_message function. Below it, add the following class definition:
class BridgeNode(Node):
def __init__(self, config_path):
super().__init__('websocket_bridge')
# Load the YAML config file
with open(config_path, 'r') as f:
self.config = yaml.safe_load(f)
self.server_url = self.config['server_url']
# Thread-safe queues for WebSocket communication
self.outgoing_queue = queue.Queue()
self.incoming_queue = queue.Queue()
# Subscribers use BEST_EFFORT to receive from any publisher
sub_qos = QoSProfile(
reliability=ReliabilityPolicy.BEST_EFFORT,
history=HistoryPolicy.KEEP_LAST,
depth=10
)
# Publishers use RELIABLE for maximum compatibility
pub_qos = QoSProfile(
reliability=ReliabilityPolicy.RELIABLE,
history=HistoryPolicy.KEEP_LAST,
depth=10
)
What does this code do?
The class inherits from Node and names itself websocket_bridge in the ROS2 graph. It reads the YAML config to learn which topics to forward and where the relay server lives.
The two queue.Queue instances are how the ROS2 thread and the WebSocket thread will exchange data safely. The QoS profiles control reliability. BEST_EFFORT on subscribers means they can receive from any publisher without negotiation failures.
Why two different QoS profiles?
Subscribers set to BEST_EFFORT can receive from both BEST_EFFORT and RELIABLE publishers. Publishers set to RELIABLE are compatible with both RELIABLE and BEST_EFFORT subscribers. This combination maximizes compatibility with any topic you bridge.
- Save bridge_node.py.
- You won't see a change yet. The next chunk adds the subscriptions that use these QoS profiles.
Create raw subscriptions with the callback factory
Raw subscriptions are the key to this bridge. When you pass raw=True to create_subscription, ROS2 delivers the message as raw CDR bytes instead of a deserialized Python object. This means zero conversion overhead.
There is a subtle Python problem here. If you create callbacks inside a loop, they all capture the same loop variable. A closure factory fixes this by binding each callback to its own copy of the topic name and type.
- Still inside the __init__ method (directly below the pub_qos block you just wrote), add the subscription loop:
self.subscriptions_list = []
for topic_config in self.config.get('publish_topics', []):
topic_name = topic_config['name']
type_string = topic_config['type']
msg_class = get_message_class(type_string)
# Closure factory binds topic_name and type_string per iteration
def make_callback(t_name, t_type):
def callback(msg):
packed = pack_message(t_name, t_type, msg)
self.outgoing_queue.put(packed)
return callback
sub = self.create_subscription(
msg_class,
topic_name,
make_callback(topic_name, type_string),
sub_qos,
raw=True
)
self.subscriptions_list.append(sub)
self.get_logger().info(f'Forwarding local topic: {topic_name} [{type_string}]')
What does this code do?
For each topic listed in publish_topics in the YAML config, this loop creates a raw subscription. When a message arrives, the callback receives raw CDR bytes (not a deserialized object) because of raw=True.
The make_callback function is the closure factory. It creates a fresh callback function that captures its own t_name and t_type values. Without this, every callback would reference the last loop iteration's variables.
Inside the callback, pack_message frames the topic name, type string, and raw bytes into the binary wire protocol you built earlier. The packed frame goes into outgoing_queue for the WebSocket thread to send.
What is CDR serialization?
CDR (Common Data Representation) is the binary format that DDS uses under the hood to encode ROS2 messages on the wire. Every ROS2 message is already CDR-encoded before it reaches your callback when raw=True is set. The bridge forwards these bytes directly without ever converting them to Python objects.
- Save bridge_node.py.
- You won't see a change yet. The next chunk adds the publishers that handle the other direction.
Seeing an indentation error?
All the code in this chunk must be indented inside __init__ (two levels of indentation: one for the class, one for the method). Make sure self.subscriptions_list = [] sits at the same indent level as self.outgoing_queue above it.
help me fix an indentation error in my BridgeNode class
Add publishers and the incoming message processor
The subscriptions handle local-to-remote. Now you need the reverse direction. Publishers will republish CDR bytes that arrive from the remote side. A fast timer polls the incoming queue and pushes data to the correct publisher.
- Directly below the subscription loop (still inside __init__), add the publisher setup and timer:
self.publishers_dict = {}
for topic_config in self.config.get('subscribe_topics', []):
topic_name = topic_config['name']
type_string = topic_config['type']
msg_class = get_message_class(type_string)
pub = self.create_publisher(msg_class, topic_name, pub_qos)
self.publishers_dict[topic_name] = pub
self.get_logger().info(f'Republishing remote topic: {topic_name} [{type_string}]')
# Poll incoming queue every 10ms
self.create_timer(0.01, self.process_incoming)
What does this code do?
For each topic listed in subscribe_topics in the config, this creates a publisher and stores it in publishers_dict keyed by topic name. When a remote message arrives for that topic, the node can look up the right publisher instantly.
The timer fires every 10 milliseconds and calls process_incoming. This gives near-real-time republishing without burning CPU in a tight spin loop.
- Now add the process_incoming method. Place it outside __init__ but still inside the BridgeNode class (one level of indentation for the class only):
def process_incoming(self):
"""Check incoming queue and publish any received messages."""
while not self.incoming_queue.empty():
try:
data = self.incoming_queue.get_nowait()
topic_name, type_string, cdr_data = unpack_message(data)
if topic_name in self.publishers_dict:
self.publishers_dict[topic_name].publish(cdr_data)
except queue.Empty:
break
except Exception as e:
self.get_logger().error(f'Error publishing: {e}')
What does this code do?
This method drains the incoming queue on every timer tick. It unpacks each frame back into a topic name, type string, and CDR payload using unpack_message. If the topic has a matching publisher in publishers_dict, it publishes the raw CDR bytes directly.
The publish(cdr_data) call accepts raw bytes. ROS2 publishes them directly onto the DDS wire without any serialization step. This is what makes the bridge zero-copy on the data path.
- Save bridge_node.py.
- Verify your file has no syntax errors by running:
python3 -c "import ast; ast.parse(open('bridge_node.py').read()); print('Syntax OK')"
- You should see Syntax OK printed in the terminal.
Seeing a SyntaxError?
Check that process_incoming is indented one level inside the class (same as def __init__). Also make sure the __init__ method's last line (self.create_timer(...)) has a blank line after it before the new method definition.
help me fix a syntax error in my bridge_node.py
✔️ Awesome, I've got everything!
Great. Make sure you have saved bridge_node.py before moving on.
ⓧ I'd like to double check the full code
Here is the full bridge_node.py as it should look at this point:
import asyncio
import queue
import struct
import sys
import threading
import yaml
import rclpy
from rclpy.node import Node
from rclpy.qos import QoSProfile, ReliabilityPolicy, HistoryPolicy
from websockets.asyncio.client import connect
def get_message_class(type_string):
"""Import a ROS2 message class from a type string like 'std_msgs/msg/String'."""
package, subfolder, message_name = type_string.split('/')
module = __import__(f'{package}.{subfolder}', fromlist=[message_name])
return getattr(module, message_name)
def pack_message(topic_name, type_string, cdr_data):
"""Pack topic name, type string, and CDR data into a binary frame."""
topic_bytes = topic_name.encode('utf-8')
type_bytes = type_string.encode('utf-8')
header = struct.pack('>HH', len(topic_bytes), len(type_bytes))
return header + topic_bytes + type_bytes + cdr_data
def unpack_message(data):
"""Unpack a binary frame into topic name, type string, and CDR data."""
topic_len, type_len = struct.unpack('>HH', data[:4])
offset = 4
topic_name = data[offset:offset + topic_len].decode('utf-8')
offset += topic_len
type_string = data[offset:offset + type_len].decode('utf-8')
offset += type_len
cdr_data = data[offset:]
return topic_name, type_string, cdr_data
class BridgeNode(Node):
def __init__(self, config_path):
super().__init__('websocket_bridge')
with open(config_path, 'r') as f:
self.config = yaml.safe_load(f)
self.server_url = self.config['server_url']
self.outgoing_queue = queue.Queue()
self.incoming_queue = queue.Queue()
sub_qos = QoSProfile(
reliability=ReliabilityPolicy.BEST_EFFORT,
history=HistoryPolicy.KEEP_LAST,
depth=10
)
pub_qos = QoSProfile(
reliability=ReliabilityPolicy.RELIABLE,
history=HistoryPolicy.KEEP_LAST,
depth=10
)
self.subscriptions_list = []
for topic_config in self.config.get('publish_topics', []):
topic_name = topic_config['name']
type_string = topic_config['type']
msg_class = get_message_class(type_string)
def make_callback(t_name, t_type):
def callback(msg):
packed = pack_message(t_name, t_type, msg)
self.outgoing_queue.put(packed)
return callback
sub = self.create_subscription(
msg_class,
topic_name,
make_callback(topic_name, type_string),
sub_qos,
raw=True
)
self.subscriptions_list.append(sub)
self.get_logger().info(f'Forwarding local topic: {topic_name} [{type_string}]')
self.publishers_dict = {}
for topic_config in self.config.get('subscribe_topics', []):
topic_name = topic_config['name']
type_string = topic_config['type']
msg_class = get_message_class(type_string)
pub = self.create_publisher(msg_class, topic_name, pub_qos)
self.publishers_dict[topic_name] = pub
self.get_logger().info(f'Republishing remote topic: {topic_name} [{type_string}]')
self.create_timer(0.01, self.process_incoming)
def process_incoming(self):
"""Check incoming queue and publish any received messages."""
while not self.incoming_queue.empty():
try:
data = self.incoming_queue.get_nowait()
topic_name, type_string, cdr_data = unpack_message(data)
if topic_name in self.publishers_dict:
self.publishers_dict[topic_name].publish(cdr_data)
except queue.Empty:
break
except Exception as e:
self.get_logger().error(f'Error publishing: {e}')
Your bridge node can now subscribe to local topics, capture raw CDR bytes, and republish incoming bytes from the remote side. Next up, you will build the WebSocket client thread that actually sends and receives data over the network.
Build the Bridge Node - WebSocket Client Thread
Your bridge node now knows which topics to subscribe to and which to republish. It can pack and unpack binary frames. But it has no way to actually send those frames to the relay server or receive frames from other bridges.
In this step, you will build the WebSocket client that connects the bridge node to the relay server. The client runs in its own thread so it does not block the ROS2 executor, and it automatically reconnects if the connection drops.
In this step, get ready to:
- Implement the send and receive coroutines that move data between queues and the WebSocket.
- Build the main coroutine with auto-reconnection logic.
- Wire the daemon thread and script entry point together.
Implement the WebSocket send and receive loops
The WebSocket client needs two concurrent jobs. One drains the outgoing queue and sends each frame to the relay server. The other listens for incoming frames and deposits them in the incoming queue for ROS2 to pick up.
- In bridge_node.py, find the process_incoming method at the bottom of the BridgeNode class.
- Add the following two methods below process_incoming:
async def ws_send_loop(self, websocket):
"""Send outgoing messages from the queue to the WebSocket server."""
while True:
try:
data = self.outgoing_queue.get_nowait()
await websocket.send(data)
except queue.Empty:
await asyncio.sleep(0.01)
async def ws_recv_loop(self, websocket):
"""Receive messages from the WebSocket server and queue them."""
async for message in websocket:
self.incoming_queue.put(message)
What does this code do?
These are the two leaf coroutines that handle all data transfer between the bridge and the relay server.
- ws_send_loop uses get_nowait() to poll the outgoing queue without blocking the event loop. If the queue is empty, it sleeps for 10ms before trying again.
- ws_recv_loop uses async for message in websocket to iterate over incoming frames. Each received frame goes straight into the incoming queue for the ROS2 timer to pick up.
- Save bridge_node.py.
- You won't see a change yet. The next chunk wires these loops into the connection lifecycle.
Add the main coroutine with auto-reconnection
A production bridge must tolerate network outages. If the relay server goes down or the connection drops, the bridge should wait and retry automatically.
- Below the ws_recv_loop method you just added, add these two methods:
def run_ws_client(self):
"""Run the WebSocket client in a separate thread."""
asyncio.run(self.ws_main())
async def ws_main(self):
"""Main WebSocket client coroutine with auto-reconnection."""
while True:
try:
async with connect(self.server_url) as websocket:
self.get_logger().info(f'Connected to relay server: {self.server_url}')
send_task = asyncio.create_task(self.ws_send_loop(websocket))
recv_task = asyncio.create_task(self.ws_recv_loop(websocket))
done, pending = await asyncio.wait(
[send_task, recv_task],
return_when=asyncio.FIRST_COMPLETED
)
for task in pending:
task.cancel()
except Exception as e:
self.get_logger().warn(f'WebSocket connection failed: {e}. Retrying in 3s...')
await asyncio.sleep(3.0)
What does this code do?
- run_ws_client is the thread entry point. It creates a fresh asyncio event loop in this thread by calling asyncio.run().
- ws_main wraps the connection in a while True loop. If either the send or receive task exits (which happens when the connection drops), it cancels the other task and falls through to the except block.
- The except block logs the failure and waits 3 seconds before the while True loop retries the connection.
- asyncio.wait with FIRST_COMPLETED ensures that if one loop crashes, the other is cleaned up immediately.
- Save bridge_node.py.
- You won't see a change yet. The next chunk starts the thread and adds the script entry point.
Wire the daemon thread and script entry point
The ROS2 executor must spin in the main thread. The WebSocket client runs in a separate daemon thread so it shuts down automatically when the main thread exits.
- In bridge_node.py, scroll up to the __init__ method.
- Find this line at the end of __init__:
self.create_timer(0.01, self.process_incoming)
- Add the following lines directly below it:
self.ws_thread = threading.Thread(target=self.run_ws_client, daemon=True)
self.ws_thread.start()
Why a daemon thread?
Setting daemon=True means the thread dies automatically when the main thread exits (e.g. on Ctrl+C). Without this flag, the WebSocket thread would keep the process alive after the ROS2 node shuts down.
Now add the script entry point. This is a standalone function outside the class that handles argument parsing, ROS2 initialization, and clean shutdown.
- Scroll to the very bottom of bridge_node.py, below the BridgeNode class.
- Add the following main() function and if __name__ guard:
def main():
if len(sys.argv) < 2:
print("Usage: python3 bridge_node.py <config.yaml>")
sys.exit(1)
rclpy.init()
node = BridgeNode(sys.argv[1])
try:
rclpy.spin(node)
except KeyboardInterrupt:
pass
finally:
node.destroy_node()
rclpy.shutdown()
if __name__ == '__main__':
main()
What does this code do?
- The argument check ensures the learner passes a YAML config file path when running the script.
- rclpy.init() starts the ROS2 runtime. rclpy.spin(node) blocks the main thread, processing callbacks until the node is interrupted.
- The finally block guarantees the node is destroyed and ROS2 is shut down cleanly, even after a Ctrl+C.
- Save bridge_node.py.
- Verify the script runs without import errors by running:
python3 bridge_node.py
- You should see the usage message: Usage: python3 bridge_node.py <config.yaml>. This confirms all imports resolve and the script reaches the argument check.
Seeing a ModuleNotFoundError?
- If the error mentions websockets, make sure you installed it with pip install websockets==16.0 in the same Python environment that ROS2 uses.
- If the error mentions rclpy, confirm you have sourced your ROS2 workspace. Run source /opt/ros/humble/setup.bash and try again.
- If you see a SyntaxError, check indentation. All the methods you added must be indented one level inside the BridgeNode class (8 spaces or 2 tabs).
Why is my bridge_node.py throwing an import error?
✔️ Awesome, I've got everything!
Great. Make sure you have saved bridge_node.py before moving on.
ⓧ I'd like to double check the full code
Here is the complete bridge_node.py after this step:
import asyncio
import queue
import struct
import sys
import threading
import yaml
import rclpy
from rclpy.node import Node
from rclpy.qos import QoSProfile, ReliabilityPolicy, HistoryPolicy
from websockets.asyncio.client import connect
def get_message_class(type_string):
"""Import a ROS2 message class from a type string like 'std_msgs/msg/String'."""
package, subfolder, message_name = type_string.split('/')
module = __import__(f'{package}.{subfolder}', fromlist=[message_name])
return getattr(module, message_name)
def pack_message(topic_name, type_string, cdr_data):
"""Pack topic name, type string, and CDR data into a binary frame."""
topic_bytes = topic_name.encode('utf-8')
type_bytes = type_string.encode('utf-8')
header = struct.pack('>HH', len(topic_bytes), len(type_bytes))
return header + topic_bytes + type_bytes + cdr_data
def unpack_message(data):
"""Unpack a binary frame into topic name, type string, and CDR data."""
topic_len, type_len = struct.unpack('>HH', data[:4])
offset = 4
topic_name = data[offset:offset + topic_len].decode('utf-8')
offset += topic_len
type_string = data[offset:offset + type_len].decode('utf-8')
offset += type_len
cdr_data = data[offset:]
return topic_name, type_string, cdr_data
class BridgeNode(Node):
def __init__(self, config_path):
super().__init__('websocket_bridge')
with open(config_path, 'r') as f:
self.config = yaml.safe_load(f)
self.server_url = self.config['server_url']
self.outgoing_queue = queue.Queue()
self.incoming_queue = queue.Queue()
sub_qos = QoSProfile(
reliability=ReliabilityPolicy.BEST_EFFORT,
history=HistoryPolicy.KEEP_LAST,
depth=10
)
pub_qos = QoSProfile(
reliability=ReliabilityPolicy.RELIABLE,
history=HistoryPolicy.KEEP_LAST,
depth=10
)
self.subscriptions_list = []
for topic_config in self.config.get('publish_topics', []):
topic_name = topic_config['name']
type_string = topic_config['type']
msg_class = get_message_class(type_string)
def make_callback(t_name, t_type):
def callback(msg):
packed = pack_message(t_name, t_type, msg)
self.outgoing_queue.put(packed)
return callback
sub = self.create_subscription(
msg_class,
topic_name,
make_callback(topic_name, type_string),
sub_qos,
raw=True
)
self.subscriptions_list.append(sub)
self.get_logger().info(f'Forwarding local topic: {topic_name} [{type_string}]')
self.publishers_dict = {}
for topic_config in self.config.get('subscribe_topics', []):
topic_name = topic_config['name']
type_string = topic_config['type']
msg_class = get_message_class(type_string)
pub = self.create_publisher(msg_class, topic_name, pub_qos)
self.publishers_dict[topic_name] = pub
self.get_logger().info(f'Republishing remote topic: {topic_name} [{type_string}]')
self.create_timer(0.01, self.process_incoming)
self.ws_thread = threading.Thread(target=self.run_ws_client, daemon=True)
self.ws_thread.start()
def process_incoming(self):
"""Check incoming queue and publish any received messages."""
while not self.incoming_queue.empty():
try:
data = self.incoming_queue.get_nowait()
topic_name, type_string, cdr_data = unpack_message(data)
if topic_name in self.publishers_dict:
self.publishers_dict[topic_name].publish(cdr_data)
except queue.Empty:
break
except Exception as e:
self.get_logger().error(f'Error publishing: {e}')
def run_ws_client(self):
"""Run the WebSocket client in a separate thread."""
asyncio.run(self.ws_main())
async def ws_main(self):
"""Main WebSocket client coroutine with auto-reconnection."""
while True:
try:
async with connect(self.server_url) as websocket:
self.get_logger().info(f'Connected to relay server: {self.server_url}')
send_task = asyncio.create_task(self.ws_send_loop(websocket))
recv_task = asyncio.create_task(self.ws_recv_loop(websocket))
done, pending = await asyncio.wait(
[send_task, recv_task],
return_when=asyncio.FIRST_COMPLETED
)
for task in pending:
task.cancel()
except Exception as e:
self.get_logger().warn(f'WebSocket connection failed: {e}. Retrying in 3s...')
await asyncio.sleep(3.0)
async def ws_send_loop(self, websocket):
"""Send outgoing messages from the queue to the WebSocket server."""
while True:
try:
data = self.outgoing_queue.get_nowait()
await websocket.send(data)
except queue.Empty:
await asyncio.sleep(0.01)
async def ws_recv_loop(self, websocket):
"""Receive messages from the WebSocket server and queue them."""
async for message in websocket:
self.incoming_queue.put(message)
def main():
if len(sys.argv) < 2:
print("Usage: python3 bridge_node.py <config.yaml>")
sys.exit(1)
rclpy.init()
node = BridgeNode(sys.argv[1])
try:
rclpy.spin(node)
except KeyboardInterrupt:
pass
finally:
node.destroy_node()
rclpy.shutdown()
if __name__ == '__main__':
main()
Your bridge node is fully wired. It subscribes to local topics, packs CDR bytes into frames, sends them over WebSocket, and republishes incoming frames locally. Next up, you will write the YAML configs and test the full bridge end-to-end across two isolated ROS2 domains.
Test the Full Bridge
Your bridge node and relay server are complete. Now it is time to prove they work together by sending a real ROS2 message across two isolated DDS domains.
You will write the YAML configuration for each "robot," launch the full system across five terminals, and watch a message travel from one domain through the WebSocket relay into the other domain.
In this step, get ready to:
- Write the YAML configs for Robot A and Robot B.
- Launch the relay server and both bridge nodes in isolated DDS domains.
- Verify end-to-end message delivery across the bridge.
Write the Robot A configuration
Each bridge node reads a YAML file that tells it which topics to forward from its local domain and which remote topics to republish locally.
- Open config_robot_a.yaml and paste the following configuration:
server_url: "ws://localhost:8765"
publish_topics:
- name: "/chatter"
type: "std_msgs/msg/String"
subscribe_topics:
- name: "/response"
type: "std_msgs/msg/String"
What does this config do?
- server_url points the bridge at your relay server running on localhost port 8765.
- publish_topics lists local topics to forward. Robot A captures /chatter messages from its domain and sends them over WebSocket.
- subscribe_topics lists remote topics to republish locally. Robot A receives /response from the relay and publishes it into its own domain.
- Save config_robot_a.yaml.
Write the Robot B configuration
Robot B is the mirror of Robot A. It forwards /response and subscribes to /chatter.
- Open config_robot_b.yaml and paste the following configuration:
server_url: "ws://localhost:8765"
publish_topics:
- name: "/response"
type: "std_msgs/msg/String"
subscribe_topics:
- name: "/chatter"
type: "std_msgs/msg/String"
Why are the topics flipped?
The publish_topics and subscribe_topics lists must never overlap on the same bridge node. If they did, the bridge would forward a message it just received, creating an infinite loop. By making Robot B the exact inverse of Robot A, each topic flows in one direction only.
- Save config_robot_b.yaml.
✔️ Awesome, I've got everything!
Great. Double check that both YAML files are saved and ready to go.
ⓧ I'd like to double check the full code
Here is config_robot_a.yaml:
server_url: "ws://localhost:8765"
publish_topics:
- name: "/chatter"
type: "std_msgs/msg/String"
subscribe_topics:
- name: "/response"
type: "std_msgs/msg/String"
Here is config_robot_b.yaml:
server_url: "ws://localhost:8765"
publish_topics:
- name: "/response"
type: "std_msgs/msg/String"
subscribe_topics:
- name: "/chatter"
type: "std_msgs/msg/String"
Launch the full system
You need five separate terminal windows to run the full bridge system. Each terminal runs one component of the pipeline.
What is ROS_DOMAIN_ID?
Setting ROS_DOMAIN_ID to different values on the same machine creates completely isolated DDS domains. Nodes in domain 0 cannot discover nodes in domain 1. This perfectly simulates two robots on separate physical networks without needing multiple machines.
- Open your first terminal. Start the relay server by running:
python3 relay_server.py
- You should see Relay server starting on ws://0.0.0.0:8765 printed in the terminal.
- Open a second terminal. Launch Bridge A in domain 0 by running:
ROS_DOMAIN_ID=0 python3 bridge_node.py config_robot_a.yaml
- You should see log messages including Connected to relay server: ws://localhost:8765 and Forwarding local topic: /chatter.
- Open a third terminal. Launch Bridge B in domain 1 by running:
ROS_DOMAIN_ID=1 python3 bridge_node.py config_robot_b.yaml
- You should see similar connection and topic log messages for Bridge B.
- Open a fourth terminal. Start an echo listener on /chatter in domain 1 by running:
ROS_DOMAIN_ID=1 ros2 topic echo /chatter
- This terminal will wait for messages. Nothing appears yet because no one is publishing.
- Open a fifth terminal. Publish a test message on /chatter in domain 0 by running:
ROS_DOMAIN_ID=0 ros2 topic pub --once /chatter std_msgs/msg/String "data: 'Hello from domain 0'"
What is happening across these five terminals?
- Terminal 5 publishes a String message on /chatter in domain 0.
- Bridge A (domain 0) captures that message as raw CDR bytes and sends the packed frame over WebSocket to the relay.
- The relay forwards the frame to Bridge B.
- Bridge B (domain 1) unpacks the frame and publishes the CDR bytes on /chatter in domain 1.
- Terminal 4's echo listener picks up the message and prints it.
Verify end-to-end message delivery
- Switch to your fourth terminal (the echo listener in domain 1).
- You should see the message appear:
data: Hello from domain 0
---
This confirms that your message traveled from domain 0, through the WebSocket relay, and into domain 1. The bridge is working.
Not seeing the message in domain 1?
- Check that the relay server terminal shows Client connected. Total clients: 2. If it shows fewer than 2 clients, one of the bridge nodes failed to connect.
- Make sure you set ROS_DOMAIN_ID=0 for Bridge A and the publisher, and ROS_DOMAIN_ID=1 for Bridge B and the echo listener. Mismatched domains mean the nodes cannot discover each other.
- Verify that config_robot_a.yaml has /chatter under publish_topics (not subscribe_topics). A flipped config means the bridge listens instead of forwarding.
If none of the above helps, help me debug my WebSocket bridge.
Your ROS2 WebSocket topic bridge is fully operational. Next up, the Secret Mission challenges you to add real-time diagnostics so you can monitor bridge health in production.
Secret mission
Add Bridge Diagnostics
Your bridge works, but you have no visibility into what it's doing. How many messages are flowing? Is it even connected? Add a /bridge_status topic that reports real-time connection state and message throughput, so operators can monitor bridge health without inspecting logs.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your project files for future experimentation, or delete them entirely.
Resources you used:
- Project files: relay_server.py, bridge_node.py, config_robot_a.yaml, config_robot_b.yaml.
- Python package: websockets (installed via pip).
Keep everything running
No action needed. Choose this if you plan to keep experimenting with your bridge or extending it with new topic configurations.
- Your project files stay on your Desktop.
- The websockets package remains installed in your Python environment.
- No processes are running unless you manually start the relay server or bridge nodes.
Pause - I'll come back to this later
Since this project has no background processes or cloud resources, pausing is the same as keeping. Nothing runs unless you start it.
- Make sure you stopped any running relay server or bridge node processes by pressing Ctrl+C in each terminal.
- Close the terminal windows you used for testing.
Delete - I don't want to use this again
Remove all project files and the installed package to start fresh.
- Stop any running relay server or bridge node processes by pressing Ctrl+C in each terminal.
- Delete your project folder from the Desktop by running this command (macOS/Linux):
cd ~/Desktop
rm -rf ros2-ws-bridge
On Windows, run:
cd ~\Desktop
rmdir /s /q ros2-ws-bridge
- Uninstall the websockets package by running:
pip uninstall websockets
- Confirm the removal when prompted by entering y.
Nice Work!
Nice Work!
Nice work! You just built a fully functional ROS2 WebSocket topic bridge from scratch.
You've learned how to:
- Used raw CDR subscriptions and publications to forward ROS2 topic data between isolated DDS domains without any deserialization overhead.
- Built a standalone asyncio WebSocket relay server and a threaded client, coordinating with ROS2 via thread-safe queues.
- Designed a custom binary wire protocol using Python's struct module and a YAML-based configuration system for bidirectional topic bridging.
- Added production-ready diagnostics with a /bridge_status topic that reports real-time connection state and message throughput.
Ready to quiz yourself?