Skip to Content
BonicBot A2DevelopmentPython ProgrammingLesson 17 - WebSockets for Robotics

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 every BonicBot call 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

websocket_basics.py
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?

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

rosbridge_message_anatomy.py
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

Open 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

pubsub_pattern.py
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 MiniRosbridgeClient with:
    • subscribe(self, topic, msg_type) — prints the properly formatted {"op": "subscribe", ...} JSON (use json.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/LaserScan for the subscribe call and /cmd_vel with 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

mini_rosbridge_client.py
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.host and bot.port together.

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

challenge_inspect_connection.py
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 returned None or real data, with time.sleep(0.2) between calls.
  • Without ever calling subscribe yourself, 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

challenge_persistent_stream.py
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

WebSocket Basics

ConceptPurpose
websocket.create_connection(url)Open a persistent connection
ws.send(data)Send a message over the open connection
ws.recv()Block until the next message arrives
ws.close()Close the connection
Persistent vs. request/responseOne handshake, many messages

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?

Last updated on