Digital Twin

Robot Arm Digital Twin with MQTT: Mirror Joint Angles in 3D and Send Commands Back

A hands-on guide to a robot arm digital twin: stream joint angles over MQTT, animate a jointed 3D model in the browser, and close the loop with command topics and acknowledgements.

Six-axis robot arm digital twin rendered in 3D next to its physical counterpart, linked by MQTT joint angle telemetry
Run this on your own MQTT brokerGet a private, managed MQTT broker on the Free plan and start from ready-made code samples.
Try it free Code samples No card required

A robot arm digital twin is a live 3D replica of an arm that moves exactly as the real one does, driven by joint angle telemetry rather than a canned animation. In this guide you will stream joint angles over MQTT, animate a jointed 3D model in the browser with Three.js, and send move commands back to the arm with acknowledgements. Everything runs against a real broker in a few minutes.

If you want the wider context first, the digital twin technology guide explains where this kind of 3D twin fits among other twin types.

Why a Robot Arm Is the Ideal First 3D Twin

Robot arms are a perfect starting point for 3D twins because their state is small and well defined. A six-axis arm is fully described by six joint angles plus a few flags such as gripper state, program running, and fault code. That is a tiny payload, yet when you apply those angles to a kinematic chain in 3D, the result is immediately understandable to anyone walking past the screen: the twin reaches, rotates and grips just like the cell on the shop floor.

That compactness matters for transport. You can publish the full pose several times per second over MQTT without straining the network, and every subscriber, whether a 3D viewer, a historian or an analytics script, gets the same stream from the broker.

Architecture of the Robot Arm Digital Twin

The design uses three roles connected through one broker:

  1. Robot gateway: reads joint positions from the controller (or simulates them) and publishes the pose. It also subscribes to command topics and forwards accepted commands to the controller.
  2. 3D viewer: a browser page that subscribes to the pose over Secure WebSocket and applies each joint angle to the matching node of a 3D model.
  3. Operator or application: publishes commands such as "move to pose" or "open gripper" and listens for acknowledgements.
Topic Direction Payload QoS Retained
.../robot1/joints Gateway to viewers {"j1":12.5,...,"j6":-3.0,"ts":...} 0 No
.../robot1/state Gateway to viewers {"mode":"auto","gripper":"closed"} 1 Yes
.../robot1/status Gateway (Last Will) online / offline 1 Yes
.../robot1/cmd/move Operator to gateway {"id":"c-42","target":{...}} 1 No
.../robot1/cmd/ack Gateway to operator {"id":"c-42","result":"accepted"} 1 No

Joint telemetry uses QoS 0 because a newer pose always supersedes an older one; losing one frame is harmless. Commands and acknowledgements use QoS 1 so they are delivered at least once, and the command id lets the gateway ignore duplicates. If you need a refresher on that trade-off, see MQTT QoS levels explained.

For this tutorial we use the public MQTT test broker at broker.connectmq.com with a unique topic prefix. Remember that the public area is readable by everyone, so never send real production data there.

Step 1: Publish Joint Angles from the Gateway

The script below simulates a six-axis arm that eases toward target angles, publishes its pose five times per second, keeps a retained state message, and accepts move commands. Replace ra-7f3k with your own random ID.

import json
import random
import time

import paho.mqtt.client as mqtt

BROKER = "broker.connectmq.com"
PREFIX = "connectmq/demo/ra-7f3k/cell1/robot1"
CLIENT_ID = f"ra-7f3k-gateway-{random.randint(1000, 9999)}"

joints = {f"j{i}": 0.0 for i in range(1, 7)}
targets = dict(joints)
LIMITS = {"j1": (-170, 170), "j2": (-90, 90), "j3": (-120, 120),
          "j4": (-180, 180), "j5": (-110, 110), "j6": (-360, 360)}
seen_commands = set()


def on_connect(client, userdata, flags, reason_code, properties):
    print("Connected:", reason_code)
    client.publish(f"{PREFIX}/status", "online", qos=1, retain=True)
    client.publish(f"{PREFIX}/state", json.dumps({"mode": "auto", "gripper": "open"}),
                   qos=1, retain=True)
    client.subscribe(f"{PREFIX}/cmd/move", qos=1)


def on_message(client, userdata, msg):
    try:
        cmd = json.loads(msg.payload)
        cmd_id = cmd["id"]
    except (ValueError, KeyError):
        return
    if cmd_id in seen_commands:
        return  # QoS 1 duplicate, already handled
    seen_commands.add(cmd_id)

    result = "accepted"
    for name, value in cmd.get("target", {}).items():
        low, high = LIMITS.get(name, (0, 0))
        if name not in LIMITS or not low <= float(value) <= high:
            result = f"rejected: {name} out of range"
            break
    if result == "accepted":
        targets.update({k: float(v) for k, v in cmd["target"].items()})
    client.publish(f"{PREFIX}/cmd/ack", json.dumps({"id": cmd_id, "result": result}), qos=1)


client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id=CLIENT_ID,
                     protocol=mqtt.MQTTv5)
client.will_set(f"{PREFIX}/status", "offline", qos=1, retain=True)
client.on_connect = on_connect
client.on_message = on_message
client.connect(BROKER, 1883, keepalive=30)
client.loop_start()

try:
    while True:
        for name in joints:
            joints[name] += (targets[name] - joints[name]) * 0.15
        payload = {k: round(v, 2) for k, v in joints.items()}
        payload["ts"] = int(time.time() * 1000)
        client.publish(f"{PREFIX}/joints", json.dumps(payload), qos=0)
        time.sleep(0.2)  # 5 Hz keeps us well under the public broker rate limit
except KeyboardInterrupt:
    client.publish(f"{PREFIX}/status", "offline", qos=1, retain=True)
    client.loop_stop()
    client.disconnect()

Install the library with pip install "paho-mqtt>=2.0" and run the script. Watch the stream from another terminal:

mosquitto_sub -h broker.connectmq.com -p 1883 -i ra-7f3k-watch -t "connectmq/demo/ra-7f3k/cell1/robot1/#" -v

On a real cell, replace the easing loop with reads from the controller interface your robot vendor provides, and forward accepted commands to it. Keep the range check in the gateway regardless: the twin must never be the thing that pushes an axis past its limit.

Step 2: Build the 3D Robot Arm Digital Twin in the Browser

A robot arm in Three.js is a chain of nested groups. Each joint is a group that rotates around one axis, and every child inherits the transforms of its parents, which is exactly how forward kinematics works. If you load a glTF model exported from CAD instead of primitives, the same idea applies: find the node for each joint by name and rotate it. The guide to binding IoT data to a 3D model covers naming conventions for those nodes.

Create a project with npm install three mqtt and a bundler such as Vite, then use this module as the entry point:

import * as THREE from "three";
import mqtt from "mqtt";

const PREFIX = "connectmq/demo/ra-7f3k/cell1/robot1";
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x1e1f24);
const camera = new THREE.PerspectiveCamera(50, innerWidth / innerHeight, 0.1, 100);
camera.position.set(3, 2.5, 3);
camera.lookAt(0, 1, 0);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(innerWidth, innerHeight);
document.body.appendChild(renderer.domElement);
scene.add(new THREE.HemisphereLight(0xffffff, 0x444444, 2.5));

const mat = new THREE.MeshStandardMaterial({ color: 0xf28c28 });
function link(length, radius = 0.08) {
  const mesh = new THREE.Mesh(new THREE.CylinderGeometry(radius, radius, length), mat);
  mesh.position.y = length / 2;
  return mesh;
}

// Kinematic chain: each joint is a Group nested inside its parent.
const joints = {};
const axes = { j1: "y", j2: "z", j3: "z", j4: "y", j5: "z", j6: "y" };
const lengths = { j1: 0.4, j2: 0.9, j3: 0.7, j4: 0.2, j5: 0.2, j6: 0.1 };
let parent = scene;
for (const name of Object.keys(axes)) {
  const g = new THREE.Group();
  g.add(link(lengths[name]));
  parent.add(g);
  joints[name] = g;
  const tip = new THREE.Group();
  tip.position.y = lengths[name];
  g.add(tip);
  parent = tip;
}

const target = {};
const client = mqtt.connect("wss://broker.connectmq.com:8084/mqtt", {
  clientId: "ra-7f3k-viewer-" + Math.random().toString(16).slice(2, 8),
  protocolVersion: 5,
});
client.on("connect", () => client.subscribe(`${PREFIX}/#`, { qos: 1 }));
client.on("message", (topic, payload) => {
  if (topic.endsWith("/joints")) Object.assign(target, JSON.parse(payload.toString()));
  if (topic.endsWith("/status")) document.title = "Robot1: " + payload.toString();
});

const toRad = THREE.MathUtils.degToRad;
renderer.setAnimationLoop(() => {
  for (const [name, axis] of Object.entries(axes)) {
    if (target[name] === undefined) continue;
    const g = joints[name];
    g.rotation[axis] += (toRad(target[name]) - g.rotation[axis]) * 0.3; // smooth between frames
  }
  renderer.render(scene, camera);
});

The interpolation in the render loop matters. Telemetry arrives at 5 Hz while the screen renders at 60 Hz, so snapping straight to each new angle looks jerky. Easing toward the latest target hides the gap without lying about the real pose. For a deeper walkthrough of scenes, cameras and loaders, see the Three.js digital twin tutorial and the official Three.js documentation.

Step 3: Close the Loop with Commands

A twin that only listens is a digital shadow. To make it bidirectional, publish a command and wait for the matching acknowledgement:

mosquitto_pub -h broker.connectmq.com -p 1883 -i ra-7f3k-operator -q 1 \
  -t "connectmq/demo/ra-7f3k/cell1/robot1/cmd/move" \
  -m '{"id":"c-42","target":{"j1":45,"j2":-30,"j3":60,"j5":20}}'

The gateway checks limits, updates its targets, and publishes {"id":"c-42","result":"accepted"} on cmd/ack. The 3D model then swings to the new pose because it follows the telemetry, not the command. That distinction is the most important design rule for a robot arm digital twin: the viewer always renders what the robot reports, never what someone asked for. If the controller refuses a move, the twin stays put and the operator sees the rejection reason.

In production, add these safeguards:

  • Gate commands behind controller-side interlocks and the robot's own safety system. MQTT commands should request moves, not bypass safety.
  • Include an expiry on commands so a delayed message is not executed late. With MQTT 5, set the message expiry interval on the publish; the MQTT 5 features guide shows how.
  • Use separate credentials for viewers and for operators, and log every command and acknowledgement.

Moving from Demo to Production

The public broker is for experiments: it limits clients to 10 messages per second and 64 KB per message, and anything you publish there is visible to others. For a real cell, move to a managed MQTT broker with private credentials and TLS. On ConnectMQ you create a broker in the portal, copy the generated username, password and client ID, and change two things in the code: connect with TLS on port 8883 (or WSS on 8084 for the browser) and pass the credentials.

client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id="YOUR_CLIENT_ID",
                     protocol=mqtt.MQTTv5)
client.username_pw_set("YOUR_USERNAME", "YOUR_PASSWORD")
client.tls_set()  # system CA store validates the Let's Encrypt certificate
client.connect("YOUR_BROKER_HOST", 8883, keepalive=30)

Because client IDs are bound to issued credentials on ConnectMQ, give the gateway and each viewer their own identity rather than sharing one. The portal's live topic explorer is handy while commissioning: you can watch the joint stream and the acknowledgements side by side without writing extra tooling.

Troubleshooting Checklist

Symptom Likely cause Fix
Model moves in the wrong direction Axis sign differs between controller and 3D model Negate the angle for that joint in the viewer mapping
Arm parts rotate around the wrong point Pivot not at the joint origin Place each mesh so the group origin sits on the joint axis
Viewer frozen, gateway still running Two clients share a client ID and kick each other off Generate a unique client ID per tab and device
Commands executed twice QoS 1 redelivery without deduplication Track command IDs in the gateway
Pose jumps after reconnect Viewer has no state until the next frame Publish a retained state topic and interpolate from it
Disconnects at high frame rates Public broker rate limit Publish at 5 Hz or less, or move to a paid plan

Run Your Robot Arm Twin on ConnectMQ

You now have a working robot arm digital twin: a gateway streaming joint angles, a 3D viewer that follows them, and a command path with acknowledgements. Prototype on broker.connectmq.com, then move the same code to a private ConnectMQ broker when the cell goes live. Create a free Serverless broker, no credit card required, and you will have TLS endpoints and credentials in minutes.

Frequently Asked Questions

How often should a robot arm digital twin update?

Five to ten pose updates per second usually looks smooth when the viewer interpolates between frames. Faster rates add load without visible benefit for most monitoring use cases.

Can I use a CAD model instead of primitives?

Yes. Export the arm to glTF with one node per joint, load it with GLTFLoader, and rotate those nodes exactly as the groups in this tutorial. Make sure each node's pivot sits on the joint axis.

Is it safe to control a real robot over MQTT?

MQTT can carry move requests, but safety must stay in the robot controller and its safety system. Validate limits in the gateway, use expiring commands and authenticated clients, and never let the twin bypass interlocks.

Why does the viewer follow telemetry instead of commands?

Commands can be rejected, delayed or partially executed. Rendering only the reported pose keeps the twin truthful, which is the whole point of a digital twin.

Related articles

All articles →

Your private MQTT broker is a few clicks away

Create an account, get your broker credentials and connect your first device — no servers to install, patch or scale.