Skip to main content

GR-EndEffector: DH Robotics PGC Grippers

1. Overview​

GR-EndEffector is the runtime module EndEffectorCore. It receives end-effector commands over DDS, controls DH Robotics PGC parallel grippers through Modbus RTU, and publishes gripper state back to DDS.

Each PGC gripper is modeled as one degree of freedom (DOF=1): one position value represents the target position of the jaws. The left and right grippers are normally connected to separate USB-RS485 adapters. Both single-gripper and dual-gripper configurations are supported.

ItemDescription
RuntimeEndEffectorCore
DDS domain ID123
Vendor/modeldh_robotics / PGC
TransportModbus RTU over RS485
DOF per side1
Command groupsleft_end_effector, right_end_effector
Recommended hardware command/state polling rate20 Hz

2. Data Path​

Text
PGC gripper
<-> USB-RS485 adapter
<-> EndEffectorCore (Modbus RTU)
<-> end_effector_state

DDS client
-> end_effector_cmd
-> EndEffectorCore
-> PGC motion

Start and keep EndEffectorCore running before using a DDS example. Each USB-RS485 adapter can be accessed by only one process. Stop any process using a serial port before changing the serial configuration or restarting the runtime.

Runtime environment

EndEffectorCore runs as a system user on the robot. The DDS client scripts on this page run as a regular user — no root/sudo required — but you must set FASTDDS_BUILTIN_TRANSPORTS=UDPv4 before creating any DDS participant (otherwise the shared-memory transport on the same host delivers no data, or loses it intermittently). The Python examples below already embed this setting.

3. Hardware and Configuration​

3.1 Serial Parameters​

The default PGC communication parameters are:

ParameterValue
Baud rate115200
Data bits8
Stop bits1
ParityN (none)
Modbus slave ID1

Connect the left and right grippers to separate USB-RS485 adapters and use stable device paths:

Bash
ls -l /dev/serial/by-id/

Set serial_port to a /dev/serial/by-id/... path instead of a /dev/ttyUSB* path, because ttyUSB numbers can change after reconnecting the adapters. Each adapter and slave ID must correspond to only one PGC device.

Find serial_port on the Target Host​

The adapter names in example configurations identify only the example hardware and cannot be copied to another host. After connecting a USB-RS485 adapter, run on the target host:

Bash
ls -l /dev/serial/by-id/
readlink -f /dev/serial/by-id/usb-FTDI_*

The symbolic-link name under /dev/serial/by-id/ contains the adapter vendor and hardware serial number. Copy the complete link path into the serial_port field in hardware_config.json. If the adapter is not FTDI, do not use the wildcard command; use the complete path shown by ls -l. To inspect vendor, model, and serial number together, run:

Bash
for port in /dev/serial/by-id/*; do
[ -e "$port" ] || continue
printf '%s -> %s\n' "$port" "$(readlink -f "$port")"
udevadm info --query=property --name="$port" | \
grep -E '^(ID_VENDOR|ID_MODEL|ID_SERIAL)='
done

The following commands can confirm that the USB adapter was recognized by the kernel:

Bash
lsusb
sudo dmesg --ctime | grep -Ei 'FTDI|ttyUSB|usb serial|cp210|ch341' | tail -50

Run ls -l /dev/serial/by-id/ again after reconnecting an adapter. The link that appears or disappears is the path for that adapter. Do not treat /dev/ttyUSB0 or /dev/ttyUSB1 as permanent identifiers; they can change with reconnect order. If the system does not create by-id links, /dev/serial/by-path/ can be used temporarily. That path depends on the physical USB port and can change when the adapter is moved, so it must be checked again for deployment.

Determine Which Serial Port Is Left or Right​

serial_port identifies the USB-RS485 adapter, not the physical left or right side. Even when only one serial link is visible, its name alone cannot identify which gripper it is connected to. During initial deployment, establish the mapping with the following single-side, small-motion procedure:

  1. Stop EndEffectorCore and all other serial-port processes, and record the current /dev/serial/by-id/ list. If EndEffectorCore is supervised by fourierpc or another process, pause or stop the supervisor first; otherwise it may restart the child and continue holding the serial port.
  2. Leave only one PGC gripper and its USB-RS485 adapter connected. Run ls -l /dev/serial/by-id/ again and record the only serial_port.
  3. Temporarily put this path in left_hand.serial_port, set left_hand.enabled to true, and set right_hand.enabled to false. Start EndEffectorCore and confirm successful initialization without serial timeouts.
  4. Make sure the area around the gripper is safe. In the "Publish a Single PGC Command" example on this page, keep only the action for the enabled side, send a small-motion target through left_end_effector, and observe which physical gripper moves. With normalized positions, send [0.7] and then [1.0]; with raw positions, send [700] and then [1000]. Do not run the other-side, dual-side, or restore actions while using a single-side configuration.
  5. Put the path in the serial_port field for the observed side (left_hand or right_hand) and temporarily set the other side to "enabled": false. Stop and restart EndEffectorCore, connect the second PGC gripper, and repeat steps 2 through 4. Complete the dual-side configuration after both mappings are known.

If one gripper cannot be disconnected, enable one side at a time while both devices are connected: put a candidate path in left_hand.serial_port, disable the right side, start the runtime, and send a safe small-motion command from the example. Use the observed motion to determine the mapping. Do not run dual-side motion until the mapping is confirmed. Physical cable labels, tracing the cable, or a controlled small-motion test is required; the serial filename alone is insufficient.

3.2 PGC Configuration Files​

After installing the DEB package, the PGC example configurations are located at:

Text
/opt/fftai/End_effector/bin/node_config/examples/hardware_config_pgc_dual.json
/opt/fftai/End_effector/bin/node_config/examples/hardware_config_pgc_right.json

Copy the appropriate example to the runtime configuration and replace the adapter paths with the paths found on the target host. Example for a dual-PGC setup:

Bash
sudo cp /opt/fftai/End_effector/bin/node_config/examples/hardware_config_pgc_dual.json \
/opt/fftai/End_effector/bin/node_config/hardware_config.json

The configuration fields for each PGC side are:

FieldMeaning
enabledWhether this side is enabled
vendorMust be dh_robotics
modelMust be PGC
transportMust be modbus_rtu
serial_portStable path for this side's USB-RS485 adapter
baud_rate, data_bits, stop_bits, paritySerial format
slave_idPGC Modbus address
command_rate_hzRuntime frequency for PGC command writes
poll_rate_hzRuntime frequency for PGC state reads
default_speedSpeed used when the command does not provide a valid value; 1..100
default_forceForce used when the command does not provide a valid value; 20..100
position_input_normalizedWhether DDS positions and states use normalized 0..1 values
response_timeout_msTimeout for one Modbus request

command_rate_hz and poll_rate_hz are frequencies for the PGC hardware worker, not DDS publication frequencies. DDS can receive commands at a higher rate, but the runtime retains only the latest pending command. Without hardware stress testing, do not set the PGC frequencies above the verified 20 Hz.

The position unit is selected by position_input_normalized:

ConfigurationCommand inputPublished state
true0..10..1
false0..10000..1000

PGC registers always use the raw 0..1000 position range. The runtime converts between DDS values and register values. PGC does not provide actual speed or grip force feedback, so the corresponding velocity and effort fields in end_effector_state are always 0. Do not use them as a force-sensor or contact detection result.

3.3 Modbus Registers​

EndEffectorCore uses the following registers for initialization, motion control, and state collection:

AddressPurposeDirectionValues
0x0100InitializationWrite1: normal initialization
0x0101Target grip forceWrite20..100
0x0103Target positionWrite0..1000
0x0104Target speedWrite1..100
0x0200Initialization stateRead1: initialization complete
0x0201Grip stateRead0: moving, 1: at target without grip, 2: gripped, 3: dropped
0x0202Actual positionRead0..1000

The runtime reads only 0x0200..0x0202 for state and does not map 0x0201 to a new DDS field. To inspect the grip state, use runtime logs or a separate Modbus diagnostic tool. Normal startup writes only initialization value 1; it should not write the full calibration value 0xA5 on every startup. The normal control loop also does not write configuration-save registers.

4. DDS Interface​

4.1 Topics​

DirectionTopicMessage typeDescription
Subscribeend_effector_cmdfourier_msgs_pod/msg/EndEffectorGroupCmdReceives left and right gripper commands
Publishend_effector_statefourier_msgs_pod/msg/EndEffectorStatePublishes left and right gripper state
Publishend_effector_metainfofourier_msgs/msg/BaseDataTypePublishes left and right gripper metadata

Commands must use the exact group names:

Text
left_end_effector
right_end_effector

To control only one side, provide only that side's group command in EndEffectorGroupCmd and keep the command values for the other side as NaN; the other side will not receive a new target. To control both sides simultaneously, provide commands for both left_end_effector and right_end_effector. The DDS example below shows how to construct the message.

Each PGC side consumes only the first element of its command arrays. When DDS messages have fixed-width arrays, later elements are ignored by PGC, but the first position element must be valid. If only position is supplied, the runtime uses that side's default_speed and default_force.

State arrays are concatenated in enabled-device order: left state first, then right state. A dual-PGC configuration has state_size=2; a single-PGC configuration has state_size=1.

5. Control Example​

5.1 Start EndEffectorCore​

Start from the DEB installation directory so that the relative paths node_config/node_config.json and node_config/hardware_config.json are loaded correctly:

Bash
cd /opt/fftai/End_effector/bin
./EndEffectorCore

Confirm from the logs that each enabled PGC has initialized successfully and that there are no serial timeouts. Keep this terminal running, then run the DDS client example from another terminal.

Precondition: do not start AuroraCore during the test.

AuroraCore publishes high-level commands to end_effector_cmd at a fixed rate. When multiple DDS publishers share the same topic, a fresh AuroraCore command can overwrite the target sent by the example. The log may then show that the test command was received while the gripper does not move, or the gripper quickly returns to the position commanded by the upper layer. A reboot may automatically start AuroraCore through the system service, so confirm before testing that it is not running or is not publishing end-effector commands. For this test, start and keep only the EndEffectorCore process from this page.

If AuroraCore was started by a process supervisor, temporarily pause it after confirming that the mechanical system is safe:

Bash
AURORA_PID=$(ps -eo pid=,stat=,comm= | \
awk '$3=="AuroraCore" && $2 !~ /^Z/ {print $1; exit}')
test -n "$AURORA_PID" || { echo "AuroraCore is not running"; exit 0; }
sudo kill -STOP "$AURORA_PID"

Keep this terminal open and run the DDS test in another terminal. After the test is complete and the device is safe, return to the original terminal and resume AuroraCore:

Bash
sudo kill -CONT "$AURORA_PID"

Confirm that AURORA_PID is not empty before pausing. Check the EndEffectorCore log to ensure that repeated upper-layer commands have stopped. Always resume AuroraCore after testing so normal upper-layer control is restored.

Note: do not pause for too long. The process supervisor (fourierpc) monitors AuroraCore via heartbeats; a process frozen with STOP fails the heartbeat check, gets terminated, and is replaced by a new instance. In that case kill -CONT targets the already-exited old process, but the new instance starts automatically and restores robot control. Read back the FSM state after recovery to confirm the robot returned to the expected state.

The following four examples are independent. They use fourierdds_py directly and do not require another control service. Start EndEffectorCore first, and make sure that no other program is continuously publishing to or subscribing from the topic under test.

5.2 Publish a Single PGC Command​

The following code publishes one position command to one selected side. It assumes position_input_normalized=true; when it is false, change POSITION to a raw value such as 700 or 1000. Speed and force are left as NaN, so the side's default_speed and default_force from the configuration are used. Make sure the area around the gripper is safe and confirm the physical meaning of 0.0 and 1.0 for the installed gripper and calibration.

Python
import math
import os
import time

os.environ.setdefault("FASTDDS_BUILTIN_TRANSPORTS", "UDPv4") # required on the robot

from fourierdds_py import DDSInterface, PublisherQosProfile
import fourier_msgs_pod.msg.RobotJointCmd as RobotJointCmd

DOMAIN_ID = 123
SIDE = "left" # Change to "right" to test the right side.
POSITION = 0.7 # normalized=true: 0..1; false: 0..1000
GROUP_NAMES = ("left_end_effector", "right_end_effector")
GROUP_INDEX = {"left": 0, "right": 1}


def set_string(char_array, value):
data = value.encode("utf-8")
if len(data) >= 64:
raise ValueError("group name must be shorter than 64 bytes")
for index, byte in enumerate(data):
char_array[index] = byte
char_array[len(data)] = 0


def clear_command(command):
# PGC uses only the first element on each side; keep other fixed-width elements invalid.
invalid = [math.nan] * len(command.position())
command.position(invalid)
command.velocity(invalid)
command.acceleration(invalid)
command.effort(invalid)


def set_position(command, value):
clear_command(command)
positions = [math.nan] * len(command.position())
positions[0] = float(value)
command.position(positions)
# Leave speed and force as NaN so hardware_config.json supplies the defaults.


dds = DDSInterface(domain_id=DOMAIN_ID)
publisher = dds.create_publisher(
RobotJointCmd.EndEffectorGroupCmdPubSubType,
"end_effector_cmd",
qos_profile=PublisherQosProfile.default(),
)

try:
# Wait for DDS discovery before publishing the first command.
time.sleep(3.0)
message = RobotJointCmd.EndEffectorGroupCmd()
for index, group_name in enumerate(GROUP_NAMES):
set_string(message.group_names()[index].content(), group_name)
clear_command(message.commands()[index])

set_position(message.commands()[GROUP_INDEX[SIDE]], POSITION)
print(f"publishing {SIDE} position={POSITION}", flush=True)
for attempt in range(3):
publisher.publish(message)
if attempt < 2:
time.sleep(0.1)
finally:
dds.close()

Both group names must still be filled because the message has a fixed structure, but all array elements for the unselected side are NaN, so that side receives no new target. If the EndEffectorCore log shows one side switching repeatedly between the test target and other values, stop the other end_effector_cmd publisher.

5.3 Complete DDS Motion Acceptance​

The complete example below is for a dual-PGC configuration with both sides enabled. It performs a left-only move, a right-only move, a concurrent move on both sides, and then restores both sides to 1.0. It subscribes to state and waits for each target to be reached. For a single-side configuration, use the single-command example above instead of running the other-side and dual-side actions in this example. The example assumes position_input_normalized=true; when it is false, replace the position values with raw values such as 500, 0, and 1000. Make sure the area around the grippers is safe and confirm the physical meaning of 0.0 and 1.0 for the installed grippers.

Python
import math
import os
import threading
import time

os.environ.setdefault("FASTDDS_BUILTIN_TRANSPORTS", "UDPv4") # required on the robot

from fourierdds_py import DDSInterface, PublisherQosProfile, SubscriberQosProfile
import fourier_msgs_pod.msg.RobotJointCmd as RobotJointCmd
import fourier_msgs_pod.msg.RobotJointState as RobotJointState

DOMAIN_ID = 123
GROUP_NAMES = ("left_end_effector", "right_end_effector")
GROUP_INDEX = {"left": 0, "right": 1}


def set_string(char_array, value):
data = value.encode("utf-8")
if len(data) >= 64:
raise ValueError("group name must be shorter than 64 bytes")
for index, byte in enumerate(data):
char_array[index] = byte
char_array[len(data)] = 0


def clear_command(command):
# PGC uses only the first element on each side; keep other fixed-width elements invalid.
invalid = [math.nan] * len(command.position())
command.position(invalid)
command.velocity(invalid)
command.acceleration(invalid)
command.effort(invalid)


def set_position(command, value):
clear_command(command)
positions = [math.nan] * len(command.position())
positions[0] = float(value)
command.position(positions)
# Leave speed and force as NaN so hardware_config.json supplies the defaults.


dds = DDSInterface(domain_id=DOMAIN_ID)
state_condition = threading.Condition()
last_state = {"left": math.nan, "right": math.nan}
state_samples = 0


def on_state(message):
global state_samples
size = int(message.state_size())
positions = message.position()
with state_condition:
if size >= 1:
last_state["left"] = float(positions[0])
if size >= 2:
last_state["right"] = float(positions[1])
state_samples += 1
if state_samples == 1 or state_samples % 20 == 0:
print(
f"state_size={size}, left={last_state['left']:.3f}, "
f"right={last_state['right']:.3f}",
flush=True,
)
state_condition.notify_all()


dds.create_subscription(
RobotJointState.EndEffectorStatePubSubType,
"end_effector_state",
on_state,
qos_profile=SubscriberQosProfile.default(),
)
publisher = dds.create_publisher(
RobotJointCmd.EndEffectorGroupCmdPubSubType,
"end_effector_cmd",
qos_profile=PublisherQosProfile.default(),
)


def publish_and_wait(message, targets, label, timeout=10.0):
for command in message.commands():
clear_command(command)
for side, position in targets.items():
set_position(message.commands()[GROUP_INDEX[side]], position)

print(f"publishing {label}: {targets}", flush=True)
for attempt in range(3):
publisher.publish(message)
if attempt < 2:
time.sleep(0.1)

def reached():
return all(
math.isfinite(last_state[side])
and abs(last_state[side] - position) <= 0.03
for side, position in targets.items()
)

with state_condition:
if not state_condition.wait_for(reached, timeout):
raise RuntimeError(
f"{label} timeout: targets={targets}, state={dict(last_state)}"
)
print(f"{label} reached: {dict(last_state)}", flush=True)


try:
# Wait for DDS discovery before publishing the first command.
time.sleep(3.0)
message = RobotJointCmd.EndEffectorGroupCmd()
for index, group_name in enumerate(GROUP_NAMES):
set_string(message.group_names()[index].content(), group_name)
clear_command(message.commands()[index])

publish_and_wait(message, {"left": 0.5}, "left-only")
publish_and_wait(message, {"right": 0.5}, "right-only")
publish_and_wait(message, {"left": 0.0, "right": 0.0}, "dual")
publish_and_wait(message, {"left": 1.0, "right": 1.0}, "restore")
finally:
dds.close()

Expected output includes a reached result and the final positions for each stage. If a stage times out, check the EndEffectorCore log for the actual target and PGC initialization status, and verify that the AuroraCore publisher is paused.

5.4 Read Gripper State​

The following code subscribes to end_effector_state and prints state_size plus the position, velocity, and force for each enabled PGC. State arrays are ordered by enabled device, with one element per PGC side. PGC does not provide actual speed or grip-force feedback, so velocity and effort are 0 by convention. For a single-side configuration, set ENABLED_SIDES to the enabled side, for example ("right",).

Python
import os
import signal
import time

os.environ.setdefault("FASTDDS_BUILTIN_TRANSPORTS", "UDPv4") # required on the robot

from fourierdds_py import DDSInterface, SubscriberQosProfile
import fourier_msgs_pod.msg.RobotJointState as RobotJointState

DOMAIN_ID = 123
ENABLED_SIDES = ("left", "right") # Single-side example: ("right",)

dds = DDSInterface(domain_id=DOMAIN_ID)


def on_state(message):
state_size = int(message.state_size())
positions = message.position()
velocities = message.velocity()
efforts = message.effort()
print(f"state_size={state_size}", flush=True)

for index, side in enumerate(ENABLED_SIDES[:state_size]):
print(
f" {side}_end_effector: "
f"position={float(positions[index]):.6g}, "
f"velocity={float(velocities[index]):.6g}, "
f"effort={float(efforts[index]):.6g}",
flush=True,
)


dds.create_subscription(
RobotJointState.EndEffectorStatePubSubType,
"end_effector_state",
on_state,
qos_profile=SubscriberQosProfile.default(),
)

running = {"value": True}


def _stop(signum, frame):
running["value"] = False
print(f"\nsignal {signum} received, stopping ...", flush=True)


# fastdds installs a native signal handler that swallows Ctrl+C;
# override it explicitly so the script can exit normally
signal.signal(signal.SIGINT, _stop)
signal.signal(signal.SIGTERM, _stop)

try:
print("Subscribed to 'end_effector_state', waiting for data...", flush=True)
while running["value"]:
time.sleep(0.1)
finally:
dds.close()

5.5 Read Device Metadata​

end_effector_metainfo publishes left and right metadata through BaseDataType.arg_string. Each side occupies four strings in the order name, type, driver_version, and hardware_version. A normal PGC response is usually dh_robotics, PGC, libmodbus, and unknown; a disabled side is empty.

Python
import os
import signal
import time

os.environ.setdefault("FASTDDS_BUILTIN_TRANSPORTS", "UDPv4") # required on the robot

from fourierdds_py import DDSInterface, SubscriberQosProfile
import fourier_msgs.msg.BaseStructType as BaseStructType

DOMAIN_ID = 123
FIELDS = ("name", "type", "driver_version", "hardware_version")

dds = DDSInterface(domain_id=DOMAIN_ID)


def on_metainfo(message):
values = message.arg_string()
size = values.size() if hasattr(values, "size") else len(values)
print("--- End Effector Metainfo ---", flush=True)
for side, offset in (("left", 0), ("right", 4)):
print(f" {side}_end_effector:", flush=True)
for index, field in enumerate(FIELDS):
position = offset + index
value = values[position] if position < size else ""
print(f" {field}: {value or '(empty)'}", flush=True)


dds.create_subscription(
BaseStructType.BaseDataTypePubSubType,
"end_effector_metainfo",
on_metainfo,
qos_profile=SubscriberQosProfile.default(),
)

running = {"value": True}


def _stop(signum, frame):
running["value"] = False
print(f"\nsignal {signum} received, stopping ...", flush=True)


# fastdds installs a native signal handler that swallows Ctrl+C;
# override it explicitly so the script can exit normally
signal.signal(signal.SIGINT, _stop)
signal.signal(signal.SIGTERM, _stop)

try:
print("Subscribed to 'end_effector_metainfo', waiting for data...", flush=True)
while running["value"]:
time.sleep(0.1)
finally:
dds.close()

6. Acceptance and Troubleshooting​

Recommended acceptance sequence:

  1. ls -l /dev/serial/by-id/ shows a stable path for every adapter.
  2. EndEffectorCore completes initialization and continues publishing end_effector_state.
  3. state_size matches the number of enabled PGC devices, with one position element per side.
  4. During a single-side command, the unselected side remains at its previous position.
  5. During a dual-side command, both sides reach their targets and then return to the safe position.

Common issues:

  • Connection timed out: check PGC power, RS485 A/B wiring, baud rate/parity, slave ID, and serial_port, and confirm that no other process owns the port.
  • no ... PGC state received: confirm that EndEffectorCore is running, the intended configuration file was loaded, and the DDS domain ID is 123.
  • Logs show a command was received but the gripper does not move: check that the group name matches exactly, PGC initialization completed, and default_speed (1..100) and default_force (20..100) are valid.
  • Both sides move together: check whether the client supplied both groups. For a single-side test, keep only the target side's command.
  • Do not interpret velocity=0 or effort=0 as actual PGC feedback; both state fields are always 0 by convention.