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:
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.
| Topic | What it carries |
|---|---|
| rt/chatter ROS 2: /chatter std_msgs:: | 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:: | 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:: | 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:: | 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:: | 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.
| Topic | What it carries |
|---|---|
| PlantReadings plant:: 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:: 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:: 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.
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
| Endpoints | What they offer or ask |
|---|---|
| The hub’s writers | Reliable, 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 readers | Best effort, volatile, XCDR1 and XCDR2, the default partition: any writer on the topic matches. |
| The participant | Vendor 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
| Sensor | Notes |
|---|---|
| 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
| Alarm | Notes |
|---|---|
| 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
| Setpoint | Notes |
|---|---|
| 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.
{"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}
{"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.