sondahub

sondahub / DDS simulator

DDS simulator

A DDS participant with two worlds behind it: a ROS 2 robot that drives itself around a room — pose, lidar, battery, the talker’s chatter — until you take over on /cmd_vel, and a pumping plant on keyed topics whose setpoints you write. Real RTPS: discovery, reliability, durability and keys, through a bridge that lends it ports on your computer.

Free, nothing to install but the bridge. Each connection gets its own participant, robot and plant.

Connect

Address
wss://api.sondahub.com/dds
From a DDS client
through the bridge: node bridge.mjs dds [domain]
Domain
0, or the one you give the bridge (0 to 232)
Discovery
peer 127.0.0.1, or multicast 239.255.0.1:7400
Wire
DDSI-RTPS 2.3 over UDPv4; samples in XCDR1, little-endian
Text messages
JSON, for any WebSocket client

The bridge binds the first free pair of ports of the standard mapping — 7410 for discovery and 7411 for data on domain 0, then 7412 and 7413, and so on — joins the discovery multicast group on every interface it can, and carries each datagram to the hub and back. The hub announces itself to the participants it can reach on your computer and answers the ones that announce themselves. The bridge prints what the hub finds and matches, and when a reader or writer does not match, why:

Run the bridge
curl -O https://sondahub.com/industrial/bridge.mjs
node bridge.mjs dds

DDS domain 0 · participant 0 · ports 7410 (discovery) and 7411 (data) · group 239.255.0.1:7400
→ wss://api.sondahub.com/dds — a client on this computer finds it at the peer 127.0.0.1 or by multicast
the hub is participant c3ff6f8d.3fb2ad77.3da88626 on domain 0
found participant 01106bff.9963e1f8.20b0bbbe — Cyclone DDS — at 127.0.0.1:7412
their reader on rt/chatter: matched — reliable, volatile
their writer on rt/cmd_vel: matched — the hub reads it, best effort
their reader on PlantReadings: not matched — type Reading, the hub's is plant::Reading

In a DDS client

Start the bridge first. Then point the client’s discovery at it: a unicast peer at 127.0.0.1 finds it on its own (DDS tries the ports of the first participant indexes; if the bridge says it took participant 2, the port is 7414), or multicast on the loopback or any interface the bridge joined. Use domain 0 unless you gave the bridge another one. Declare the types from sondahub-dds.idl exactly as they are — DDS matches types by their full names — and subscribe or publish.

In LockFlare Sonda: Discovery Peers only with the peer 127.0.0.1, the loopback interface (lo0 on macOS), and the types from the IDL file.

Topics

The robot, as ROS 2 sees it

A small differential-drive robot in an 8 × 8 m room with a pillar and a crate. It drives itself, turning away from what its lidar sees, until something arrives on rt/cmd_vel.

TopicWhat it carries
rt/chatter
ROS 2: /chatter
std_msgs::msg::dds_::String_
The hub writes it · 1 Hz
“Hello World: 1”, “Hello World: 2”… — what the ROS 2 demo talker says, so the demo listener hears it.
rt/robot/pose
ROS 2: /robot/pose
geometry_msgs::msg::dds_::PoseStamped_
The hub writes it · 5 Hz
Where the robot is, in the frame map: metres from the middle of an 8 × 8 m room, heading as a quaternion about z.
rt/robot/scan
ROS 2: /robot/scan
sensor_msgs::msg::dds_::LaserScan_
The hub writes it · 2 Hz
A 360° lidar, 180 rays two degrees apart from −π, ranging the walls, a pillar and a crate. No intensities.
rt/robot/battery
ROS 2: /robot/battery
sensor_msgs::msg::dds_::BatteryState_
The hub writes it · 1 Hz
A six-cell lithium-ion pack draining faster when the robot moves; at 15 % it charges where it stands, back to 95 %.
rt/cmd_vel
ROS 2: /cmd_vel
geometry_msgs::msg::dds_::Twist_
The hub reads it
Drives the robot: linear.x (−1 to 1 m/s) and angular.z (−2 to 2 rad/s). A command holds for 1.5 s; ten seconds after the last one the robot drives itself again.

The plant, as plain DDS

A tank filled through an inlet valve and pumped down through a filter: keyed topics, one instance per sensor, alarm and setpoint.

TopicWhat it carries
PlantReadings
plant::Reading
key: sensor
The hub writes it · 1 Hz × 6
One instance per sensor: TANK-LEVEL, FLOW-IN, FLOW-OUT, PUMP-SPEED, MOTOR-TEMP, FILTER-DP.
PlantAlarms
plant::Alarm
key: name
The hub writes it · on change
One instance per alarm — FILTER-DP, LEVEL-HIGH, MOTOR-HOT, SETPOINT — written when it raises or clears. The filter clogs every five minutes on its own.
PlantSetpoints
plant::Setpoint
key: name
The hub reads it
PUMP-SPEED (0 to 1800 rpm, 1200 to start with) and INLET-VALVE (0 to 100 %, 60 to start with). Anything else raises the SETPOINT alarm.

Types

All of them, with what they use, in one file: sondahub-dds.idl. The ROS 2 types keep their DDS names — geometry_msgs::msg::dds_::Twist_ — and their fields as ROS 2 defines them. The plant’s keyed types bound their key strings to 11 characters, so the key fits the 16 bytes of an RTPS key hash as it is.

plant::Setpoint
module plant {
struct Setpoint {
  @key string<11> name;
  double value;
};
};

The hub writes XCDR1 (encapsulation CDR_LE). It reads XCDR1 and XCDR2 — final or appendable types, either byte order; mutable types (parameter-list encodings) are not read.

QoS and matching

EndpointsWhat they offer or ask
The hub’s writersReliable, transient-local, keep last 10 of each instance, XCDR1, the default partition. Heartbeats every second, NACKs answered, a GAP for what is no longer kept.
The hub’s readersBest effort, volatile, XCDR1 and XCDR2, the default partition: any writer on the topic matches.
The participantVendor id 0x0000 (no vendor’s implementation), lease 30 s, an announcement every 3 s; it drops a participant whose lease runs out.

A match needs the same topic name and the same type name (no type information is exchanged), a durability the hub can give (volatile or transient-local; transient and persistent readers are refused), the default partition, and a data representation both sides take. A late transient-local reader gets the last ten samples of each instance before the live ones.

The plant

The pump starts when the tank reaches 4 m and stops at 1 m; between them, the inlet fills it and the pump empties it, about a twelve-minute cycle as it starts. The filter clogs on its own and backwashes every five minutes, raising and clearing FILTER-DP.

PlantReadings

SensorNotes
TANK-LEVEL
unit m
Level in the tank, 0 to 5 m. The pump starts at 4 m and stops at 1 m.
FLOW-IN
unit m3/h
Into the tank, through the inlet valve: 0.3 m³/h per percent open.
FLOW-OUT
unit m3/h
Pumped out: 45 m³/h at 1800 rpm, less as the filter clogs.
PUMP-SPEED
unit rpm
Ramps 200 rpm a second to the setpoint while the pump runs.
MOTOR-TEMP
unit degC
Settles at 25 °C plus 45 °C × (speed / 1800)², with a minute’s lag.
FILTER-DP
unit kPa
Across the filter: climbs from 20 to 85 kPa in five minutes, then a ten-second backwash.

PlantAlarms

AlarmNotes
FILTER-DP
severity 2
Filter differential pressure at 80 kPa or above; clears below 75 (after the backwash).
LEVEL-HIGH
severity 3
Tank at 4.5 m or above; clears below 4.3 m. Open the inlet or slow the pump to see it.
MOTOR-HOT
severity 2
Motor at 60 °C or above; clears below 55 °C. Run the pump at 1800 rpm for a couple of minutes.
SETPOINT
severity 1
The last setpoint was refused (unknown name or out of range); the next good one clears it.

PlantSetpoints

SetpointNotes
PUMP-SPEED
0–1800 rpm
The speed the pump runs at when it runs; 1200 rpm to start with. At 1800 the motor heats past 60 °C.
INLET-VALVE
0–100 %
How far the inlet is open; 60 % to start with. Open it and slow the pump to raise LEVEL-HIGH.

JSON, from any WebSocket client

A text message is a request: subscribe to the topics the hub writes (the present value of each instance comes first, as a transient-local reader gets it), publish to the ones it reads, or list the topics with their types and IDL. Topics answer to their DDS or their ROS 2 names.

Requests
{"subscribe":["rt/chatter","PlantAlarms"]}
{"subscribe":"*","cdr":true}             # every topic, payloads in hex
{"publish":"/cmd_vel","sample":{"linear":{"x":0.3},"angular":{"z":0.5}}}
{"publish":"PlantSetpoints","sample":{"name":"PUMP-SPEED","value":1500}}
{"unsubscribe":"*"}
{"topics":true}
A sample
{"topic":"PlantReadings","seq":214,"time":"2026-10-07T09:04:00.165Z",
 "sample":{"sensor":"TANK-LEVEL","value":3.629,"unit":"m"}}

The bridge, on the wire

For anyone writing a bridge of their own: it opens wss://api.sondahub.com/dds and says hello in text — {"bridge":"dds","version":1,"domain":0,"participant":0,"meta":7410,"user":7411,"addresses":["127.0.0.1",…],"multicast":true} — then each datagram is a binary message: a byte for the port (0 discovery, 1 data, 2 the multicast group), the IPv4 address and the port (big-endian) it came from or goes to, and the datagram. The hub’s text messages are {"log":…} lines.

Questions

Why does DDS need the bridge?

DDS travels in UDP datagrams, and only HTTP reaches sondahub — no UDP comes in. So the participant runs on the hub and the bridge lends it two UDP ports on your computer, carrying every datagram over one WebSocket. To a DDS client on your computer the hub is simply another participant on the machine, on the ports of the standard mapping.

Which DDS implementations can talk to it?

Any that speaks DDSI-RTPS 2.x over UDP and IPv4: discovery (SPDP and SEDP), reliable and best-effort endpoints, heartbeats, ACKNACKs, GAPs and fragmented samples are handled as the specification describes. It has been run against Eclipse Cyclone DDS and eProsima Fast DDS. Shared memory, TCP and DDS Security are not offered; the hub announces UDPv4 locators only.

Can a ROS 2 node use the robot?

Its topics are named and typed the way ROS 2 maps topics onto DDS — rt/chatter is /chatter, with std_msgs/msg/String as std_msgs::msg::dds_::String_ — on domain 0, ROS 2’s default. With the bridge running on the same computer, a node subscribes to /chatter or publishes to /cmd_vel as it would on a robot.

Does what I publish reach other people?

No. Each connection gets its own participant, robot and plant: what you publish moves your robot and your pump, and disappears with the connection. When the bridge reconnects, the hub is a new participant, and the old one leaves when its lease runs out.