Lesson 17 - WebSockets for Robotics
Learning Objective
- Explain how a WebSocket connection differs from a normal HTTP request.
- Open a raw WebSocket connection in Python and exchange messages by hand.
- Recognize the JSON “op” message format that rosbridge — and therefore
bonicbot_bridge— speaks underneath everyBonicBotcall you’ve made since Lesson 1.
Introduction
Every lesson so far has started the same way:
with BonicBot(host=...) as bot:and from that single line you’ve been able to read the lidar, watch servo angles update, and stream camera frames — all without writing a single line of networking code.
None of that is magic. It’s one persistent WebSocket connection, opened the moment BonicBot(...) runs, carrying a constant stream of small JSON messages back and forth between your laptop and the robot’s onboard rosbridge server.
This lesson opens that connection up and looks at it directly, with no SDK in the way. Lesson 18 will point it at a real robot; for now, the goal is simply to understand what a WebSocket actually is and what those JSON messages look like.
[!NOTE] When connecting to a physical robot (such as in Lesson 18 and 19), make sure to replace the host IP address with your BonicBot’s actual IP address before running the code.
Part A — WebSocket vs. HTTP
Code
import websocket
ws = websocket.create_connection("wss://ws.postman-echo.com/raw")
ws.send("Hello from Python!")
print("Server replied:", ws.recv())
ws.send("Still the same connection?")
print("Server replied:", ws.recv())
ws.close()Expected Output
Click to see expected output
Server replied: Hello from Python!
Server replied: Still the same connection?🔧 Under the Hood
Why didn’t we reconnect between the two ws.send() calls?
ws.send() calls?With requests.get(...), every call opens a fresh TCP connection, sends one request, waits for one response, and tears the connection back down.
websocket.create_connection(...) only does that handshake once. ws then stays open, and ws.send() / ws.recv() reuse it as many times as you like, in either direction, without asking permission again.
That’s the entire point of a WebSocket: instead of “ask a question, get an answer, hang up,” it’s “stay on the line.”
This is exactly why your robot can push you a new lidar scan every 200ms without you calling anything — the connection was never hung up in the first place. It was opened once, back in BonicBot.connect(), and has been sitting there open the whole time.
Part B — The Message Format Robots Actually Speak
rosbridge (the server your robot runs) doesn’t send plain text over its WebSocket — it sends small JSON objects called op messages. Everything you can do — subscribe to a topic, publish a command, cancel a subscription — is one JSON dictionary with an "op" field describing which of those three it is.
Code
import json
subscribe_message = {
"op": "subscribe",
"topic": "/scan",
"type": "sensor_msgs/LaserScan"
}
publish_message = {
"op": "publish",
"topic": "/cmd_vel",
"msg": {
"linear": {"x": 0.2, "y": 0, "z": 0},
"angular": {"x": 0, "y": 0, "z": 0}
}
}
unsubscribe_message = {
"op": "unsubscribe",
"topic": "/scan"
}
for label, message in [
("Subscribe", subscribe_message),
("Publish", publish_message),
("Unsubscribe", unsubscribe_message),
]:
print(f"{label}:")
print(json.dumps(message, indent=2))
print()Expected Output
Click to see expected output
Subscribe:
{
"op": "subscribe",
"topic": "/scan",
"type": "sensor_msgs/LaserScan"
}
Publish:
{
"op": "publish",
"topic": "/cmd_vel",
"msg": {
"linear": {
"x": 0.2,
"y": 0,
"z": 0
},
"angular": {
"x": 0,
"y": 0,
"z": 0
}
}
}
Unsubscribe:
{
"op": "unsubscribe",
"topic": "/scan"
}🔧 You’ve Been Reading These Fields Since Lesson 1
Where “/scan” and “sensor_msgs/LaserScan” come from
“/scan” and “sensor_msgs/LaserScan” come fromOpen utils.py in bonicbot_bridge and you’ll find:
LASER_SCAN_MESSAGE_TYPE = "sensor_msgs/LaserScan"
LASER_SCAN_TOPIC = "/scan"Then in sensors.py:
self.scan_sub = Topic(
self.ros,
LASER_SCAN_TOPIC,
LASER_SCAN_MESSAGE_TYPE,
throttle_rate=LASER_SCAN_THROTTLE_MS,
)
self.scan_sub.subscribe(self._scan_callback)Calling .subscribe() on that Topic object is what generates the exact {"op": "subscribe", "topic": "/scan", "type": "sensor_msgs/LaserScan"} message you just built by hand in Part B, and sends it down the socket.
Every constant you’ve been quietly importing from utils.py for sixteen lessons — CAMERA_INFO_TOPIC, CMD_VEL_TOPIC, JOINT_STATES_TOPIC — is a "topic" field waiting to go into one of these three op messages.
Part C — Why Publish/Subscribe, Not Just “Ask and Wait”
Code
class TinyBroker:
def __init__(self):
self.subscribers = {}
def subscribe(self, topic, callback):
self.subscribers.setdefault(topic, []).append(callback)
def publish(self, topic, message):
for callback in self.subscribers.get(topic, []):
callback(message)
broker = TinyBroker()
def on_scan_for_navigation(msg):
print("Navigation logic sees:", msg)
def on_scan_for_logging(msg):
print("Logger sees:", msg)
broker.subscribe("/scan", on_scan_for_navigation)
broker.subscribe("/scan", on_scan_for_logging)
broker.publish("/scan", {"closest_obstacle_m": 0.8})Expected Output
Click to see expected output
Navigation logic sees: {'closest_obstacle_m': 0.8}
Logger sees: {'closest_obstacle_m': 0.8}🔧 This Is Exactly How CameraManager Fans Out a Frame
One incoming message, one callback — the SDK’s real pattern
TinyBroker above is a toy, but camera.py runs the identical idea for real. Every decoded frame ends in:
if self.user_callback:
try:
self.user_callback(image)
except Exception as exc:
print(f"⚠️ Error in user callback: {exc}")self.user_callback is whatever function you passed into start_streaming(callback=...). One image arrives on the WebSocket; it gets handed to whichever callback you registered — just like broker.publish("/scan", ...) handed one message to every subscriber in Part C.
sensors.py does the same thing on a smaller scale — self.scan_sub.subscribe(self._scan_callback) registers exactly one internal callback, which simply caches the latest scan on self.lidar_data for get_lidar_scan() to hand back to you later.
Student Challenge
Challenge 1 — Python Only
Build a tiny stand-in for a rosbridge client — no network required.
- Write a class
MiniRosbridgeClientwith:subscribe(self, topic, msg_type)— prints the properly formatted{"op": "subscribe", ...}JSON (usejson.dumps(..., indent=2)).publish(self, topic, msg)— prints the properly formatted{"op": "publish", ...}JSON.unsubscribe(self, topic)— prints the properly formatted{"op": "unsubscribe", ...}JSON.
- Instantiate it and call all three methods once, using
/scan/sensor_msgs/LaserScanfor the subscribe call and/cmd_velwith a simple{"linear": {"x": 0.2}}payload for the publish call.
Hint
Each method just needs to build the same three-key (or two-key, for unsubscribe) dictionary from Part B and print it with json.dumps(message, indent=2). You are not opening any socket — this challenge is purely about getting the message shape right.
Click to see solution
import json
class MiniRosbridgeClient:
def subscribe(self, topic, msg_type):
message = {
"op": "subscribe",
"topic": topic,
"type": msg_type
}
print(json.dumps(message, indent=2))
def publish(self, topic, msg):
message = {
"op": "publish",
"topic": topic,
"msg": msg
}
print(json.dumps(message, indent=2))
def unsubscribe(self, topic):
message = {
"op": "unsubscribe",
"topic": topic
}
print(json.dumps(message, indent=2))
client = MiniRosbridgeClient()
client.subscribe("/scan", "sensor_msgs/LaserScan")
client.publish("/cmd_vel", {"linear": {"x": 0.2}})
client.unsubscribe("/scan")Challenge 2 — Robot
Prove to yourself that bot.ros really is a WebSocket connection object, using only what you already know from Lessons 1–16.
- Connect with
with BonicBot(host='192.168.0.188') as bot:. - Print
type(bot.ros).__name__. - Print
bot.ros.is_connected. - Print
bot.hostandbot.porttogether.
Hint
core.py builds the connection as:
self.ros = Ros(host=self.host, port=self.port)
self.ros.run()Ros comes straight from roslibpy, the library that wraps the raw WebSocket logic you saw in Part A.
Click to see solution
from bonicbot_bridge import BonicBot
with BonicBot(host='192.168.0.188') as bot:
print(type(bot.ros).__name__)
print(bot.ros.is_connected)
print(f"{bot.host}:{bot.port}")Challenge 3 — Robot
Watch the “persistent connection” idea from Part A play out live, using data you already know how to read.
- Connect with
with BonicBot(host='192.168.0.188') as bot:. - In a loop that runs 10 times, call
bot.sensors.get_lidar_scan()and print whether it returnedNoneor real data, withtime.sleep(0.2)between calls. - Without ever calling
subscribeyourself, count how many of the 10 reads were non-None.
Hint
If the connection were request/response like HTTP, you’d need to ask the robot for a fresh scan on every single loop iteration. Instead, sensors.py subscribed once in __init__, and the same open WebSocket has been quietly delivering new scans in the background ever since.
Click to see solution
import time
from bonicbot_bridge import BonicBot
with BonicBot(host='192.168.0.188') as bot:
received = 0
for i in range(10):
scan = bot.sensors.get_lidar_scan()
print(f"Read {i}: {'data' if scan else 'None'}")
if scan is not None:
received += 1
time.sleep(0.2)
print(f"Non-empty reads: {received} / 10")WebSocket Quick Reference
Reflection Questions
ws.recv() in Part A blocks — your program stops and waits until a message arrives. But sensors.py’s _scan_callback runs automatically in the background while the rest of your code keeps going. What do you think roslibpy and bonicbot_bridge are doing differently under the hood to make that possible?
In Part B, the publish message needed a "msg" field but subscribe needed a "type" field instead. Looking at the three op types side by side, why do you think each one needs different fields — what information does rosbridge actually need to do each job?