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.
| Item | Description |
|---|---|
| Runtime | EndEffectorCore |
| DDS domain ID | 123 |
| Vendor/model | dh_robotics / PGC |
| Transport | Modbus RTU over RS485 |
| DOF per side | 1 |
| Command groups | left_end_effector, right_end_effector |
| Recommended hardware command/state polling rate | 20 Hz |
2. Data Path
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.
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:
| Parameter | Value |
|---|---|
| Baud rate | 115200 |
| Data bits | 8 |
| Stop bits | 1 |
| Parity | N (none) |
| Modbus slave ID | 1 |
Connect the left and right grippers to separate USB-RS485 adapters and use stable device paths:
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:
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:
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:
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:
- Stop
EndEffectorCoreand all other serial-port processes, and record the current/dev/serial/by-id/list. IfEndEffectorCoreis supervised byfourierpcor another process, pause or stop the supervisor first; otherwise it may restart the child and continue holding the serial port. - Leave only one PGC gripper and its USB-RS485 adapter connected. Run
ls -l /dev/serial/by-id/again and record the onlyserial_port. - Temporarily put this path in
left_hand.serial_port, setleft_hand.enabledtotrue, and setright_hand.enabledtofalse. StartEndEffectorCoreand confirm successful initialization without serial timeouts. - 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. - Put the path in the
serial_portfield for the observed side (left_handorright_hand) and temporarily set the other side to"enabled": false. Stop and restartEndEffectorCore, 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:
/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:
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:
| Field | Meaning |
|---|---|
enabled | Whether this side is enabled |
vendor | Must be dh_robotics |
model | Must be PGC |
transport | Must be modbus_rtu |
serial_port | Stable path for this side's USB-RS485 adapter |
baud_rate, data_bits, stop_bits, parity | Serial format |
slave_id | PGC Modbus address |
command_rate_hz | Runtime frequency for PGC command writes |
poll_rate_hz | Runtime frequency for PGC state reads |
default_speed | Speed used when the command does not provide a valid value; 1..100 |
default_force | Force used when the command does not provide a valid value; 20..100 |
position_input_normalized | Whether DDS positions and states use normalized 0..1 values |
response_timeout_ms | Timeout 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:
| Configuration | Command input | Published state |
|---|---|---|
true | 0..1 | 0..1 |
false | 0..1000 | 0..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:
| Address | Purpose | Direction | Values |
|---|---|---|---|
0x0100 | Initialization | Write | 1: normal initialization |
0x0101 | Target grip force | Write | 20..100 |
0x0103 | Target position | Write | 0..1000 |
0x0104 | Target speed | Write | 1..100 |
0x0200 | Initialization state | Read | 1: initialization complete |
0x0201 | Grip state | Read | 0: moving, 1: at target without grip, 2: gripped, 3: dropped |
0x0202 | Actual position | Read | 0..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
| Direction | Topic | Message type | Description |
|---|---|---|---|
| Subscribe | end_effector_cmd | fourier_msgs_pod/msg/EndEffectorGroupCmd | Receives left and right gripper commands |
| Publish | end_effector_state | fourier_msgs_pod/msg/EndEffectorState | Publishes left and right gripper state |
| Publish | end_effector_metainfo | fourier_msgs/msg/BaseDataType | Publishes left and right gripper metadata |
Commands must use the exact group names:
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:
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
AuroraCoreduring the test.
AuroraCorepublishes high-level commands toend_effector_cmdat a fixed rate. When multiple DDS publishers share the same topic, a freshAuroraCorecommand 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 startAuroraCorethrough 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 theEndEffectorCoreprocess from this page.If
AuroraCorewas started by a process supervisor, temporarily pause it after confirming that the mechanical system is safe:BashAURORA_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:Bashsudo kill -CONT "$AURORA_PID"Confirm that
AURORA_PIDis not empty before pausing. Check theEndEffectorCorelog to ensure that repeated upper-layer commands have stopped. Always resumeAuroraCoreafter testing so normal upper-layer control is restored.Note: do not pause for too long. The process supervisor (
fourierpc) monitorsAuroraCorevia heartbeats; a process frozen withSTOPfails the heartbeat check, gets terminated, and is replaced by a new instance. In that casekill -CONTtargets 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.
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.
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",).
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.
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:
ls -l /dev/serial/by-id/shows a stable path for every adapter.EndEffectorCorecompletes initialization and continues publishingend_effector_state.state_sizematches the number of enabled PGC devices, with one position element per side.- During a single-side command, the unselected side remains at its previous position.
- 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, andserial_port, and confirm that no other process owns the port.no ... PGC state received: confirm thatEndEffectorCoreis running, the intended configuration file was loaded, and the DDS domain ID is123.- 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) anddefault_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=0oreffort=0as actual PGC feedback; both state fields are always0by convention.