TM Robot × Isaac Sim: Externally Controlling Pick & Place via Modbus SLAVE Polling
Introduction
In a previous article, we set up a Modbus TCP server (slave) inside Isaac Sim and controlled a gripper from TMFlow (the master).
This time we flip the architecture around. The TM Robot itself runs as a Modbus slave, and an external Python script acts as the master, polling registers to drive the entire Pick & Place sequence.
This approach is useful when:
- You want a vision system or a higher-level MES to issue motion commands directly
- You want to drive the robot with logic (conditional branching, loops) written in Python rather than a TMFlow flow
- You want to coordinate multiple robots from a single master process
- You want to use the same control code in both simulation and on the real hardware
Note: The code in this article is a simplified proof-of-concept (PoC) version. For production use, please add proper error handling, timeout handling, and logging.
Architecture Comparison
| Previous Article | This Article | |
|---|---|---|
| Master (controller) | TMFlow | External Python script |
| Slave (controlled device) | Isaac Sim (gripper Modbus server) | TM Robot (or Isaac Sim emulating the slave) |
| Where the sequence lives | TMFlow flow | Python state machine |
| Best suited for | Robot programmer-led development | Higher-level system (MES / Python controller)-led development |
System Overview
┌─────────────────────────────────┐
│ Python master script │
│ (Pick & Place state machine) │
│ │
│ 1. Write position registers │
│ 2. Send motion command │
│ 3. Poll completion flag │
│ 4. Transition to next step │
└──────────────┬──────────────────┘
│ Modbus TCP
│ (Port 502 / 5020)
▼
┌─────────────────────────────────┐
│ TM Robot (Modbus TCP slave) │
│ or │
│ Isaac Sim slave simulator │
│ │
│ - Status registers (robot state) │
│ - Target position registers │
│ - Gripper control registers │
└─────────────────────────────────┘
Configuring the Modbus Slave in TMFlow
To make the TM Robot behave as a Modbus slave, you use a Listen Node in TMFlow.
Basic Flow Structure
[Start]
↓
[Listen Node] ← waits for an external Modbus connection here
↓
(the robot moves based on commands from the external master)
↓
[End]
While the Listen Node is active, the TM Robot accepts Modbus TCP connections on the configured port. The external master controls the robot by writing to registers and confirms its state by reading them.
Key Register Map (TM Robot Modbus Slave)
The main register groups exposed by the TM Robot's Modbus slave (see the "External Signal" chapter of the TM Robot user manual for full details):
Robot Status (Read-Only)
| Address (example) | Name | Meaning |
|---|---|---|
| 0x0000 | Robot Mode | 0=Initializing / 5=Auto Run / 7=Error |
| 0x0001 | Motion Status | 0=Idle / 1=Moving |
| 0x0002 | Error Code | Error number (0=normal) |
TCP Current Position (Read-Only)
| Address (example) | Name | Unit |
|---|---|---|
| 0x0010–0x0015 | TCP X/Y/Z/Rx/Ry/Rz | mm / deg (scaled by ×100 to an integer) |
Position Command (Write)
| Address (example) | Name | Description |
|---|---|---|
| 0x0200–0x0205 | Target X/Y/Z/Rx/Ry/Rz | Target TCP coordinates |
| 0x0206 | Speed | Motion speed (%) |
| 0x0207 | Command | 0=None / 1=PTP motion / 2=Line motion |
| 0x0210 | Gripper Command | 0=Open / 1=Close |
Implementation note: The actual addresses vary depending on the TM Robot's firmware version and configuration. Always check the manual for your specific unit (the "TM Robot Expression Editor and Listen Node Reference Guide").
Python Master: Implementing the Polling Loop
State Machine Design
The Pick & Place state transitions:
IDLE
→ MOVE_TO_APPROACH (move above the pick position)
→ MOVE_TO_PICK (descend to the pick position)
→ GRIPPER_CLOSE (close the gripper)
→ CHECK_GRASP (verify the grasp succeeded)
→ MOVE_TO_LIFT (lift the object)
→ MOVE_TO_PLACE (move to the place position)
→ GRIPPER_OPEN (open the gripper)
→ MOVE_TO_HOME (return home)
→ DONE / ERROR
Basic Implementation
import time
from pymodbus.client import ModbusTcpClient
from enum import Enum, auto
class State(Enum):
IDLE = auto()
MOVE_TO_APPROACH = auto()
MOVE_TO_PICK = auto()
GRIPPER_CLOSE = auto()
CHECK_GRASP = auto()
MOVE_TO_LIFT = auto()
MOVE_TO_PLACE = auto()
GRIPPER_OPEN = auto()
MOVE_TO_HOME = auto()
DONE = auto()
ERROR = auto()
# ---- Register addresses (adjust to your environment) ----
REG_MOTION_STATUS = 0x0001 # 0=idle, 1=moving
REG_GRASP_STATUS = 0x0002 # 0=not grasped, 1=grasped
REG_TARGET_X = 0x0200
REG_TARGET_Y = 0x0201
REG_TARGET_Z = 0x0202
REG_TARGET_RX = 0x0203
REG_TARGET_RY = 0x0204
REG_TARGET_RZ = 0x0205
REG_SPEED = 0x0206
REG_COMMAND = 0x0207
REG_GRIPPER_CMD = 0x0210 # 0=open, 1=close
CMD_EXECUTE_PTP = 1
CMD_EXECUTE_LINE = 2
# ---- Task coordinates (mm, deg) ----
APPROACH_POS = [300, -200, 200, 180, 0, 90] # above the pick position
PICK_POS = [300, -200, 100, 180, 0, 90] # pick position
LIFT_POS = [300, -200, 250, 180, 0, 90] # after lifting
PLACE_POS = [300, 200, 200, 180, 0, 90] # place position
HOME_POS = [ 0, 0, 300, 180, 0, 0] # home position
SPEED_NORMAL = 30 # %
SPEED_SLOW = 10 # % (for approaching pick/place)
POLL_INTERVAL = 0.05 # 50ms
MOTION_TIMEOUT = 30 # seconds
class PickAndPlaceController:
def __init__(self, host: str, port: int = 502):
self.client = ModbusTcpClient(host, port=port)
self.state = State.IDLE
def connect(self) -> bool:
return self.client.connect()
def disconnect(self):
self.client.close()
# ---- Register access ----
def read_register(self, address: int) -> int:
result = self.client.read_holding_registers(address, count=1)
if result.isError():
raise IOError(f"Register read error: {address:#x}")
return result.registers[0]
def write_register(self, address: int, value: int):
result = self.client.write_register(address, value)
if result.isError():
raise IOError(f"Register write error: {address:#x}")
# ---- Polling: wait for motion completion ----
def wait_motion_complete(self, timeout: float = MOTION_TIMEOUT) -> bool:
deadline = time.time() + timeout
while time.time() < deadline:
status = self.read_register(REG_MOTION_STATUS)
if status == 0: # idle = motion complete
return True
time.sleep(POLL_INTERVAL)
return False # timeout
# ---- Motion command ----
def move_to(self, pos: list, speed: int, motion_type: int = CMD_EXECUTE_PTP):
# Convert coordinates to integer registers (×100) and write them
addrs = [REG_TARGET_X, REG_TARGET_Y, REG_TARGET_Z,
REG_TARGET_RX, REG_TARGET_RY, REG_TARGET_RZ]
for addr, val in zip(addrs, pos):
self.write_register(addr, int(val * 100))
self.write_register(REG_SPEED, speed)
self.write_register(REG_COMMAND, motion_type)
# ---- Gripper control ----
def gripper_open(self):
self.write_register(REG_GRIPPER_CMD, 0)
def gripper_close(self):
self.write_register(REG_GRIPPER_CMD, 1)
def check_grasp_success(self) -> bool:
return self.read_register(REG_GRASP_STATUS) == 1
# ---- Main loop ----
def run(self):
self.state = State.MOVE_TO_APPROACH
print("[START] Starting Pick & Place")
while self.state not in (State.DONE, State.ERROR):
try:
self._step()
except IOError as e:
print(f"[ERROR] Communication error: {e}")
self.state = State.ERROR
print(f"[{self.state.name}] Finished")
def _step(self):
s = self.state
if s == State.MOVE_TO_APPROACH:
print(" → Moving to approach position")
self.move_to(APPROACH_POS, SPEED_NORMAL)
if not self.wait_motion_complete():
self.state = State.ERROR; return
self.state = State.MOVE_TO_PICK
elif s == State.MOVE_TO_PICK:
print(" → Descending to pick position")
self.move_to(PICK_POS, SPEED_SLOW, CMD_EXECUTE_LINE)
if not self.wait_motion_complete():
self.state = State.ERROR; return
self.state = State.GRIPPER_CLOSE
elif s == State.GRIPPER_CLOSE:
print(" → Closing gripper")
self.gripper_close()
time.sleep(0.8) # wait for gripper motion
self.state = State.CHECK_GRASP
elif s == State.CHECK_GRASP:
if self.check_grasp_success():
print(" ✓ Grasp successful")
self.state = State.MOVE_TO_LIFT
else:
print(" ✗ Grasp failed")
self.state = State.ERROR
elif s == State.MOVE_TO_LIFT:
print(" → Lifting")
self.move_to(LIFT_POS, SPEED_SLOW, CMD_EXECUTE_LINE)
if not self.wait_motion_complete():
self.state = State.ERROR; return
self.state = State.MOVE_TO_PLACE
elif s == State.MOVE_TO_PLACE:
print(" → Moving to place position")
self.move_to(PLACE_POS, SPEED_NORMAL)
if not self.wait_motion_complete():
self.state = State.ERROR; return
self.state = State.GRIPPER_OPEN
elif s == State.GRIPPER_OPEN:
print(" → Opening gripper")
self.gripper_open()
time.sleep(0.5)
self.state = State.MOVE_TO_HOME
elif s == State.MOVE_TO_HOME:
print(" → Returning home")
self.move_to(HOME_POS, SPEED_NORMAL)
if not self.wait_motion_complete():
self.state = State.ERROR; return
self.state = State.DONE
if __name__ == "__main__":
ctrl = PickAndPlaceController(host="192.168.1.100") # TM Robot's IP
if ctrl.connect():
ctrl.run()
ctrl.disconnect()
else:
print("Connection failed")
Simulating the Slave in Isaac Sim
You can validate the logic in Isaac Sim before touching the real robot. Set up a Modbus server inside Isaac Sim that emulates the TM Robot slave, and connect the Python master code above to it as-is.
Isaac Sim Slave Simulator (Skeleton)
# isaac_sim_slave_server.py
# Run this from Isaac Sim's Script Editor
import threading
import time
from pymodbus.server import StartTcpServer
from pymodbus.datastore import (
ModbusSequentialDataBlock,
ModbusSlaveContext,
ModbusServerContext,
)
# Register store
store = ModbusSlaveContext(
hr=ModbusSequentialDataBlock(0, [0] * 0x300),
)
context = ModbusServerContext(slaves=store, single=True)
# ---- Isaac Sim control loop (50Hz) ----
def control_loop():
"""Apply the commands written by the master to the Isaac Sim arm"""
while True:
command = store.getValues(3, 0x0207, 1)[0] # REG_COMMAND
if command in (1, 2): # execute PTP / Line
# ① Clear the command register
store.setValues(3, 0x0207, [0])
# ② Read the target coordinates
vals = store.getValues(3, 0x0200, 6)
target = [v / 100.0 for v in vals]
# ③ Move the Isaac Sim arm to the target position
# (solve IK via the Articulation API and set joint positions)
store.setValues(3, 0x0001, [1]) # Motion Status = moving
_move_arm_to(target)
_wait_arm_settle()
store.setValues(3, 0x0001, [0]) # Motion Status = complete
# Gripper control
gripper_cmd = store.getValues(3, 0x0210, 1)[0]
_control_gripper(gripper_cmd)
time.sleep(0.02) # 50Hz
def _move_arm_to(target_tcp):
"""Move the arm using Isaac Sim's IK (implementation omitted)"""
pass
def _control_gripper(cmd):
"""Open/close the gripper (can reuse the implementation from the existing Modbus server article)"""
pass
def _wait_arm_settle():
"""Wait for the arm to reach the target (simplified)"""
time.sleep(1.5)
# Start the Modbus server in the background
control_thread = threading.Thread(target=control_loop, daemon=True)
control_thread.start()
StartTcpServer(context=context, address=("0.0.0.0", 5020))
Just change the Python master's connection target from the TM Robot's IP to 127.0.0.1:5020 and you can validate the entire logic inside Isaac Sim.
Implementation Notes for the Polling Approach
Choosing the Polling Interval
| Interval | Characteristics |
|---|---|
| Under 10ms | High CPU load. Avoid outside small PoCs |
| 50ms (recommended) | Good balance between control responsiveness and load |
| Over 100ms | Lightweight, but can delay detecting completion of short motions |
Designing Timeouts
Set motion timeouts with a comfortable margin. A good rule of thumb is 20–30% longer than the sum of robot motion time, dwell time, and round-trip communication time.
# Example: 500mm max travel distance at 30% speed
# Estimated motion time upper bound ≈ 8 seconds
# Timeout setting ≈ 12 seconds
MOTION_TIMEOUT = 12
When to Use This vs. the Previous Article's TMFlow-Master Approach
| TMFlow Master Approach | Polling (Python Master) Approach | |
|---|---|---|
| Sequence definition | TMFlow nodes | Python code |
| Conditional branching | TMFlow If node | Python if statements (flexible) |
| Higher-level system integration | Difficult | Easy (REST APIs, DB connections, etc.) |
| Debugging | TMFlow logs | Python debugger / logging |
| Best fit | Robot SE-led development | Software SE-led development, higher-level integration |
Planning a Robot Validation or PoC with Isaac Sim?
We support everything from Modbus SLAVE integration to Pick & Place PoC design and implementation, including vision integration and higher-level system connectivity.
A 4-week, fixed-price Quick Start Package is also available.
Learn more about our Robotics Simulation Service →
Summary
- Running the TM Robot as a Modbus slave lets an external Python script take full control of the Pick & Place sequence
- The state machine + polling loop combination is well suited to complex conditional branching and higher-level system integration that's hard to express with TMFlow's node-based flows
- By emulating the slave with a server in Isaac Sim, you can validate the same master code first in simulation, then on the real robot
- Design around a 50ms polling interval and a timeout of roughly 130% of the expected motion time
Related Articles
- Isaac Sim × Modbus TCP: Remote Control of the OnRobot 2FG7 Gripper from TMFlow — The reverse setup (Isaac Sim as the slave, TMFlow as the master)
- Bridging Reality and Simulation: Integrating TM Robot with Isaac Sim via ROS2 — A different approach to arm control using ROS2
- Adding Isaac Lab to an Existing Isaac Sim Environment — The next step: automating Pick & Place with reinforcement learning
- Rule-Based Automation vs. Physical AI: Which Should You Choose? — Choosing between rule-based (this article) and learning-based approaches
- NVIDIA Isaac Sim Environment Setup on Ubuntu 24.04 (December 2025 Edition) — Basic Isaac Sim environment setup
