chore: 框架科学优化前的完整基线

在开始'设备契约层 + 目录重构 + 通止规装配'改造之前,先钉住当前可运行状态,
使后续任何删除/重构都可回退。

本基线包含:
- 现有 ROS 2 工作区全部源码(arm_application / arm_control / arm_bringup 等)
- 官方 flexiv_ros2 栈(含 flexiv_hardware / flexiv_controllers 等符号链接)
- 设备契约层 src/arm_interfaces(新增,46 项单测通过)
- devices/ 设备选型与品牌清单(新增)
- scripts/gen_site_example.py 与由它生成的 config/site.example.yaml

按 .gitignore 排除:build/ / install/ / log/ / config/site.yaml / 模型权重二进制。
This commit is contained in:
arm_dev
2026-09-23 10:53:06 +08:00
commit 6edb8d39ab
635 changed files with 1165230 additions and 0 deletions
+44
View File
@@ -0,0 +1,44 @@
# 本机现场配置(含 IP / 序列号;从 site.example.yaml 复制)
config/site.yaml
# YOLO 二进制
models/yolo/*.pt
models/yolo/*.onnx
models/yolo/*.engine
models/yolo/*.trt
models/yolo/active.pt
models/hole_insert/*.pt
models/hole_insert/*.onnx
models/hole_insert/*.engine
models/hole_insert/*.trt
# 编译产物
build/
install/
log/
.colcon/
# Python
*.pyc
__pycache__/
*.egg-info/
.pytest_cache/
# Editor / OS
.vscode/
.idea/
.DS_Store
*.swp
# ROS bags
*.bag
*.db3
# Hand-eye backups
*.bak
*.bak.*
# 可选离线 RDK / Isaac(体积大)
thirdparty/
vendor/
+82
View File
@@ -0,0 +1,82 @@
# Flexiv Rizon 视觉伺服(`~/ros2`)
独立工作空间:腕部 D405 眼在手上、手眼标定、YOLO 找工件、法兰悬停跟踪、DH 夹爪、入孔检测。
**一份仓库即可跑完整跟踪与检测**,不依赖 `camera_test` 或装配 HMI。
工作目录必须是 ASCII 路径(推荐克隆到 `~/ros2`)。
| 项 | 值 |
|----|-----|
| 配置 | [`config/site.yaml`](config/site.example.yaml)(从 example 复制,不进 git) |
| 操作 | [`scripts/`](scripts/README.md) |
| 目录说明 | **[docs/WORKSPACE.md](docs/WORKSPACE.md)**(每个文件夹做什么) |
| Conda | `ros_flexiv` |
| ROS | Humble |
## 克隆后
```bash
cd ~/ros2
cp config/site.example.yaml config/site.yaml
# 编辑 site.yaml:robot.sn / robots[].ip / camera.serial / paths.yolo_weights
# 权重文件放到 models/yolo/(二进制不在 git 里)
conda activate ros_flexiv
source scripts/env.sh
./scripts/build_all.sh
source scripts/env.sh
```
示教器:**AUTO + Remote Ethernet**,关掉 Flexiv Studio(RDK 独占)。
```bash
# 三个终端必须带同一个 --arm(89=左 91=右)
bash ~/ros2/scripts/run_real.sh --arm 89
bash ~/ros2/scripts/run_camera_d405.sh --arm 89 --preview
bash ~/ros2/scripts/run_visual_servo_rt.sh --arm 89 --control
```
改 `site.yaml`:锁高 / YOLO / 伺服 → 重启第三终端;分辨率 / 曝光 → 重启相机。
## 功能入口
| 功能 | 命令 |
|------|------|
| 实机 | `scripts/run_real.sh --arm 89\|91` |
| 腕部相机 | `scripts/run_camera_d405.sh --arm … --preview` |
| 顶视 D435 | `scripts/run_camera_d435.sh --preview` |
| 列出相机 | `scripts/list_realsense.sh` |
| 跟踪 | `scripts/run_visual_servo_rt.sh --arm … [--control]` |
| 回 home | `scripts/go_home.sh --arm …` |
| 手眼 | `scripts/run_handeye.sh --arm … teach` → `merge` → `calibrate` |
| PGEA / RGI 夹爪 | `scripts/run_gripper.sh --arm 89\|91 --cli …` |
| 双爪 ROS | `scripts/run_dual_gripper.sh` |
| 入孔检测 | `scripts/run_hole_detect.sh --arm … --preview` |
| Orisys 触觉 | `scripts/run_tactile.sh [--cli]` |
| 看配置 | `scripts/show_site_config.sh` |
89 与 91 **各有** 手眼、相机序列号、锁高、named poses,禁止混用。
## 文档
| 文档 | 内容 |
|------|------|
| [docs/WORKSPACE.md](docs/WORKSPACE.md) | 每个目录 / 脚本 / 包 |
| [docs/QUICKSTART.md](docs/QUICKSTART.md) | 环境与第一次跑通 |
| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | 进程与几何 |
| [docs/SITE_CONFIG.md](docs/SITE_CONFIG.md) | `site.yaml` 字段 |
| [docs/HANDEYE.md](docs/HANDEYE.md) | 手眼标定 |
| [docs/TRACKING.md](docs/TRACKING.md) | 视觉伺服 |
| [docs/GRIPPER.md](docs/GRIPPER.md) | PGEA + RGI |
| [docs/HOLE_DETECT.md](docs/HOLE_DETECT.md) | 入孔检测 |
| [docs/TACTILE.md](docs/TACTILE.md) | Orisys 视触觉 |
| [docs/INTERFACES.md](docs/INTERFACES.md) | ROS / 脚本接口 |
| [docs/TRAJECTORIES.md](docs/TRAJECTORIES.md) | 轨迹回放 |
## 上传 git 时
- **要提交**:`src/` `scripts/` `docs/` `config/site.example.yaml` `models/yolo/README.md` `models/hole_insert/README.md`
- **不要提交**:`config/site.yaml`(含本机 IP/序列号)、`build/` `install/` `log/`、YOLO `.pt/.onnx/.engine`、`thirdparty/` `vendor/`
- 规则已写在 `.gitignore`
别人克隆后必须自己 `cp site.example.yaml site.yaml` 并重新手眼标定(不要直接用仓库里某台现场的 `T_4x4` 当真值)。
+9
View File
@@ -0,0 +1,9 @@
# config/
| 文件 | git | 说明 |
|------|-----|------|
| `site.example.yaml` | 提交 | 模板。克隆后复制为 `site.yaml` |
| `site.yaml` | 不提交 | 本机 SN、IP、相机、锁高、权重路径 |
| `fastdds_visual_servo.xml` | 提交 | 可选 DDS;默认 `site.yaml` 里 `fastdds_profile: ""` |
改跟踪 / 手眼 / 相机:**只改 `site.yaml`**。字段说明见 [docs/SITE_CONFIG.md](../docs/SITE_CONFIG.md)。
+84
View File
@@ -0,0 +1,84 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!--
FastDDS profile for visual-servo hard paths (Ubuntu 22.04 / Humble default RMW).
Use:
export FASTRTPS_DEFAULT_PROFILES_FILE=~/ros2/config/fastdds_visual_servo.xml
export RMW_IMPLEMENTATION=rmw_fastrtps_cpp
Goals:
- Prefer UDP unicast for low-latency control topics
- Shrink discovery / participant lease so late joiners do not stall 100 Hz
- Keep history shallow so subscribers never chase a backlog
-->
<profiles xmlns="http://www.eprosima.com/XMLSchemas/fastRTPS_Profiles">
<transport_descriptors>
<transport_descriptor>
<transport_id>udp_transport</transport_id>
<type>UDPv4</type>
<!-- Avoid shared-memory backlog stalls between heavy perception and servo -->
<maxMessageSize>65500</maxMessageSize>
</transport_descriptor>
</transport_descriptors>
<participant profile_name="visual_servo_participant" is_default_profile="true">
<rtps>
<userTransports>
<transport_id>udp_transport</transport_id>
</userTransports>
<useBuiltinTransports>false</useBuiltinTransports>
<builtin>
<discovery_config>
<!-- Faster discovery; reduce lease so dead peers drop quickly -->
<leaseDuration>
<sec>10</sec>
<nanosec>0</nanosec>
</leaseDuration>
<leaseAnnouncement>
<sec>3</sec>
<nanosec>0</nanosec>
</leaseAnnouncement>
<initialAnnouncements>
<count>5</count>
<period>
<sec>0</sec>
<nanosec>100000000</nanosec>
</period>
</initialAnnouncements>
</discovery_config>
</builtin>
</rtps>
</participant>
<!-- Perception image topics: BEST_EFFORT + KEEP_LAST (drop old frames) -->
<data_reader profile_name="sensor_best_effort">
<qos>
<reliability>
<kind>BEST_EFFORT</kind>
</reliability>
<history>
<kind>KEEP_LAST</kind>
<depth>5</depth>
</history>
<durability>
<kind>VOLATILE</kind>
</durability>
</qos>
</data_reader>
<!-- /visual_servo/cmd_vel: always only the newest Twist -->
<data_writer profile_name="cmd_vel_latest">
<qos>
<reliability>
<kind>RELIABLE</kind>
</reliability>
<history>
<kind>KEEP_LAST</kind>
<depth>1</depth>
</history>
<durability>
<kind>VOLATILE</kind>
</durability>
</qos>
</data_writer>
</profiles>
+483
View File
@@ -0,0 +1,483 @@
# =============================================================================
# site.example.yaml — 新环境部署模板
#
# !本文件由 scripts/gen_site_example.py 从 config/site.yaml 自动生成,请勿手改。
# 要改模板内容,请改 config/site.yaml(它是唯一权威)后重跑:
# python3 scripts/gen_site_example.py
# 或在改完 site.yaml 后跑一次以确认模板同步:
# python3 scripts/gen_site_example.py --check
#
# 用法:复制本文件为 config/site.yaml,然后把所有 <占位符> 换成现场实际值。
# 现场值包括:机械臂 SN / IP、相机序列号、夹爪串口、传感器 USB 路径。
#
# 说明:site.yaml 里的路径出现 /home/USER 是因为生成时匿名化了用户名;
# 部署时请改成实际用户的 home 目录。
# =============================================================================
# =============================================================================
# site.yaml — 本机唯一配置入口(不进 git;从 site.example.yaml 复制而来)
# 路径:~/ros2/config/site.yaml
#
# 89 与 91 的手眼 / 腕部相机 / 夹爪 / 点位互相独立,禁止混用。
# 切换机械臂:改 robot.sn,或所有终端加同一 --arm 89|91。
# =============================================================================
robot:
# >>> 切换机械臂:改成 robots 里某一台的完整 SN <<<
sn: "Rizon4-XXXXXX"
# Studio Tools 名(一字不差)。空=不切换。查:bash ~/ros2/scripts/rdk_switch_tool.sh --list
tool_name: ""
# 每台登记 SN + IP,以及该臂自己的相机/夹爪/手眼/点位(覆盖下面的全局段)。
robots:
- sn: "Rizon4-XXXXXX"
arm: left
ip: "192.168.2.XXX"
pc_ip: "192.168.2.XXX"
iface: "enp4s0"
label: "左臂89"
camera:
name: d405_left # 话题 /d405_left/... 与 91 分开
serial: "<序列号>" # 左腕 D405(一直在用的那台)
auto_exposure: true
ae_limit_us: 4000
emitter_enabled: false
gripper:
model: "PGEA-100-20-W"
sn: "<序列号>"
port: "/dev/ttyUSB0"
stroke_mm: 20.0
named_poses_file: "/home/USER/ros2/src/arm_control/config/named_poses_89.yaml"
paths:
handeye_file: "/home/USER/ros2/src/arm_application/config/handeye_result_89.yaml"
handeye_waypoints: "/home/USER/ros2/src/arm_application/config/handeye_poses_89.yaml"
handeye_workdir: "/tmp/handeye_calib_89"
# 左臂跟踪高度(只在 --arm 89 时生效,改 91 的 servo 不影响这里)
servo:
ee_z_m: 0.35 # 跟踪锁高:法兰相对基座 Z [m]
min_ee_z_m: 0.20
safe_z_m: 0.45
z_min_m: 0.22
xy_track: hover # 法兰(夹爪中心) XY 压在工件上,不是画面中心
ee_vertical_down: true # 法兰 +Z 朝世界 −Z
hover_offset_ee_xy_m: [0.0, 0.0] # 爪心相对法兰 XY;平行爪同轴保持 0
# >>> 二阶段孔定位悬停锁高:只改这里,91 用下面那段 <<<
# 法兰 Z [m];要兼顾:够低拍清 k1/k2 螺纹孔,够高通止规尖不碰工件。
# 调试:bash ~/ros2/scripts/run_stage2_locate.sh --arm 89 --hover-only
hole_hover_z_m: 0.23 # ← 二阶段悬停高度调这一行
- sn: "Rizon4-XXXXXX"
arm: right
ip: "192.168.2.XXX"
pc_ip: "192.168.2.XXX"
iface: "enp4s0"
label: "右臂91"
camera:
name: d405_right # 话题 /d405_right/... 禁止和 89 的 /d405_left 混用
serial: "<序列号>" # 右腕新 D405
auto_exposure: true
ae_limit_us: 0 # 0=不封顶,让固件自己调亮
emitter_enabled: false
laser_power: 0
gripper:
model: "RGI-100-30-O-S-M1" # 旋转爪:run_gripper.sh --arm 91
sn: ""
port: "/dev/ttyUSB1"
stroke_mm: 30.0
auto_init: false
named_poses_file: "/home/USER/ros2/src/arm_control/config/named_poses_91.yaml"
paths:
handeye_file: "/home/USER/ros2/src/arm_application/config/handeye_result_91.yaml"
handeye_waypoints: "/home/USER/ros2/src/arm_application/config/handeye_poses_91.yaml"
handeye_workdir: "/tmp/handeye_calib_91"
# >>> 右臂跟踪高度:只改这里,89 仍用上面那段 <<<
# 法兰 Z;RGI 比平行爪高,通常要比 89 略抬一点。改完重启:
# bash ~/ros2/scripts/run_visual_servo_rt.sh --arm 91 --control
servo:
ee_z_m: 0.25 # 跟踪锁高:法兰相对基座 Z [m] ← 日常调这一行
min_ee_z_m: 0.20
safe_z_m: 0.45
z_min_m: 0.22
xy_track: hover # 用法兰系手眼 T:夹爪中心(法兰原点)压在工件正上方
ee_vertical_down: true # 法兰/RGI 轴线朝下;相机有 29° 倾角,绿框不必对准十字
hover_offset_ee_xy_m: [0.0, 0.0] # RGI 与法兰同轴;爪心偏了再改这两项 [m]
# 二阶段孔定位悬停锁高(91 装了传感器后同样可用)
hole_hover_z_m: 0.22
# --control:先只降到 ee_z_m(XY 不动),到位后才 hover,用手眼把爪心压到工件正上方
paths:
# >>> YOLO 权重(标准放法,不必改代码)<<<
# 1) 把 .onnx / .pt 放到 /home/USER/ros2/models/yolo/
# 2) 这一行改成那个绝对路径(写 .onnx 即可;TensorRT 会找同名 .engine)
# 3) backend=tensorrt 时必须先导出引擎(只需做一次):
# bash ~/ros2/scripts/export_yolo_trt.sh /home/USER/ros2/models/yolo/detect_best_0.onnx 640
# 4) yolo.imgsz 必须等于 ONNX 输入边长(本文件 640;旧 hc90 才是 416)
# 5) 改权重/imgsz 后重启:bash ~/ros2/scripts/run_visual_servo_rt.sh [--control]
# 当前:detect_best_0.onnx(2026-09-04 从 ~/下载 移入,640 输入)
yolo_weights: "/home/USER/ros2/models/yolo/detect_best_0.onnx"
# yolo_weights: "/home/USER/ros2/models/yolo/detect_best.onnx"
# yolo_weights: "/home/USER/ros2/models/yolo/detect_hc90.pt"
# yolo_weights: "/home/USER/ros2/models/yolo/detect_yolo26_gongjian.pt"
# 入孔 YOLO(backend=yolo 时把孔当框)。two_stage 用上面的工件权重。
hole_weights: "/home/USER/ros2/models/hole_insert/best.pt"
# 下面是缺省(未匹配到 robots[] 时)。真机以 robots[].paths 为准:89 / 91 各一份。
handeye_file: "/home/USER/ros2/src/arm_application/config/handeye_result_89.yaml"
handeye_waypoints: "/home/USER/ros2/src/arm_application/config/handeye_poses_89.yaml"
handeye_calibrate: "/home/USER/ros2/src/arm_application/config/handeye_calibrate.yaml"
handeye_workdir: "/tmp/handeye_calib_89"
# 空=默认 DDS(SHM);勿强制 UDP-only,否则相机预览收不到 /workpiece/detection
fastdds_profile: ""
handeye:
mode: "eye_in_hand"
# >>> 标定板尺寸(内角点数 + 方格边长 m);改这里即可 <<<
# 例:A5 板 12×17 方格 → 内角点 11×16,方格 11 mm
board_cols: 8 # 内角点列数 (方格列数 - 1)
board_rows: 11 # 内角点行数 (方格行数 - 1)
square_size_m: 0.015 # 方格边长 [m]
# 运动更顺滑:慢速 + 到位等静止 + 零速 pin 后再拍照
velocity_scale: 0.05
move_time_sec: 8.0
settle_sec: 1.5
settle_qd_max: 0.015 # |qd|_max 低于此值才算静
settle_stable_sec: 0.6
settle_timeout_sec: 8.0
pin_holds: 2
pin_hold_sec: 0.45
skip_unstable_poses: true # 某点看不到棋盘则跳过,不整段中止
# 点位:paths.handeye_waypoints
camera:
name: "d405"
serial: "<序列号>" # 全局缺省=左腕;真机以 robots[].camera.serial 为准
# USB3 推荐 848x480@30;USB2 自动降到 640x480@15,深度/右目仍开
width: 848
height: 480 # 改宽高/帧率必须重启相机:bash ~/ros2/scripts/run_camera_d405.sh --preview
fps: 30
enable_depth: true
enable_infra: true # 同一 SDK 进程开左右红外;失败则自动退回彩图+深度
preview: true
preview_stereo: true # 预览同时显示 彩图 | 深度 | 左目 | 右目(不另开相机、不抢 USB)
overlay_hold_sec: 0.10 # 预览只留很短;长残留会看起来像绿框不跟
use_site_resolution: true
auto_exposure: true
ae_limit_us: 4000 # D405 腕部近距;91 在 robots[] 里更严
emitter_enabled: false # 腕部关红外投射,否则彩图发白
laser_power: 0
# 现场三台 RealSense(按序列号钉死,禁止按「第一台 D405」乱开)
cameras:
d405_left:
model: d405
name: d405_left
serial: "<序列号>"
role: left_wrist
arm: left
label: 左腕D405(89 平行夹爪)
width: 848 # 相机分辨率以本段为准(全局 camera: 段为旧路径,P2 收敛)
height: 480
emitter_enabled: false # 腕部必须关投射器,红外点阵会污染跟踪图像
d405_right:
model: d405
name: d405_right
serial: "<序列号>"
role: right_wrist
arm: right
label: 右腕D405(91 旋转夹爪)
width: 848
height: 480
emitter_enabled: false
d435:
model: d435
serial: "<序列号>"
role: overhead_eye_to_hand
label: 双臂中间手外D435
name: d435
width: 1280
height: 720
fps: 30
auto_exposure: true
ae_limit_us: 8000
emitter_enabled: true # 顶视深度可用投射器;只看彩图可改 false
yolo:
conf: 0.10 # 检测器门槛:略低于 min_conf,让运动模糊框也能进跟踪
iou: 0.45
imgsz: 640 # detect_best.onnx 固定 640;hc90.engine 才是 416
device: "0"
half_precision: true
backend: "tensorrt" # 强制 .engine;没有则启动失败。导出:bash ~/ros2/scripts/export_yolo_trt.sh
mp_infer: true # YOLO 独立进程,不和 ROS 抢 GIL
min_conf: 0.12 # 新锁门槛;已锁定的框可略低于此
vision_hz: 30.0
infer_max_width: 640 # 进 GPU 进程前缩到 640,减小进程间拷图,避免队列堵
box_ema_alpha: 1.00 # 绿框用预测补偿后的框,不再 EMA
miss_clear_frames: 10 # ~0.33 s @30 Hz;期间绿框按像素速度滑行
overlay_hold_on_miss: true # 滑行失败时仍续发上一框
hold_point_on_miss: false # 眼在手上:丢检续发旧点会让臂追错位置
miss_hold_point_frames: 1
jump_px: 400.0
jump_z_m: 0.10
z_ref_m: 0.22
min_depth_m: 0.04
max_depth_m: 0.55
work_z_max_m: 0.50
meas_blend: 0.85 # 3D 跟手;抖则略降
z_alpha_min: 0.10
z_alpha_max: 0.40
z_alpha_gain: 1.0
camera_frame: "d405_color_optical_frame"
object_frame: "tracked_object"
depth_topic: "/d405/aligned_depth_to_color/image_raw"
feature_mode: "plane" # plane=框内近表面立体点(工件本体);box=单目射线
plane_inlier_m: 0.008 # 拟合平面内点阈值 [m]
plane_band_m: 0.06 # 近表面深度带宽 [m]
servo:
control_hz: 100.0
# --- 基座坐标系下的末端约束(法兰,单位 m)---
ee_x_m: 0.25
ee_y_m: 0.00
ee_z_m: 0.35 # 全局缺省锁高;真机以 robots[].servo.ee_z_m 为准(89/91 分开调)
ee_hold_xy: false # false=跟踪工件 XY;true=锁死 ee_x/ee_y
xy_track: "hover" # hover=法兰悬停在目标正上方(任意锁高) lookat=画面中心对准目标
use_static_handeye_tf: false # 只用 handeye_result.yaml,避免 TF 树抢父子帧
ee_vertical_down: true
# --- 启动回初始位(已停用:--control 不再调用 goto_ee_plane)---
safe_z_m: 0.45 # >>> 防撞安全高度:先纯 Z 抬到这里再回 home,避免扫到料框/壁
home_pose_name: "home" # 非夕初始点位(/set_named_pose:home | ready | inspect | pos_left)
home_pose_speed: 0.30 # 回初始位的速度比例 (0,1],越小越稳
ee_hold_kp: 2.0
ee_w_max: 0.60
z_ref_m: 0.22
z_min_m: 0.22
approach_xy_gate_m: 0.040 # --control 先降到锁高,|ee_z-ee_z_m| 小于此值才开始 XY hover
v_approach_z: 0.025
watchdog_timeout_sec: 1.2
lost_timeout_sec: 1.2
stale_hold_sec: 0.35 # 丢检空窗内用基座 KF 预测继续跟(自运动安全),超过才钉住
max_extrapolate_sec: 0.35 # 预测时间上限(图像延迟 + 前瞻)
predict_enable: true # 基座系恒速预测,补视觉延迟
predict_sec: 0.16 # 额外前瞻 [s]
predict_v_max: 0.80 # 工件速度上限 [m/s],防乱飞
predict_jump_m: 0.08 # 基座 XY 跳变大于此则重锁速度
kp_xy: 4.20 # 很跟手:v = kp × 误差,再经加速度整形;抖则降回 3.5
ki_xy: 0.00
kd_xy: 0.20
kp_z: 0.80
ki_z: 0.00
kd_z: 0.35
i_sep_pos_m: 0.008
deadzone_m: 0.003 # 3 mm;收紧提高对准精度。若抖动调回 0.006
deadzone_z_m: 0.012
lock_hold_sec: 0.7 # 死区驻留多久才发 /visual_servo/locked(串联 0.6s 已够)
e_xy_clip_m: 0.200 # 大误差仍按 v_max 走,靠 a_max 加速
v_max_xy: 0.45 # 法兰 XY 上限 450 mm/s
v_max_z: 0.04
min_ee_z_m: 0.20
workspace_xyz_min: [0.08, -0.55, 0.18] # 法兰工作盒;盒外只允许往回走,不再卡死 no traj
workspace_xyz_max: [0.85, 0.55, 0.65]
a_max: 3.00 # 加速度上限(真正作用在 hover 指令上)
vel_lpf_alpha: 0.80 # 速度低通:越大越跟手(1=不滤波)
dq_window_size: 1 # 1=不做滑窗平均,去掉 ~20ms 滞后
traj_stream_hz: 50.0 # 轨迹点流率:JTC open_loop+样条,吃得起
exec_horizon_sec: 0.08 # 每个轨迹点的执行窗口
max_qdot: 2.00 # 关节速度上限 [rad/s]
min_cmd_delta_rad: 0.001
point_topic: "visual_servo/filtered_point"
traj_topic: "rizon_arm_controller/joint_trajectory"
# 大寰夹爪:官方 Modbus RTU,USB–RS485 插 IPC,不走 Studio/RDK。
# 左 89 PGEA → /dev/ttyUSB0;右 91 RGI → /dev/ttyUSB1。
# bash ~/ros2/scripts/run_gripper.sh --arm 89 --cli open|close|pos
# bash ~/ros2/scripts/run_gripper.sh --arm 91 --cli open|close|rotate 90
# bash ~/ros2/scripts/run_dual_gripper.sh
# ROS:/gripper/open|close(PGEA 单爪)或 /dual_gripper_driver/{left,right}/execute
gripper:
backend: "usb" # usb=工控机官方Modbus rdk=Studio设备(不用)
vendor: "dh"
model: "PGEA-100-20-W"
sn: "<序列号>" # 铭牌,不进协议
device_name: "GripperDahuanModbus"
port: "/dev/ttyUSB0" # 本机 MACROSILICON/pl2303
baud: 115200 # 官方默认 115200 8N1
slave_id: 1 # 官方默认从站 1
rs485_rts: "" # 空=不拉RTS;scan 若 OK 在 tx/tx_inv 再填
stroke_mm: 20.0
# --- 开合 / 力 / 速(大寰官方寄存器,超出会被驱动钳位)---
force_pct: 20 # 夹持力 区间 20–100 %(低于 20 会被钳到 20;PGEA-100 约 30–100 N)
speed_pct: 50 # 开合速度 区间 1–100 %
open_position: 1000 # 张开目标 区间 0–1000 ‰(1000=全开)
close_position: 0 # 闭合目标 区间 0–1000 ‰(0=全合;留缝改 50–200)
auto_init: true
state_hz: 20.0
# 双爪共用参数(run_dual_gripper.sh / load_from_site)
parallel:
stroke_min: 0.0
stroke_max: 20.0
default_speed: 50
default_force: 20
force_min: 20
force_max: 100
auto_initialize: true
rotary:
auto_initialize: false
angle_min: -32767.0
angle_max: 32767.0
max_step_deg: 30000.0
finger_open_per_mille: 1000
finger_close_per_mille: 0
default_speed: 50
default_torque: 50
default_finger_speed: 50
default_finger_force: 50
torque_min: 20
torque_max: 100
# RDK 回退字段(backend: rdk 才用)
force_n: 50.0
vel_mps: 0.05
# 入孔检测。算法参数写在这里(覆盖包内 hole_detect.yaml 默认值)。
# bash ~/ros2/scripts/run_hole_detect.sh --arm 89 --preview
hole_detect:
backend: "two_stage" # two_stage | yolo | hough
weights: "/home/USER/ros2/models/hole_insert/best.pt"
# 二阶段工件检测权重(YOLO 先框出工件,再 Hough 找 k1/k2 螺纹孔)
workpiece_weights: "/home/USER/ros2/models/hole_insert/detect_best_1.onnx"
config: "/home/USER/ros2/src/arm_application/config/hole_detect.yaml"
conf: 0.25
iou: 0.7
imgsz: 640
center_gate_enable: true
center_max_offset_ratio_x: 0.15
center_max_offset_ratio_y: 0.18
center_gate_draw: true
refine_hole_centers: true
bbox_padding: 80
gaussian_ksize: 5
gaussian_sigma: 1.5
hough_dp: 1.2
hough_min_dist: 50
hough_param1: 100
hough_param2: 26
hough_min_radius: 6 # 悬停较高时螺纹孔半径只有几像素
hough_max_radius: 30
min_radius: 6
max_radius: 30
hole_distance_min: 90 # 悬停 0.23m 两螺纹孔约 170px;过小会配到中心孔
hole_distance_max: 700
expected_holes: 2
min_hole_sep_px: 20
max_hole_sep_px: 2000
stabilize_hold_frames: 15
stabilize_ema_alpha: 0.35
stabilize_max_jump: 60
default_depth_m: 0.10
camera_intrinsics:
fx: 425.0
fy: 425.0
cx: 640.0
cy: 360.0
# ===== 二阶段一次性拍照定位(stage2_locate_node,不做实时推理)=====
# bash ~/ros2/scripts/run_stage2_locate.sh --arm 89 # 完整流程
# bash ~/ros2/scripts/run_stage2_locate.sh --arm 89 --hover-only # 只悬停调高度
stage2:
target_hole: "k1" # 对准目标孔:k1=图像左孔 / k2=右孔(见 k1_label)
k1_label: "left" # k1 是画面里的左孔;现场相反就改 right
align_tol_m: 0.0007 # 对准到位判据 [m]
align_max_iters: 8 # 拍照→修正→复拍 迭代上限
coarse_max_moves: 2 # 工件粗居中(bbox 中心)移动上限
# —— 柔顺运动(夹爪持规,防急加速/急停/过冲把规震脱):速度放慢 + 平滑刹车 ——
align_speed: 0.014 # 对准段笛卡尔速度 [m/s](原0.03,放缓防冲)
move_speed: 0.022 # 升降/粗居中段速度 [m/s](原0.05,放缓防震规)
final_gap_mm: 2.0 # 对准后轻降目标:规尖悬在孔面上方这么高 [mm](不接触;安全下限1.2mm自动停)
final_descent_speed: 0.005 # 最后轻降速度 [m/s](很慢,柔顺)
final_descent_enable: true # 对准后是否轻降到 final_gap_mm(false=停在悬停高)
verticalize_tol_deg: 0.8 # 垂直朝下判据 [deg]
settle_sec: 0.45 # 到位后静止多久再拍照
snap_frames: 5 # 每次精拍取中值的帧数
min_depth_m: 0.06 # 有效深度范围(防孔底/桌面拉偏 XY)
max_depth_m: 0.60
linger_sec: 8.0 # 完成后继续发布结果/叠图的时长
# ===== 三阶段柔顺搜孔(stage3_search_node,Orisys 触觉 + 力闭环螺旋)=====
# 前置:二阶段已完成,臂悬停在目标孔上方。先 run_tactile.sh --cli 看力读数量级再调阈值。
# bash ~/ros2/scripts/run_stage3_search.sh --arm 89 # 搜孔+落座
# bash ~/ros2/scripts/run_stage3_search.sh --arm 89 --depth 0 # 只搜孔定心不插入
# bash ~/ros2/scripts/run_stage3_search.sh --arm 89 --depth 6 --pitch 1.0 # 螺纹规旋入
stage3:
# —— 力阈值全是「相对静止均值的增量」(Orisys 读数是形变任意单位且基线会漂移,
# 节点启动时自动测 1.2s 静止均值 μ0/方差 σ0,看日志「触觉基线/派生阈值」两行)——
touch_delta: 250.0 # 触面判定:fnormal > μ0 + max(4σ0, 本值)
press_delta: 200.0 # 轻触目标按压量(触面阈值之上再加这么多)
max_press_delta: 900.0 # 螺旋中允许的最大按压(超过→力闭环抬起)
safe_press_delta: 2000.0 # 安全上限:fnormal > μ0+本值 立即抬升中止
shear_delta: 600.0 # 切向拖拽保护:shear > 静止切向均值+本值 → 贴面时微抬 0.15mm(Z 钉在触面)
insert_press_delta: 1200.0 # 插入力上限(触面阈值之上;超过回退微抖重试)
kp_z_mm_per_unit: 0.001 # Z 力闭环增益:每单位读数偏差调整的毫米数
approach_step_mm: 0.8 # 触面前每步下降量(驱动死区已修,0.5mm 微步可跟踪;小步=触面冲击小)
enter_probe_mm: 2.0 # 触面深于此值→疑似直接入孔,四向微探验证后跳过螺旋
mouth_lift_mm: 2.0 # 直接入孔后抬到孔口倒角段再定心(深插横探会别死孔壁)
baseline_min: 8.0 # 触觉基线健康窗下限(<此值=几乎全 0,tare/断流)
baseline_max: 1500.0 # 上限(>此值=近饱和,接触余量不足)——出窗即中止并提示重启触觉
approach_ft_touch_n: 6.0 # approach 腕部 |F| 旁路触面判据(标定牛顿;自由空气≈2-3N)
approach_max_mm: 60.0 # 从当前位姿最多下降这么多(找不到面就中止;悬停被抬高后仍有余量)
spiral_pitch_mm: 1.0 # 螺旋螺距(< 通止规直径,保证重叠覆盖)
spiral_step_mm: 0.5 # 沿螺旋的弧长步长
spiral_r_max_mm: 4.0 # 最大搜索半径(覆盖二阶段残差 1.5mm + 余量)
spiral_expand_max: 1 # 找不到孔时扩大半径的次数(残差≤1.8mm 时代码不再扩大)
spiral_expand_step_mm: 2.0
drop_ratio: 0.45 # 入孔判据:fnormal 相对接触均值跌落比例
drop_frames: 2 # 跌落需持续的帧数(防毛刺)
center_scan: false # 入孔后十字微扫;默认关(0.8mm 扫会把已入孔的规刮偏)
center_scan_mm: 0.3
insert_depth_mm: 0.0 # 默认只搜孔+轻压,不拧入;拧入阶段再用 --depth
insert_retry: 3
screw_pitch_mm: 0.0 # 螺纹通止规螺距:>0 右旋 / <0 左旋 / 0=不旋(光面规)
search_speed: 0.03 # 搜孔笛卡尔速度 [m/s]
settle_sec: 0.25 # 每个微步后的静止时间
tactile_timeout_s: 3.0 # 触觉话题断流多久中止
ft_watchdog: true # 订阅 /<sn>/flexiv_robot_states 做腕部力看门狗
ft_safe_n: 35.0 # 腕部 |F| 安全上限(真牛顿,标定过的;连续3拍超限才中止)
linger_sec: 8.0 # 完成后继续发布结果的时长
# Orisys 视触觉(夹爪左右指摄像机,不是腕部 D405)。SDK 单独 pip 安装。
# bash ~/ros2/scripts/run_tactile.sh --cli
# bash ~/ros2/scripts/run_tactile.sh
tactile:
backend: "orisys" # orisys | mock
model: "dd03" # 传感器型号(设备契约层用的键;须与 devices/sensors/orisys/models/ 下的目录同名)
config: "dd03" # 旧键,与 model 同义。仅为兼容现有节点保留,P2 会移除
# 型号配置随 SDK 一起放:devices/sensors/orisys/models/<型号>/config.json
config_file: "/home/USER/ros2/devices/sensors/orisys/models/dd03/config.json"
# 用 by-path 稳定路径(插哪个 USB 口就认哪个口,不受 video 编号漂移影响)。
# 当前:3.4.1=左指 3.4.2=右指;若左右反了,互换两行即可。
left_video: "/dev/v4l/by-path/pci-0000:00:14.0-usb-0:X.Y.Z:1.0-video-index0"
right_video: "/dev/v4l/by-path/pci-0000:00:14.0-usb-0:X.Y.Z:1.0-video-index0"
cuda: false # 两路进程抢 GPU 上下文会互相拖慢;CPU 解算仅 ~9ms/帧,足够
isstitch: true
infer_backend: "cpu" # cupy | opencl | cpu;双路并行用 cpu 最稳
baseline_seconds: 2.0
filter_alpha: 0.25
motion_threshold: 0.0
target_fps: 30.0
publish_hz: 20.0
rotation_config: "" # 左右共用;有分指文件则用下面两项
left_rotation: "" # 例:orisys SDK 的 rotation_config_0.json
right_rotation: ""
go_home:
pose: "home"
speed: 0.25
network:
# User1=enp4s0 本机 <PC_IP> ↔ <左臂SN>=.101 / <右臂SN>=.100
prefer_iface: "enp4s0"
# 仿真平台(当前 Gazebo 尚未接线,详见 devices/sim/gazebo/README.md)
# 设计约束:仿真与真机共用同一套参数(servo.* / hole_detect.stage2.*),
# 只有物理引擎自身的参数放在这里,避免「仿真调好的值搬到真机不生效」。
sim:
platform: "gazebo"
world: "" # 世界文件路径;留空用 Gazebo 默认空场景
physics_enable: true
joint_prefix: "sim_" # 仿真关节前缀,必须与真实臂前缀区分
+62
View File
@@ -0,0 +1,62 @@
# devices/ —— 设备与外部文件总目录
这个目录存在的唯一目的:**把「换个硬件」从改代码变成改配置**。
## 设计原则(务必先读)
### 1. 选型与数值分离
| 关注点 | 放在哪 | 谁改 |
|--------|--------|------|
| **用哪个品牌的哪个驱动** | `devices/registry.yaml` | 集成工程师(很少改) |
| **设备号 / 速度 / 阈值 / 悬高** | `config/site.yaml` | 现场操作人员(经常改) |
**`devices/` 下不允许出现任何现场数值。** 数值只有 `config/site.yaml` 一个来源。
这样可以避免现在这种「同一个值三处定义、改了不生效」的问题。
### 2. 目录约定
```
devices/
├── registry.yaml # 选型开关(唯一入口)
├── robots/ # 机械臂:RDK + 适配器
├── grippers/ # 夹爪
├── sensors/
│ └── orisys/ # ★ 视触觉核心,长期迭代,单独占一个大文件夹
├── cameras/ # 相机
└── sim/ # 仿真平台(当前 Gazebo)
```
每个品牌一个子目录,子目录内固定结构:
```
<类别>/<品牌>/
├── manifest.yaml # 我是谁 / 支持什么能力 / 需要哪些配置 / 外部文件放哪
├── driver/ # 适配器(把厂商 API 翻译成框架接口)
└── <厂商物料>/ # 如 rdk/、sdk/,原样放置,不要改名
```
### 3. 一个新品牌要接进来,只需要四步
1. **丢物料**:把厂商的 RDK / SDK / 驱动包放进 `<类别>/<品牌>/` 下对应文件夹
(格式和命名要求写在该类别的 `README.md` 里,**必须按它要求放**)。
2. **填 manifest**:复制同类别的 `manifest.yaml` 改四个字段(id / label / driver.package / config_schema)。
3. **写适配器**:在 `driver/` 下实现框架的接口契约(见 `src/arm_interfaces/`)。
这一步的工作量取决于厂商 —— 如果厂商本来就提供符合契约的 ROS 2 驱动,这一步几乎为零。
4. **改一行选型**:把 `registry.yaml` 里对应类别的值改成新品牌 id。
> **诚实说明**:第 3 步无法完全避免。Flexiv 的 RDK 是 C++/Python 库而不是 ROS 2 驱动,
> 所以它外面永远需要一层薄适配器。框架能做的是把这层适配器写到最薄(只做 API 翻译,
> 不含任何业务逻辑),而不是让你零代码接入。
### 4. 启动时的校验
框架启动会依次读 `registry.yaml` → 各 `manifest.yaml` → `config/site.yaml`,
校验三件事:
- 选中的品牌目录存在,且 `manifest.yaml` 可解析;
- manifest 里 `assets` 声明的必需物料文件都在;
- `config_schema` 里标记 `required: true` 的配置键,在 `site.yaml` 里都有值。
任何一项不满足**立即报错并明确指出该去哪个文件补什么**,不允许静默降级。
这是替代现在「`run_handeye.sh` 传了 5 个节点没声明的参数、调参静默失效」那类问题的机制。
+51
View File
@@ -0,0 +1,51 @@
# devices/cameras/ —— 相机
## 厂商物料放置要求
| 项 | 要求 |
|----|------|
| 格式 | 厂商 SDK / 驱动包原始格式。RealSense 用 `pyrealsense2` 或官方 ROS 2 包 |
| 命名 | 保持原样,不要改名 |
| 序列号 | **不要写在这里**。序列号在 `config/site.yaml` 的 `cameras.<名字>.serial` |
| 生效 | 放好物料 → 改 `registry.yaml` 选型 → 改 `site.yaml` 的 serial / 分辨率 / 曝光 → 重启对应相机脚本 |
## 目录结构
```
cameras/
├── README.md
├── realsense/ # Intel RealSense(当前在用)
│ ├── manifest.yaml
│ └── driver/
└── <新品牌>/
```
## 相机角色与命名(重要)
框架里每台相机必须有一个**角色**,因为不同角色参与完全不同的流程:
| 角色 | 当前相机 | 参与流程 | 是否参与手眼标定 |
|------|----------|----------|------------------|
| `wrist_eye_in_hand` | D405(左腕 / 右腕各一台) | 视觉跟踪 k0、拍照推理 k1/k2 | 是(眼在手上) |
| `overhead_eye_to_hand` | D435(双臂中间) | 粗定位兜底(S03) | 是(眼在手外) |
**注意**:D435 当前**还没有做手眼标定**,所以 S03 粗定位链路暂时不可用。
但这不影响 S04 跟踪链路,两条链路用的是同一套权重、同一个跟踪目标 k0。
## 已知的坑(迁移时必须保留的处理)
1. **USB 独占**:RealSense 同时只能被一个进程打开。启动前必须 `pkill` 掉旧进程,
现有脚本里那句 `sleep 2` 是必要的,不要删。
2. **USB2 vs USB3 会自动降分辨率**:现有脚本检测到 USB2 会降到 `640x480@15` 并告警,
这个降级逻辑要保留,否则会在弱链路上直接起不来。
3. **`run_camera_d435.sh` 曾把分辨率/曝光/emitter 硬编码**,与 `site.yaml` 的
`cameras.d435` 字段脱节。迁移后必须全部从 `site.yaml` 读,不允许硬编码。
## 当前状态(迁移参考)
| 现有位置 | 内容 | 迁移去向 |
|----------|------|----------|
| `src/arm_application/arm_application/realsense_color_pub.py` | 采集 + 发布彩图/对齐深度/红外 | `driver/` |
| `src/arm_application/arm_application/realsense_view.py` | 独立预览进程(与采集分离) | `driver/` 或 tools/ |
| `src/arm_application/arm_application/realsense_sdk_view.py` | Qt 四宫格窗口(被上面两者共用) | 保留 |
| `src/arm_application/arm_application/handeye_realsense.py` | 内参查询、话题解析、曝光设置 | 拆开:相机相关的进 `driver/`,标定相关的进 `handeye/` |
+141
View File
@@ -0,0 +1,141 @@
# =============================================================================
# devices/cameras/realsense/manifest.yaml
#
# Intel RealSense 相机驱动清单。覆盖两种型号:
# D405 —— 腕部相机,眼在手上,参与跟踪与手眼标定
# D435 —— 手外相机,顶视,参与粗定位兜底与手眼标定
#
# 这里【不放现场数值】。序列号 / 分辨率 / 曝光一律在 config/site.yaml。
# =============================================================================
id: realsense
label: Intel RealSense D405 / D435
kind: camera
vendor: intel
version: "pyrealsense2"
# 从哪里读出「当前用哪个型号」。值是 config_schema 里的 field key。
model_selector: model
description: >
RealSense 深度相机。D405 装在法兰/夹爪上做腕部视觉(眼在手上),
D435 架在双臂中间做顶视(眼在手外)。USB 独占,同一时刻只能一个进程打开。
# --- 厂商物料放置要求 ------------------------------------------------------
assets:
- path: driver
required: false
what: 厂商驱动(若使用 pyrealsense2 则无需放置,由 conda 环境提供)
format: 厂商原始格式
naming: 保持原样
where_used: driver 通过 pyrealsense2 或官方 ROS 2 包访问相机
# --- 适配器 ----------------------------------------------------------------
driver:
package: arm_driver_realsense
executable: realsense_publisher
build: colcon
# --- 能力声明 --------------------------------------------------------------
capabilities:
- color
- depth
- aligned_depth_to_color
- infrared_left
- infrared_right
- query_intrinsics # 手眼标定必需
- set_exposure
# --- 型号与角色 ------------------------------------------------------------
models:
- id: d405
label: D405 腕部相机
roles: [wrist_eye_in_hand]
default_resolution: "848x480"
default_fps: 30
notes: >
腕部使用,必须关闭红外投射器(emitter_enabled: false),
否则投射的红外点阵会污染跟踪图像。
- id: d435
label: D435 手外顶视相机
roles: [overhead_eye_to_hand]
default_resolution: "1280x720"
default_fps: 30
notes: >
手外使用,通常需要开启红外投射器以提升无纹理表面的深度质量。
当前尚未完成手眼标定。
# --- 配置项声明 ------------------------------------------------------------
config_schema:
- key: model
site_path: cameras[].model
type: str
required: true
doc: >
相机型号。必须与上面 models 列表里的某个 id 一致(d405 或 d435)。
型号决定默认分辨率与是否应开启红外投射器。
- key: serial
site_path: cameras[].serial
type: str
required: true
doc: >
相机序列号。用 scripts/list_realsense.sh 查看当前接入的全部序列号并映射角色。
每个角色必须是不同的序列号,否则两台相机抢同一台设备。
- key: role
site_path: cameras[].role
type: str
required: true
doc: >
相机角色,合法值: left_wrist | right_wrist | overhead_eye_to_hand。
以 _wrist 结尾表示装在法兰上(眼在手上,手眼标定用 eye_in_hand);
overhead_eye_to_hand 表示固定安装(眼在手外,手眼标定用 eye_to_hand)。
角色决定该相机参与哪条流程,所以必须与物理安装位置一致。
- key: width
site_path: cameras[].width
type: int
unit: px
required: true
doc: 彩色流宽度。注意 USB2 链路会自动降到 640 并告警。
- key: height
site_path: cameras[].height
type: int
unit: px
required: true
doc: 彩色流高度。注意 USB2 链路会自动降到 480 并告警。
- key: fps
site_path: cameras[].fps
type: int
unit: Hz
required: false
default: 30
doc: 帧率。
- key: auto_exposure
site_path: cameras[].auto_exposure
type: bool
required: false
default: true
doc: 自动曝光。
- key: ae_limit_us
site_path: cameras[].ae_limit_us
type: float
unit: us
required: false
doc: >
曝光上限。腕部 D405 建议限到 4000us 以内 —— 跟踪时曝光过长会导致运动拖影。
0 表示不封顶。
- key: emitter_enabled
site_path: cameras[].emitter_enabled
type: bool
required: true
doc: >
红外投射器。腕部必须 false(投射点阵污染跟踪图像),
手外通常 true(提升无纹理表面深度质量)。
+52
View File
@@ -0,0 +1,52 @@
# devices/grippers/ —— 夹爪
## 厂商物料放置要求
| 项 | 要求 |
|----|------|
| 格式 | 厂商原始发布的驱动/SDK/协议文档,不要转换 |
| 命名 | 保持原文件名。若厂商只提供 PDF 协议手册,把它放进来即可(适配器按手册实现) |
| 通讯参数 | **不要写在这里**。串口、波特率、从站号、行程全部在 `config/site.yaml` |
| 生效 | 放好物料 → 改 `devices/registry.yaml` 选型 → 改 `site.yaml` 里的串口与行程 → 重启 `scripts/run_gripper.sh` |
## 目录结构
```
grippers/
├── README.md
├── dh_pgea/ # 大寰(当前在用)
│ ├── manifest.yaml
│ ├── protocol/ # 厂商协议手册 / 寄存器表
│ └── driver/
└── <新品牌>/
```
## 适配器必须实现的接口契约
夹爪的接口刻意做成和机械臂对称 —— 上层只调接口,不关心是哪个品牌、走 Modbus 还是走 RDK。
| 能力 | 用途 |
|------|------|
| `open` | 张开 |
| `close` | 夹紧(可带力/速度参数) |
| `move` | 移动到指定开度 |
| `rotate` | 旋转到指定角度(旋转爪) |
| `get_status` | 读开度、力、是否夹持到位 |
**关键设计:`rotate` 与 `open/close` 解耦。** 通止规装配的 S08 循环是
「转 180° → 松爪 → 快回转 → 夹紧」四步反复,这四步必须是四个独立可调用的原语,
不能封装成一个「拧螺丝」复合动作 —— 否则中途掉件时无法精确恢复。
## 当前状态(迁移参考)
大寰现有实现有两套互不复用的 Modbus 栈,P1 之后会合并到 `dh_pgea/driver/` 下:
| 文件 | 内容 | 处置 |
|------|------|------|
| `src/arm_application/arm_application/dh_pgea.py` | PGEA Modbus 客户端 + 自带 CRC 表 + CLI | 保留,收敛为驱动 |
| `src/arm_application/arm_application/dh_official.py` | 另一份寄存器级客户端(含 RGI 旋转寄存器) | 合并进来,消除重复 |
| `src/arm_application/arm_application/dual_gripper.py` | PGEA/RGI 统一抽象 | 改为调用新驱动接口 |
| `src/arm_application/arm_application/dh_pgea_node.py` | ROS 节点封装 | 改为通用夹爪节点的品牌无关实现 |
**注意**:`site.yaml` 里 `gripper.backend: rdk` 这条路径已被 `run_gripper.sh` 明确拒绝,
`scripts/rdk_gripper.py` 是孤儿脚本。这条「走控制器 RS485」的分支在 P2 会被清理。
+135
View File
@@ -0,0 +1,135 @@
# =============================================================================
# devices/grippers/dh_pgea/manifest.yaml
#
# 大寰(DH)夹爪驱动清单。覆盖两种型号:
# PGEA —— 平行夹爪(左臂 89 使用)
# RGI —— 旋转夹爪(右臂 91 使用)
#
# 这里【不放现场数值】。串口 / 波特率 / 从站号 / 行程一律在 config/site.yaml。
# =============================================================================
id: dh_pgea
label: 大寰 PGEA 平行爪 / RGI 旋转爪
kind: gripper
vendor: dh
version: "Modbus RTU(工控机 USB-RS485 直连)"
# 从哪里读出「当前用哪个型号」。值是 config_schema 里的 field key。
# RGI 型号会在此基础上叠加 rotate 能力。
model_selector: model
description: >
大寰夹爪。PGEA 提供开合,RGI 在开合之外提供绕轴旋转。
通讯走工控机 USB 转 RS485,不经过机械臂控制器。
# --- 厂商物料放置要求 ------------------------------------------------------
assets:
- path: protocol
required: false
what: 大寰 Modbus 协议手册与寄存器表
format: 厂商原始 PDF / 文档
naming: 保持原文件名
where_used: 适配器实现寄存器读写时的依据
notes: >
寄存器表已固化进驱动代码(见 driver/)。此目录只作为排查时的对照资料,
缺失不影响运行。
# --- 适配器 ----------------------------------------------------------------
driver:
package: arm_driver_dh_gripper
executable: dh_gripper_driver
build: colcon
# --- 能力声明 --------------------------------------------------------------
capabilities:
- open
- close
- move
- get_status
# RGI 型号额外支持旋转。框架按 manifest 的型号分支决定是否暴露 rotate。
models:
- id: PGEA-100-20-W
label: PGEA 平行爪(左臂)
capabilities: [open, close, move, get_status]
stroke_mm: 20.0
- id: RGI-100-30-O-S-M1
label: RGI 旋转爪(右臂)
capabilities: [open, close, move, rotate, get_status]
stroke_mm: 30.0
notes: >
rotate 是通止规装配 S08 循环的核心原语,必须可独立调用且可随时中止。
单次转角默认 180°(配置项 system.screw_step_deg),因为不确定基于工具系
能转多少度是极限。
# --- 配置项声明 ------------------------------------------------------------
config_schema:
- key: model
site_path: robot.robots[].gripper.model
type: str
required: true
doc: >
当前使用的夹爪型号。必须与上面 models 列表里的某个 id 一致。
型号决定是否具备 rotate 能力(PGEA 没有,RGI 有)。
- key: port
site_path: robot.robots[].gripper.port
type: str
required: true
doc: 串口设备路径(如 /dev/ttyUSB0)。建议用 /dev/serial/by-id/ 下的稳定路径,
因为 ttyUSB 编号会随插拔漂移。
- key: baud
site_path: gripper.baud
type: int
required: false
default: 115200
doc: 波特率。
- key: slave_id
site_path: gripper.slave_id
type: int
required: false
default: 1
doc: Modbus 从站号。
- key: stroke_mm
site_path: robot.robots[].gripper.stroke_mm
type: float
unit: mm
required: true
doc: 该爪的总行程。用于把 0–1000‰ 开度换算成毫米。
- key: force_pct
site_path: gripper.force_pct
type: int
unit: '%'
required: false
default: 20
doc: 夹紧力百分比。低于 20 会被硬件钳位到 20。
- key: speed_pct
site_path: gripper.speed_pct
type: int
unit: '%'
required: false
default: 50
doc: 动作速度百分比。
- key: open_position
site_path: gripper.open_position
type: int
unit: 千分比
required: false
default: 1000
doc: 「张开」对应的开度。1000 = 全开。
- key: close_position
site_path: gripper.close_position
type: int
unit: 千分比
required: false
default: 0
doc: 「夹紧」对应的开度。0 = 全合。
注意:通止规是异形件,全合可能压不到,实际夹紧位需要按件标定后填这里。
@@ -0,0 +1,31 @@
# devices/grippers/dh_pgea/protocol/ —— 大寰 Modbus 协议资料
## 放什么
大寰(DH)夹爪的 Modbus RTU 协议手册、寄存器表、型号规格书。
## 格式与命名要求
| 项 | 要求 |
|----|------|
| 格式 | 厂商原始 PDF / 文档,不要转换 |
| 命名 | 保持原文件名,或改为 `<型号>_<文档类型>.pdf`,如 `PGEA-100-20-W_寄存器表.pdf` |
| 是否必需 | **不必需**。寄存器表已固化进驱动代码,此目录仅供人工排查时对照 |
## 为什么放这里
驱动里硬编码了寄存器地址与 CRC 表。当现场出现「某个动作没反应」时,
需要拿手册逐条核对寄存器定义与从站号,所以把手册放在驱动旁边最方便。
## 已知的两处不一致(迁移时要处理)
1. 现在代码里有**两套独立的 Modbus 实现**,各自定义了一份寄存器常量:
- `src/arm_application/arm_application/dh_pgea.py`(PGEA,自带 CRC 表)
- `src/arm_application/arm_application/dh_official.py`(含 RGI 旋转寄存器)
两份功能重叠,P1 之后合并成一份。
2. `site.yaml` 里 `gripper.backend: rdk` 这条「走机械臂控制器 RS485」的分支
已被 `run_gripper.sh` 明确拒绝,对应的 `scripts/rdk_gripper.py` 是孤儿脚本。
如果你其实需要走控制器 RS485(而不是工控机 USB),请明确告知 ——
这会影响驱动是挂在 `devices/grippers/` 还是 `devices/robots/flexiv/` 下。
+30
View File
@@ -0,0 +1,30 @@
# =============================================================================
# devices/registry.yaml —— 设备选型开关
#
# 这个文件只回答一个问题:每一类设备当前由哪个驱动实现来接管。
# 这里【不放任何现场数值】。数值一律在 config/site.yaml。
#
# 改动任何一个值之后:重启对应的启动脚本即生效,不需要重新编译。
# =============================================================================
version: 1
# 机械臂驱动实现。可选值 = devices/robots/ 下的目录名
# flexiv -> devices/robots/flexiv/ (非夕 Rizon / AICO)
robots: flexiv
# 夹爪驱动实现。可选值 = devices/grippers/ 下的目录名
# dh_pgea -> devices/grippers/dh_pgea/ (大寰 PGEA 平行爪 / RGI 旋转爪)
grippers: dh_pgea
# 传感器驱动实现。可选值 = devices/sensors/ 下的目录名
# orisys -> devices/sensors/orisys/ (视触觉:3 切向力 + 1 法向力 + 深度图 + 力矢量图)
sensors: orisys
# 相机驱动实现。可选值 = devices/cameras/ 下的目录名
# realsense -> devices/cameras/realsense/ (D405 腕部 / D435 手外)
cameras: realsense
# 仿真平台。可选值 = devices/sim/ 下的目录名
# gazebo -> devices/sim/gazebo/
sim: gazebo
+61
View File
@@ -0,0 +1,61 @@
# devices/robots/ —— 机械臂
## 厂商物料放置要求
每个品牌一个子目录,物料放在该子目录下的 `rdk/`(或厂商自定的物料目录,须在 `manifest.yaml`
的 `assets` 里声明)。
**通用要求:**
| 项 | 要求 |
|----|------|
| 格式 | 厂商**原始发布格式**,不要自行转换、不要重新打包成 zip/tar 后不展开 |
| 命名 | **保持厂商原目录名和原文件名,一律不要改名**。改名会导致 RDK 内部的 include 路径、CMake 查找路径、Python 包名全部失效 |
| 版本 | 在 `manifest.yaml` 的 `version` 字段里记录版本号,方便日后排查 |
| 编译产物 | 不要把编译产物(`.a` `.so` `build/`)提交进 git;离线库放 `vendor/` |
| 生效 | 放好物料 → 编译一次 → 改 `devices/registry.yaml` 选型 → 重启 `scripts/run_real.sh` |
## 目录结构
```
robots/
├── README.md
├── flexiv/ # 非夕(当前在用)
│ ├── manifest.yaml
│ ├── rdk/ # 厂商 RDK 源码或安装包,原样放置
│ └── driver/ # 适配器:把 RDK API 翻译成框架接口
└── <新品牌>/
├── manifest.yaml
├── rdk/ 或 sdk/
└── driver/
```
## 适配器必须实现的接口契约
框架上层(伺服、跟踪、装配状态机、手眼标定)**只调用下面这组接口,不认识任何厂商 SDK**。
契约定义在 `src/arm_interfaces/`,每个方法对应的 ROS 2 话题/服务/action 也在那里声明。
| 能力 | 用途 | 谁在用 |
|------|------|--------|
| `get_arm_state` | 读关节角 + 末端位姿 | 手眼标定、状态记录 |
| `follow_joint_trajectory` | 关节轨迹(点到点、多路点) | 手眼标定走点、装配移动 |
| `servo_joint` | 高频关节伺服 | 视觉伺服 `servo_node` |
| `servo_cartesian` | 笛卡尔位姿伺服 | 跟踪、悬停 |
| `compliant_descent` | 柔顺下臂(接触力约束) | 装配接触阶段 |
| `set_tool` / `switch_tool` | 设置/切换工具坐标系(如 tool1) | 全局工具系锁定 |
| `home` | 回到初始位姿 | 每个循环开始、异常复位 |
| `enable` / `clear_fault` | 使能、清故障 | 启动自检、异常恢复 |
**缺哪个能力就在 manifest 的 `capabilities` 里不写哪个**,框架启动时会检查上层要用的能力是否都被支持,
不支持就明确报错,而不是运行时莫名其妙失败。
## 当前状态(迁移参考)
非夕臂的现有实现分散在这些地方,P1 之后会收敛到 `flexiv/driver/` 下:
- 硬件接口:`src/flexiv_hardware/`(ros2_control 插件,上游官方)
- bringup:`src/arm_bringup/stack.launch.py` 的 `backend:=real` 分支
- 轨迹适配:`src/arm_control/arm_control/real_trajectory_adapter.py`
- 几何 IK 与手眼加载:`src/arm_application/arm_application/geom_ik.py`
- 软拖拽:`scripts/rdk_drag_bridge.py`
- 工具系切换:`scripts/rdk_switch_tool.py`
+116
View File
@@ -0,0 +1,116 @@
# =============================================================================
# devices/robots/flexiv/manifest.yaml
#
# 非夕机械臂(Flexiv Rizon / AICO)驱动清单。
# 框架启动时读这个文件,决定加载哪个适配器、校验哪些配置、检查哪些外部物料。
#
# 这里【不放现场数值】。SN / IP / 速度 / 悬高一律在 config/site.yaml。
# =============================================================================
id: flexiv
label: 非夕 Flexiv Rizon / AICO
kind: robot
vendor: flexiv
version: "RDK 1.9.x(与 flexiv_ros2 humble-v1 v1.9.1 配套)"
description: >
非夕七轴力控机械臂。当前项目使用 Rizon4 双臂(SN 尾号 89 / 91)。
上层通过 ros2_control + rizon_arm_controller 下发关节轨迹,柔顺下臂走 RDK 力控原语。
# --- 厂商物料放置要求 ------------------------------------------------------
# 每个条目会在框架启动时被检查;required: true 且文件缺失时直接报错。
assets:
- path: rdk
required: true
what: Flexiv RDK 源码或安装包
format: 厂商原始发布格式,不要转换、不要重新打包
naming: 保持厂商原目录名与文件名,一律不要改名
where_used: driver 通过此路径加载 RDK
notes: >
与 flexiv_ros2 humble-v1 配套的是 RDK 1.9.x。仓库外的已编译安装位于
~/flexiv_install(由 scripts/env.sh 与 run_real.sh 注入 LD_LIBRARY_PATH)。
离线安装库在 vendor/rdk_libs/。
# --- 适配器 ----------------------------------------------------------------
driver:
package: arm_driver_flexiv
executable: flexiv_arm_driver
build: colcon
# --- 能力声明 --------------------------------------------------------------
# 框架上层只会调用这里列出的能力。少写一项,上层用到时会启动即报错。
capabilities:
- get_arm_state
- follow_joint_trajectory
- set_named_pose
- servo_joint
- servo_cartesian
- compliant_descent
- set_tool
- switch_tool
- home
- enable
- clear_fault
- soft_drag # 软拖拽,手眼标定走点用
# --- 配置项声明 ------------------------------------------------------------
# 框架启动时按 site_path 去 config/site.yaml 取值,required 的键缺失即报错。
# unit 会打印在人读的错误信息里,避免「填了毫米但代码按米」这类问题。
config_schema:
- key: sn
site_path: robot.robots[].sn
type: str
required: true
doc: 机械臂完整 SN(如 Rizon4-063989)。必须与示教器上显示的一致。
- key: ip
site_path: robot.robots[].ip
type: str
required: true
doc: 控制柜 IP。同一网段的工控机地址填 pc_ip。
- key: pc_ip
site_path: robot.robots[].pc_ip
type: str
required: true
doc: 工控机在本网段的地址,RDK 白名单用。
- key: iface
site_path: robot.robots[].iface
type: str
required: true
doc: 连接控制柜的网卡名(如 enp4s0)。
- key: tool_name
site_path: robot.tool_name
type: str
required: false
default: ""
doc: 示教器 Studio 里的工具坐标系名。留空表示不切换。
本项目要求 tool1 全程保持垂直向下且位姿锁定,需在此指定。
- key: tool_z_extend_m
site_path: robot.tool_z_extend_m
type: float
unit: m
required: false
default: 0.0
doc: >
工具坐标系沿 Z 的延长量。用于补偿「夹具 + 夹持物」超出原质心的距离。
当前 tool1 质心在未装夹具的平行夹爪末端,装上夹具并夹持通止规后需沿 Z 延长。
标定方法见 docs/TOOL_FRAME.md。
- key: ee_z_m
site_path: robot.robots[].servo.ee_z_m
type: float
unit: m
required: true
doc: 视觉跟踪锁高 —— 法兰相对基座系的 Z。
注意与悬高(相对工具系)区分,两者是不同概念。
- key: safe_z_m
site_path: robot.robots[].servo.safe_z_m
type: float
unit: m
required: true
doc: 防撞安全高度。启动归位时先抬到这个高度再水平移动。
+53
View File
@@ -0,0 +1,53 @@
# devices/robots/flexiv/rdk/ —— 放置非夕 RDK
## 放什么
Flexiv RDK(Robotic Development Kit)的源码或安装包。
## 格式与命名要求(必须遵守)
| 项 | 要求 | 违反的后果 |
|----|------|-----------|
| 格式 | 厂商原始发布格式(`.tar.gz` 需展开,不要留着压缩包) | 驱动加载不到 |
| 目录名 | 保持厂商原目录名(通常是 `flexiv_rdk`) | CMake 的 include 路径失效 |
| 文件名 | 一律不改名 | CMake 查找 `libflexiv_rdk.*.a` 时按原名匹配 |
| 版本 | 在 `../manifest.yaml` 的 `version` 字段记录 | 日后无法排查版本不匹配 |
| 编译产物 | 不要把 `build/` 提交进 git | 仓库膨胀 |
## 版本必须配对(这是已踩过的坑)
项目里同时存在两个 RDK 版本,**不能混用**:
| 用途 | 需要的 RDK 版本 | 位置 |
|------|----------------|------|
| 编译 `flexiv_ros2`(humble-v1,v1.9.1) | **1.9.x** | 已编译安装于 `~/flexiv_install` |
| 单独跑 RDK Python 脚本(软拖拽、切工具) | 2.1.x | `src/flexiv_rdk`(符号链接到仓库外) |
`flexiv_ros2` 是 humble-v1 版本,它期望的 RDK 是 1.9.x。当前 `src/flexiv_rdk` 指向的是
**2.1.x**,两者靠 `~/flexiv_install` 里已编译好的 1.9.1 解耦。
离线安装 1.9.1 需要的静态库在 `vendor/rdk_libs/`,由
`scripts/install_rdk_offline.sh` 校验 sha256 后使用。
## 放好之后怎么让它生效
```bash
# 1. 若 RDK 未编译,先编译并安装到 ~/flexiv_install
bash scripts/build_flexiv_rdk.sh
# 2. 重新编译依赖 RDK 的 ROS 包
source scripts/env.sh
./scripts/build_all.sh
# 3. 确认选型
cat devices/registry.yaml # robots: flexiv
# 4. 实机启动
bash scripts/run_real.sh --arm 89
```
## 排查
- `run_real.sh` 会先调 `scripts/rdk_switch_tool.py` 做预检;失败会直接 `exit 1` 并打印原因。
- 网络不通先跑 `scripts/check_robot_network.sh`。
- RDK Python 冒烟测试:`scripts/test_rdk_python.sh <SN>`。
+80
View File
@@ -0,0 +1,80 @@
# devices/sensors/orisys/ —— Orisys 视触觉(框架核心)
**这是整个框架的核心。** 后续所有 ROS 2 项目都会围绕它开发,它也会持续迭代优化更新,
所以它单独占一个文件夹,不与其它传感器混放。
## 先搞清楚它输出什么
这是上层算法真正依赖的数据契约。**注意单位与坐标系的定义,这是最容易出错的地方。**
| 数据 | 维度 | 说明 | 框架内部话题 |
|------|------|------|--------------|
| 切向力 1 | 标量 | 任意单位(非牛顿),且**基线会漂移** | `~/tangential_1` |
| 切向力 2 | 标量 | 同上,另一正交方向 | `~/tangential_2` |
| 法向力 | 标量 | 同上 | `~/normal` |
| 深度图 | 图像 | 接触面深度 | `~/depth` |
| 力矢量图 | 图像 | 接触点的力矢量可视化 | `~/force_vector` |
### 两个必须记住的坑
**1. 力是任意单位,不是牛顿。** 现有 `site.yaml` 里所有力阈值(`touch_delta`、`press_delta`、
`safe_press_delta` 等)全部是「相对静止均值的**增量**」,不是绝对值。节点启动后会先自动测基线。
改阈值时改的也是增量,不是绝对读数。
**2. 基线会漂移,所以阈值必须相对化。** 写任何新的力判据时,不允许直接比较绝对读数。
**3. 切向力的 X / Y 通道归属尚未确认。** 通止规装配里「接触到面时某一路切向力突变」「拧紧判定时
某一路切向力超阈值」都需要知道是哪个通道。在确认之前,框架按**两个切向通道都监控、任一满足即判定**
来实现,避免猜错通道导致整段流程静默失效。确认后会把配置项收敛为指定通道。
## 厂商物料放置要求
| 项 | 要求 |
|----|------|
| SDK 格式 | 厂商**原始发布格式**,不要转换、不要重新打包 |
| SDK 命名 | **保持厂商原目录名与文件名**,一律不要改名。SDK 内部常按自己包名做导入与资源查找 |
| SDK 版本 | 在 `manifest.yaml` 的 `version` 记录,并写进 `docs/CHANGELOG_ORISYS.md` |
| 型号配置 | 每个型号一份配置放 `models/<型号名>/`,不要混在一起 |
| 相机路径 | **必须用 `/dev/v4l/by-path/` 稳定路径**,不要写 `/dev/video0` —— video 编号会随插拔漂移,这是现有框架已经踩过的坑 |
| 生效 | 放好 SDK → `pip install -e` 或加进 `PYTHONPATH` → 改 `registry.yaml` 选 `orisys` → 改 `site.yaml` 的 `tactile.models.<型号>` → 重启 `scripts/run_tactile.sh` |
## 目录结构
```
sensors/orisys/
├── README.md # 本文件
├── manifest.yaml # 型号清单 + 能力 + 配置项声明
├── sdk/ # ★ 厂商 SDK 原样放置
│ └── README.md # 放进来之后怎么让它生效(见下)
├── models/ # ★ 多个传感器型号
│ ├── README.md
│ └── <型号名>/ # 每个型号一份配置
│ └── config.json
└── driver/ # 适配器:统一成上面的数据契约
```
## 多型号怎么切换
传感器有多个型号,切换只改两处,**不改任何代码**:
1. `devices/registry.yaml` → `sensors: orisys`(选厂商,通常不用改)
2. `config/site.yaml` → `tactile.model: "<型号名>"`,指向 `models/<型号名>/config.json`
型号之间的差异(通道数、内参、标定参数、拼接参数)全部由 `models/<型号名>/` 承载。
上层只订阅统一话题,换型号不影响上层代码。
## 当前状态(迁移参考)
| 现有位置 | 内容 | 迁移去向 |
|----------|------|----------|
| `src/arm_application/arm_application/orisys_tactile.py` | SDK 适配 + 多进程 worker + `load_orisys_from_site` | `driver/` |
| `src/arm_application/arm_application/tactile_node.py` | ROS 节点,双指各一个进程 | `driver/` |
| `src/arm_application/arm_application/tactile_cli.py` | 读数量级 CLI(调试阈值用) | 保留为调试工具 |
| `src/arm_application/arm_application/tactile_interface.py` | 数据类 | `driver/`,其中的 `MockTactile` 类已死,P2 删除 |
| `src/arm_application/config/orisys/dd03.json` | 当前型号 dd03 的厂商配置 | `models/dd03/config.json` |
**已解决的历史坑,迁移时必须保留这些做法:**
- 左右指各起一个独立进程,**不要合并成一个进程双路并行** —— 会抢 CUDA 上下文互相拖慢。
- `tactile.cuda` 保持 `false`,`infer_backend` 用 `cpu`。
- 退出时残留的孤儿 worker 会占死 `/dev/video`,`run_tactile.sh` 里那段 `pkill` 清理必须保留。
+162
View File
@@ -0,0 +1,162 @@
# =============================================================================
# devices/sensors/orisys/manifest.yaml
#
# Orisys 视触觉传感器驱动清单(框架核心)。
#
# 这里【不放现场数值】。video 路径 / 型号 / 发布率一律在 config/site.yaml。
# =============================================================================
id: orisys
label: Orisys 视触觉传感器
kind: sensor
vendor: orisys
version: "待填 —— 放入 SDK 后在此记录实际版本号"
# 从哪里读出「当前用哪个型号」。值是 config_schema 里的 field key。
# 框架据此叠加该型号的额外能力(型号能力与顶层能力取并集)。
model_selector: model
description: >
视触觉传感器,输出两路切向力 + 一路法向力,以及深度图与力矢量图。
采用双指方案,左右指各一个独立进程(防止抢 CUDA 上下文)。
是本框架的长期核心,后续所有项目围绕它开发。
# 重要:上层算法依赖的数据契约
outputs:
- topic: tangential_1
type: scalar
unit: 任意单位(非牛顿)
notes: 基线会漂移,所有阈值必须按「相对静止均值的增量」使用
- topic: tangential_2
type: scalar
unit: 任意单位(非牛顿)
notes: 与 tangential_1 正交。通止规装配需要确认哪一路对应哪个方向,见 README
- topic: normal
type: scalar
unit: 任意单位(非牛顿)
notes: 法向力。拧紧判定需要「法向力突增」这一条件
- topic: depth
type: image
unit: null
notes: 接触面深度图
- topic: force_vector
type: image
unit: null
notes: 接触点力矢量可视化
# --- 厂商物料放置要求 ------------------------------------------------------
assets:
- path: sdk
required: true
what: Orisys 厂商 SDK(Python 包或原生库)
format: 厂商原始发布格式,不要转换、不要重新打包
naming: 保持厂商原目录名与文件名,一律不要改名
where_used: driver 通过 PYTHONPATH 或 pip install -e 加载
notes: >
SDK 内部常按自己的包名做导入与资源查找,改名会直接导致 ImportError。
- path: models
required: true
what: 各传感器型号的配置
format: 每个型号一个子目录,内含 config.json
naming: 子目录名即型号名,会被 site.yaml 的 tactile.model 引用
where_used: driver 按 site.yaml 选中的型号加载对应配置
# --- 适配器 ----------------------------------------------------------------
driver:
package: arm_driver_orisys
executable: orisys_tactile_driver
build: colcon
notes: >
必须保持「左右指各一个独立进程」的架构,不要合并。
必须保留退出时清理孤儿 worker 的逻辑(否则占死 /dev/video)。
# --- 能力声明 --------------------------------------------------------------
capabilities:
- read_tangential
- read_normal
- read_depth
- read_force_vector
- measure_baseline
# --- 配置项声明 ------------------------------------------------------------
config_schema:
- key: model
site_path: tactile.model
type: str
required: true
doc: 当前使用的传感器型号名。必须与 devices/sensors/orisys/models/ 下的子目录名一致。
- key: left_video
site_path: tactile.left_video
type: str
required: true
doc: >
左指相机路径。必须用 /dev/v4l/by-path/ 下的稳定路径,
不要写 /dev/video0(编号会随插拔漂移)。
- key: right_video
site_path: tactile.right_video
type: str
required: true
doc: 右指相机路径,同样用 by-path 稳定路径。
- key: baseline_seconds
site_path: tactile.baseline_seconds
type: float
unit: s
required: false
default: 2.0
doc: 启动后采集静止基线的时间。所有力阈值都是相对这个基线的增量。
- key: publish_hz
site_path: tactile.publish_hz
type: float
unit: Hz
required: false
default: 20.0
doc: 数据发布率。
- key: cuda
site_path: tactile.cuda
type: bool
required: false
default: false
doc: >
双指并行时保持 false —— 开 GPU 会让两路互相抢 CUDA 上下文,反而更慢。
- key: infer_backend
site_path: tactile.infer_backend
type: str
required: false
default: cpu
doc: 推理后端。cpu / cupy / opencl。双指场景建议 cpu。
# --- 力阈值(通止规装配用)-------------------------------------------------
# 全部是「相对静止均值的增量」,不是绝对值。见 README 的坑点说明。
force_thresholds:
- key: touch_delta
site_path: hole_detect.stage3.touch_delta
unit: 增量
doc: 触面判定 —— 超过这个增量认为接触到了工件表面
- key: screw_done_shear_delta
site_path: system.screw_done_shear_delta
unit: 增量
doc: >
拧紧判定条件之一 —— 切向力超过这个增量。
必须与 screw_done_normal_delta 同时满足才判定拧紧到位。
- key: screw_done_normal_delta
site_path: system.screw_done_normal_delta
unit: 增量
doc: 拧紧判定条件之二 —— 法向力突增。
- key: safe_delta
site_path: hole_detect.stage3.safe_press_delta
unit: 增量
doc: 安全上限。任何时候超过立即抬升并报错。
+59
View File
@@ -0,0 +1,59 @@
# devices/sensors/orisys/models/ —— 传感器型号配置
## 放什么
每个传感器型号一个子目录,子目录内存放该型号的厂商配置。
## 格式与命名要求(必须遵守)
| 项 | 要求 | 违反的后果 |
|----|------|-----------|
| 子目录名 | **就是型号名**,会被 `config/site.yaml` 的 `tactile.model` 引用 | 选型找不到配置 |
| 配置文件名 | 固定为 `config.json` | 驱动按固定名加载 |
| 内容格式 | 厂商原始 JSON 结构,不要自己重排字段 | 解析失败 |
| 新增型号 | 从现有型号复制整个目录再改,不要在原型号上改 | 污染在用型号 |
| 生效 | 改 `site.yaml` 的 `tactile.model` → 重启 `scripts/run_tactile.sh` | — |
## 目录结构
```
models/
├── README.md # 本文件
├── dd03/ # 当前在用型号
│ └── config.json # 从 src/arm_application/config/orisys/dd03.json 迁来
└── <新型号>/
└── config.json
```
## 型号之间的差异由这里承载
不同型号可能在以下方面不同,**全部通过这个目录承载,上层代码不感知**:
- 相机分辨率与内参(影响深度图与力矢量图的标定)
- 力的通道数(有的型号只有 1 路切向力)
- 基线与噪声特性(影响阈值标定)
- 左右指的拼接参数
- 是否需要旋转配置(现有 `site.yaml` 里的 `left_rotation` / `right_rotation`)
## 新增一个型号的完整步骤
```bash
# 1. 复制现有型号作为模板
cp -r devices/sensors/orisys/models/dd03 devices/sensors/orisys/models/<新型号>
# 2. 按厂商资料修改 config.json 里的分辨率/内参/通道数
# 3. 在 site.yaml 里切换型号
# tactile:
# model: "<新型号>"
# 4. 重启并对比读数
bash scripts/run_tactile.sh --cli
```
## 关键提醒
换型号之后,**必须重新测基线并重新标定所有力阈值**。
`site.yaml` 里的 `touch_delta` / `screw_done_shear_delta` / `screw_done_normal_delta` /
`safe_delta` 都是「相对静止均值的增量」,不同型号的噪声水平不同,直接沿用旧阈值会导致
接触判定误触发或完全不触发。
@@ -0,0 +1,35 @@
{
"name": "dd03",
"type": "single_camera",
"version": "1.0",
"camera": {
"resolution_raw": [1280, 720],
"fps": 30,
"resolution_stitch": [420, 450],
"source_scale": 0.4,
"old": [672, 728, 1, 0.5],
"work": [504, 546, 1],
"work2": [420, 450, 0.8],
"work3": [350, 375, 4, 0.7],
"work4": [280, 300, 4, 0.4, 0.2]
},
"tracker": {
"adaptive": true,
"lower_threshold": 12.0,
"upper_threshold": 18.0,
"loop_closure_interval": 1,
"algorithm": "dis",
"dis_preset": "medium",
"dis_mode": 3,
"algorithm_list": ["raft", "dis"],
"hhd_downsample": 3
},
"contact": {
"min_contour_area": 3000,
"flow_threshold": 0.2,
"div_threshold": 0.022,
"expansion_threshold": 0.01,
"kernel_size": [5, 5],
"denoise_sigma": 12
}
}
+70
View File
@@ -0,0 +1,70 @@
# devices/sensors/orisys/sdk/ —— 放置 Orisys 厂商 SDK
## 放什么
Orisys 视触觉传感器的厂商 SDK(Python 包、原生库、或 ROS 包)。
## 格式与命名要求(必须遵守)
| 项 | 要求 | 违反的后果 |
|----|------|-----------|
| 格式 | 厂商原始发布格式。若是 `.whl`/`.tar.gz`,展开后放进来,不要留压缩包 | 无法 import |
| 目录名 | 保持厂商原包名(如 `orisys_sdk/`),**一律不要改名** | Python 内部按包名做相对导入与资源查找,改名直接 `ImportError` |
| 文件名 | 一律不改名 | 原生库按 `.so` 原名加载 |
| 版本 | 记录进 `../manifest.yaml` 的 `version` | 日后无法排查版本问题 |
| 原生库 | 若含 `.so`,确认架构是 x86_64 | 加载报错 |
| 编译产物 | 不要把 `build/` 提交进 git | 仓库膨胀 |
## 放好之后怎么让它生效
```bash
# 方式 A:可编辑安装(推荐,方便日后迭代 SDK)
conda activate ros_flexiv
pip install -e devices/sensors/orisys/sdk
# 方式 B:加入 PYTHONPATH(不改环境,只在当前 shell 生效)
export PYTHONPATH="$PWD/devices/sensors/orisys/sdk:$PYTHONPATH"
# 冒烟验证
python3 -c "import orisys_sdk; print(orisys_sdk.__file__)"
```
然后:
```bash
# 1. 确认选型
cat devices/registry.yaml # sensors: orisys
# 2. 在 site.yaml 里选中型号
# tactile:
# model: "dd03" # 必须与 models/ 下的子目录名一致
# left_video: "..." # 必须用 /dev/v4l/by-path/ 稳定路径
# right_video: "..."
# 3. 确认型号配置存在
ls devices/sensors/orisys/models/dd03/config.json
# 4. 启动
bash scripts/run_tactile.sh # 图传 + 状态话题
bash scripts/run_tactile.sh --cli # 只读数值,用来调阈值
```
## 迭代 SDK 时的注意事项
这个 SDK 会持续迭代更新,升级时按下面顺序做:
1. 把新版本放在原目录名旁边(如 `sdk_new/`),**不要直接覆盖旧版本**;
2. 用 `--cli` 对比新旧版本的读数(量纲、基线漂移、噪声水平是否变化);
3. 读数一致后再切换;
4. 更新 `../manifest.yaml` 的 `version` 字段;
5. **如果量纲变了**,`site.yaml` 里所有力阈值(`touch_delta` / `screw_done_shear_delta` /
`screw_done_normal_delta` / `safe_delta`)都需要重新标定 —— 这些阈值是相对基线的增量,
单位一变全部失效。
## 已踩过的坑(不要重犯)
1. **左右指必须各一个独立进程**,不要合并成单进程双路 —— 会抢 CUDA 上下文互相拖慢。
2. **`cuda` 保持 `false`**,`infer_backend` 用 `cpu`。
3. **退出时残留的孤儿 worker 会占死 `/dev/video`**,导致下次启动失败。
`run_tactile.sh` 里那段 `pkill` 清理逻辑必须保留。
4. **不要用 `/dev/video0`**,用 `/dev/v4l/by-path/pci-...-video-index0`。
+95
View File
@@ -0,0 +1,95 @@
# devices/sim/gazebo/ —— Gazebo 仿真平台
## 先读这段:当前仿真是假的
**现在项目里的「仿真」并不是 Gazebo。** 实际情况是:
- `src/arm_description/urdf/arm.gazebo.xacro` 是零引用的死文件;
- `arm_bringup/stack.launch.py` 的 `isaac` 分支只有一行日志,没有任何实际动作;
- `run_sim.sh` 走的是 `sim.launch.py`,底层是 **mock 驱动**(`mock_arm_driver.py`),
即用假数据模拟机器人状态,不是物理仿真;
- Gazebo 从未真正接线。
所以这份文档分两部分:**第一部分是怎么把 Gazebo 真正跑起来**(当前必须做的前置工作),
**第二部分才是日常怎么用**。不要跳过第一部分。
---
## 第一部分:把 Gazebo 真正接起来(前置工作)
要让 Gazebo 跑起来,缺的是这几块,按顺序补齐:
### 1. URDF 加 Gazebo 标签
现有 `arm_description/urdf/arm.gazebo.xacro` 是死文件,需要重写。每个 link 必须补:
```xml
<!-- 视觉碰撞体 -->
<gazebo reference="${prefix}link1">
<material>Gazebo/Grey</material>
</gazebo>
```
并在 `arm.ros2_control.xacro` 里把 `ros2_control` 标签的 hardware 插件从
`flexiv_hardware/FlexivHardware` 换成 `gazebo_ros2_control/GazeboSystem`。
### 2. 关节传动与控制器
Gazebo 需要 `transmission` 标签把关节映射到 `ros2_control`。控制器配置可以直接复用
`arm_control/config/controllers.yaml` 里的 `rizon_arm_controller`,只需把关节名前缀
从真实 SN(如 `Rizon4-063989_`)换成仿真专用的前缀(如 `sim_`)。
### 3. 传感器插件
| 仿真对象 | 插件 | 对应真实设备 |
|----------|------|--------------|
| 腕部相机 | `libgazebo_ros_camera.so` | D405 |
| 手外相机 | `libgazebo_ros_camera.so` | D435 |
| 深度 | `libgazebo_ros_depth_camera.so` | 对齐深度流 |
| 触觉力 | **需要自己写插件** | Orisys |
**最关键的一条:Orisys 视触觉没有现成的 Gazebo 插件。**
3 路切向力 + 1 路法向力 + 深度图 + 力矢量图 这套数据需要自定义 Gazebo 插件来模拟。
在它做出来之前,仿真里**无法验证通止规装配的力控部分**(S07 接触、S08 拧紧判定)。
建议的自定义插件做法:订阅 Gazebo 的接触传感器数据,按接触深度和相对滑动速度合成
出一组「类触觉」读数,再按真实传感器的量纲(任意单位增量)发布到同样的内部话题上。
### 4. 工件与通止规模型
需要给工件(含 k0 中心孔、k1/k2 螺纹孔)和通止规建简化网格,并保证:
- 工件的 k0/k1/k2 三个点在模型坐标系里的位置与实际一致 —— 否则推理出的坐标映射到仿真里会对不上;
- 螺纹孔要能表达「拧入」的几何阻力,否则 S08 的力反馈无法触发。
---
## 第二部分:日常怎么用
### 放置格式与命名要求
| 类型 | 放哪 | 格式 | 命名要求 |
|------|------|------|----------|
| 世界文件 | `worlds/` | `.world` 或 `.sdf` | `<场景名>.world`,如 `assembly.world` |
| 机器人模型 | `urdf/` | `.urdf` / `.xacro` | 必须以 `sim_` 为关节前缀,与真实臂区分 |
| 工件/夹具网格 | `models/<模型名>/` | `<模型名>.sdf` + `meshes/` | **模型名必须与推理用的类别名一致**,如 `workpiece` |
| 传感器插件 | `sensor_plugins/` | `.so` 源码 + `CMakeLists.txt` | 插件类名以 `Orisys` 开头 |
| 网格文件 | `models/<模型名>/meshes/` | `.stl` / `.dae` | 单位必须是米。用毫米建模的必须先缩放到米 |
### 导入一个新场景
1. 把 `.world` 放进 `worlds/`,网格放进 `models/<模型名>/`
2. 改 `config/site.yaml` 的 `sim.world` 指向该文件
3. 改 `devices/registry.yaml` 的 `sim: gazebo`(通常已是)
4. 启动 `scripts/run_sim.sh`
### 修改哪个配置才会生效
| 想改什么 | 改哪里 |
|----------|--------|
| 用哪个场景 | `site.yaml` → `sim.world` |
| 仿真是否启用物理 | `site.yaml` → `sim.physics_enable` |
| 仿真里的悬高/速度 | **复用真实的那套参数**(`servo.*` / `hole_detect.stage2.*`),不要另建一套,否则仿真调好的值搬到真机不生效 |
| 关节前缀 | `site.yaml` → `sim.joint_prefix`(默认 `sim_`) |
> **设计约束**:仿真与真机必须共用同一套 `site.yaml` 参数。任何「仿真专用参数」都是未来
> 仿真调参搬到真机失效的隐患。只允许物理引擎本身的参数(如重力、步长)放在 `sim.*` 下。
+91
View File
@@ -0,0 +1,91 @@
# =============================================================================
# devices/sim/gazebo/manifest.yaml
#
# Gazebo 仿真平台清单。
#
# 重要:本项目的 Gazebo 目前【尚未接线】。详见本目录 README.md。
# 这份清单声明的是「接线完成之后」应有的配置项,并把 world 设为非必填,
# 这样在仿真还没搭建好时不会阻塞 device_check。
# =============================================================================
id: gazebo
label: Gazebo 仿真
kind: sim
vendor: openrobotics
version: "待填 —— 接线后记录 Gazebo 版本"
description: >
物理仿真平台。当前项目的「仿真」实际是 mock 驱动(假数据),Gazebo 尚未接线:
arm.gazebo.xacro 零引用、stack.launch.py 的 isaac 分支只有日志、没有 world 文件。
接线步骤见本目录 README.md 的第一部分。
注意:Orisys 视触觉没有现成 Gazebo 插件,自定义插件做出来之前,
仿真里无法验证通止规装配的 S07 接触与 S08 拧紧力判据。
# --- 物料放置要求 ----------------------------------------------------------
assets:
- path: worlds
required: false
what: Gazebo 世界文件(.world / .sdf)
format: Gazebo 原生格式
naming: 场景名.world,如 assembly.world
where_used: 由 site.yaml 的 sim.world 指向
- path: models
required: false
what: 工件 / 夹具 / 通止规模型
format: 每个模型一个子目录,内含 <模型名>.sdf 与 meshes/
naming: >
模型名必须与推理用的类别名一致(如 workpiece),否则仿真里算出的坐标
与推理结果对不上。网格单位必须是米,用毫米建模的必须先缩放。
where_used: Gazebo 场景加载
- path: urdf
required: false
what: 仿真版机器人描述
format: .urdf / .xacro,需含 gazebo 与 ros2_control 标签
naming: 关节前缀必须以 sim_ 开头,与真实臂(Rizon4-xxxxxx_)区分
where_used: 仿真机器人加载
- path: sensor_plugins
required: false
what: 传感器插件(含 Orisys 触觉自定义插件)
format: 插件源码 + CMakeLists.txt
naming: 插件类名以 Orisys 开头
where_used: 仿真传感器数据发布
# --- 适配器 ----------------------------------------------------------------
driver:
package: arm_sim_gazebo
executable: gazebo_sim_launcher
build: colcon
notes: >
当前尚未实现。接线前 run_sim.sh 走的是 arm_bringup 的 mock 驱动路径。
# --- 配置项声明 ------------------------------------------------------------
# 设计约束:仿真与真机共用同一套 site.yaml 参数(servo.* / hole_detect.stage2.*),
# 避免「仿真调好的值搬到真机不生效」。只有物理引擎自身的参数放在 sim.* 下。
config_schema:
- key: world
site_path: sim.world
type: str
required: false
default: ""
doc: >
仿真世界文件路径。留空表示使用 Gazebo 默认空场景。
Gazebo 接线完成后填真实路径,如 devices/sim/gazebo/worlds/assembly.world。
- key: physics_enable
site_path: sim.physics_enable
type: bool
required: false
default: true
doc: 是否启用物理引擎。关闭可用于纯运动学验证。
- key: joint_prefix
site_path: sim.joint_prefix
type: str
required: false
default: sim_
doc: >
仿真关节名前缀。必须与真实臂的前缀(Rizon4-xxxxxx_)不同,
否则仿真与真机的 TF 会互相污染。
View File
View File
View File
+54
View File
@@ -0,0 +1,54 @@
# 架构
## 进程(实机跟踪)
```
run_real.sh Flexiv RDK + ros2_control + named_pose
run_camera_d405.sh 该臂 D405 → /d405_left 或 /d405_right(彩图+对齐深度+预览)
run_visual_servo_rt.sh
perception_node YOLO + 平面中心 → /visual_servo/filtered_point
servo_node 100 Hz,法兰 hover / 锁高 → JointTrajectory
static TF flange → camera(手眼 T)
run_gripper.sh 可选,IPC Modbus PGEA 或 RGI
run_dual_gripper.sh 双爪同一节点
run_hole_detect.sh 入孔检测(当前臂 D405)
run_tactile.sh Orisys 左右指视触觉
```
RDK 独占:`run_real.sh` 与 `run_handeye.sh teach`(RDK 拖拽)不能同时开。
D405 USB 独占:相机节点与示教/标定自己开会互斥;wrapper 会先停旧相机。
## 目录
```
~/ros2/
├── README.md # 克隆后怎么跑
├── config/
│ ├── site.example.yaml # 模板(入库)
│ └── site.yaml # 本机配置(不入库)
├── docs/ # 说明;总表见 WORKSPACE.md
├── models/yolo/ # 工件权重二进制不入库
├── models/hole_insert/ # 入孔权重二进制不入库
├── scripts/ # 唯一操作入口
├── src/ # ROS 2 包
├── build/ install/ log/ # colcon,不入库
```
## 配置流
```
config/site.yaml
→ scripts/lib/export_site_env.py SITE_* 环境变量 + ROS 覆盖字典
→ run_*.sh / visual_servo.launch.py
→ 节点参数(覆盖包内 perception.yaml / servo.yaml)
```
包内 yaml 只是默认值,日常不要改。
## 跟踪几何
手眼 `T_cam_to_gripper`(`handeye_result.yaml`)把相机点变到法兰。
`servo.xy_track`:
- `hover`:基座系法兰 XY = 工件 3D 点 XY,任意 `ee_z_m`
- `lookat`:光影像中心对准工件(倾斜相机时只有某一高度法兰才在正上方)
+68
View File
@@ -0,0 +1,68 @@
# 大寰夹爪(PGEA + RGI)
两只爪都走工控机 **USB–RS485 + 官方 Modbus RTU**,不走 Flexiv `Gripper` / Studio `GripperDahuanModbus`。示教器里关掉对应设备,避免抢总线。`run_real.sh` 保持 `load_gripper:=false`。
| 臂 | 型号 | 串口 | 脚本 |
|----|------|------|------|
| 左 89 | PGEA-100-20-W 平行 | `/dev/ttyUSB0` | `run_gripper.sh --arm 89` |
| 右 91 | RGI-100-30-O-S-M1 旋转 | `/dev/ttyUSB1` | `run_gripper.sh --arm 91` |
端口、行程、力/速写在 `site.yaml`:`robots[].gripper`(每臂)+ 全局 `gripper` / `gripper.parallel` / `gripper.rotary`。双爪 ROS 节点读 `robots[]` 两侧。不要改包内 Python 默认值。
源码:`src/arm_application/arm_application/dh_official.py`、`dual_gripper.py`、`dh_pgea.py`。消息:`arm_msgs`(**不要**往官方 `flexiv_msgs` 塞自定义夹爪类型)。
## 命令行
```bash
# 左臂平行爪
bash ~/ros2/scripts/run_gripper.sh --arm 89 --cli probe
bash ~/ros2/scripts/run_gripper.sh --arm 89 --cli init # 上电第一次
bash ~/ros2/scripts/run_gripper.sh --arm 89 --cli open
bash ~/ros2/scripts/run_gripper.sh --arm 89 --cli close
bash ~/ros2/scripts/run_gripper.sh --arm 89 --cli pos 500 # 0=合 1000=开(‰)
# 右臂旋转爪
bash ~/ros2/scripts/run_gripper.sh --arm 91 --cli open
bash ~/ros2/scripts/run_gripper.sh --arm 91 --cli close
bash ~/ros2/scripts/run_gripper.sh --arm 91 --cli rotate 90
bash ~/ros2/scripts/run_gripper.sh --arm 91 --cli revolutions 2
```
PGEA 力/速:`gripper.force_pct`(最低 20)`speed_pct` `open_position` `close_position`。RGI 不要 `auto_init: true`(`robots[]` 里已关)。
## ROS 节点
单爪:
```bash
bash ~/ros2/scripts/run_gripper.sh --arm 89 # /gripper/open /gripper/close
bash ~/ros2/scripts/run_gripper.sh --arm 91 # dual_gripper 仅 right
```
双爪同时:
```bash
bash ~/ros2/scripts/run_dual_gripper.sh
```
| 名称 | 类型 |
|------|------|
| `/dual_gripper_driver/left/command` | `arm_msgs/msg/GripperCommand` |
| `/dual_gripper_driver/right/command` | 同上 |
| `/dual_gripper_driver/left/status` | `arm_msgs/msg/GripperStatus` |
| `/dual_gripper_driver/right/status` | 同上 |
| `/dual_gripper_driver/left/execute` | `arm_msgs/srv/GripperExecute` |
| `/dual_gripper_driver/right/execute` | 同上 |
`command` 字段:`init` `open` `close` `move`;右爪另有 `rotate` `rotate_by` `rotate_revolutions` `home_rotation`。
```bash
ros2 service call /dual_gripper_driver/left/execute arm_msgs/srv/GripperExecute \
"{command: {command: 'open', wait_complete: true, timeout: 10.0}}"
ros2 service call /dual_gripper_driver/right/execute arm_msgs/srv/GripperExecute \
"{command: {command: 'rotate', position: 90.0, wait_complete: true, timeout: 10.0}}"
```
USB 未插齐时节点仍可起来(`allow_offline:=true`),对应侧 `status.error_msg` 会写连接失败。
用户需在 `dialout`:`sudo usermod -aG dialout $USER`。
+44
View File
@@ -0,0 +1,44 @@
# 手眼标定(眼在手上)
每台机械臂一份结果,**禁止混用**:
| 臂 | 结果 | 示教关节 | 工作目录 |
|----|------|----------|----------|
| 89 左 | `src/arm_application/config/handeye_result_89.yaml` | `handeye_poses_89.yaml` | `/tmp/handeye_calib_89` |
| 91 右 | `src/arm_application/config/handeye_result_91.yaml` | `handeye_poses_91.yaml` | `/tmp/handeye_calib_91` |
`T_4x4` = **相机 → 法兰**。跟踪和静态 TF 都读当前 `--arm` 对应的文件。
棋盘尺寸只改 `site.yaml` → `handeye.board_*` / `square_size_m`(内角点 = 方格数 − 1)。两臂可共用同一块板。
## 流程
`teach`(RDK 软拖拽)与 `run_real.sh` **互斥**。全程带同一 `--arm`。
```bash
# 1) 停掉 run_real.sh 和视觉伺服
# 2) 示教 8–12 个姿态(棋盘清晰、占画面 30–70%,姿态拉开)
bash ~/ros2/scripts/run_handeye.sh --arm 89 teach
# 窗口:d=拖拽 Enter=拍照 q=结束
# 接着采、不清空旧点: teach --append
# 3) 写入该臂的 handeye_poses_XX.yaml
bash ~/ros2/scripts/run_handeye.sh --arm 89 merge
# 4) 再开实机 + 相机,棋盘不要动
bash ~/ros2/scripts/run_real.sh --arm 89
bash ~/ros2/scripts/run_camera_d405.sh --arm 89 --preview
bash ~/ros2/scripts/run_handeye.sh --arm 89 calibrate
```
91 把 `--arm 89` 改成 `--arm 91`。91 的结果文件初始是 `uncalibrated: true`,标定成功后才会写出 `T_4x4`。
查看路径与板参数:`bash ~/ros2/scripts/run_handeye.sh --arm 89 show`
## 结果怎么用
`visual_servo.launch.py` 读当前臂 `T_4x4`,发布 `{ee_frame}` → `{camera_frame}` 的 static TF。
`servo_node` 用同一份 T 做 `hover` / `lookat`。换结果后**重启** `run_visual_servo_rt.sh --arm …`。
装配 HMI(`ros_flexiv`)选左/右臂后,读同一份 `handeye_result_89.yaml` / `_91.yaml`。
+164
View File
@@ -0,0 +1,164 @@
# 入孔检测
只做视觉定位(像素孔心),**不包含**装配插入运动 / HMI。跟踪仍用 `run_visual_servo_rt.sh`。
日常改 **`config/site.yaml` → `hole_detect:`**(Hough、居中门控、间距都会覆盖包内 yaml)。
`src/arm_application/config/hole_detect.yaml` 只是缺省值。
源码:
| 文件 | 作用 |
|------|------|
| `src/arm_application/arm_application/hole_detector.py` | 统一入口:`yolo` / `hough` / `two_stage` |
| `src/arm_application/arm_application/hole_detect_hough.py` | YOLO 工件框 + HoughCircles 左右孔 |
| `src/arm_application/arm_application/hole_detect_node.py` | 订当前臂 D405 彩图 |
| `src/arm_application/config/hole_detect.yaml` | `two_stage` 参数 |
| `src/arm_application/config/hole_insert.yaml` | `yolo`/`hough` 参数 |
| `models/hole_insert/` | 孔模型权重(二进制不进 git) |
## 跑
先开该臂相机,再开检测:
```bash
bash ~/ros2/scripts/run_camera_d405.sh --arm 89 --preview
bash ~/ros2/scripts/run_hole_detect.sh --arm 89 --preview
```
91 把 `--arm` 换成 `91`(话题 `/d405_right/...`)。
`site.yaml`:
```yaml
hole_detect:
backend: "two_stage" # two_stage | yolo | hough
weights: "/home/USER/ros2/models/hole_insert/best.pt"
workpiece_weights: "" # 空 = paths.yolo_weights
conf: 0.25
hough_param2: 26
hole_distance_min: 150
```
- `two_stage`:用工件 YOLO 找 bbox,再在 ROI 里 Hough 找左右螺纹孔(原 `camera_test/hole_detect.py`)。
- `yolo`:模型框直接当孔(原 `hole_insert` HoleDetector)。
- `hough`:整幅图圆检测,不用权重。
权重放到 `~/ros2/models/hole_insert/best.pt`。`two_stage` 的工件模型默认就是跟踪用的 `paths.yolo_weights`。
## ROS
| 话题 | 类型 |
|------|------|
| `/hole_detect/result` | `arm_msgs/msg/HoleDetections` |
| `/hole_detect/holes` | `std_msgs/Float32MultiArray` `[ok, n, u,v,r,conf, …]` |
| `/hole_detect/overlay` | `sensor_msgs/Image` 画框画孔 |
改 `hole_detect.yaml` 的 Hough / 居中门控后重启 `run_hole_detect.sh`。`two_stage` 会做时序稳帧(`stabilize_*`)。
## 三阶段串联(推荐日常入口)
一条命令跑完:**视觉跟踪锁定 → 拍照对孔 → 搜孔并轻轻按压**。默认**不拧入**
(拧入是后面单独阶段)。三个阶段在同一个节点里交接,相机 / 触觉 / 手眼 TF 只起一次。
```bash
# 终端 1 —— 控制栈(一直开着)
bash ~/ros2/scripts/run_real.sh --arm 89
# 终端 2 —— 相机预览(一直开着,看跟踪画面)
bash ~/ros2/scripts/run_camera_d405.sh --arm 89 --preview
# 终端 3 —— 串联(搜到孔后轻压保持,不拧)
bash ~/ros2/scripts/run_all.sh --arm 89 # 跟踪 + 对孔 + 搜孔轻压
bash ~/ros2/scripts/run_all.sh --arm 89 --hole k2 # 换 k2
bash ~/ros2/scripts/run_all.sh --arm 89 --skip-stage1 # 臂已在工件上方,跳过跟踪
# 若这一次就要拧入(一般留给后续阶段):
bash ~/ros2/scripts/run_all.sh --arm 89 --depth 6 --pitch 1.0
```
`run_pipeline.sh` 是 `run_all.sh` 的别名。
**跑之前确认**:夹爪夹着通止规;工件在相机大概能看见的位置(一阶段会自己找)。
左指触觉如果掉线会自动降级成单指。
盯这几行就知道跑到哪了:
1. `[pipeline] === stage1_track ===` → 等跟踪锁定
2. `一阶段已锁定 — 停跟踪、钉住当前关节,直接进二阶段`
3. `[pipeline] === stage2_locate ===` → 降到孔悬停高,拍照对准
4. `[pipeline] === stage3_search ===` → `触面` → `★ 入孔` → `轻压到位`(不拧入)
急停:终端 3 Ctrl-C。搜孔中会先抬 5mm。失败时臂保持当前位姿,看
`/tmp/pipeline_stage1.log`、`/tmp/stage2_result.json`、`/tmp/stage3_result.json`。
高度接口:`robots[89].servo.ee_z_m`(跟踪锁高)、
`robots[89].servo.hole_hover_z_m`(拍照悬停高)。
分阶段单独跑仍用 `run_visual_servo_rt.sh`、`run_stage2_locate.sh`、
`run_stage3_search.sh`,下面两节。
## 二阶段一次性拍照定位(stage2,与一阶段跟踪分开跑)
先悬停垂直朝下 → 拍一张照 → `detect_best_1.onnx` 框工件 → Hough 解 k1/k2 孔心 →
深度(孔外工作面中值)+ 内参 + 手眼换算基座坐标 → 移动夹爪/通止规轴线到目标孔正上方 →
复拍验证(≤`align_max_iters` 次,收敛判据 `align_tol_m`)→ 锁定保持。
```bash
# 前置:T1 run_real.sh --arm 89;T3 run_camera_d405.sh --arm 89 --preview
bash ~/ros2/scripts/run_stage2_locate.sh --arm 89 # 完整流程(自动拉起触觉服务,仅左臂)
bash ~/ros2/scripts/run_stage2_locate.sh --arm 89 --hover-only # 只垂直+悬停锁定,用来调高度
bash ~/ros2/scripts/run_stage2_locate.sh --arm 89 --hole k2 # 对准 k2(默认 k1)
bash ~/ros2/scripts/run_stage2_locate.sh --arm 89 --hover-z 0.28 # 临时改悬停高,不动 yaml
```
悬停锁高接口:`site.yaml → robots[89].servo.hole_hover_z_m`(91 在同名段)。
工件权重:`site.yaml → hole_detect.workpiece_weights`(默认 `models/hole_insert/detect_best_1.onnx`)。
其余二阶段参数:`site.yaml → hole_detect.stage2:`(对准容差、迭代次数、垂直判据等)。
| 话题 | 类型 |
|------|------|
| `/stage2/state` | `std_msgs/String` 阶段状态机 |
| `/stage2/result` | `std_msgs/String` JSON:k1/k2 基座坐标、残差、悬停高 |
| `/stage2/overlay` | `sensor_msgs/Image` 标注图(同时落盘 /tmp/stage2_overlay.png) |
## 三阶段柔顺搜孔(stage3,Orisys 触觉 + 力闭环螺旋)
前置:二阶段刚跑完(臂悬停在目标孔上方、垂直朝下、夹持通止规)。流程:
1. **verify** — 垂直度核验;Orisys 双指触觉话题判活(connected=true 才算数),
并自动测 ~1.2s **静止基线 μ0/σ0**(日志打印「触觉基线/派生阈值」两行)
2. **approach** — 1.5mm 小步下降,`fnormal > μ0 + max(4σ0, touch_delta)` 判定触面,记录 `z_contact`
3. **seat** — Z 力闭环建立轻触(fn ≈ 触面阈值 + `press_delta`)
4. **spiral** — 阿基米德螺旋搜孔:XY 沿螺旋走(螺距 `spiral_pitch_mm`,保证轨迹重叠
覆盖),Z 由力闭环维持轻触;`fnormal` 相对接触均值跌落 `drop_ratio` 持续
`drop_frames` 帧 → **尖端入孔**。找不到则每次扩大 `spiral_expand_step_mm` 再搜
5. **center** — 入孔点十字微扫(±`center_scan_mm`),剪切力最小点 = 孔心
6. **insert** — 力限下压 `insert_depth_mm`(超限回退微抖重试 `insert_retry` 次);
`screw_pitch_mm ≠ 0` 时按螺距同步绕世界 Z 旋入(**螺纹通止规**:>0 右旋 / <0 左旋;
光面规保持 0)。`insert_depth_mm: 0` = 只搜孔定心不插入
7. **hold** — 锁定关节,发布结果
```bash
# 前置:T1 run_real.sh --arm 89;二阶段已完成(臂保持不动)
bash ~/ros2/scripts/run_stage3_search.sh --arm 89 # 搜孔+落座
bash ~/ros2/scripts/run_stage3_search.sh --arm 89 --depth 0 # 只搜孔定心,不插入
bash ~/ros2/scripts/run_stage3_search.sh --arm 89 --depth 6 --pitch 1.0 # M6 螺纹规旋入
bash ~/ros2/scripts/run_stage3_search.sh --arm 89 --r-max 5 # 临时改搜索半径
bash ~/ros2/scripts/run_stage3_search.sh --arm 89 --resnap # 搜前用 D405 复拍对准一次
```
**力阈值是「相对静止均值的增量」,不用标定牛顿**:Orisys 读数是形变任意单位且
夹持基线会漂移,节点每次启动自动测基线后派生全部阈值。要调灵敏度就改
`site.yaml → hole_detect.stage3:` 的 `touch_delta / press_delta / safe_press_delta /
shear_delta`(日志里能看到实际派生值)。
安全(任一触发立即抬 5mm 中止):Orisys `fnormal > μ0 + safe_press_delta`;腕部 F/T
`|F| > ft_safe_n`(订 `/<sn>/flexiv_robot_states`,订不到只警告);触觉断流 >
`tactile_timeout_s`。切向力超 `μ0shear + shear_delta` 自动抬 0.3mm 减拖。
| 话题 | 类型 |
|------|------|
| `/stage3/state` | `std_msgs/String` 阶段状态机 |
| `/stage3/result` | `std_msgs/String` JSON:孔心基座坐标、触面 Z、插入深度、峰值力 |
结果同时落盘 `/tmp/stage3_result.json`。退出码:0 成功;3 未触面;4 搜孔失败;
5 插入受阻;6 安全中止;7 触觉断流。
+149
View File
@@ -0,0 +1,149 @@
# 对外接口
三层:操作员脚本、ROS 话题/服务、可 import 的 Python。业务代码不要直接调 `rizon_arm_controller`。
---
## 1. 操作员脚本(推荐)
所有脚本在 `scripts/`。先 `source scripts/env.sh`。
| 脚本 | 输入 | 输出 / 行为 |
|------|------|-------------|
| `env.sh` | — | conda + Humble + `install/setup.bash` + `SITE_*` |
| `build_all.sh` | 源码 | colcon 编 `arm_*` + `flexiv_*` |
| `run_real.sh [--sn SN]` | `site.yaml` robot | 实机 stack,`backend:=real` |
| `run_camera_d405.sh [--preview]` | `camera.*` | 发布图像;可选 Qt 四宫格 |
| `run_visual_servo_rt.sh [--control]` | `paths` `yolo` `servo` | perception + servo + 手眼 TF |
| `go_home.sh` | `go_home.*` | 杀伺服后 `/set_named_pose` |
| `run_handeye.sh teach\|merge\|calibrate\|show` | `handeye.*` | 示教 / 写入 poses / 解 T |
| `run_gripper.sh --arm 89\|91 --cli …` | `robots[].gripper` + `gripper.*` | PGEA / RGI Modbus |
| `run_dual_gripper.sh` | 两侧 `robots[].gripper` | 双爪 ROS 节点 |
| `run_hole_detect.sh --arm …` | `hole_detect.*` `camera` | 入孔像素 |
| `run_tactile.sh [--cli]` | `tactile.*` | Orisys 左右指 |
| `export_yolo_trt.sh [onnx] [imgsz]` | 权重 | 同名 `.engine` |
| `show_site_config.sh` | `site.yaml` | 打印解析结果 |
| `rdk_switch_tool.sh --list` | SN | Studio 工具列表 |
| `run_sim.sh` | — | mock stack |
| `convert_traj.sh FILE.traj` | Flexiv 轨迹 | `*_trajectory.yaml` |
环境变量契约由 `scripts/lib/export_site_env.py` 定义:`flatten()`、`perception_overrides()`、`servo_overrides()`、`gripper_overrides()`。
---
## 2. ROS 接口(稳定)
### 机械臂(`run_real.sh`)
| 名称 | 类型 | 方向 | 说明 |
|------|------|------|------|
| `/set_named_pose` | `arm_msgs/srv/SetNamedPose` | 应用 → 控制 | `home` `ready` `inspect` `pos_left` |
| `/get_arm_state` | `arm_msgs/srv/GetArmState` | 查询 | 关节 + EE 位姿 |
| `/arm_status` | `arm_msgs/msg/ArmStatus` | 状态 | 模式 / 是否在动 |
| `/follow_joint_trajectory` | `arm_msgs/action/FollowJointTrajectory` | 应用 → 适配器 | 应用只走这个 |
| `/rizon_arm_controller/joint_trajectory` | `trajectory_msgs/JointTrajectory` | 伺服 → 驱动 | **仅 servo_node 写** |
| `/joint_states` | `sensor_msgs/JointState` | 驱动 → 全局 | |
| `/tf` `/tf_static` | TF | | 基座 / 连杆 / 法兰 / 手眼 |
命名位姿表:`src/arm_control/config/named_poses.yaml`。
```bash
ros2 service call /set_named_pose arm_msgs/srv/SetNamedPose \
"{pose_name: 'home', velocity_scale: 0.25, acceleration_scale: 0.25}"
```
### 相机(`realsense_color_pub`)
| 话题 | 类型 | 说明 |
|------|------|------|
| `/d405/color/image_raw` | `sensor_msgs/Image` bgr8 | 彩图 |
| `/d405/color/camera_info` | `sensor_msgs/CameraInfo` | 内参 |
| `/d405/aligned_depth_to_color/image_raw` | `sensor_msgs/Image` 16UC1 | 对齐深度,毫米 |
`d405` 来自 `camera.name`。QoS:SensorData(BEST_EFFORT)。
### 感知 / 伺服
| 名称 | 类型 | 说明 |
|------|------|------|
| `/visual_servo/filtered_point` | `geometry_msgs/PointStamped` | 相机系工件点,perception → servo |
| `/workpiece/detection` | `std_msgs/Float32MultiArray` | `[x1,y1,x2,y2,conf,cls,cx,cy,z]`,空=清绿框 |
| `/cmd_vel` | `geometry_msgs/Twist` | 伺服调试用,真机动臂走轨迹 |
`PointStamped.header.frame_id` = `d405_color_optical_frame`。
### 夹爪(IPC Modbus,`arm_msgs`)
PGEA 单爪节点(`--arm 89` 无 `--cli`):
| 服务 | 类型 |
|------|------|
| `/gripper/open` | `std_srvs/srv/Trigger` |
| `/gripper/close` | `std_srvs/srv/Trigger` |
双爪 / 右爪 RGI:
| 名称 | 类型 |
|------|------|
| `/dual_gripper_driver/{left,right}/command` | `arm_msgs/msg/GripperCommand` |
| `/dual_gripper_driver/{left,right}/status` | `arm_msgs/msg/GripperStatus` |
| `/dual_gripper_driver/{left,right}/execute` | `arm_msgs/srv/GripperExecute` |
开合力/速在 `site.yaml` → `gripper` 与 `robots[].gripper`。
### 入孔检测(`hole_detect`)
| 话题 | 类型 |
|------|------|
| `/hole_detect/result` | `arm_msgs/msg/HoleDetections` |
| `/hole_detect/holes` | `std_msgs/Float32MultiArray` |
| `/hole_detect/overlay` | `sensor_msgs/Image` |
### 视触觉(`tactile` 节点)
| 话题 | 类型 |
|------|------|
| `/tactile/left/status` | `arm_msgs/msg/TactileStatus` |
| `/tactile/right/status` | `arm_msgs/msg/TactileStatus` |
---
## 3. `site.yaml` 契约
字段说明见 [SITE_CONFIG.md](SITE_CONFIG.md)。Launch 覆盖键:
- 感知:`perception_overrides()` → `weights` `conf` `imgsz` `backend` …
- 伺服:`servo_overrides()` → `ee_z_m` `xy_track` `v_max_xy` `kp_xy` …
改 yaml **不会**热加载权重 / 分辨率 / 锁高(`overlay_hold_sec` 相机会跟)。重启对应脚本。
---
## 4. Python(二次开发)
```python
from arm_application.yolo_detector import YoloDetector, resolve_weights, pick_best
from arm_application.dh_pgea import DhPgeaGripper
from arm_application.dual_gripper import DualArmGripperManager, load_from_site
from arm_application.hole_detector import HoleDetector
from arm_application.orisys_tactile import make_tactile, load_orisys_from_site
from arm_application.geom_ik import load_handeye_T, geometric_jacobian, dls_twist
from arm_application.handeye_lib import calibrate_hand_eye
from arm_application.plane_feature import geometric_center_on_plane
```
模块职责写在 `src/arm_application/arm_application/__init__.py`。
不要从业务节点里 `import flexivrdk`(ABI 在 `rdk_py310`)。夹爪走 Modbus;示教拖拽走 `scripts/rdk_drag_bridge.py` 子进程。
---
## 5. 坐标系
| 帧 | 谁发布 | 含义 |
|----|--------|------|
| `{SN}_base_link` | robot_state_publisher | 基座 |
| `{SN}_flange` | robot_state_publisher | 法兰 |
| `d405_color_optical_frame` | 手眼 static TF | 相机光心,Z 朝外 |
`hover`:基座 XY 上法兰对齐工件。`ee_z_m`:法兰在基座 Z。
+67
View File
@@ -0,0 +1,67 @@
# 快速上手
更完整的目录说明:[WORKSPACE.md](WORKSPACE.md)。
## 依赖
- Ubuntu 22.04 + ROS 2 Humble
- Conda 环境 `ros_flexiv`(本仓库脚本默认这个名字)
- Flexiv RDK Python:`/home/cc/rdk_py310/bin/python`(flexivrdk 1.9.x)
- 工控机与机器人在同一网段(例:`192.168.2.10` ↔ `192.168.2.101`)
- Intel RealSense D405,`pyrealsense2`
- YOLO:`ultralytics`;TensorRT:`tensorrt-cu12`(不要装 meta 包 `tensorrt`)
- 夹爪:`pyserial`,USB–RS485 → `/dev/ttyUSB0`(PGEA)`/dev/ttyUSB1`(RGI)
每次新开终端:
```bash
conda activate ros_flexiv
source ~/ros2/scripts/env.sh
```
## 配置
```bash
cp ~/ros2/config/site.example.yaml ~/ros2/config/site.yaml
```
必填:`robot.sn`、`robot.robots[].ip`、`camera.serial`(多相机时)、`paths.yolo_weights`。
查看解析结果:
```bash
bash ~/ros2/scripts/show_site_config.sh
```
## 编译
```bash
cd ~/ros2
./scripts/build_all.sh
source scripts/env.sh
```
只改 Python 应用时,develop 安装会直接读 `src/`。改了 `setup.py` 的 `console_scripts` 后需要再编一次 `arm_application`。
## 第一次实机
1. 示教器:AUTO + Remote Ethernet,清故障。关 Studio。
2. `bash ~/ros2/scripts/test_rdk_python.sh`(可选)
3. T1 `run_real.sh`,T2 `run_camera_d405.sh --preview`
4. 手眼:见 [HANDEYE.md](HANDEYE.md)
5. T3 `run_visual_servo_rt.sh` 先观察,再 `--control`
回零:
```bash
bash ~/ros2/scripts/go_home.sh
```
会先杀掉 perception/servo,避免轨迹抢臂。
## Mock(无机器人)
```bash
bash ~/ros2/scripts/run_sim.sh
ros2 run arm_application demo_named_poses
```
+74
View File
@@ -0,0 +1,74 @@
# site.yaml
路径:`~/ros2/config/site.yaml`(从 `config/site.example.yaml` 复制,**不进 git**)。
目录总表:[WORKSPACE.md](WORKSPACE.md)。查看解析:`bash ~/ros2/scripts/show_site_config.sh`
改完后重启对应进程才会进节点(夹爪 CLI 每次重读)。
## 三台相机
| 相机 | 序列号 | 位置 | 脚本 |
|------|--------|------|------|
| 左腕 D405 | `352122274408` | 89 平行夹爪 | `run_camera_d405.sh --arm 89` |
| 右腕 D405 | `260522274442` | 91 旋转夹爪(新,易过曝) | `run_camera_d405.sh --arm 91` |
| 中间 D435 | `262522073161` | 双臂之间手外 | `run_camera_d435.sh --preview` |
列出:`bash ~/ros2/scripts/list_realsense.sh`
右腕过曝:`robots[]` 里 `ae_limit_us: 1500` 且关投射器。仍亮则把该臂 `auto_exposure: false` 并设 `exposure_us: 800`。
## 双臂(89 / 91)
89 和 91 **各有一份** 手眼、腕部相机序列号、夹爪串口、named poses。
`robot.robots[]` 里写该臂覆盖项;`run_*.sh --arm 89|91` 会合并进去。
| 臂 | SN | IP | 手眼 | 夹爪 |
|----|----|----|------|------|
| 左 89 | Rizon4-063989 | 192.168.2.101 | `handeye_result_89.yaml` | PGEA `/dev/ttyUSB0` |
| 右 91 | Rizon4-063991 | 192.168.2.100 | `handeye_result_91.yaml` | RGI `/dev/ttyUSB1` |
```bash
bash ~/ros2/scripts/run_real.sh --arm 89
bash ~/ros2/scripts/run_camera_d405.sh --arm 89 --preview
bash ~/ros2/scripts/run_visual_servo_rt.sh --arm 89 --control
bash ~/ros2/scripts/run_handeye.sh --arm 91 teach # 91 必须单独标定
```
每个终端都要带同一 `--arm`,或改 `robot.sn`。不要把 89 的 `T_4x4` 拷到 91。
棋盘尺寸仍是全局 `handeye.board_*`(两臂共用同一块板)。
## 谁读哪些段
| 段 | 读者 |
|----|------|
| `robot` `network` | `run_real.sh`、RDK 工具切换 |
| `camera` | `run_camera_d405.sh`;`overlay_hold_sec` 运行中会跟文件 |
| `paths` `yolo` `servo` | `visual_servo.launch.py` |
| `handeye` `paths.handeye_*` | `run_handeye.sh` |
| `gripper` | `run_gripper.sh` / `dh_pgea` / `dual_gripper` |
| `hole_detect` | `run_hole_detect.sh`(算法参数也在这一段) |
| `tactile` | `run_tactile.sh` |
| `go_home` | `go_home.sh` |
## 常改项
| 键 | 含义 |
|----|------|
| `robot.sn` / `--arm` | 当前臂。可被 `run_real.sh --arm 91` 覆盖 |
| `robot.robots[].camera.serial` | 该臂腕部 D405。91 必须单独填写 |
| `robot.robots[].paths.handeye_file` | 该臂手眼结果,禁止混用 |
| `paths.yolo_weights` | `.onnx` 或 `.pt`;`backend: tensorrt` 时必须有同名 `.engine` |
| `yolo.imgsz` | 必须等于模型输入边长 |
| `yolo.backend` | `tensorrt` / `onnx` / `auto` |
| `camera.serial` `width` `height` `fps` | D405 流。改分辨率要重启相机 |
| `servo.ee_z_m` | 法兰锁高 [m];**写在 `robots[].servo`,89/91 分开** |
| `servo.xy_track` | `hover`(法兰/爪心正对工件)或 `lookat` |
| `camera.name` | `d405_left` / `d405_right`,话题与预览按臂分开 |
| `servo.v_max_xy` `kp_xy` `a_max` | 跟踪快慢 |
| `gripper.force_pct` `open_position` `close_position` | 左臂 PGEA 力与开度 |
| `robots[].gripper.port` `model` | 该臂串口与型号(91 为 RGI) |
| `hole_detect.backend` `hough_*` `conf` | 入孔检测 |
| `tactile.left_video` `right_video` `config` | Orisys 左右指设备与 dd03 |
路径写成**绝对路径**。换机器改 `USER` 和 SN,不要改 Python。
+40
View File
@@ -0,0 +1,40 @@
# Orisys 视触觉
夹爪左右指上的视触觉相机(**不是**腕部 D405)。SDK 用厂家 wheel 安装
(`pip install --no-deps ~/sensor_ws/0.4.2/orisys-0.4.2-cp311-*.whl` 到 ros_flexiv 环境),
本仓库只放适配器、现场参数和 `dd03.json`。
源码:
| 文件 | 作用 |
|------|------|
| `arm_application/orisys_tactile.py` | SDK 适配 + `load_orisys_from_site` + `tactile_worker`(多进程工作器) |
| `arm_application/tactile_interface.py` | 可替换接口 / Mock |
| `arm_application/tactile_node.py` | 发布 `/tactile/left\|right/status` |
| `config/orisys/dd03.json` | 传感器几何/跟踪(厂家配置,可进 git) |
**架构:每侧手指一个独立进程**(厂家示例 `two_sensor_pipeline.py` 的推荐做法,
每进程独立 CUDA 上下文)。实测单进程双开两路会互相拖垮(30→0.8fps),
双进程各 30fps。`tactile` 节点内部用 `multiprocessing(spawn)` 起两个 worker,
主进程只做队列→话题转发。worker 进程 comm 名 `orisys_tac_w`,runner 脚本会
连带清理(**孤儿 worker 会占死 /dev/video\***,表现为下次启动
"Failed to open video source",手动清:`pkill orisys_tac_w`)。
现场要点(都在 **`config/site.yaml` → `tactile:`**):
- `left_video/right_video` 用 **`/dev/v4l/by-path/...` 稳定路径**——Orisys 是
"USB Camera"(不是 RealSense!),video 编号随插拔漂移,by-path 绑定物理 USB 口
- `cuda: false` + `infer_backend: cpu`——双路并行抢 GPU 上下文会互相拖慢,
CPU 解算仅 ~9ms/帧足够(单路想快可开 cuda)
- 读数是**形变任意单位**(非牛顿)且夹持基线会漂移;三阶段节点启动时自动测
静止基线,所有力阈值都是相对增量(见 `docs/HOLE_DETECT.md` 三阶段段落)
```bash
bash ~/ros2/scripts/run_tactile.sh --cli # 打印力 若干秒
bash ~/ros2/scripts/run_tactile.sh # ROS 节点
```
无硬件时:`tactile.backend: mock`。
搜孔插入运动(机械臂螺旋 + 力闭环)已由三阶段实现:`stage3_search_node.py`,
见 `docs/HOLE_DETECT.md` 三阶段段落,跑 `bash ~/ros2/scripts/run_stage3_search.sh --arm 89`。
+45
View File
@@ -0,0 +1,45 @@
# 视觉伺服
## 启动
```text
T1 bash ~/ros2/scripts/run_real.sh --arm 89 # 或 --arm 91
T2 bash ~/ros2/scripts/run_camera_d405.sh --arm 89 --preview
T3 bash ~/ros2/scripts/run_visual_servo_rt.sh --arm 89 # 只看框
bash ~/ros2/scripts/run_visual_servo_rt.sh --arm 89 --control # 动臂
```
三个终端必须带**同一个** `--arm`。89 相机话题是 `/d405_left/...`,91 是 `/d405_right/...`,预览顶栏会写「左臂89 / 右臂91」。不要混用。
`--control` 从当前法兰位姿开节点,不再先抬 `safe_z_m`、也不回 `home`。
`hover` + `ee_vertical_down`:法兰(夹爪中心)XY 压在工件上,法兰轴线朝下。腕部相机有倾角,绿框**不必**对准画面十字。锁高在 `robots[].servo.ee_z_m`,89/91 分开。
## 数据流
```
D405 彩图+深度
→ perception_node(绿框中心 × 立体平面 Z;红点应在绿框里)
→ /visual_servo/filtered_point (相机系)
→ servo_node 100 Hz
hover: 法兰 XY → 工件 XY(基座)
锁高: 法兰 Z → ee_z_m
姿态: 法兰 +Z 朝世界 −Z
→ /rizon_arm_controller/joint_trajectory
```
绿框:相机回调立刻把最新帧丢给 GPU;画出的框会按像素速度补上推理延迟,所以应贴在**当前**工件上,而不是 YOLO 看到的旧位置。丢检时绿框只滑行几帧(不驱动 3D);预览 `overlay_hold_sec` 必须短,否则旧框会糊在新画面上。丢检空窗内伺服用**基座系 KF 预测**继续跟(眼在手上安全,不随臂动漂移),超过 `stale_hold_sec` 才钉住。
## 调参(`site.yaml` → `servo` / `yolo`)
| 目标 | 键 |
|------|-----|
| 绿框跟手、少丢 | 略降 `conf`/`min_conf`;加大 `jump_px`、`miss_clear_frames`;重启 T3 |
| 绿框稳、少跳 | 略降 `box_ema_alpha`;重启 T3 |
| 臂跟手 | 加大 `v_max_xy` `kp_xy` |
| 臂丝滑 | 加大 `a_max` 会更快起步;减小 `a_max` / `vel_lpf_alpha` 更顺 |
| 轨迹预测 | `predict_enable` `predict_sec`;乱飞则减小 `predict_sec` 或关预测 |
| 画面中心对准 | `xy_track: lookat` |
| 换模型 | `paths.yolo_weights` + `yolo.imgsz` + 导出 `.engine` |
改完重启 T3。
+14
View File
@@ -0,0 +1,14 @@
# 关节轨迹回放
把 Flexiv Elements `.traj` 转成 YAML,经 `/follow_joint_trajectory` 播放(不要直写控制器)。
```bash
./scripts/convert_traj.sh src/arm_application/trajectories/xunhuan.traj
# 生成 src/arm_application/config/xunhuan_trajectory.yaml
# 先 run_real.sh
ros2 run arm_application run_joint_trajectory --ros-args \
--params-file src/arm_application/config/xunhuan_trajectory.yaml
```
YAML 用扁平数组:`waypoint_times`、`waypoint_positions`(每点 7 关节)。
+189
View File
@@ -0,0 +1,189 @@
# 工作空间地图
本仓库 **`~/ros2`** 是完整的 Flexiv Rizon **眼在手上视觉伺服** 工作空间:实机驱动、腕部 D405、手眼标定、YOLO 跟踪、法兰悬停、DH 夹爪、入孔检测、Orisys 视触觉读数。
**不依赖** `camera_test` / `ros_flexiv` HMI。装配插入运动不在这里。
路径必须是 ASCII(推荐 `~/ros2`)。不要在中文目录里 `colcon build` 消息包。
日常只改 **`config/site.yaml`**(从 `config/site.example.yaml` 复制,**不进 git**)。
日常只跑 **`scripts/*.sh`**。包内 yaml 是默认值,被 site 覆盖。
---
## 顶层
| 路径 | 进 git? | 做什么 |
|------|----------|--------|
| `README.md` | 是 | 克隆后怎么跑 |
| `docs/` | 是 | 说明文档(本文件是目录总表) |
| `config/site.example.yaml` | 是 | 现场配置模板 |
| `config/site.yaml` | **否** | 本机 SN / IP / 相机 / 锁高 |
| `config/fastdds_visual_servo.xml` | 是 | 可选 FastDDS;默认不用 |
| `scripts/` | 是 | 唯一操作入口 |
| `src/` | 是 | ROS 2 包源码 |
| `models/yolo/` | 目录是;`.pt/.onnx/.engine` **否** | 工件 YOLO 权重,自己放 |
| `models/hole_insert/` | 目录是;权重二进制 **否** | 入孔 YOLO(`backend=yolo` 时) |
| `build/` `install/` `log/` | **否** | `colcon` 产物,本地编译生成 |
| `thirdparty/` `vendor/` | **否** | 可选离线 RDK / Isaac 克隆 |
| `.gitignore` | 是 | 上面「否」的规则 |
---
## `config/` — 现场配置
| 文件 | 作用 |
|------|------|
| `site.example.yaml` | 克隆后 `cp` 成 `site.yaml`,填你的 SN、IP、相机序列号、权重路径 |
| `site.yaml` | 本机唯一入口。`robots[]` 里 89/91 各写相机、手眼、锁高、夹爪 |
| `fastdds_visual_servo.xml` | 仅当 DDS 丢图时再把路径填进 `site.yaml` → `paths.fastdds_profile` |
`site.yaml` 如何进节点:`scripts/lib/export_site_env.py` 按 `--arm` 合并 `robots[]`,导出 `SITE_*` 和环境,launch 再覆盖 `perception.yaml` / `servo.yaml`。
---
## `scripts/` — 操作入口
先:`conda activate ros_flexiv && source ~/ros2/scripts/env.sh`
### 每天用
| 脚本 | 作用 |
|------|------|
| `run_real.sh --arm 89\|91` | Flexiv RDK + ros2_control。示教器 AUTO + Remote Ethernet,先关 Studio |
| `run_camera_d405.sh --arm … --preview` | 该臂腕部 D405。89→`/d405_left/`,91→`/d405_right/` |
| `run_camera_d435.sh --preview` | 顶视 D435(手外,可选) |
| `list_realsense.sh` | 列出已接 RealSense 序列号 |
| `run_visual_servo_rt.sh --arm … [--control]` | YOLO + 伺服。无 `--control` 只看框;有则动臂。从当前姿态开始,不回 home |
| `go_home.sh --arm …` | 回 named pose `home`(会先停伺服) |
| `show_site_config.sh` | 打印当前臂解析出的路径/锁高/相机 |
### 手眼
| 脚本 | 作用 |
|------|------|
| `run_handeye.sh --arm … teach` | RDK 软拖拽记点。必须先停 `run_real.sh`(RDK 独占) |
| `run_handeye.sh --arm … merge` | 把 `/tmp/handeye_calib_XX/taught_poses.txt` 写入该臂 `handeye_poses_XX.yaml` |
| `run_handeye.sh --arm … calibrate` | 走 12 点拍棋盘,解 T,写入 `handeye_result_XX.yaml`。需要 T1 实机 + T2 相机 |
| `run_handeye.sh --arm … show` | 打印该臂板尺寸、poses 路径、点数 |
| `merge_handeye_poses.py` | merge 的实现,一般不直接调 |
### 夹爪 / 网络 / 编译
| 脚本 | 作用 |
|------|------|
| `run_gripper.sh --arm 89\|91 --cli …` | 左 PGEA / 右 RGI。无 `--cli` 起该臂 ROS 节点 |
| `run_dual_gripper.sh` | 双爪同一 ROS 节点(`arm_msgs`) |
| `run_hole_detect.sh --arm … [--preview]` | 入孔检测(只出像素孔心,不插入) |
| `run_tactile.sh [--cli]` | Orisys 左右指视触觉 |
| `env.sh` | 加载 conda + ROS + `install/setup.bash` + `site.yaml` |
| `build_all.sh` | `build.sh`(arm 包)+ `build_real.sh`(Flexiv 硬件包) |
| `build.sh` | colcon:`arm_*` + `flexiv_description` |
| `build_real.sh` | colcon:`flexiv_hardware` 等,需本机已装 RDK(`~/flexiv_install`) |
| `check_robot_network.sh` `fix_robot_lan.sh` | 查/修工控机到机器人网段 |
| `test_rdk_python.sh` | 测 RDK Python 能否连上 |
| `rdk_switch_tool.sh` | 切换 Studio Tools 名(`site.yaml` `robot.tool_name`) |
| `export_yolo_trt.sh` | ONNX → TensorRT `.engine` |
| `run_sim.sh` `run_mock_demo.sh` | 无真机 mock |
| `run_display.sh` | RViz 等显示 |
| `convert_traj.sh` `convert_flexiv_traj.py` | 非夕轨迹 → ROS |
| `fetch_flexiv.sh` `build_flexiv_rdk.sh` `install_rdk_offline.sh` `use_local_flexiv.sh` | 拉/编官方 RDK(新机器) |
| `rdk_drag_bridge.py` `rdk_gripper.py` | 底层 RDK 辅助,由 wrapper 调用 |
### `scripts/lib/`
| 文件 | 作用 |
|------|------|
| `parse_arm_args.sh` | 解析 `--arm 89\|91\|left\|right` |
| `load_site_config.sh` | 调 Python,把 `site.yaml` 变成 bash `SITE_*` |
| `export_site_env.py` | 合并当前臂 overlay;`--print` 给人看 |
---
## `src/` — ROS 2 包
### 本仓库业务(改跟踪/手眼看这里)
| 包 | 作用 |
|----|------|
| `arm_application` | 相机发布、预览、YOLO、伺服、手眼、PGEA/RGI 夹爪、入孔检测、Orisys 触觉 |
| `arm_bringup` | `stack.launch.py`:`backend:=real\|mock`,被 `run_real.sh` 调用 |
| `arm_control` | 命名位姿 `/set_named_pose`、状态、轨迹适配。点位:`config/named_poses_89.yaml` / `_91.yaml` |
| `arm_msgs` | 本栈消息 / 服务 / action |
| `arm_description` | 本栈用的机器人描述(若与 flexiv_description 并存,以 bringup 为准) |
| `arm_moveit_config` | MoveIt 配置(跟踪主路径不用) |
`arm_application` 节点见该包 README。手眼结果:
- `src/arm_application/config/handeye_result_89.yaml` / `_91.yaml` — **相机→法兰** `T_4x4`,标定写入
- `handeye_poses_89.yaml` / `_91.yaml` — 示教关节
- `perception.yaml` `servo.yaml` `handeye_calibrate.yaml` — 默认值,被 site 覆盖
### Flexiv 官方(一般不要改)
| 包 | 作用 |
|----|------|
| `flexiv_hardware` | ros2_control 硬件接口,RDK 发关节 |
| `flexiv_bringup` | 官方 launch |
| `flexiv_msgs` | 官方消息 |
| `flexiv_gripper` | 官方夹爪(本栈 **不用**;腕部爪走 IPC Modbus) |
| `flexiv_description` | URDF / 网格 |
| `flexiv_controllers` `flexiv_moveit_config` `flexiv_test_nodes` | 官方控制器 / MoveIt / 测试 |
| `flexiv_rdk` | RDK 源码树(编译安装到 `~/flexiv_install`) |
| `flexiv_ros2/` | 官方仓库的完整拷贝;`build_real.sh` 选其中的包 |
---
## `models/yolo/` 与 `models/hole_insert/`
工件跟踪权重放 `models/yolo/`,`site.yaml` → `paths.yolo_weights`。
入孔 `backend: yolo` 的孔模型放 `models/hole_insert/`。
二进制不进 git。`backend: tensorrt` 时必须有同名 `.engine`(`export_yolo_trt.sh`)。
---
## `docs/`
| 文件 | 内容 |
|------|------|
| `WORKSPACE.md` | 本页,目录与职责 |
| `QUICKSTART.md` | 环境、编译、第一次实机 |
| `ARCHITECTURE.md` | 进程与数据流 |
| `SITE_CONFIG.md` | `site.yaml` 字段 |
| `HANDEYE.md` | 示教 / merge / 标定 |
| `TRACKING.md` | 视觉伺服、hover、锁高 |
| `GRIPPER.md` | PGEA + RGI |
| `HOLE_DETECT.md` | 入孔检测 |
| `TACTILE.md` | Orisys 视触觉 |
| `INTERFACES.md` | 脚本 / ROS topic / 参数契约 |
| `TRAJECTORIES.md` | 关节轨迹回放 |
---
## 别人克隆后
```bash
cd ~/ros2
cp config/site.example.yaml config/site.yaml # 填硬件
# 权重放到 models/yolo/,改 site.yaml paths.yolo_weights
conda activate ros_flexiv
source scripts/env.sh
./scripts/build_all.sh
source scripts/env.sh
```
示教器 Remote Ethernet,关 Studio。三终端同一 `--arm`:
```bash
bash ~/ros2/scripts/run_real.sh --arm 89
bash ~/ros2/scripts/run_camera_d405.sh --arm 89 --preview
bash ~/ros2/scripts/run_visual_servo_rt.sh --arm 89 --control
```
91 把 `89` 换成 `91`。91 必须用自己的手眼文件,不要拷 89 的 `T_4x4`。
## 本仓库不包含
- `camera_test` 训练 GUI / `infer_realtime.py`
- `ros_flexiv` 装配 HMI、孔插入运动
- colcon 的 `build/` `install/` `log/`
- 本机 `site.yaml` 与 YOLO 权重文件
+23
View File
@@ -0,0 +1,23 @@
# 入孔 / 孔定位权重
二进制(`.pt` / `.onnx` / `.engine`)**不进 git**。
`hole_detect.backend: yolo` 时把本目录的检测权重绝对路径写进 `config/site.yaml`:
```yaml
paths:
hole_weights: "/home/USER/ros2/models/hole_insert/best.pt"
hole_detect:
backend: "yolo"
weights: "/home/USER/ros2/models/hole_insert/best.pt"
```
`backend: two_stage` 用的是工件 YOLO(`paths.yolo_weights`),本目录可空。
从旧工程拷贝示例:
```bash
mkdir -p ~/ros2/models/hole_insert
# 若有独立孔模型:
# cp /path/to/best.pt ~/ros2/models/hole_insert/best.pt
```
View File
+19
View File
@@ -0,0 +1,19 @@
# YOLO 权重
二进制(`.pt` / `.onnx` / `.engine`)**不进 git**。放到本目录后改 `config/site.yaml`:
```yaml
paths:
yolo_weights: "/home/USER/ros2/models/yolo/YOUR.onnx"
yolo:
backend: "tensorrt"
imgsz: 640 # 必须等于该模型输入
```
TensorRT:
```bash
bash ~/ros2/scripts/export_yolo_trt.sh /home/USER/ros2/models/yolo/YOUR.onnx 640
```
会生成同名 `.engine`。换 ONNX 或 imgsz 后重导。然后重启 `run_visual_servo_rt.sh`。
+16
View File
@@ -0,0 +1,16 @@
# scripts — 唯一操作入口
实现细节在 [docs/WORKSPACE.md](../docs/WORKSPACE.md) 和 [docs/](../docs/)。
**每天:** `run_real.sh` · `run_camera_d405.sh` · `run_visual_servo_rt.sh` · `go_home.sh`
**手眼:** `run_handeye.sh`(`teach` / `merge` / `calibrate` / `show`)
**夹爪:** `run_gripper.sh --arm 89|91` · `run_dual_gripper.sh`
**入孔:** `run_hole_detect.sh --arm … [--preview]`
**触觉:** `run_tactile.sh [--cli]`
**权重:** `export_yolo_trt.sh`
**配置:** `show_site_config.sh` · `lib/export_site_env.py`
**环境:** `env.sh` · `build_all.sh`
**RDK:** `test_rdk_python.sh` · `rdk_switch_tool.sh`
`lib/parse_arm_args.sh` 解析 `--arm 89|91`。
`lib/load_site_config.sh` 把 `config/site.yaml` 变成 `SITE_*`。
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
set -eo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
if [[ -f "${HOME}/anaconda3/etc/profile.d/conda.sh" ]]; then
# shellcheck disable=SC1091
source "${HOME}/anaconda3/etc/profile.d/conda.sh"
set +u
conda activate ros_flexiv 2>/dev/null || true
set -u 2>/dev/null || true
fi
if [[ -z "${ROS_DISTRO:-}" ]]; then
if [[ -f /opt/ros/jazzy/setup.bash ]]; then
# shellcheck disable=SC1091
source /opt/ros/jazzy/setup.bash
elif [[ -f /opt/ros/humble/setup.bash ]]; then
# shellcheck disable=SC1091
source /opt/ros/humble/setup.bash
elif [[ -f /opt/ros/iron/setup.bash ]]; then
# shellcheck disable=SC1091
source /opt/ros/iron/setup.bash
else
echo "未检测到系统 ROS 2;使用 conda ros_flexiv 环境"
fi
fi
PACKAGES=(flexiv_description arm_msgs arm_description arm_control arm_moveit_config arm_bringup arm_application)
colcon build --symlink-install --packages-select "${PACKAGES[@]}" "$@"
echo "构建完成。请执行: source ${ROOT}/install/setup.bash"
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env bash
# Build entire workspace: arm stack + flexiv real drivers
set -eo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
if [[ -f "${HOME}/anaconda3/etc/profile.d/conda.sh" ]]; then
# shellcheck disable=SC1091
source "${HOME}/anaconda3/etc/profile.d/conda.sh"
set +u
conda activate ros_flexiv 2>/dev/null || true
set -u 2>/dev/null || true
fi
if [[ -z "${ROS_DISTRO:-}" ]]; then
# shellcheck disable=SC1091
source /opt/ros/humble/setup.bash 2>/dev/null || source /opt/ros/jazzy/setup.bash 2>/dev/null || true
fi
echo "=== [1/2] arm stack ==="
bash "${ROOT}/scripts/build.sh"
echo "=== [2/2] flexiv real stack ==="
bash "${ROOT}/scripts/build_real.sh"
echo ""
echo "Done. Use:"
echo " source ${ROOT}/scripts/env.sh"
echo " bash ${ROOT}/scripts/run_real.sh"
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Build Flexiv RDK v1.x into ~/flexiv_install (required for real robot / flexiv_hardware).
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
RDK="${ROOT}/src/flexiv_rdk"
PREFIX="${FLEXIV_INSTALL_PREFIX:-$HOME/flexiv_install}"
if [[ ! -d "$RDK" ]]; then
echo "Missing $RDK — run scripts/fetch_flexiv.sh first"
exit 1
fi
echo "Building RDK thirdparty deps into $PREFIX ..."
# Prefer system /opt/ros if present; else conda env already has compilers
if [[ -f /opt/ros/humble/setup.bash ]]; then
# shellcheck disable=SC1091
source /opt/ros/humble/setup.bash
fi
cd "$RDK"
if [[ -f thirdparty/build_and_install_dependencies_not_in_ros2.sh ]]; then
bash thirdparty/build_and_install_dependencies_not_in_ros2.sh "$PREFIX"
elif [[ -f thirdparty/build_and_install_dependencies.sh ]]; then
bash thirdparty/build_and_install_dependencies.sh "$PREFIX"
else
echo "No thirdparty build script found; see RDK README"
ls thirdparty || true
fi
rm -rf build && mkdir build && cd build
cmake .. -DCMAKE_INSTALL_PREFIX="$PREFIX" -DCMAKE_BUILD_TYPE=Release
cmake --build . --target install -j"$(nproc)"
echo "RDK installed to $PREFIX"
echo "Then: cd $ROOT && colcon build --symlink-install --cmake-args -DCMAKE_PREFIX_PATH=$PREFIX"
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# Build Flexiv hardware packages for real robot (after RDK installed to ~/flexiv_install)
set -eo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
if [[ -f "${HOME}/anaconda3/etc/profile.d/conda.sh" ]]; then
# shellcheck disable=SC1091
source "${HOME}/anaconda3/etc/profile.d/conda.sh"
set +u
conda activate ros_flexiv 2>/dev/null || true
set -u 2>/dev/null || true
fi
if [[ -z "${ROS_DISTRO:-}" ]]; then
# shellcheck disable=SC1091
source /opt/ros/humble/setup.bash 2>/dev/null || source /opt/ros/jazzy/setup.bash 2>/dev/null || true
fi
PREFIX="${HOME}/flexiv_install"
CMAKE_PREFIX="${PREFIX}"
if [[ -n "${CONDA_PREFIX:-}" ]]; then
CMAKE_PREFIX="${PREFIX};${CONDA_PREFIX}"
fi
colcon build --symlink-install --packages-select \
flexiv_hardware \
flexiv_msgs \
flexiv_gripper \
flexiv_test_nodes \
flexiv_robot_states_broadcaster \
flexiv_bringup \
--cmake-args "-DCMAKE_PREFIX_PATH=${CMAKE_PREFIX}" "$@"
echo "Real stack build done. source ${ROOT}/scripts/env.sh"
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env bash
# Check PC ↔ Flexiv robot network (addresses from site.yaml)
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck disable=SC1091
source "${ROOT}/scripts/lib/load_site_config.sh" 2>/dev/null || true
ROBOT_IP="${ROBOT_IP:-${SITE_ROBOT_IP:-192.168.100.1}}"
ROBOT_SN="${ROBOT_SN:-${SITE_ROBOT_SN:-Rizon4-063989}}"
PC_IP="${PC_IP:-${SITE_PC_IP:-192.168.100.10}}"
pick_iface() {
if [[ -n "${IFACE:-}" ]]; then
echo "$IFACE"
return
fi
if [[ -n "${SITE_NET_IFACE:-}" && -d "/sys/class/net/${SITE_NET_IFACE}" ]]; then
echo "${SITE_NET_IFACE}"
return
fi
local d
for d in enp4s0 enp5s0 eth0; do
[[ -d /sys/class/net/$d ]] && echo "$d" && return
done
for d in $(ls /sys/class/net); do
[[ "$d" == lo ]] && continue
[[ "$d" == en* || "$d" == eth* ]] || continue
local state
state=$(cat /sys/class/net/"$d"/operstate 2>/dev/null || echo down)
[[ "$state" == up ]] && echo "$d" && return
done
echo enp4s0
}
IFACE="$(pick_iface)"
echo "=== 本机网卡 ==="
ip -4 -br addr
echo
echo "=== 目标机器人(site.yaml) ==="
echo " SN $ROBOT_SN"
echo " 标签 ${SITE_ROBOT_LABEL:-}"
echo " 目录 ${SITE_ROBOT_CATALOG:-}"
echo " IP $ROBOT_IP"
echo " 本机 $PC_IP"
echo " 网卡 $IFACE"
if ping -c 1 -W 1 "$ROBOT_IP" >/dev/null 2>&1; then
echo " ping UP — 链路 OK"
else
echo " ping DOWN — PC 与机器人必须同网段(本机 ${PC_IP} ↔ 机器人 ${ROBOT_IP})"
echo
echo " 一键修复(交换机 / 有线):"
echo " bash ~/ros2/scripts/fix_robot_lan.sh"
echo " 独立交换机(不要办公网 DHCP)可用:"
echo " bash ~/ros2/scripts/fix_robot_lan.sh --only"
fi
echo
echo "=== 常见地址探测 ==="
CANDIDATES=(
"$ROBOT_IP"
192.168.2.100 192.168.2.10 192.168.101.1 192.168.101.10
192.168.100.1 192.168.100.10
192.168.100.1 192.168.100.2 192.168.100.10
192.168.1.1
)
seen=""
for ip in "${CANDIDATES[@]}"; do
[[ " ${seen} " == *" ${ip} "* ]] && continue
seen+=" ${ip}"
if ping -c 1 -W 1 "$ip" >/dev/null 2>&1; then
echo " UP $ip"
else
echo " down $ip"
fi
done
echo
echo "=== 当前邻居 (ARP) ==="
ip neigh | grep -v FAILED || true
echo
echo "说明:"
echo "1) 网线接机器人控制柜网口 ↔ 本机 $IFACE。"
echo "2) 示教器开启 RDK / 远程控制后再连。"
echo "3) RDK 连接用 SN(不是 IP):flexivrdk.Robot('$ROBOT_SN')"
echo "4) 切换机械臂:改 ~/ros2/config/site.yaml 的 robot.sn"
echo "5) 手册: https://flexiv.cn/software/rdk/manual/index.html"
+117
View File
@@ -0,0 +1,117 @@
#!/usr/bin/env python3
"""Convert Flexiv Elements .traj (text) -> arm_application YAML waypoints."""
from __future__ import annotations
import argparse
import math
import re
import sys
from pathlib import Path
from typing import Any
def parse_flexiv_traj(text: str) -> list[dict[str, Any]]:
"""Parse Elements Studio scatter-point .traj file."""
blocks = re.split(r'pattern_params\s*\{', text)[1:]
waypoints: list[dict[str, Any]] = []
for block in blocks:
name_m = re.search(r'pattern_name:\s*"([^"]+)"', block)
vel_m = re.search(r'velocity:\s*"([^"]+)"', block)
joints: dict[str, float] = {}
for j in range(1, 8):
m = re.search(rf'joint{j}:\s*"([^"]+)"', block)
if m:
joints[f'joint{j}'] = math.radians(float(m.group(1)))
if len(joints) != 7:
continue
positions = [joints[f'joint{i}'] for i in range(1, 8)]
waypoints.append({
'name': name_m.group(1) if name_m else f'WPT{len(waypoints)}',
'positions': positions,
'velocity': float(vel_m.group(1)) if vel_m else 0.2,
})
return waypoints
def assign_times(waypoints: list[dict[str, Any]], min_seg: float = 3.0) -> None:
"""Cumulative time_from_start from joint-space segment length."""
t = 0.0
prev = None
for wp in waypoints:
pos = wp['positions']
if prev is None:
t += min_seg
else:
delta = max(abs(a - b) for a, b in zip(pos, prev))
# ~4 s per radian max joint move, floored at min_seg
t += max(min_seg, delta * 4.0)
wp['time'] = round(t, 2)
prev = pos
def to_yaml(
waypoints: list[dict[str, Any]],
*,
joint_prefix: str = 'Rizon4-063989_',
velocity_scale: float | None = None,
) -> str:
if velocity_scale is None:
velocity_scale = waypoints[0].get('velocity', 0.2) if waypoints else 0.2
times = [wp['time'] for wp in waypoints]
flat: list[float] = []
for wp in waypoints:
flat.extend(wp['positions'])
pos_line = ', '.join(f'{v:.6f}' for v in flat)
time_line = ', '.join(str(t) for t in times)
names = ', '.join(f' # {wp.get("name", "")}' if i == 0 else '' for i, wp in enumerate(waypoints))
_ = names # names kept in comments below
lines = [
'# Auto-converted from Flexiv Elements .traj (joints: deg -> rad)',
'# ROS params: flat waypoint_positions + waypoint_times (no nested dicts)',
'# Edit velocity_scale / loop_count as needed (no rebuild).',
'run_joint_trajectory:',
' ros__parameters:',
f' joint_prefix: "{joint_prefix}"',
' joint_names: [joint1, joint2, joint3, joint4, joint5, joint6, joint7]',
f' velocity_scale: {velocity_scale}',
' loop_count: 1 # 1=once, 0=forever, N=repeat N',
' pause_between_loops: 0.5',
f' waypoint_times: [{time_line}]',
' waypoint_positions: [',
]
for i, wp in enumerate(waypoints):
pos = ', '.join(f'{v:.6f}' for v in wp['positions'])
lines.append(f' {pos}, # {wp.get("name", f"WPT{i}")}')
lines.append(' ]')
return '\n'.join(lines) + '\n'
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument('input', type=Path, help='Flexiv Elements .traj file')
ap.add_argument('-o', '--output', type=Path, help='Output YAML path')
ap.add_argument('--prefix', default='Rizon4-063989_', help='Joint name prefix (site SN + _)')
ap.add_argument('--min-seg', type=float, default=3.0, help='Min seconds per segment')
args = ap.parse_args()
text = args.input.read_text(encoding='utf-8')
wps = parse_flexiv_traj(text)
if not wps:
print('ERROR: no waypoints parsed', file=sys.stderr)
return 1
assign_times(wps, min_seg=args.min_seg)
yaml_text = to_yaml(wps, joint_prefix=args.prefix)
out = args.output
if out:
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(yaml_text, encoding='utf-8')
print(f'Wrote {len(wps)} waypoints -> {out}')
else:
print(yaml_text)
return 0
if __name__ == '__main__':
raise SystemExit(main())
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env bash
# Convert Flexiv Elements .traj → arm_application YAML (standard entry for all trajectories).
# Usage:
# ./scripts/convert_traj.sh ~/文档/my_task.traj
# ./scripts/convert_traj.sh ~/文档/my_task.traj -o /path/to/out.yaml
# ./scripts/convert_traj.sh ~/文档/my_task.traj --min-seg 2.5 --no-copy
set -eo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PY="${ROOT}/scripts/convert_flexiv_traj.py"
if [[ $# -lt 1 ]]; then
echo "Usage: $0 INPUT.traj [-o OUT.yaml] [--min-seg SECONDS] [--no-copy]"
echo "Example: $0 ~/文档/my_task.traj"
exit 1
fi
INPUT="$(realpath "$1")"
shift
BASENAME="$(basename "${INPUT}" .traj)"
OUT_DEFAULT="${ROOT}/src/arm_application/config/${BASENAME}_trajectory.yaml"
TRAJ_COPY="${ROOT}/src/arm_application/trajectories/${BASENAME}.traj"
OUT="${OUT_DEFAULT}"
MIN_SEG="3.0"
DO_COPY=1
EXTRA=()
while [[ $# -gt 0 ]]; do
case "$1" in
-o|--output)
OUT="$(realpath -m "$2")"
shift 2
;;
--min-seg)
MIN_SEG="$2"
shift 2
;;
--no-copy)
DO_COPY=0
shift
;;
*)
EXTRA+=("$1")
shift
;;
esac
done
if [[ ! -f "${INPUT}" ]]; then
echo "ERROR: input not found: ${INPUT}" >&2
exit 1
fi
python3 "${PY}" "${INPUT}" -o "${OUT}" --min-seg "${MIN_SEG}" "${EXTRA[@]}"
if [[ "${DO_COPY}" -eq 1 ]]; then
mkdir -p "$(dirname "${TRAJ_COPY}")"
cp -f "${INPUT}" "${TRAJ_COPY}"
echo "Copied source traj → ${TRAJ_COPY}"
fi
echo ""
echo "Next (stack must be running):"
echo " conda activate ros_flexiv"
echo " source ${ROOT}/install/setup.bash"
echo " export LD_LIBRARY_PATH=~/flexiv_install/lib:\$LD_LIBRARY_PATH"
echo " ros2 run arm_application run_joint_trajectory --ros-args \\"
echo " --params-file ${OUT}"
Executable
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# Source this workspace environment (conda + ROS + flexiv libs).
# Usage: source scripts/env.sh
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
if [[ -f /opt/ros/humble/setup.bash ]]; then
# shellcheck disable=SC1091
source /opt/ros/humble/setup.bash
elif [[ -f /opt/ros/jazzy/setup.bash ]]; then
# shellcheck disable=SC1091
source /opt/ros/jazzy/setup.bash
fi
if [[ -f "${HOME}/anaconda3/etc/profile.d/conda.sh" ]]; then
# shellcheck disable=SC1091
source "${HOME}/anaconda3/etc/profile.d/conda.sh"
conda activate ros_flexiv 2>/dev/null || true
fi
# shellcheck disable=SC1091
source "${ROOT}/install/setup.bash"
export LD_LIBRARY_PATH="${HOME}/flexiv_install/lib:${LD_LIBRARY_PATH:-}"
# Pip TensorRT (tensorrt-cu12) — needed to load YOLO .engine
_TRT_SITE="${CONDA_PREFIX:-${HOME}/anaconda3/envs/ros_flexiv}/lib/python3.11/site-packages"
for _d in "${_TRT_SITE}/tensorrt" "${_TRT_SITE}/tensorrt_libs" "${_TRT_SITE}/nvidia/tensorrt/lib"; do
if [[ -d "${_d}" ]]; then
export LD_LIBRARY_PATH="${_d}:${LD_LIBRARY_PATH}"
fi
done
unset _d _TRT_SITE
export YOLO_AUTOINSTALL=false
export ROS2_WS="${ROOT}"
# Master site config → SITE_* (YOLO weights, camera, robot SN, …)
# shellcheck disable=SC1091
source "${ROOT}/scripts/lib/load_site_config.sh" 2>/dev/null || true
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env bash
# Export YOLO .pt/.onnx → TensorRT .engine (FP16).
#
# bash ~/ros2/scripts/export_yolo_trt.sh
# bash ~/ros2/scripts/export_yolo_trt.sh /home/cc/ros2/models/yolo/detect_best.onnx 640
#
# Needs: tensorrt-cu12 + onnx + onnxslim in ros_flexiv (do NOT pip install
# the meta package `tensorrt`, it pulls CUDA 13 wheels).
# Then set yolo.backend: tensorrt in site.yaml and restart visual servo.
set -eo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck disable=SC1091
source "${ROOT}/scripts/lib/load_site_config.sh"
PT="${1:-${SITE_YOLO_WEIGHTS:-${ROOT}/models/yolo/detect_best.onnx}}"
IMGSZ="${2:-${SITE_YOLO_IMGSZ:-640}}"
PY="${HOME}/anaconda3/envs/ros_flexiv/bin/python"
if [[ ! -f "${PT}" ]]; then
echo "ERROR: weights not found: ${PT}" >&2
exit 1
fi
# Pip TensorRT shared libs (needed at export + runtime).
TRT_LIBS="$("${PY}" - <<'PY'
import pathlib, tensorrt
p = pathlib.Path(tensorrt.__file__).resolve().parent
cands = [p, p.parent / "tensorrt_libs", p.parent / "nvidia" / "tensorrt" / "lib"]
print(":".join(str(x) for x in cands if x.exists()))
PY
)"
export LD_LIBRARY_PATH="${TRT_LIBS}${LD_LIBRARY_PATH:+:${LD_LIBRARY_PATH}}"
export YOLO_AUTOINSTALL=false
echo "Export TensorRT engine from ${PT} imgsz=${IMGSZ}"
"${PY}" - <<PY
from pathlib import Path
import tensorrt as trt
src = Path("${PT}").expanduser()
print("tensorrt", trt.__version__)
print("source", src)
engine_path = src.with_suffix(".engine")
if src.suffix.lower() == ".onnx":
logger = trt.Logger(trt.Logger.INFO)
flag = 1 << int(trt.NetworkDefinitionCreationFlag.EXPLICIT_BATCH)
builder = trt.Builder(logger)
network = builder.create_network(flag)
parser = trt.OnnxParser(network, logger)
raw = src.read_bytes()
if not parser.parse(raw):
for i in range(parser.num_errors):
print("ONNX parse error:", parser.get_error(i))
raise SystemExit("ONNX parse failed")
config = builder.create_builder_config()
config.set_memory_pool_limit(trt.MemoryPoolType.WORKSPACE, 4 << 30)
if builder.platform_has_fast_fp16:
config.set_flag(trt.BuilderFlag.FP16)
print("FP16 enabled")
serialized = builder.build_serialized_network(network, config)
if serialized is None:
raise SystemExit("TensorRT build failed")
engine_path.write_bytes(bytes(serialized))
print("wrote", engine_path)
else:
from ultralytics import YOLO
m = YOLO(str(src))
try:
out = m.export(
format="engine",
imgsz=int("${IMGSZ}"),
device=0,
quantize=16,
simplify=True,
workspace=4,
)
except TypeError:
out = m.export(
format="engine",
imgsz=int("${IMGSZ}"),
device=0,
half=True,
simplify=True,
workspace=4,
)
print("wrote", out)
PY
echo "Done. Restart: bash ~/ros2/scripts/run_visual_servo_rt.sh --control"
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env bash
# Clone official Flexiv ROS 2 stack for Rizon (RDK v1.x → humble-v1 branch).
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SRC="${ROOT}/src"
BRANCH="${FLEXIV_ROS2_BRANCH:-humble-v1}"
mkdir -p "${SRC}"
cd "${SRC}"
# Always ensure official URDF package is present (meshes + xacro)
if [[ ! -d flexiv_description ]]; then
echo "Fetching flexiv_description (${BRANCH})..."
if ! git clone --depth 1 -b "${BRANCH}" https://github.com/flexivrobotics/flexiv_description.git; then
echo "git clone slow/failed — downloading zip..."
python3 - <<PY
import urllib.request, socket, zipfile, io, shutil, os
url = "https://codeload.github.com/flexivrobotics/flexiv_description/zip/refs/heads/${BRANCH}"
old = socket.getaddrinfo
socket.getaddrinfo = lambda h, p, f=0, t=0, pr=0, fl=0: [ai for ai in old(h, p, socket.AF_INET, t, pr, fl)]
data = urllib.request.urlopen(urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}), timeout=300).read()
dest = "${SRC}/flexiv_description"
with zipfile.ZipFile(io.BytesIO(data)) as z:
z.extractall("/tmp")
shutil.rmtree(dest, ignore_errors=True)
shutil.move("/tmp/flexiv_description-${BRANCH}", dest)
print("flexiv_description ready at", dest)
PY
fi
else
echo "flexiv_description already present"
fi
if [[ ! -d flexiv_ros2 ]]; then
echo "Cloning flexiv_ros2 (${BRANCH})..."
git clone https://github.com/flexivrobotics/flexiv_ros2.git -b "${BRANCH}"
else
echo "flexiv_ros2 already present — pull if needed: git -C flexiv_ros2 pull"
fi
REPOS_FILE=""
for candidate in flexiv_ros2/flexiv.humble.repos flexiv_ros2/flexiv.repos; do
if [[ -f "${candidate}" ]]; then
REPOS_FILE="${candidate}"
break
fi
done
if [[ -z "${REPOS_FILE}" ]]; then
echo "ERROR: no .repos file found under flexiv_ros2"
exit 1
fi
echo "Importing dependencies from ${REPOS_FILE}..."
if ! command -v vcs >/dev/null 2>&1; then
echo "Please install: sudo apt install python3-vcstool"
exit 1
fi
# Prefer local Flexiv_Rizon4 RDK (avoids GitHub clone when present)
LOCAL_FLEXIV="${LOCAL_FLEXIV:-$HOME/文档/Flexiv_Rizon4}"
if [[ -d "${LOCAL_FLEXIV}/flexiv_rdk" ]]; then
echo "Using local RDK: ${LOCAL_FLEXIV}/flexiv_rdk"
bash "${ROOT}/scripts/use_local_flexiv.sh"
elif [[ -d flexiv_rdk ]]; then
touch flexiv_rdk/COLCON_IGNORE
else
echo "Importing flexiv_rdk from ${REPOS_FILE}..."
vcs import "${SRC}" < "${REPOS_FILE}" --recursive --skip-existing
if [[ -d flexiv_rdk ]]; then
touch flexiv_rdk/COLCON_IGNORE
fi
fi
# Always import remaining deps from repos (description already handled; skip-existing)
vcs import "${SRC}" < "${REPOS_FILE}" --recursive --skip-existing || true
cat <<EOF
Done. Next steps (Ubuntu 22.04 / Humble + Rizon):
Local RDK wired via: scripts/use_local_flexiv.sh
src/flexiv_rdk -> ${LOCAL_FLEXIV}/flexiv_rdk (your copy, often RDK 2.x)
src/flexiv_rdk_v1 -> RDK 1.9.1 for flexiv_ros2 humble-v1 (if present)
1) Install ROS deps:
sudo apt install -y python3-colcon-common-extensions python3-vcstool \\
ros-humble-ros2-control ros-humble-ros2-controllers \\
ros-humble-moveit ros-humble-xacro ros-humble-joint-state-publisher-gui
2) Build & install RDK **v1.9.1** into ~/flexiv_install (for flexiv_hardware):
RDK_SRC=${SRC}/flexiv_rdk_v1
# fallback if only one tree: RDK_SRC=${SRC}/flexiv_rdk
cd \${RDK_SRC}/thirdparty
source /opt/ros/humble/setup.bash # or: conda activate ros_flexiv
bash build_and_install_dependencies_not_in_ros2.sh ~/flexiv_install
cd \${RDK_SRC} && rm -rf build && mkdir build && cd build
cmake .. -DCMAKE_INSTALL_PREFIX=~/flexiv_install -DCMAKE_PREFIX_PATH=~/flexiv_install
cmake --build . --target install --config Release
3) Build this workspace:
cd ${ROOT}
source /opt/ros/humble/setup.bash
colcon build --symlink-install --cmake-args -DCMAKE_PREFIX_PATH=~/flexiv_install
source install/setup.bash
4) Real robot (site SN, no gripper yet):
ros2 launch arm_bringup real.launch.py
# or explicitly:
ros2 launch arm_bringup real.launch.py robot_sn:=Rizon4-063989 load_gripper:=false
Official fake hardware:
ros2 launch flexiv_bringup rizon.launch.py robot_sn:=Rizon4-063989 use_fake_hardware:=true load_gripper:=false
EOF
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
# Clone Flexiv Isaac Sim integration repos into thirdparty/
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TP="${ROOT}/thirdparty"
mkdir -p "${TP}"
cd "${TP}"
clone_or_update() {
local url="$1"
local dir="$2"
if [[ -d "${dir}/.git" ]]; then
echo "Updating ${dir}..."
git -C "${dir}" pull --ff-only || true
else
echo "Cloning ${dir}..."
git clone --depth 1 "${url}" "${dir}"
fi
}
clone_or_update https://github.com/flexivrobotics/flexiv_sim_plugin.git flexiv_sim_plugin
clone_or_update https://github.com/flexivrobotics/isaac_sim_ws.git isaac_sim_ws
clone_or_update https://github.com/flexivrobotics/flexiv_description.git flexiv_description
# Optional: RDK sources next to sim (if not already under src/)
if [[ ! -d "${ROOT}/src/flexiv_rdk" ]]; then
clone_or_update https://github.com/flexivrobotics/flexiv_rdk.git flexiv_rdk
fi
cat <<EOF
Isaac / description repos are under: ${TP}
Next (Isaac path):
1) Install NVIDIA Isaac Sim
2) Follow flexiv_sim_plugin README (Elements Studio → External / Isaac)
3) cd ${TP}/isaac_sim_ws && bash install_ws.sh <isaac_sim_root>
4) Configure robot instance for Rizon4-063989 in app_config.yaml
5) Primitive docs: https://primitive.flexiv.cn/primitives/zh-CN/3.10/rizon4/index.html
6) RDK manual (CN): https://flexiv.cn/software/rdk/manual/index.html
ROS 2 path (separate):
${ROOT}/scripts/fetch_flexiv.sh
EOF
+146
View File
@@ -0,0 +1,146 @@
#!/usr/bin/env bash
# 工控机 User1(enp4s0)↔ 交换机 ↔ 两台 Flexiv
#
# 本机 192.168.2.10/24(User1)。两台臂:063989=192.168.2.101 063991=192.168.2.100
#
# bash ~/ros2/scripts/fix_robot_lan.sh
# bash ~/ros2/scripts/fix_robot_lan.sh --only # 不要办公网 DHCP(独立交换机)
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck disable=SC1091
source "${ROOT}/scripts/lib/load_site_config.sh" 2>/dev/null || true
PC_IP_PRIMARY="${SITE_PC_IP:-192.168.2.10}"
PC_ADDRS=("192.168.2.10/24" "192.168.100.10/24")
ROBOT_IPS=("${SITE_ROBOT_IP:-192.168.2.100}")
# Catalog extras (second arm)
if [[ -n "${SITE_ROBOT_CATALOG:-}" ]]; then
:
fi
MODE=secondary
while [[ $# -gt 0 ]]; do
case "$1" in
--only) MODE=only; shift ;;
--secondary) MODE=secondary; shift ;;
-h|--help)
sed -n '2,12p' "$0"
exit 0
;;
*) echo "Unknown: $1" >&2; exit 1 ;;
esac
done
pick_iface() {
if [[ -n "${IFACE:-}" ]]; then
echo "$IFACE"
return
fi
if [[ -n "${SITE_NET_IFACE:-}" && -d "/sys/class/net/${SITE_NET_IFACE}" ]]; then
echo "${SITE_NET_IFACE}"
return
fi
for d in enp4s0 enp5s0 eth0; do
[[ -d /sys/class/net/$d ]] || continue
echo "$d"
return
done
return 1
}
IFACE="$(pick_iface)" || {
echo "ERROR: 找不到 User1 有线网卡" >&2
ip -br link
exit 1
}
ADDR_CSV="$(IFS=,; echo "${PC_ADDRS[*]}")"
echo "=== User1 / ${IFACE} 模式=${MODE} ==="
echo "本机副 IP: ${ADDR_CSV}"
echo "当前臂: SN=${SITE_ROBOT_SN:-?} IP=${SITE_ROBOT_IP:-?} catalog=${SITE_ROBOT_CATALOG:-}"
ip -4 -br addr
echo
if ! command -v nmcli >/dev/null; then
echo "ERROR: 需要 nmcli" >&2
exit 1
fi
CON="$(nmcli -t -f NAME,DEVICE connection show --active | awk -F: -v d="$IFACE" '$2==d{print $1; exit}')"
if [[ -z "$CON" ]]; then
CON="$(nmcli -t -f NAME,DEVICE connection show | awk -F: -v d="$IFACE" '$2==d{print $1; exit}')"
fi
if [[ -z "$CON" ]]; then
echo "ERROR: NetworkManager 没有绑定 $IFACE" >&2
nmcli device status
exit 1
fi
echo "NM 连接: $CON"
nmcli_mod() {
if nmcli connection modify "$CON" "$@"; then
return 0
fi
sudo nmcli connection modify "$CON" "$@"
}
nmcli_up() {
if nmcli connection up "$CON"; then
return 0
fi
sudo nmcli connection up "$CON"
}
if [[ "$MODE" == only ]]; then
nmcli_mod \
ipv4.method manual \
ipv4.addresses "${PC_IP_PRIMARY}/24" \
ipv4.gateway "" \
ipv4.dns "" \
ipv4.ignore-auto-dns yes \
ipv4.never-default yes
else
nmcli_mod \
ipv4.method auto \
ipv4.addresses "${ADDR_CSV}" \
ipv4.never-default no
fi
nmcli_up
echo
echo "=== 修复后 ==="
ip -4 -br addr show "$IFACE"
echo
ping_one() {
local ip="$1"
if ping -c 2 -W 1 "$ip" >/dev/null 2>&1; then
echo " UP $ip"
return 0
fi
echo " down $ip"
return 1
}
echo "=== ping 机器人网段 ==="
ok=0
for ip in 192.168.2.100 192.168.101.1 192.168.2.101 "${SITE_ROBOT_IP:-}"; do
[[ -z "$ip" ]] && continue
ping_one "$ip" && ok=1 || true
done
# unique
echo
if ping -c 1 -W 1 "${SITE_ROBOT_IP:-192.168.2.100}" >/dev/null 2>&1; then
echo "OK — 当前臂 ${SITE_ROBOT_SN:-} @ ${SITE_ROBOT_IP} 可达。RDK 用 SN 连接。"
echo " 063989 @ 192.168.2.101;063991 @ 192.168.2.100。"
else
echo "当前臂 ${SITE_ROBOT_IP:-192.168.2.100} 仍不通。检查:"
echo " 1) User1 网线 ↔ 交换机 ↔ 控制柜网口(灯亮)"
echo " 2) 示教器 About 里的 IPv4(出厂多为 192.168.2.100)"
echo " 3) Remote / Ethernet RDK 已开"
exit 2
fi
+139
View File
@@ -0,0 +1,139 @@
#!/usr/bin/env python3
"""从 ``config/site.yaml`` 生成 ``config/site.example.yaml``。
为什么要有这个脚本
------------------
``config/site.yaml`` 是现场唯一权威配置,也是全项目注释最全的文件。
``config/site.example.yaml`` 供新环境部署时复制参考。
过去两者是各写各的,结果是模板长期落后于现场配置:分析时发现模板缺了
114 项(``servo.*``、``hole_detect.stage2.*``、``sim.*`` 等),新人照着模板
配完会发现一堆功能不工作。
本脚本把「模板」变成「site.yaml 的匿名化副本」,于是模板永远不会漂移:
改完 site.yaml 重跑本脚本即可。
做什么
------
读 ``config/site.yaml``,把现场专属的值替换成占位值,其余**逐字节保留**
(包括全部中文注释与排版),然后写入 ``config/site.example.yaml``。
用法::
python3 scripts/gen_site_example.py # 生成 / 更新模板
python3 scripts/gen_site_example.py --check # 只检查是否已同步(CI / 测试用)
``--check`` 在不一致时以退出码 1 结束,方便接进测试。
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
SITE = ROOT / "config" / "site.yaml"
EXAMPLE = ROOT / "config" / "site.example.yaml"
#: 生成文件顶部的说明。生成物是只读产物,手改会在下次生成时丢失。
HEADER = """\
# =============================================================================
# site.example.yaml — 新环境部署模板
#
# !本文件由 scripts/gen_site_example.py 从 config/site.yaml 自动生成,请勿手改。
# 要改模板内容,请改 config/site.yaml(它是唯一权威)后重跑:
# python3 scripts/gen_site_example.py
# 或在改完 site.yaml 后跑一次以确认模板同步:
# python3 scripts/gen_site_example.py --check
#
# 用法:复制本文件为 config/site.yaml,然后把所有 <占位符> 换成现场实际值。
# 现场值包括:机械臂 SN / IP、相机序列号、夹爪串口、传感器 USB 路径。
#
# 说明:site.yaml 里的路径出现 /home/USER 是因为生成时匿名化了用户名;
# 部署时请改成实际用户的 home 目录。
# =============================================================================
"""
#: 逐字替换。长串必须排在前面,否则会被短串规则截断。
LITERAL_REPLACEMENTS: list[tuple[str, str]] = [
# 头部那句"从 example 复制"与本文件的实际流程相反,改掉。
(
"# site.yaml — 本机唯一配置入口(不进 git;从 site.example.yaml 复制)",
"# site.yaml — 本机唯一配置入口(不进 git;从 site.example.yaml 复制而来)",
),
# 用户名匿名化。放在其它规则之前,避免路径被二次处理。
("/home/cc", "/home/USER"),
# 注释里裸写的机械臂编号(无引号,正则抓不到)。
(
"# User1=enp4s0 本机 192.168.2.10 ↔ 063989=.101 / 063991=.100",
"# User1=enp4s0 本机 <PC_IP> ↔ <左臂SN>=.101 / <右臂SN>=.100",
),
]
#: 正则替换。按顺序应用。
REGEX_REPLACEMENTS: list[tuple[str, str]] = [
# 机械臂序列号:Rizon4-063989 -> Rizon4-XXXXXX
(r'"(Rizon4)-\d+"', r'"\1-XXXXXX"'),
# IP 地址(仅带引号的配置值;注释里的裸 IP 由上面的字面替换处理)
(r'"192\.168\.\d{1,3}\.\d{1,3}"', '"192.168.2.XXX"'),
# 8 位及以上的纯数字字符串 = 设备序列号(相机 12 位 / 夹爪 8 位)。
# 不会误伤别的值:本文件里没有其它 8 位以上的数字字符串字面量。
(r'"\d{8,}"', '"<序列号>"'),
# USB 视频设备的 by-path 稳定路径:端口号随机器而变。
(r"(usb-0:)\d+\.\d+\.\d+", r"\1X.Y.Z"),
]
def render(site_text: str) -> str:
"""Turn the live config text into the anonymised template text."""
out = site_text
for old, new in LITERAL_REPLACEMENTS:
out = out.replace(old, new)
for pattern, replacement in REGEX_REPLACEMENTS:
out = re.sub(pattern, replacement, out)
return HEADER + out
def read_site() -> str:
if not SITE.is_file():
print(f"错误: 找不到 {SITE}", file=sys.stderr)
raise SystemExit(2)
return SITE.read_text(encoding="utf-8")
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="从 config/site.yaml 生成 config/site.example.yaml",
)
parser.add_argument(
"--check",
action="store_true",
help="只检查模板是否与 site.yaml 同步;不同步则以退出码 1 结束(不写文件)",
)
args = parser.parse_args(argv)
expected = render(read_site())
if args.check:
actual = EXAMPLE.read_text(encoding="utf-8") if EXAMPLE.is_file() else ""
if actual == expected:
print(f"模板已同步: {EXAMPLE}")
return 0
print(
"模板与 config/site.yaml 不同步。\n"
" config/site.yaml 改动后,请重跑: python3 scripts/gen_site_example.py",
file=sys.stderr,
)
return 1
EXAMPLE.parent.mkdir(parents=True, exist_ok=True)
EXAMPLE.write_text(expected, encoding="utf-8")
print(f"已生成 {EXAMPLE}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+283
View File
@@ -0,0 +1,283 @@
#!/usr/bin/env bash
# Move Rizon4 to a named pose (default: home) and FORCE stop.
#
# Critical: visual_servo.launch / servo_node must be killed first, otherwise
# they keep streaming JointTrajectory and the arm never settles.
#
# Usage:
# bash ~/ros2/scripts/go_home.sh
# bash ~/ros2/scripts/go_home.sh --pose ready --speed 0.3
set -eo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
POSE="${POSE:-}"
SPEED="${SPEED:-}"
KEEP_SERVO=0
# shellcheck disable=SC1091
source "${ROOT}/scripts/lib/parse_arm_args.sh"
parse_arm_args "$@"
set -- "${PARSE_ARM_REST[@]}"
usage() {
cat <<'EOF'
Move arm to a named pose (default: home), then hold with zero velocity.
Default: kill perception/servo/launch first (required to stop shaking).
Options:
--pose NAME home | ready | inspect | pos_left
--speed FLOAT velocity_scale (0,1] default 0.25
--arm 89|91 与 run_real 同一臂
--keep-servo do NOT kill servo (arm may keep shaking)
-h, --help
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--pose) POSE="${2:?}"; shift 2 ;;
--speed) SPEED="${2:?}"; shift 2 ;;
--keep-servo) KEEP_SERVO=1; shift ;;
--stop-track) shift ;; # legacy
-h|--help) usage; exit 0 ;;
*) echo "Unknown: $1" >&2; usage >&2; exit 1 ;;
esac
done
if ! command -v ros2 >/dev/null 2>&1 || [[ -z "${ROS_DISTRO:-}" ]]; then
# shellcheck disable=SC1091
source "${ROOT}/scripts/env.sh"
fi
# shellcheck disable=SC1091
source "${ROOT}/scripts/lib/load_site_config.sh"
POSE="${POSE:-${SITE_GO_HOME_POSE:-home}}"
SPEED="${SPEED:-${SITE_GO_HOME_SPEED:-0.25}}"
kill_streamers() {
echo "=== killing visual-servo streamers ==="
# Prefer disable control before kill (if node still up)
ros2 param set /servo_node enable_control false >/dev/null 2>&1 || true
# Kill launch parent first, then executables (SIGTERM then SIGKILL)
local patterns=(
'visual_servo.launch.py'
'/lib/arm_application/servo_node'
'/lib/arm_application/perception_node'
)
local p
for p in "${patterns[@]}"; do
if pgrep -f "$p" >/dev/null 2>&1; then
echo " TERM $p"
pkill -TERM -f "$p" 2>/dev/null || true
fi
done
sleep 1.0
for p in "${patterns[@]}"; do
if pgrep -f "$p" >/dev/null 2>&1; then
echo " KILL $p"
pkill -KILL -f "$p" 2>/dev/null || true
fi
done
sleep 0.5
if pgrep -f '/lib/arm_application/servo_node' >/dev/null 2>&1; then
echo "ERROR: servo_node still alive — refuse to move (would keep shaking)" >&2
pgrep -af '/lib/arm_application/servo_node' >&2 || true
exit 2
fi
echo " servo_node gone OK"
}
print_joints() {
python3 <<'PY'
import rclpy
from rclpy.node import Node
from sensor_msgs.msg import JointState
rclpy.init()
n = Node("go_home_joints")
box = [None]
n.create_subscription(JointState, "joint_states", lambda m: box.__setitem__(0, m), 10)
for _ in range(40):
rclpy.spin_once(n, timeout_sec=0.05)
if box[0] is not None:
break
if box[0] is None:
print(" (no joint_states)")
else:
m = dict(zip(box[0].name, box[0].position))
v = dict(zip(box[0].name, box[0].velocity)) if box[0].velocity else {}
for i in range(1, 8):
k = f"{__import__('os').environ.get('SITE_JOINT_PREFIX','Rizon4-063989_')}joint{i}"
if k in m:
print(f" joint{i}: {m[k]:+.4f} qd={v.get(k, 0.0):+.4f}")
n.destroy_node(); rclpy.shutdown()
PY
}
# Strong settle via controller ACTION (not topic — matches JTC reliably)
settle_hold_action() {
local pose="$1"
python3 - "$pose" <<'PY'
import sys, time
import rclpy
from rclpy.action import ActionClient
from rclpy.node import Node
from control_msgs.action import FollowJointTrajectory
from trajectory_msgs.msg import JointTrajectory, JointTrajectoryPoint
from sensor_msgs.msg import JointState
POSES = {
"home": [0.0, -0.698, 0.0, 1.57, 0.0, 0.698, 0.0],
"ready": [0.0, -0.698, 0.0, 1.57, 0.0, 0.698, 0.0],
"inspect": [0.698, -0.698, 0.698, 1.57, 0.698, 0.698, 0.698],
"pos_left": [-0.698, -0.698, 0.698, 1.57, 0.698, 0.698, 0.698],
}
name = sys.argv[1]
target = POSES.get(name)
if target is None:
raise SystemExit(f"unknown pose {name}")
import os
PREFIX = os.environ.get("SITE_JOINT_PREFIX", "Rizon4-063989_")
JOINTS = [f"{PREFIX}joint{i}" for i in range(1, 8)]
rclpy.init()
n = Node("go_home_hold_action")
js_box = [None]
n.create_subscription(JointState, "joint_states", lambda m: js_box.__setitem__(0, m), 10)
cli = ActionClient(n, FollowJointTrajectory, "rizon_arm_controller/follow_joint_trajectory")
if not cli.wait_for_server(timeout_sec=5.0):
print(" WARN: JTC action not available — skip action hold")
n.destroy_node(); rclpy.shutdown(); raise SystemExit(0)
def spin_until(pred, timeout=3.0):
t0 = time.monotonic()
while time.monotonic() - t0 < timeout:
rclpy.spin_once(n, timeout_sec=0.05)
if pred():
return True
return False
spin_until(lambda: js_box[0] is not None, 3.0)
# Two-point: current -> target (or target hold), BOTH with zero velocity
q_now = list(target)
if js_box[0] is not None:
m = dict(zip(js_box[0].name, js_box[0].position))
try:
q_now = [float(m[j]) for j in JOINTS]
except KeyError:
pass
def send_hold(q, T=0.40):
traj = JointTrajectory()
traj.joint_names = list(JOINTS)
# start
p0 = JointTrajectoryPoint()
p0.positions = [float(v) for v in q]
p0.velocities = [0.0] * 7
p0.accelerations = [0.0] * 7
p0.time_from_start.sec = 0
p0.time_from_start.nanosec = 50_000_000
# end same pose, still zero vel (pin)
p1 = JointTrajectoryPoint()
p1.positions = [float(v) for v in q]
p1.velocities = [0.0] * 7
p1.accelerations = [0.0] * 7
ns = int(round(T * 1e9))
p1.time_from_start.sec = ns // 1_000_000_000
p1.time_from_start.nanosec = ns % 1_000_000_000
traj.points = [p0, p1]
goal = FollowJointTrajectory.Goal()
goal.trajectory = traj
fut = cli.send_goal_async(goal)
spin_until(lambda: fut.done(), 3.0)
if not fut.done():
print(" hold goal send timeout")
return False
gh = fut.result()
if gh is None or not gh.accepted:
print(" hold goal rejected")
return False
rfut = gh.get_result_async()
spin_until(lambda: rfut.done(), T + 3.0)
return True
# 1) snap toward named pose with zero end vel
print(f" action hold → {name} (zero vel)")
send_hold(target, T=0.60)
time.sleep(0.4)
# 2–4) re-pin measured pose several times (align JTC open-loop cmd to encoder)
for i in range(3):
spin_until(lambda: js_box[0] is not None, 1.0)
if js_box[0] is None:
break
m = dict(zip(js_box[0].name, js_box[0].position))
q_pin = [float(m[j]) for j in JOINTS]
print(f" action re-pin #{i+1} @ measured joints (zero vel)")
send_hold(q_pin, T=0.45)
time.sleep(0.25)
# Wait until |qd| small
print(" waiting for joints to stop ...")
t0 = time.monotonic()
while time.monotonic() - t0 < 5.0:
rclpy.spin_once(n, timeout_sec=0.05)
if js_box[0] is None or not js_box[0].velocity:
continue
vmap = dict(zip(js_box[0].name, js_box[0].velocity))
vmax = max(abs(float(vmap.get(j, 0.0))) for j in JOINTS)
if vmax < 0.015:
print(f" settled |qd|_max={vmax:.4f} rad/s")
break
else:
print(" WARN: still some residual velocity")
n.destroy_node()
rclpy.shutdown()
PY
}
if [[ "${KEEP_SERVO}" -eq 0 ]]; then
kill_streamers
else
echo "WARNING: --keep-servo — shaking likely"
fi
if ! ros2 service list 2>/dev/null | grep -q '/set_named_pose'; then
echo "ERROR: /set_named_pose missing — start run_real.sh" >&2
exit 1
fi
echo "=== joints before ==="
print_joints
echo "=== move: ${POSE} speed=${SPEED} ==="
RESP="$(
ros2 service call /set_named_pose arm_msgs/srv/SetNamedPose \
"{pose_name: '${POSE}', velocity_scale: ${SPEED}, acceleration_scale: 0.0}" 2>&1
)"
echo "${RESP}"
if ! echo "${RESP}" | grep -q 'success=True'; then
echo "ERROR: move failed" >&2
exit 1
fi
WAIT_SEC="$(python3 -c "print(max(2.5, 1.0 / max(float('${SPEED}'), 0.05) + 2.0))")"
echo "Waiting ${WAIT_SEC}s ..."
sleep "${WAIT_SEC}"
echo "=== settle (action, vel=0) ==="
settle_hold_action "${POSE}"
# Final check: servo must still be dead
if pgrep -f '/lib/arm_application/servo_node' >/dev/null 2>&1; then
echo "WARNING: servo_node came back — killing again"
kill_streamers
settle_hold_action "${POSE}"
fi
echo "=== joints after ==="
print_joints
echo "Done. Arm should be still."
echo "Tracking later: bash ~/ros2/scripts/run_visual_servo_rt.sh --control"
+104
View File
@@ -0,0 +1,104 @@
#!/usr/bin/env bash
# Offline-friendly Flexiv RDK v1.9.1 install for ROS (humble-v1 / flexiv_hardware).
# CMakeLists already supports FLEXIV_RDK_OFFLINE_LIB / vendor/rdk_libs/.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
RDK_SRC="${RDK_SRC:-$ROOT/src/flexiv_rdk_v1}"
PREFIX="${PREFIX:-$HOME/flexiv_install}"
LIB_NAME="libflexiv_rdk.x86_64-linux-gnu.a"
EXPECTED_HASH="$(tr -d '[:space:]' < "${RDK_SRC}/lib/${LIB_NAME}.sha256")"
VENDOR_LIB="${ROOT}/vendor/rdk_libs/${LIB_NAME}"
JOBS="${JOBS:-$(nproc)}"
# Prefer conda ros_flexiv toolchain (cmake/g++ often only there)
if ! command -v cmake >/dev/null 2>&1; then
# shellcheck disable=SC1091
source "${HOME}/anaconda3/etc/profile.d/conda.sh" 2>/dev/null \
|| source "${HOME}/miniconda3/etc/profile.d/conda.sh" 2>/dev/null \
|| true
conda activate "${CONDA_ENV:-ros_flexiv}" 2>/dev/null || true
fi
if ! command -v cmake >/dev/null 2>&1; then
echo "ERROR: cmake not found. Activate ros_flexiv (or install cmake) first."
exit 1
fi
echo "Using cmake: $(command -v cmake) ($(cmake --version | head -1))"
mkdir -p "${ROOT}/vendor/rdk_libs" "${PREFIX}"
if [[ -f "${VENDOR_LIB}" ]] && [[ "$(sha256sum "${VENDOR_LIB}" | awk '{print $1}')" == "${EXPECTED_HASH}" ]]; then
echo "OK: ${VENDOR_LIB}"
else
echo "Need ${LIB_NAME} (SHA256 ${EXPECTED_HASH})"
base="https://github.com/flexivrobotics/flexiv_rdk/releases/download/v1.9.1/${LIB_NAME}"
ok=0
for url in \
"${base}" \
"https://ghproxy.net/${base}" \
"https://gh.ddlc.top/${base}" \
"https://hub.gitmirror.com/${base}" \
"https://cf.ghproxy.cc/${base}"
do
echo "TRY ${url}"
if curl -fL --connect-timeout 15 --max-time 120 -o "${VENDOR_LIB}.part" "${url}" \
&& [[ "$(sha256sum "${VENDOR_LIB}.part" | awk '{print $1}')" == "${EXPECTED_HASH}" ]]; then
mv "${VENDOR_LIB}.part" "${VENDOR_LIB}"
ok=1
break
fi
rm -f "${VENDOR_LIB}.part"
done
if [[ "${ok}" -ne 1 ]]; then
cat <<EOF
FAILED to download ${LIB_NAME}.
Manual (any PC with GitHub access):
wget -O ${LIB_NAME} '${base}'
sha256sum ${LIB_NAME} # must be ${EXPECTED_HASH}
scp ${LIB_NAME} $(hostname):${VENDOR_LIB}
Then re-run: $0
EOF
exit 1
fi
fi
export FLEXIV_RDK_OFFLINE_LIB="${VENDOR_LIB}"
echo "Install deps into ${PREFIX} (may still need git mirrors)..."
DEP_SCRIPT=""
for s in \
"${RDK_SRC}/thirdparty/build_and_install_dependencies_not_in_ros2.sh" \
"${RDK_SRC}/thirdparty/build_and_install_dependencies.sh"
do
[[ -f "$s" ]] && DEP_SCRIPT="$s" && break
done
if [[ -n "${DEP_SCRIPT}" ]]; then
bash "${DEP_SCRIPT}" "${PREFIX}" "${JOBS}" || {
echo "WARN: deps script failed. If Eigen/spdlog/Fast-DDS/RBDyn already in ${PREFIX}, continue."
}
else
echo "WARN: no dependency script found"
fi
rm -rf "${RDK_SRC}/build"
mkdir -p "${RDK_SRC}/build" && cd "${RDK_SRC}/build"
cmake .. -DCMAKE_BUILD_TYPE=Release \
-DCMAKE_INSTALL_PREFIX="${PREFIX}" \
-DCMAKE_PREFIX_PATH="${PREFIX}"
cmake --build . --target install -j "${JOBS}"
cat <<EOF
RDK installed to ${PREFIX}
Next:
conda activate ros_flexiv
cd ${ROOT}
colcon build --symlink-install --cmake-args -DCMAKE_PREFIX_PATH=${PREFIX} \\
--packages-up-to flexiv_bringup flexiv_hardware arm_bringup
Python (already ready, no GitHub):
/home/cc/rdk_py310/bin/python
bash ${ROOT}/scripts/test_rdk_python.sh
EOF
+563
View File
@@ -0,0 +1,563 @@
#!/usr/bin/env python3
"""Load ~/ros2/config/site.yaml and print bash export lines or JSON.
Usage:
eval "$(python3 ~/ros2/scripts/lib/export_site_env.py)"
python3 ~/ros2/scripts/lib/export_site_env.py --json
python3 ~/ros2/scripts/lib/export_site_env.py --print
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from pathlib import Path
try:
import yaml
except ImportError:
yaml = None # type: ignore
def _root() -> Path:
return Path(__file__).resolve().parents[2]
def site_path() -> Path:
env = os.environ.get("SITE_CONFIG", "").strip()
if env:
return Path(env).expanduser()
return _root() / "config" / "site.yaml"
def load_site(path: Path | None = None) -> dict:
path = path or site_path()
if not path.is_file():
raise FileNotFoundError(f"site config not found: {path}")
text = path.read_text(encoding="utf-8")
if yaml is None:
raise RuntimeError("PyYAML required: pip/conda install pyyaml")
data = yaml.safe_load(text) or {}
if not isinstance(data, dict):
raise ValueError(f"invalid site.yaml: {path}")
return data
def list_robots(data: dict) -> list[dict]:
"""Catalog entries from robot.robots (list or SN-keyed map)."""
raw = (data.get("robot") or {}).get("robots") or []
out: list[dict] = []
if isinstance(raw, dict):
for sn, item in raw.items():
row = dict(item or {}) if isinstance(item, dict) else {}
row.setdefault("sn", str(sn))
out.append(row)
return out
if isinstance(raw, list):
for item in raw:
if isinstance(item, dict) and str(item.get("sn") or "").strip():
out.append(dict(item))
return out
# --arm 89|91|left|right 与 SN 别名。禁止把 89 的手眼/相机套到 91。
_ARM_ALIASES = {
"89": "Rizon4-063989",
"left": "Rizon4-063989",
"左": "Rizon4-063989",
"左臂": "Rizon4-063989",
"左臂89": "Rizon4-063989",
"91": "Rizon4-063991",
"right": "Rizon4-063991",
"右": "Rizon4-063991",
"右臂": "Rizon4-063991",
"右臂91": "Rizon4-063991",
}
_ARM_SECTION_KEYS = ("camera", "gripper", "paths", "servo", "handeye", "go_home", "yolo")
def _deep_merge(base: dict, overlay: dict) -> dict:
out = dict(base)
for k, v in overlay.items():
if isinstance(out.get(k), dict) and isinstance(v, dict):
out[k] = _deep_merge(out[k], v)
else:
out[k] = v
return out
def normalize_robot_sn(data: dict, want: str) -> str:
"""Map 89/91/left/right/label → full Flexiv SN."""
w = (want or "").strip()
if not w:
return w
mapped = _ARM_ALIASES.get(w) or _ARM_ALIASES.get(w.lower())
if mapped:
return mapped
for item in list_robots(data):
sn = str(item.get("sn") or "").strip()
label = str(item.get("label") or "").strip()
arm = str(item.get("arm") or "").strip().lower()
if w == sn or w == label or w.lower() == arm:
return sn
if sn.endswith(w) and w.isdigit() and len(w) >= 2:
return sn
return w
def resolve_robot(data: dict, sn: str | None = None) -> dict:
"""Active arm: ROBOT_SN env overrides robot.sn; catalog fills ip/iface/frames."""
robot = dict(data.get("robot") or {})
catalog = list_robots(data)
want = normalize_robot_sn(
data,
sn or os.environ.get("ROBOT_SN") or os.environ.get("ROBOT_ARM") or robot.get("sn") or "",
)
match: dict = {}
for item in catalog:
if str(item.get("sn") or "").strip() == want:
match = dict(item)
break
out = {k: v for k, v in robot.items() if k != "robots"}
out.update({k: v for k, v in match.items() if k != "robots"})
out["sn"] = want
if not str(out.get("arm") or "").strip():
if "063989" in want:
out["arm"] = "left"
elif "063991" in want:
out["arm"] = "right"
if not str(out.get("joint_prefix") or "").strip() and want:
out["joint_prefix"] = f"{want}_"
if not str(out.get("base_frame") or "").strip() and want:
out["base_frame"] = f"{want}_base_link"
if not str(out.get("ee_frame") or "").strip() and want:
out["ee_frame"] = f"{want}_flange"
net = data.get("network") or {}
if not str(out.get("iface") or "").strip():
out["iface"] = str(net.get("prefer_iface") or "")
if not str(out.get("ip") or "").strip():
out["ip"] = "192.168.100.1"
if not str(out.get("pc_ip") or "").strip():
out["pc_ip"] = "192.168.100.10"
return out
def apply_active_arm(data: dict, sn: str | None = None) -> tuple[dict, dict]:
"""Merge robots[] camera/gripper/paths/servo onto site defaults for the active SN."""
robot = resolve_robot(data, sn)
merged = dict(data)
for key in _ARM_SECTION_KEYS:
base = merged.get(key) if isinstance(merged.get(key), dict) else {}
ov = robot.get(key) if isinstance(robot.get(key), dict) else {}
merged[key] = _deep_merge(dict(base or {}), dict(ov or {}))
return merged, robot
def camera_ns(data: dict) -> str:
"""Topic namespace for the active wrist camera (d405_left / d405_right)."""
cam = data.get("camera") or {}
name = str(cam.get("name") or "d405").strip().strip("/")
return name or "d405"
def camera_frames(data: dict) -> tuple[str, str, str]:
"""(namespace, optical_frame, depth_topic) for the active arm."""
ns = camera_ns(data)
yolo = data.get("yolo") or {}
frame = str(yolo.get("camera_frame") or "").strip()
depth = str(yolo.get("depth_topic") or "").strip()
if frame in ("", "d405_color_optical_frame"):
frame = f"{ns}_color_optical_frame"
if depth in ("", "/d405/aligned_depth_to_color/image_raw"):
depth = f"/{ns}/aligned_depth_to_color/image_raw"
return ns, frame, depth
def flatten(data: dict) -> dict:
"""Flat SITE_* env map for bash scripts."""
data, robot = apply_active_arm(data)
paths = data.get("paths") or {}
cam = data.get("camera") or {}
yolo = data.get("yolo") or {}
servo = data.get("servo") or {}
home = data.get("go_home") or {}
net = data.get("network") or {}
he = data.get("handeye") or {}
grip = data.get("gripper") or {}
named_poses = str(
robot.get("named_poses_file")
or paths.get("named_poses_file")
or str(_root() / "src" / "arm_control" / "config" / "named_poses.yaml")
)
weights = str(paths.get("yolo_weights") or "")
# Resolve active.pt symlink to real path for display
try:
weights_resolved = str(Path(weights).expanduser().resolve())
except Exception:
weights_resolved = weights
out = {
"SITE_CONFIG": str(site_path()),
"SITE_ROS2_ROOT": str(_root()),
"SITE_ROBOT_SN": str(robot.get("sn") or "Rizon4-063989"),
"SITE_ROBOT_IP": str(robot.get("ip") or "192.168.100.1"),
"SITE_PC_IP": str(robot.get("pc_ip") or "192.168.100.10"),
"SITE_JOINT_PREFIX": str(robot.get("joint_prefix") or ""),
"SITE_BASE_FRAME": str(robot.get("base_frame") or ""),
"SITE_EE_FRAME": str(robot.get("ee_frame") or ""),
"SITE_ROBOT_TOOL_NAME": str(robot.get("tool_name") or ""),
"SITE_ROBOT_LABEL": str(robot.get("label") or ""),
"SITE_ROBOT_ARM": str(robot.get("arm") or ""),
"SITE_NAMED_POSES_FILE": named_poses,
"SITE_ROBOT_CATALOG": ",".join(
str(r.get("sn") or "") for r in list_robots(data) if r.get("sn")
),
"SITE_YOLO_WEIGHTS": weights,
"SITE_YOLO_WEIGHTS_RESOLVED": weights_resolved,
"SITE_HANDEYE_WAYPOINTS": str(
paths.get("handeye_waypoints")
or str(_root() / "src" / "arm_application" / "config" / "handeye_poses.yaml")
),
"SITE_HANDEYE_CALIBRATE": str(
paths.get("handeye_calibrate")
or str(_root() / "src" / "arm_application" / "config" / "handeye_calibrate.yaml")
),
"SITE_HANDEYE_TOUR": str(
paths.get("handeye_tour")
or str(_root() / "src" / "arm_application" / "config" / "handeye_waypoints.yaml")
),
"SITE_HANDEYE_WORKDIR": str(
paths.get("handeye_workdir") or "/tmp/handeye_calib"
),
"SITE_HANDEYE_FILE": str(
paths.get("handeye_file")
or str(_root() / "src" / "arm_application" / "config" / "handeye_result.yaml")
),
"SITE_HANDEYE_MODE": str(he.get("mode") or "eye_in_hand"),
"SITE_HANDEYE_BOARD_COLS": str(int(he.get("board_cols") or 11)),
"SITE_HANDEYE_BOARD_ROWS": str(int(he.get("board_rows") or 16)),
"SITE_HANDEYE_SQUARE_M": str(float(he.get("square_size_m") or 0.011)),
"SITE_HANDEYE_VEL_SCALE": str(float(he.get("velocity_scale") or 0.05)),
"SITE_HANDEYE_MOVE_T": str(float(he.get("move_time_sec") or 8.0)),
"SITE_HANDEYE_SETTLE_SEC": str(float(he.get("settle_sec") or 1.5)),
"SITE_HANDEYE_SETTLE_QD": str(float(he.get("settle_qd_max") or 0.015)),
"SITE_HANDEYE_SETTLE_STABLE": str(float(he.get("settle_stable_sec") or 0.6)),
"SITE_HANDEYE_SETTLE_TO": str(float(he.get("settle_timeout_sec") or 8.0)),
"SITE_HANDEYE_PIN_HOLDS": str(int(he.get("pin_holds") if he.get("pin_holds") is not None else 2)),
"SITE_HANDEYE_PIN_SEC": str(float(he.get("pin_hold_sec") or 0.45)),
"SITE_HANDEYE_SKIP_UNSTABLE": (
"true" if he.get("skip_unstable_poses", True) else "false"
),
"SITE_FASTDDS_PROFILE": str(paths.get("fastdds_profile") or ""),
"SITE_CAM_NAME": camera_ns(data),
"SITE_CAM_SERIAL": str(cam.get("serial") or ""),
"SITE_CAM_WIDTH": str(int(cam.get("width") or 848)),
"SITE_CAM_HEIGHT": str(int(cam.get("height") or 480)),
"SITE_CAM_FPS": str(int(cam.get("fps") or 30)),
"SITE_CAM_ENABLE_DEPTH": "1" if cam.get("enable_depth", True) else "0",
"SITE_CAM_ENABLE_INFRA": "1" if cam.get("enable_infra", True) else "0",
"SITE_CAM_PREVIEW": "1" if cam.get("preview", True) else "0",
"SITE_CAM_PREVIEW_STEREO": "1" if cam.get("preview_stereo", True) else "0",
"SITE_CAM_OVERLAY_HOLD": str(float(cam.get("overlay_hold_sec") or 1.5)),
"SITE_CAM_USE_SITE_RES": "1" if cam.get("use_site_resolution", True) else "0",
"SITE_CAM_AUTO_EXPOSURE": "1" if cam.get("auto_exposure", True) else "0",
"SITE_CAM_AE_LIMIT_US": str(float(cam.get("ae_limit_us") if cam.get("ae_limit_us") is not None else 4000)),
"SITE_CAM_EMITTER": "1" if cam.get("emitter_enabled", False) else "0",
"SITE_CAM_LASER_POWER": str(float(cam.get("laser_power") if cam.get("laser_power") is not None else 0)),
"SITE_CAM_EXPOSURE_US": str(float(cam.get("exposure_us") or 0)),
"SITE_CAM_GAIN": str(float(cam.get("gain") or 0)),
"SITE_D435_SERIAL": str(((data.get("cameras") or {}).get("d435") or {}).get("serial") or ""),
"SITE_D435_WIDTH": str(int(((data.get("cameras") or {}).get("d435") or {}).get("width") or 1280)),
"SITE_D435_HEIGHT": str(int(((data.get("cameras") or {}).get("d435") or {}).get("height") or 720)),
"SITE_D435_FPS": str(int(((data.get("cameras") or {}).get("d435") or {}).get("fps") or 30)),
"SITE_D405_LEFT_SERIAL": str(((data.get("cameras") or {}).get("d405_left") or {}).get("serial") or "352122274408"),
"SITE_D405_RIGHT_SERIAL": str(((data.get("cameras") or {}).get("d405_right") or {}).get("serial") or "260522274442"),
"SITE_YOLO_CONF": str(yolo.get("conf") or 0.35),
"SITE_YOLO_IMGSZ": str(int(yolo.get("imgsz") or 416)),
"SITE_YOLO_DEVICE": str(yolo.get("device") or "0"),
"SITE_YOLO_MIN_CONF": str(yolo.get("min_conf") or 0.35),
"SITE_GO_HOME_POSE": str(home.get("pose") or "home"),
"SITE_GO_HOME_SPEED": str(home.get("speed") or 0.25),
"SITE_NET_IFACE": str(robot.get("iface") or net.get("prefer_iface") or ""),
"SITE_SERVO_Z_REF": str(servo.get("z_ref_m") or 0.40),
"SITE_EE_X_M": str(float(servo.get("ee_x_m") or 0.25)),
"SITE_EE_Y_M": str(float(servo.get("ee_y_m") or 0.0)),
"SITE_EE_Z_M": str(float(servo.get("ee_z_m") or 0.24)),
"SITE_SERVO_WATCHDOG": str(servo.get("watchdog_timeout_sec") or 0.3),
"SITE_CAMERA_FRAME": camera_frames(data)[1],
"SITE_GRIP_VENDOR": str(grip.get("vendor") or "dh"),
"SITE_GRIP_MODEL": str(grip.get("model") or "PGEA-100-20-W"),
"SITE_GRIP_SN": str(grip.get("sn") or ""),
"SITE_GRIP_BACKEND": str(grip.get("backend") or "usb"),
"SITE_GRIP_DEVICE": str(grip.get("device_name") or "GripperDahuanModbus"),
"SITE_GRIP_FORCE_N": str(float(grip.get("force_n") or 50.0)),
"SITE_GRIP_VEL_MPS": str(float(grip.get("vel_mps") or 0.05)),
"SITE_GRIP_PORT": str(grip.get("port") or "/dev/ttyUSB0"),
"SITE_GRIP_BAUD": str(int(grip.get("baud") or 115200)),
"SITE_GRIP_SLAVE": str(int(grip.get("slave_id") or 1)),
"SITE_GRIP_STROKE_MM": str(float(grip.get("stroke_mm") or 20.0)),
"SITE_GRIP_FORCE": str(int(grip.get("force_pct") or 50)),
"SITE_GRIP_SPEED": str(int(grip.get("speed_pct") or 50)),
"SITE_GRIP_AUTO_INIT": "true" if grip.get("auto_init", True) else "false",
"SITE_GRIP_RS485_RTS": str(grip.get("rs485_rts") or ""),
"SITE_HOLE_BACKEND": str((data.get("hole_detect") or {}).get("backend") or "two_stage"),
"SITE_HOLE_WEIGHTS": str(
(data.get("hole_detect") or {}).get("weights")
or paths.get("hole_weights")
or str(_root() / "models" / "hole_insert" / "best.pt")
),
"SITE_HOLE_WORKPIECE_WEIGHTS": str(
(data.get("hole_detect") or {}).get("workpiece_weights")
or paths.get("hole_workpiece_weights")
or weights
),
"SITE_HOLE_CONFIG": str(
(data.get("hole_detect") or {}).get("config")
or (
str(_root() / "src" / "arm_application" / "config" / "hole_detect.yaml")
if str((data.get("hole_detect") or {}).get("backend") or "two_stage")
in ("two_stage", "hough_roi")
else str(_root() / "src" / "arm_application" / "config" / "hole_insert.yaml")
)
),
"SITE_TACTILE_BACKEND": str((data.get("tactile") or {}).get("backend") or "orisys"),
"SITE_TACTILE_CONFIG": str((data.get("tactile") or {}).get("config") or "dd03"),
"SITE_TACTILE_CONFIG_FILE": str(
(data.get("tactile") or {}).get("config_file")
or str(_root() / "src" / "arm_application" / "config" / "orisys" / "dd03.json")
),
"SITE_TACTILE_LEFT": str((data.get("tactile") or {}).get("left_video", 0)),
"SITE_TACTILE_RIGHT": str((data.get("tactile") or {}).get("right_video", 2)),
}
return out
def gripper_overrides(data: dict) -> dict:
"""ROS parameter dict for dh_pgea_node (DH Modbus, not Flexiv gripper)."""
data, _robot = apply_active_arm(data)
g = data.get("gripper") or {}
return {
"port": str(g.get("port") or "/dev/ttyUSB0"),
"baud": int(g.get("baud") or 115200),
"slave_id": int(g.get("slave_id") or 1),
"stroke_mm": float(g.get("stroke_mm") or 20.0),
"force_pct": int(g.get("force_pct") or 50),
"speed_pct": int(g.get("speed_pct") or 50),
"auto_init": bool(g.get("auto_init", True)),
"state_hz": float(g.get("state_hz") or 20.0),
"model": str(g.get("model") or "PGEA-100-20-W"),
"serial_number": str(g.get("sn") or ""),
"open_position": int(g.get("open_position") if g.get("open_position") is not None else 1000),
"close_position": int(g.get("close_position") if g.get("close_position") is not None else 0),
"rs485_rts": str(g.get("rs485_rts") or ""),
}
def perception_overrides(data: dict) -> dict:
"""ROS parameter dict for perception_node."""
data, _robot = apply_active_arm(data)
paths = data.get("paths") or {}
yolo = data.get("yolo") or {}
cam_ns, cam_frame, depth_topic = camera_frames(data)
obj = str(yolo.get("object_frame") or "").strip()
if not obj or obj == "tracked_object":
arm = str((_robot or {}).get("arm") or "")
sn = str((_robot or {}).get("sn") or "")
if "063991" in sn or arm == "right":
obj = "tracked_object_91"
elif "063989" in sn or arm == "left":
obj = "tracked_object_89"
else:
obj = "tracked_object"
return {
"weights": str(paths.get("yolo_weights") or ""),
"conf": float(yolo.get("conf") or 0.10),
"iou": float(yolo.get("iou") or 0.45),
"imgsz": int(yolo.get("imgsz") or 416),
"device": str(yolo.get("device") or "0"),
"half_precision": bool(yolo.get("half_precision", True)),
"backend": str(yolo.get("backend") or "auto"),
"mp_infer": bool(yolo.get("mp_infer", True)),
"min_conf": float(yolo.get("min_conf") or 0.12),
"vision_hz": float(yolo.get("vision_hz") or 30.0),
"infer_max_width": int(yolo.get("infer_max_width") if yolo.get("infer_max_width") is not None else 0),
"box_ema_alpha": float(yolo.get("box_ema_alpha") or 0.85),
"miss_clear_frames": int(yolo.get("miss_clear_frames") or 12),
"overlay_hold_on_miss": bool(yolo.get("overlay_hold_on_miss", True)),
"jump_px": float(yolo.get("jump_px") or 280.0),
"jump_z_m": float(yolo.get("jump_z_m") or 0.20),
"z_ref_m": float(yolo.get("z_ref_m") or 0.22),
"min_depth_m": float(yolo.get("min_depth_m") or 0.04),
"max_depth_m": float(yolo.get("max_depth_m") or 0.55),
"work_z_max_m": float(yolo.get("work_z_max_m") or 0.50),
"hold_point_on_miss": bool(yolo.get("hold_point_on_miss", True)),
"miss_hold_point_frames": int(yolo.get("miss_hold_point_frames") or 2),
"meas_blend": float(yolo.get("meas_blend") or 0.65),
"z_alpha_min": float(yolo.get("z_alpha_min") or 0.10),
"z_alpha_max": float(yolo.get("z_alpha_max") or 0.60),
"z_alpha_gain": float(yolo.get("z_alpha_gain") or 2.0),
"camera_frame": cam_frame,
"object_frame": obj,
"depth_topic": depth_topic,
"camera_source": cam_ns,
"feature_mode": str(yolo.get("feature_mode") or "plane"),
"plane_inlier_m": float(yolo.get("plane_inlier_m") or 0.008),
"plane_band_m": float(yolo.get("plane_band_m") or 0.06),
}
def servo_overrides(data: dict) -> dict:
"""ROS parameter dict for servo_node."""
data, robot = apply_active_arm(data)
paths = data.get("paths") or {}
yolo = data.get("yolo") or {}
servo = data.get("servo") or {}
_ns, cam_frame, _depth = camera_frames(data)
out = {
"joint_prefix": str(robot.get("joint_prefix") or ""),
"base_frame": str(robot.get("base_frame") or ""),
"ee_frame": str(robot.get("ee_frame") or ""),
"handeye_file": str(paths.get("handeye_file") or ""),
"camera_frame": cam_frame,
"handeye_child_frame": cam_frame,
"arm_label": str(robot.get("label") or robot.get("arm") or ""),
"z_ref_m": float(servo.get("z_ref_m") or yolo.get("z_ref_m") or 0.40),
}
# Pass through remaining servo keys that match node params
keys = [
"control_hz", "watchdog_timeout_sec", "lost_timeout_sec", "stale_hold_sec",
"max_extrapolate_sec", "predict_enable", "predict_sec",
"predict_v_max", "predict_jump_m",
"kp_xy", "ki_xy", "kd_xy", "kp_z", "ki_z", "kd_z", "i_sep_pos_m",
"deadzone_m", "deadzone_z_m", "v_max_xy", "v_max_z", "v_approach_z",
"z_min_m", "approach_xy_gate_m", "min_ee_z_m", "e_xy_clip_m",
"ee_x_m", "ee_y_m", "ee_z_m", "ee_hold_xy", "ee_vertical_down",
"xy_track", "hover_offset_ee_xy_m", "arm_label",
"ee_hold_kp", "ee_w_max",
"lock_hold_sec",
"a_max", "max_qdot", "damping",
"vel_lpf_alpha", "dq_window_size", "traj_stream_hz", "exec_horizon_sec",
"min_cmd_delta_rad", "point_topic", "traj_topic",
"use_static_handeye_tf",
"workspace_xyz_min", "workspace_xyz_max",
]
for k in keys:
if k in servo:
out[k] = servo[k]
return out
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--json", action="store_true")
ap.add_argument("--print", action="store_true", dest="do_print")
ap.add_argument("--config", default="")
ap.add_argument("--sn", "--arm", dest="sn", default="", help="89|91|left|right|full SN")
args = ap.parse_args()
if args.sn:
os.environ["ROBOT_SN"] = str(args.sn)
path = Path(args.config).expanduser() if args.config else site_path()
data = load_site(path)
flat = flatten(data)
if args.json:
print(json.dumps({"site": data, "env": flat}, ensure_ascii=False, indent=2))
return 0
if args.do_print:
print(f"site config: {path}")
print(
f" robot SN : {flat['SITE_ROBOT_SN']}"
f"{(' (' + flat['SITE_ROBOT_LABEL'] + ')') if flat.get('SITE_ROBOT_LABEL') else ''}"
f" arm={flat.get('SITE_ROBOT_ARM') or '?'}"
)
print(f" catalog : {flat.get('SITE_ROBOT_CATALOG') or '(none)'}")
print(f" robot IP : {flat['SITE_ROBOT_IP']} pc={flat['SITE_PC_IP']} iface={flat.get('SITE_NET_IFACE') or '?'}")
print(f" frames : prefix={flat['SITE_JOINT_PREFIX']} ee={flat['SITE_EE_FRAME']}")
print(f" tool name : {flat.get('SITE_ROBOT_TOOL_NAME') or '(keep current)'}")
catalog_sns = [str(r.get("sn") or "") for r in list_robots(data)]
if catalog_sns and flat["SITE_ROBOT_SN"] not in catalog_sns:
print(
f" WARNING: robot.sn {flat['SITE_ROBOT_SN']!r} is not in robots[]",
file=sys.stderr,
)
print(f" yolo weights : {flat['SITE_YOLO_WEIGHTS']}")
print(f" resolved : {flat['SITE_YOLO_WEIGHTS_RESOLVED']}")
print(f" handeye : {flat['SITE_HANDEYE_FILE']}")
print(f" handeye wp : {flat['SITE_HANDEYE_WAYPOINTS']}")
print(f" handeye dir : {flat['SITE_HANDEYE_WORKDIR']}")
print(f" named poses : {flat.get('SITE_NAMED_POSES_FILE')}")
print(
" board : "
f"{flat['SITE_HANDEYE_BOARD_COLS']}x{flat['SITE_HANDEYE_BOARD_ROWS']} "
f"inner, square={flat['SITE_HANDEYE_SQUARE_M']} m"
)
print(
f" camera : serial={flat['SITE_CAM_SERIAL'] or '(empty)'} "
f"{flat['SITE_CAM_WIDTH']}x{flat['SITE_CAM_HEIGHT']}@{flat['SITE_CAM_FPS']} "
f"ae={flat.get('SITE_CAM_AUTO_EXPOSURE')} "
f"exp={flat.get('SITE_CAM_EXPOSURE_US')}us "
f"gain={flat.get('SITE_CAM_GAIN')} "
f"ae_limit={flat.get('SITE_CAM_AE_LIMIT_US')}us emitter={flat.get('SITE_CAM_EMITTER')}"
)
print(
f" catalog cam : leftD405={flat.get('SITE_D405_LEFT_SERIAL')} "
f"rightD405={flat.get('SITE_D405_RIGHT_SERIAL')} "
f"D435={flat.get('SITE_D435_SERIAL')}"
)
print(
f" gripper : {flat.get('SITE_GRIP_MODEL')} port={flat.get('SITE_GRIP_PORT')}"
)
print(
f" hole_detect : backend={flat.get('SITE_HOLE_BACKEND')} "
f"weights={flat.get('SITE_HOLE_WEIGHTS')}"
)
print(
f" tactile : backend={flat.get('SITE_TACTILE_BACKEND')} "
f"L={flat.get('SITE_TACTILE_LEFT')} R={flat.get('SITE_TACTILE_RIGHT')} "
f"cfg={flat.get('SITE_TACTILE_CONFIG')}"
)
print(f" go_home : pose={flat['SITE_GO_HOME_POSE']} speed={flat['SITE_GO_HOME_SPEED']}")
he_path = Path(flat["SITE_HANDEYE_FILE"]).expanduser()
if not he_path.is_file():
print(f" WARNING: hand-eye file missing: {he_path}", file=sys.stderr)
else:
try:
he_data = yaml.safe_load(he_path.read_text(encoding="utf-8")) or {}
except Exception:
he_data = {}
if isinstance(he_data, dict) and (
he_data.get("uncalibrated") or "T_4x4" not in he_data
):
print(
f" WARNING: {he_path.name} is not calibrated for this arm "
f"(run_handeye.sh teach/calibrate --arm {flat.get('SITE_ROBOT_ARM') or '91'})",
file=sys.stderr,
)
if "063991" in flat["SITE_ROBOT_SN"] and flat.get("SITE_CAM_SERIAL") == flat.get("SITE_D405_LEFT_SERIAL"):
print(
" WARNING: 91 is using the LEFT wrist D405 serial — check robots[].camera.serial",
file=sys.stderr,
)
if "RGI" in str(flat.get("SITE_GRIP_MODEL") or ""):
print(
" NOTE: 本臂是 RGI。CLI: bash ~/ros2/scripts/run_gripper.sh --arm 91 --cli open|rotate",
file=sys.stderr,
)
w = Path(flat["SITE_YOLO_WEIGHTS"]).expanduser()
if not w.is_file():
print(f" WARNING: weights file missing: {w}", file=sys.stderr)
return 2
return 0
# bash exports
for k, v in flat.items():
# escape for eval
vv = str(v).replace("'", "'\"'\"'")
print(f"export {k}='{vv}'")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
# Load ~/ros2/config/site.yaml into SITE_* environment variables.
# Usage (from other scripts):
# source "${ROOT}/scripts/lib/load_site_config.sh"
_SITE_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
_SITE_EXPORTER="${_SITE_ROOT}/scripts/lib/export_site_env.py"
_SITE_FILE="${SITE_CONFIG:-${_SITE_ROOT}/config/site.yaml}"
if [[ ! -f "${_SITE_FILE}" ]]; then
echo "[site] WARN: missing ${_SITE_FILE}" >&2
else
# shellcheck disable=SC1090
eval "$(python3 "${_SITE_EXPORTER}" --config "${_SITE_FILE}")"
export SITE_CONFIG="${_SITE_FILE}"
# Resolve 89/91/left/right aliases to the full Flexiv SN for RDK.
if [[ -n "${SITE_ROBOT_SN:-}" ]]; then
export ROBOT_SN="${SITE_ROBOT_SN}"
fi
fi
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
# Parse --sn / --arm from argv. Must be sourced BEFORE load_site_config.sh
# so ROBOT_SN is visible to export_site_env.py.
#
# source "${ROOT}/scripts/lib/parse_arm_args.sh"
# parse_arm_args "$@"
# set -- "${PARSE_ARM_REST[@]}"
#
# Aliases (89|91|left|right) are resolved in export_site_env.py.
parse_arm_args() {
PARSE_ARM_REST=()
while [[ $# -gt 0 ]]; do
case "$1" in
--sn|--arm)
export ROBOT_SN="${2:?}"
export ROBOT_ARM="${2}"
shift 2
;;
*)
PARSE_ARM_REST+=("$1")
shift
;;
esac
done
}
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
# List plugged RealSense cameras (serials used in site.yaml).
set -eo pipefail
python3 - <<'PY'
import pyrealsense2 as rs
known = {
"352122274408": "left wrist D405 (arm 89, parallel gripper)",
"260522274442": "right wrist D405 (arm 91, rotary gripper)",
"262522073161": "overhead D435 (between arms, eye-to-hand)",
}
ds = list(rs.context().devices)
print(f"n={len(ds)}")
for d in ds:
sn = d.get_info(rs.camera_info.serial_number)
name = d.get_info(rs.camera_info.name)
usb = d.get_info(rs.camera_info.usb_type_descriptor)
fw = d.get_info(rs.camera_info.firmware_version)
role = known.get(sn, "(unassigned — add to site.yaml cameras:)")
print(f" {name:22} serial={sn} USB={usb} fw={fw}")
print(f" -> {role}")
PY
+143
View File
@@ -0,0 +1,143 @@
#!/usr/bin/env python3
"""Merge taught joint lines into handeye waypoints YAML.
Input (from handeye_teach / handeye_waypoints append file):
# pose 0
0.123456, -0.700000, ...
Target can be either:
- config/handeye/waypoints.yaml (/**: ros__parameters)
- handeye_calibrate.yaml (handeye_calibrate: ros__parameters)
Usage:
python3 scripts/merge_handeye_poses.py \\
~/ros2/config/handeye/taught_poses.txt \\
~/ros2/config/handeye/waypoints.yaml
# or via wrapper:
bash ~/ros2/scripts/run_handeye.sh merge
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
def parse_poses(path: Path) -> list[list[float]]:
lines = path.read_text(encoding="utf-8").splitlines()
poses: list[list[float]] = []
buf: list[float] = []
num_re = re.compile(r"[-+]?(?:\d+\.\d*|\.\d+|\d+)(?:[eE][-+]?\d+)?")
for raw in lines:
line = raw.split("#", 1)[0].strip()
if not line:
if buf:
if len(buf) != 7:
raise ValueError(f"Expected 7 joints, got {len(buf)}: {buf}")
poses.append(buf)
buf = []
continue
vals = [float(x) for x in num_re.findall(line)]
if not vals:
continue
if len(vals) == 7:
poses.append(vals)
elif len(vals) < 7:
buf.extend(vals)
if len(buf) > 7:
raise ValueError(f"Too many joint values in line: {line!r}")
else:
poses.append(vals[:7])
if buf:
if len(buf) != 7:
raise ValueError(f"Expected 7 joints at EOF, got {len(buf)}")
poses.append(buf)
return poses
def format_positions_block(poses: list[list[float]], indent: str = " ") -> str:
rows = [f"{indent}waypoint_positions: ["]
for i, q in enumerate(poses):
rows.append(f"{indent} # pose {i}")
s = ", ".join(f"{v:.6f}" for v in q) + ","
rows.append(f"{indent} {s}")
rows.append(f"{indent}]")
return "\n".join(rows)
def merge(yaml_path: Path, poses: list[list[float]]) -> None:
text = yaml_path.read_text(encoding="utf-8").replace("\r\n", "\n").replace("\r", "\n")
# Detect indent of waypoint_positions (2 or 4 spaces typical)
m_ind = re.search(r"^([ \t]*)waypoint_positions:\s*\[", text, re.MULTILINE)
indent = m_ind.group(1) if m_ind else " "
block = format_positions_block(poses, indent=indent)
pattern = re.compile(
rf"{re.escape(indent)}waypoint_positions:\s*\[[\s\S]*?{re.escape(indent)}\]",
re.MULTILINE,
)
# Also match a one-line empty list: waypoint_positions: []
one_line = re.compile(
rf"{re.escape(indent)}waypoint_positions:\s*\[\s*\]",
re.MULTILINE,
)
if pattern.search(text):
new_text = pattern.sub(block, text, count=1)
elif one_line.search(text):
new_text = one_line.sub(block, text, count=1)
else:
raise RuntimeError("waypoint_positions block not found in YAML")
# Keep num_poses in sync when present
n = len(poses)
if re.search(r"^\s*num_poses:\s*\d+", new_text, re.MULTILINE):
new_text = re.sub(
r"^(\s*num_poses:\s*)\d+",
rf"\g<1>{n}",
new_text,
count=1,
flags=re.MULTILINE,
)
else:
# Insert num_poses just before waypoint_positions
new_text = new_text.replace(
block,
f"{indent}num_poses: {n}\n{block}",
1,
)
yaml_path.write_text(new_text, encoding="utf-8")
def main(argv: list[str]) -> int:
if len(argv) != 3:
print(
"Usage: merge_handeye_poses.py <taught_poses.txt> <waypoints.yaml>",
file=sys.stderr,
)
return 1
src = Path(argv[1]).expanduser()
dst = Path(argv[2]).expanduser()
if not src.is_file():
print(f"Not found: {src}", file=sys.stderr)
return 2
if not dst.is_file():
print(f"Not found: {dst}", file=sys.stderr)
return 3
poses = parse_poses(src)
if len(poses) < 5:
print(f"Need >=5 poses, got {len(poses)}", file=sys.stderr)
return 4
merge(dst, poses)
print(f"Merged {len(poses)} poses -> {dst}")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv))
+329
View File
@@ -0,0 +1,329 @@
#!/usr/bin/env python3
"""RDK Free-Drive bridge (must run under rdk_py310 / Python 3.10).
Why soft-impedance drag
-----------------------
Under AUTO (Remote) the Studio Free Drive button is unavailable (remote owns
the robot). RDK FloatingJoint/FloatingCartesian often need an extra license.
So we default to soft NRT_JOINT_IMPEDANCE: low stiffness + target tracks
measured joints → arm can be pushed by hand while Remote stays on.
IMPORTANT: flexivrdk.Robot is NOT thread-safe. All robot API calls (including
the drag loop) share one lock. Joint reads during drag use a cache so Enter
does not race Stop()/SendJointPosition.
Line protocol (JSON on stdout only; RDK logs → stderr):
{"cmd":"ping"}
{"cmd":"connect","sn":"Rizon4-063989"}
{"cmd":"drag_on","mode":"soft"} # soft | floating
{"cmd":"drag_off"}
{"cmd":"joints"}
{"cmd":"quit"}
"""
from __future__ import annotations
import json
import sys
import threading
import time
from typing import List, Optional
_JSON_OUT = sys.stdout
sys.stdout = sys.stderr
def reply(obj: dict) -> None:
_JSON_OUT.write(json.dumps(obj, ensure_ascii=False) + '\n')
_JSON_OUT.flush()
def log(msg: str) -> None:
print(f'[rdk_bridge] {msg}', file=sys.stderr, flush=True)
class SoftDrag:
def __init__(self, robot, flexivrdk, lock: threading.Lock) -> None:
self.robot = robot
self.flexivrdk = flexivrdk
self._lock = lock
self._stop = threading.Event()
self._thread: Optional[threading.Thread] = None
self.latest_q: List[float] = []
self._groups = {}
def start(self) -> None:
mode = self.flexivrdk.Mode
robot = self.robot
with self._lock:
robot.SwitchMode(mode.NRT_JOINT_IMPEDANCE)
st = robot.states()
self.latest_q = [float(v) for v in list(st.q)[:7]]
try:
info = robot.info()
self._groups = dict(getattr(info, 'single_arm_groups', None) or {})
except Exception as exc:
log(f'info.single_arm_groups: {exc}')
self._groups = {}
if self._groups:
for g in self._groups:
try:
nom = list(info.K_q_nom[g])
soft = [max(8.0, 0.08 * float(v)) for v in nom]
robot.SetJointImpedance(g, soft)
log(f'SetJointImpedance {g} K={soft}')
except Exception as exc:
log(f'SetJointImpedance group {g}: {exc}')
else:
n = max(7, len(self.latest_q))
try:
robot.SetJointImpedance([35.0] * n, [0.7] * n)
except Exception as exc:
log(f'SetJointImpedance flat: {exc}')
self._stop.clear()
self._thread = threading.Thread(target=self._loop, name='soft-drag', daemon=True)
self._thread.start()
def _loop(self) -> None:
robot = self.robot
flexivrdk = self.flexivrdk
while not self._stop.is_set():
try:
with self._lock:
if self._stop.is_set():
break
st = robot.states()
q = [float(v) for v in list(st.q)[:7]]
self.latest_q = q
if self._groups:
cmds = {}
for g in self._groups:
try:
gq = list(st[g].q)
except Exception:
gq = q
n = len(gq)
cmds[g] = flexivrdk.NrtJointPositionCmd(
gq, [0.0] * n, [1.5] * n, [2.0] * n
)
robot.SendJointPosition(cmds)
else:
n = len(q)
robot.SendJointPosition(q, [0.0] * n, [1.5] * n, [2.0] * n)
except Exception as exc:
log(f'soft loop: {exc}')
time.sleep(0.15)
# ~20 Hz — keep lock released most of the time
for _ in range(10):
if self._stop.is_set():
return
time.sleep(0.005)
def stop(self) -> None:
self._stop.set()
th = self._thread
if th is not None:
th.join(timeout=4.0)
if th.is_alive():
log('soft loop still alive after join (will idle anyway)')
self._thread = None
def _ensure_operational(robot) -> Optional[str]:
try:
if robot.fault():
robot.ClearFault()
time.sleep(0.5)
if not robot.operational():
for fn in ('ServoOn', 'Enable'):
if hasattr(robot, fn):
try:
getattr(robot, fn)()
break
except Exception:
pass
t0 = time.monotonic()
while not robot.operational() and time.monotonic() - t0 < 10:
time.sleep(0.2)
if not robot.operational():
return (
'Robot not operational (fault/E-Stop?). '
'Clear fault on pendant, release E-Stop, AUTO Remote.'
)
except Exception as exc:
return str(exc)
return None
def _idle_async(robot, lock: threading.Lock, flexivrdk, timeout: float = 4.0) -> bool:
"""Switch to IDLE without blocking the stdin loop forever."""
done = threading.Event()
def run() -> None:
try:
acquired = lock.acquire(timeout=2.0)
try:
if acquired:
try:
robot.Stop()
except Exception as exc:
log(f'Stop: {exc}')
try:
robot.SwitchMode(flexivrdk.Mode.IDLE)
except Exception as exc:
log(f'SwitchMode IDLE: {exc}')
finally:
if acquired:
lock.release()
finally:
done.set()
threading.Thread(target=run, name='idle', daemon=True).start()
ok = done.wait(timeout)
if not ok:
log('IDLE/Stop timed out — stdin loop continues')
return ok
def main() -> int:
import flexivrdk
robot = None
drag_on = False
soft: Optional[SoftDrag] = None
rlock = threading.Lock()
cached_q: List[float] = []
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
msg = json.loads(line)
except Exception as exc: # noqa: BLE001
reply({'ok': False, 'error': f'bad json: {exc}'})
continue
cmd = str(msg.get('cmd', '')).strip()
try:
if cmd == 'ping':
reply({'ok': True, 'py': sys.version.split()[0]})
elif cmd == 'connect':
sn = str(msg.get('sn', 'Rizon4-063989'))
iface = str(msg.get('iface') or '')
robot = None
last_err = None
attempts = []
if iface:
attempts.extend([(sn, [iface]), (sn, [iface], True)])
attempts.append((sn,))
for args in attempts:
try:
robot = flexivrdk.Robot(*args)
log(f'connected {sn} args={args!r}')
last_err = None
break
except TypeError:
continue
except Exception as exc:
last_err = exc
log(f'connect {args!r}: {exc}')
robot = None
if robot is None:
reply({'ok': False, 'error': str(last_err or 'connect failed')})
continue
err = _ensure_operational(robot)
if err:
reply({'ok': False, 'error': err})
continue
with rlock:
q = [float(v) for v in list(robot.states().q)[:7]]
cached_q = q
reply({'ok': True, 'sn': sn, 'q': q})
elif cmd == 'drag_on':
if robot is None:
reply({'ok': False, 'error': 'not connected'})
continue
err = _ensure_operational(robot)
if err:
reply({'ok': False, 'error': err})
continue
prefer = str(msg.get('mode', 'soft')).strip().lower()
if prefer in ('floating', 'primitive', 'floatingjoint'):
name = str(msg.get('primitive', 'FloatingJoint')).replace('()', '')
try:
with rlock:
robot.SwitchMode(flexivrdk.Mode.NRT_PRIMITIVE_EXECUTION)
robot.ExecutePrimitive(name, {})
drag_on = True
reply({'ok': True, 'drag': True, 'mode': 'floating', 'primitive': name})
continue
except Exception as exc:
log(f'Floating failed, fallback soft: {exc}')
try:
if soft is not None:
soft.stop()
soft = SoftDrag(robot, flexivrdk, rlock)
soft.start()
if soft.latest_q:
cached_q = list(soft.latest_q)
drag_on = True
log('soft drag ON')
reply({
'ok': True,
'drag': True,
'mode': 'soft',
'hint': 'Push the arm gently; low joint impedance. Enter=photo (s=hold).',
})
except Exception as exc:
reply({'ok': False, 'error': f'soft drag failed: {exc}'})
elif cmd == 'drag_off':
log('drag_off requested')
if soft is not None:
if soft.latest_q:
cached_q = list(soft.latest_q)
soft.stop()
soft = None
if robot is not None:
_idle_async(robot, rlock, flexivrdk, timeout=4.0)
drag_on = False
log('drag_off done')
reply({'ok': True, 'drag': False, 'q': cached_q})
elif cmd == 'joints':
q: Optional[List[float]] = None
if soft is not None and len(soft.latest_q) >= 7:
q = list(soft.latest_q)
elif len(cached_q) >= 7:
q = list(cached_q)
if q is None or len(q) < 7:
reply({'ok': False, 'error': 'no joint cache'})
continue
cached_q = q
reply({'ok': True, 'q': q, 'drag': drag_on})
elif cmd == 'quit':
if soft is not None:
soft.stop()
soft = None
if robot is not None and drag_on:
_idle_async(robot, rlock, flexivrdk, timeout=3.0)
reply({'ok': True})
return 0
else:
reply({'ok': False, 'error': f'unknown cmd {cmd}'})
except Exception as exc: # noqa: BLE001
log(f'cmd={cmd} error: {exc}')
reply({'ok': False, 'error': str(exc)})
return 0
if __name__ == '__main__':
raise SystemExit(main())
+207
View File
@@ -0,0 +1,207 @@
#!/usr/bin/env python3
"""Control Studio device GripperDahuan / GripperDahuanModbus via Flexiv RDK.
Must run under RDK Python (rdk_py310). Uses a *lite* RDK instance so it can
share the robot with run_real.sh (ros2_control holds the normal RDK).
The controller talks RS485 itself (its own /dev/ttyUSB0), not the IPC serial.
/home/cc/rdk_py310/bin/python ~/ros2/scripts/rdk_gripper.py --list
/home/cc/rdk_py310/bin/python ~/ros2/scripts/rdk_gripper.py status
/home/cc/rdk_py310/bin/python ~/ros2/scripts/rdk_gripper.py init
/home/cc/rdk_py310/bin/python ~/ros2/scripts/rdk_gripper.py open
/home/cc/rdk_py310/bin/python ~/ros2/scripts/rdk_gripper.py close
"""
from __future__ import annotations
import argparse
import sys
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "scripts"))
from rdk_switch_tool import _iface_ipv4s # noqa: E402
def connect_lite(sn: str, iface: str = ""):
import flexivrdk
ips = _iface_ipv4s(iface)
if ips:
try:
return flexivrdk.Robot(sn, ips, True, True)
except TypeError:
pass
except RuntimeError as exc:
msg = str(exc).lower()
if "whitelist" not in msg and "filtered out" not in msg:
raise
return flexivrdk.Robot(sn, [], True, True)
def _fmt_params(p) -> str:
return (
f"width=[{p.min_width:.4f},{p.max_width:.4f}] m "
f"vel=[{p.min_vel:.4f},{p.max_vel:.4f}] m/s "
f"force=[{p.min_force:.1f},{p.max_force:.1f}] N"
)
def _fmt_states(s) -> str:
return f"width={s.width:.4f} m force={s.force:.2f} N moving={s.is_moving}"
def _clamp(val: float, lo: float, hi: float) -> float:
if lo > hi:
lo, hi = hi, lo
return max(lo, min(hi, val))
def _move(grip, params, width: float, vel: float, force: float) -> None:
vmin = float(params.min_vel)
vmax = float(params.max_vel)
if vmax <= 0:
vmax = 0.05
# Move velocity cannot be 0 (RDK).
vel = _clamp(vel, max(vmin, 1e-4), vmax)
force = _clamp(force, float(params.min_force), float(params.max_force))
width = _clamp(width, float(params.min_width), float(params.max_width))
print(f"Move width={width:.4f} m vel={vel:.4f} m/s force={force:.1f} N")
grip.Move(width, vel, force)
def main() -> int:
ap = argparse.ArgumentParser(description="Flexiv RDK gripper (GripperDahuanModbus)")
ap.add_argument("--sn", default="")
ap.add_argument("--iface", default="")
ap.add_argument("--device", default="", help="Studio Device name")
ap.add_argument("--force-n", type=float, default=0.0, help="force limit [N]; 0=mid")
ap.add_argument("--vel", type=float, default=0.0, help="speed [m/s]; 0=half max")
ap.add_argument(
"cmd",
nargs="?",
default="status",
choices=["list", "status", "init", "open", "close", "pos", "enable"],
)
ap.add_argument("value", nargs="?", default="", help="pos: meters or 0–1000")
args = ap.parse_args()
sys.path.insert(0, str(ROOT / "scripts" / "lib"))
from export_site_env import load_site, resolve_robot # type: ignore
data = load_site()
robot_cfg = resolve_robot(data)
g = data.get("gripper") or {}
sn = (args.sn or robot_cfg.get("sn") or "").strip()
iface = (args.iface or robot_cfg.get("iface") or "").strip()
device = (args.device or g.get("device_name") or "GripperDahuanModbus").strip()
if not sn:
print("ERROR: robot SN missing", file=sys.stderr)
return 2
import flexivrdk
print(f"=== RDK lite gripper SN={sn} device={device} iface={iface or 'auto'} ===")
robot = connect_lite(sn, iface)
dev = flexivrdk.Device(robot)
names = list(dev.list() or [])
if args.cmd == "list":
print("Studio devices:")
for n in names:
try:
en = dev.enabled(n)
cn = dev.connected(n)
except Exception:
en, cn = "?", "?"
mark = " <--" if n == device else ""
print(f" {n}: enabled={en} connected={cn}{mark}")
return 0
if device not in names:
print(
f"ERROR: device {device!r} not in Studio pool.\n available: {', '.join(names)}",
file=sys.stderr,
)
return 1
print(
f" Studio: enabled={dev.enabled(device)} connected={dev.connected(device)}"
)
if not dev.connected(device):
print(
"WARN: connected=False — 控制器自己的 /dev/ttyUSB0 没接到爪。\n"
" USB–RS485 要插 Flexiv 机箱 USB(不是工控机),A/B + 24V 到 PGEA。\n"
" 或用法兰工具口 485(腕部白绿+/绿-)。"
)
grip = flexivrdk.Gripper(robot)
if args.cmd in ("enable", "status", "init", "open", "close", "pos"):
try:
grip.Enable(device)
print(f"Enable [{device}] OK")
except Exception as exc:
print(f"ERROR: Enable failed: {exc}", file=sys.stderr)
return 1
params = grip.params()
print(" params:", _fmt_params(params))
print(" states:", _fmt_states(grip.states()))
force = float(args.force_n or g.get("force_n") or 0.0)
if force <= 0:
force = 0.5 * (float(params.min_force) + float(params.max_force))
vel = float(args.vel or g.get("vel_mps") or 0.0)
if vel <= 0:
vel = 0.5 * float(params.max_vel)
if args.cmd in ("enable", "status"):
if not dev.connected(device):
return 2
return 0
if args.cmd == "init":
print("Init (fingers home, ~10 s) …")
grip.Init()
time.sleep(2.0)
print(" states:", _fmt_states(grip.states()))
return 0
if args.cmd == "open":
_move(grip, params, float(params.max_width), vel, force)
time.sleep(0.4)
print(" states:", _fmt_states(grip.states()))
return 0
if args.cmd == "close":
_move(grip, params, float(params.min_width), vel, force)
time.sleep(0.4)
print(" states:", _fmt_states(grip.states()))
return 0
if args.cmd == "pos":
if args.value == "":
print("usage: pos <meters|0-1000>", file=sys.stderr)
return 2
raw = float(args.value)
if raw > 1.5:
span = float(params.max_width) - float(params.min_width)
width = float(params.min_width) + (raw / 1000.0) * span
else:
width = raw
_move(grip, params, width, vel, force)
time.sleep(0.4)
print(" states:", _fmt_states(grip.states()))
return 0
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except Exception as exc:
print(f"ERROR: {exc}", file=sys.stderr)
raise SystemExit(1)
+292
View File
@@ -0,0 +1,292 @@
#!/usr/bin/env python3
"""Switch the Flexiv active tool (TCP + payload) to a Studio-configured name.
Must run under RDK Python (rdk_py310). Robot must be IDLE; stop run_real.sh first
if ros2_control already owns the RDK connection.
/home/cc/rdk_py310/bin/python ~/ros2/scripts/rdk_switch_tool.py --list
/home/cc/rdk_py310/bin/python ~/ros2/scripts/rdk_switch_tool.py --name Flange
/home/cc/rdk_py310/bin/python ~/ros2/scripts/rdk_switch_tool.py # site.yaml robot.tool_name
"""
from __future__ import annotations
import argparse
import sys
import time
def _single_arm_groups(robot):
"""RDK 1.9 Tool API is single-arm (no JointGroup). Older builds use groups."""
info = robot.info()
groups = getattr(info, "single_arm_groups", None) or {}
if isinstance(groups, dict):
keys = list(groups.keys())
else:
keys = list(groups)
if keys:
return keys
try:
import flexivrdk
if hasattr(flexivrdk, "JointGroup"):
return [flexivrdk.JointGroup.ARM_1]
except Exception:
pass
return [None] # sentinel: call Tool.name()/Switch() without a group
def _tool_name(tool, group):
if group is None:
return tool.name()
return tool.name(group)
def _tool_params(tool, group):
if group is None:
return tool.params()
return tool.params(group)
def _tool_switch(tool, group, target: str) -> None:
if group is None:
tool.Switch(target)
return
tool.Switch(group, target)
def _group_name(group) -> str:
if group is None:
return "arm"
try:
import flexivrdk
return str(flexivrdk.kJointGroupNames[group])
except Exception:
return str(group)
def _fmt_params(params) -> str:
mass = getattr(params, "mass", None)
com = list(getattr(params, "CoM", []) or [])
tcp = list(getattr(params, "tcp_location", []) or [])
com_s = ",".join(f"{float(v):.4f}" for v in com[:3]) if com else "?"
tcp_s = ",".join(f"{float(v):.4f}" for v in tcp[:3]) if tcp else "?"
return f"mass={mass} kg CoM=[{com_s}] m TCP_xyz=[{tcp_s}] m"
def _ensure_idle(robot, mode) -> None:
try:
robot.SwitchMode(mode.IDLE)
return
except Exception:
pass
if hasattr(robot, "Enable"):
robot.Enable()
else:
robot.ServoOn()
for _ in range(40):
if robot.operational():
break
time.sleep(0.25)
if not robot.operational():
raise RuntimeError(
"robot not operational — AUTO Remote + Enable, release E-stop"
)
robot.SwitchMode(mode.IDLE)
def _resolve_name(tool, want: str) -> str:
want = (want or "").strip()
if not want:
return ""
names = list(tool.list() or [])
if want in names:
return want
lower = {n.lower(): n for n in names}
hit = lower.get(want.lower())
if hit:
return hit
raise SystemExit(
"tool %r not found in Studio pool.\n available: %s"
% (want, ", ".join(names) or "(empty)")
)
def _iface_ipv4s(name: str) -> list:
"""FastDDS whitelist wants IPv4 addresses, not NIC names like enp4s0."""
import socket
import fcntl
import struct
name = (name or "").strip()
if not name:
return []
if name.count(".") == 3:
return [name]
addrs = []
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
ifreq = struct.pack("256s", name.encode("utf-8")[:15])
addrs.append(socket.inet_ntoa(fcntl.ioctl(s.fileno(), 0x8915, ifreq)[20:24]))
s.close()
except OSError:
pass
try:
import subprocess
out = subprocess.check_output(
["ip", "-4", "-o", "addr", "show", "dev", name],
text=True,
stderr=subprocess.DEVNULL,
)
for line in out.splitlines():
parts = line.split()
if "inet" in parts:
cidr = parts[parts.index("inet") + 1]
addrs.append(cidr.split("/")[0])
except Exception:
pass
# unique, keep order
seen = set()
uniq = []
for a in addrs:
if a and a not in seen:
seen.add(a)
uniq.append(a)
return uniq
def _rdk_whitelist(iface: str) -> list[list[str]]:
"""RDK 手册要的是「连机器人的那张网卡」。enp4s0 上还有办公网 IP,不能整张卡的地址都塞进白名单。"""
import os
lists: list[list[str]] = []
pc = (os.environ.get("SITE_PC_IP") or "").strip()
if pc:
lists.append([pc])
if iface:
lists.append([iface])
robot_lan = [a for a in _iface_ipv4s(iface) if a.startswith("192.168.2.")]
if robot_lan:
lists.append(robot_lan)
# unique
seen: set[tuple[str, ...]] = set()
out: list[list[str]] = []
for item in lists:
key = tuple(item)
if key not in seen:
seen.add(key)
out.append(item)
return out
def connect_rdk(sn: str, iface: str = ""):
import flexivrdk
for listen in _rdk_whitelist(iface):
print("RDK whitelist:", ", ".join(listen))
for args in ((sn, listen), (sn, listen, True), (sn, listen, True, False)):
try:
return flexivrdk.Robot(*args)
except TypeError:
continue
except RuntimeError as exc:
msg = str(exc)
if "whitelist" in msg.lower() or "filtered out" in msg.lower():
print(" whitelist rejected, try next")
break
if "Failed to connect" in msg or "failed to connect" in msg.lower():
print(" search timeout with this whitelist")
break
raise
print("retry without iface filter")
return flexivrdk.Robot(sn)
def main() -> int:
ap = argparse.ArgumentParser(description="Switch Flexiv Studio tool / TCP")
ap.add_argument("--sn", default="", help="robot serial (default: site.yaml)")
ap.add_argument("--iface", default="", help="NIC whitelist for RDK (e.g. enp4s0)")
ap.add_argument(
"--name",
default="",
help="Studio tool name (Flange = no tool). Empty = site.yaml robot.tool_name",
)
ap.add_argument("--list", action="store_true", help="list tools and exit")
args = ap.parse_args()
sn = (args.sn or "").strip()
want = (args.name or "").strip()
if not sn:
print("ERROR: --sn is required (run via run_real.sh / rdk_switch_tool.sh)", file=sys.stderr)
return 2
import flexivrdk
mode = flexivrdk.Mode
iface = (args.iface or "").strip()
print(f"=== RDK tool SN={sn} iface={iface or 'auto'} ===")
robot = None
try:
robot = connect_rdk(sn, iface)
if robot.fault():
print("fault present, ClearFault ...")
if not robot.ClearFault():
print("ERROR: fault cannot be cleared", file=sys.stderr)
return 1
tool = flexivrdk.Tool(robot)
names = list(tool.list() or [])
groups = _single_arm_groups(robot)
print("Studio tools:", ", ".join(names) or "(none)")
for g in groups:
cur = _tool_name(tool, g)
extra = ""
try:
extra = " " + _fmt_params(_tool_params(tool, g))
except Exception:
pass
print(f" active [{_group_name(g)}]: {cur}{extra}")
if args.list:
return 0
if not want:
print("tool_name empty — keep current (set robot.tool_name in site.yaml to switch)")
print("RDK OK")
return 0
target = _resolve_name(tool, want)
already = all(_tool_name(tool, g) == target for g in groups)
if already:
print(f"already on [{target}]")
print("RDK OK")
return 0
print(f"switching → [{target}] (requires IDLE)")
_ensure_idle(robot, mode)
for g in groups:
_tool_switch(tool, g, target)
for g in groups:
cur = _tool_name(tool, g)
extra = ""
try:
extra = " " + _fmt_params(_tool_params(tool, g))
except Exception:
pass
print(f" now [{_group_name(g)}]: {cur}{extra}")
if cur != target:
print(f"ERROR: switch failed, still {cur!r}", file=sys.stderr)
return 1
print("RDK OK")
return 0
except Exception as exc:
print(f"ERROR: {exc}", file=sys.stderr)
print(" Stop run_real.sh first (RDK is exclusive).", file=sys.stderr)
print(" Pendant: AUTO Remote + Enable Ethernet/RDK.", file=sys.stderr)
return 1
finally:
robot = None
if __name__ == "__main__":
raise SystemExit(main())
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# Switch / list Flexiv Studio tools via RDK (reads name from site.yaml).
#
# bash ~/ros2/scripts/rdk_switch_tool.sh # switch to robot.tool_name
# bash ~/ros2/scripts/rdk_switch_tool.sh --list # print Studio tool pool
# bash ~/ros2/scripts/rdk_switch_tool.sh --name Flange
#
# Stop run_real.sh first — RDK allows only one client.
set -eo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck disable=SC1091
source "${ROOT}/scripts/lib/load_site_config.sh"
RDK_PYTHON="${RDK_PYTHON:-/home/cc/rdk_py310/bin/python}"
SN="${ROBOT_SN:-${SITE_ROBOT_SN:-Rizon4-063989}}"
NAME="${SITE_ROBOT_TOOL_NAME:-}"
IFACE="${SITE_NET_IFACE:-}"
LIST=false
while [[ $# -gt 0 ]]; do
case "$1" in
--list|-l) LIST=true; shift ;;
--name) NAME="$2"; shift 2 ;;
--sn) SN="$2"; shift 2 ;;
--iface) IFACE="$2"; shift 2 ;;
-h|--help) sed -n '2,12p' "$0"; exit 0 ;;
*) echo "Unknown: $1" >&2; exit 1 ;;
esac
done
ARGS=(--sn "${SN}")
if [[ -n "${IFACE}" ]]; then
ARGS+=(--iface "${IFACE}")
fi
if [[ "${LIST}" == true ]]; then
ARGS+=(--list)
elif [[ -n "${NAME}" ]]; then
ARGS+=(--name "${NAME}")
fi
exec "${RDK_PYTHON}" "${ROOT}/scripts/rdk_switch_tool.py" "${ARGS[@]}"
+158
View File
@@ -0,0 +1,158 @@
#!/usr/bin/env bash
# Terminal: D405 via official pyrealsense2 SDK — color + depth + stereo IR topics.
# With --preview the Qt window runs in a SEPARATE viewer process (realsense_view)
# so painting never slows the camera stream. Closing the window ≠ stop camera.
# Do NOT start view_realsense_depth.sh at the same time (USB is exclusive).
#
# Usage:
# bash ~/ros2/scripts/run_camera_d405.sh
# bash ~/ros2/scripts/run_camera_d405.sh --preview
# bash ~/ros2/scripts/run_camera_d405.sh --arm 91 --preview
set -eo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PREVIEW=-1 # -1 = use site; 0/1 = force
HIGH_FPS=0
# shellcheck disable=SC1091
source "${ROOT}/scripts/lib/parse_arm_args.sh"
parse_arm_args "$@"
set -- "${PARSE_ARM_REST[@]}"
while [[ $# -gt 0 ]]; do
case "$1" in
--preview) PREVIEW=1; shift ;;
--no-preview) PREVIEW=0; shift ;;
--30fps) HIGH_FPS=1; shift ;;
-h|--help) sed -n '2,14p' "$0"; exit 0 ;;
*) echo "Unknown: $1" >&2; exit 1 ;;
esac
done
# shellcheck disable=SC1091
source "${ROOT}/scripts/env.sh"
# shellcheck disable=SC1091
source "${ROOT}/scripts/lib/load_site_config.sh"
pkill -f 'lib/arm_application/realsense_color_pub' 2>/dev/null || true
sleep 2
USB_INFO="$(python3 - <<PY 2>/dev/null || true
import pyrealsense2 as rs
want_sn = "${SITE_CAM_SERIAL:-}"
ds = list(rs.context().devices)
if not ds:
print("")
raise SystemExit
pick = None
for d in ds:
sn = d.get_info(rs.camera_info.serial_number)
if want_sn and sn == want_sn:
pick = d
break
if pick is None:
for d in ds:
name = (d.get_info(rs.camera_info.name) or "").lower()
if "405" in name:
pick = d
break
pick = pick or ds[0]
usb = pick.get_info(rs.camera_info.usb_type_descriptor)
sn = pick.get_info(rs.camera_info.serial_number)
name = pick.get_info(rs.camera_info.name)
print(f"{usb}|{sn}|{name}")
PY
)"
USB_TYPE="${USB_INFO%%|*}"
echo "=== RealSense USB=${USB_TYPE:-unknown} ${USB_INFO} ==="
echo "=== site: ${SITE_CONFIG:-?} weights=${SITE_YOLO_WEIGHTS:-?} ==="
if [[ "${USB_TYPE}" == 2* ]]; then
CAM_W=640; CAM_H=480; CAM_FPS=15
echo "=== camera 640x480@15 (USB2: depth+IR still ON) ==="
echo "=== WARN: D405 is on USB2. Plug it into a USB3 (blue) port for 848x480@30 ==="
elif [[ "${SITE_CAM_USE_SITE_RES:-1}" == "1" ]]; then
# site.yaml camera.width/height/fps 优先;--30fps 只抬帧率,不再写死 848x480
CAM_W="${SITE_CAM_WIDTH:-848}"
CAM_H="${SITE_CAM_HEIGHT:-480}"
CAM_FPS="${SITE_CAM_FPS:-30}"
if [[ "${HIGH_FPS}" -eq 1 && "${CAM_FPS}" -lt 30 ]]; then
CAM_FPS=30
fi
echo "=== camera ${CAM_W}x${CAM_H}@${CAM_FPS} serial=${SITE_CAM_SERIAL:-} arm=${SITE_ROBOT_SN:-} ==="
elif [[ "${HIGH_FPS}" -eq 1 ]]; then
CAM_W=848; CAM_H=480; CAM_FPS=30
echo "=== camera 848x480@30 (--30fps, site use_site_resolution=false) ==="
else
CAM_W=1280; CAM_H=720; CAM_FPS=15
echo "=== camera 1280x720@15 ==="
fi
CAM_NAME="${SITE_CAM_NAME:-d405}"
CAM_ARGS=(
--ros-args
-p camera_name:="${CAM_NAME}"
-p width:="${CAM_W}"
-p height:="${CAM_H}"
-p fps:="${CAM_FPS}"
)
if [[ -n "${SITE_CAM_SERIAL:-}" ]]; then
CAM_ARGS+=(-p camera_serial:="${SITE_CAM_SERIAL}")
fi
CAM_ARGS+=(
-p auto_exposure:="$([ "${SITE_CAM_AUTO_EXPOSURE:-1}" = "1" ] && echo true || echo false)"
-p ae_limit_us:="${SITE_CAM_AE_LIMIT_US:-4000}"
-p emitter_enabled:="$([ "${SITE_CAM_EMITTER:-0}" = "1" ] && echo true || echo false)"
-p laser_power:="${SITE_CAM_LASER_POWER:-0}"
)
if [[ -n "${SITE_CAM_EXPOSURE_US:-}" && "${SITE_CAM_EXPOSURE_US}" != "0" && "${SITE_CAM_EXPOSURE_US}" != "0.0" ]]; then
CAM_ARGS+=(-p exposure_us:="${SITE_CAM_EXPOSURE_US}")
fi
if [[ -n "${SITE_CAM_GAIN:-}" && "${SITE_CAM_GAIN}" != "0" && "${SITE_CAM_GAIN}" != "0.0" ]]; then
CAM_ARGS+=(-p gain:="${SITE_CAM_GAIN}")
fi
if [[ "${SITE_CAM_ENABLE_DEPTH:-1}" == "1" ]]; then
CAM_ARGS+=(-p enable_depth:=true)
echo "=== depth ON ==="
else
CAM_ARGS+=(-p enable_depth:=false)
echo "=== depth OFF (site.yaml) ==="
fi
DO_PREVIEW="${SITE_CAM_PREVIEW:-1}"
if [[ "${PREVIEW}" -ge 0 ]]; then
DO_PREVIEW="${PREVIEW}"
fi
if [[ "${SITE_CAM_ENABLE_INFRA:-1}" == "1" ]]; then
CAM_ARGS+=(-p enable_infra:=true)
else
CAM_ARGS+=(-p enable_infra:=false)
fi
if [[ "${SITE_CAM_PREVIEW_STEREO:-1}" == "1" ]]; then
CAM_ARGS+=(-p preview_stereo:=true)
else
CAM_ARGS+=(-p preview_stereo:=false)
fi
if [[ "${DO_PREVIEW}" == "1" ]]; then
echo "=== SDK preview ON (separate viewer process, hold ${SITE_CAM_OVERLAY_HOLD:-0.1}s) ==="
echo "=== close window = hide preview only; camera keeps publishing ==="
fi
echo "=== next: bash ~/ros2/scripts/run_visual_servo_rt.sh --arm ${SITE_ROBOT_ARM:-89} [--control] ==="
if [[ "${DO_PREVIEW}" == "1" ]]; then
# Camera publishes only; the Qt viewer runs in its own process so painting
# can never starve the capture loop (shared GIL cost ~70 ms/frame before).
pkill -f 'lib/arm_application/realsense_view' 2>/dev/null || true
ros2 run arm_application realsense_color_pub "${CAM_ARGS[@]}" -p show_preview:=false &
CAM_PID=$!
trap 'kill "${CAM_PID}" 2>/dev/null || true' EXIT INT TERM
ros2 run arm_application realsense_view --ros-args \
-p camera_name:="${CAM_NAME}" \
-p overlay_hold_sec:="${SITE_CAM_OVERLAY_HOLD:-0.1}" \
-p hud_label:="${SITE_ROBOT_LABEL:-} ${SITE_ROBOT_SN:-} ${SITE_CAM_SERIAL:-}"
else
exec ros2 run arm_application realsense_color_pub "${CAM_ARGS[@]}"
fi
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
# Overhead D435 between the two arms (eye-to-hand). Does not open either D405.
#
# bash ~/ros2/scripts/run_camera_d435.sh --preview
#
set -eo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PREVIEW=1
while [[ $# -gt 0 ]]; do
case "$1" in
--preview) PREVIEW=1; shift ;;
--no-preview) PREVIEW=0; shift ;;
-h|--help) sed -n '2,8p' "$0"; exit 0 ;;
*) echo "Unknown: $1" >&2; exit 1 ;;
esac
done
# shellcheck disable=SC1091
source "${ROOT}/scripts/env.sh"
# shellcheck disable=SC1091
source "${ROOT}/scripts/lib/load_site_config.sh"
SN="${SITE_D435_SERIAL:-}"
if [[ -z "${SN}" ]]; then
echo "ERROR: cameras.d435.serial empty in site.yaml" >&2
exit 2
fi
echo "=== D435 overhead serial=${SN} ${SITE_D435_WIDTH:-1280}x${SITE_D435_HEIGHT:-720}@${SITE_D435_FPS:-30} ==="
pkill -f 'camera_name:=d435' 2>/dev/null || true
CAM_ARGS=(
--ros-args
-p camera_name:=d435
-p camera_serial:="${SN}"
-p width:="${SITE_D435_WIDTH:-1280}"
-p height:="${SITE_D435_HEIGHT:-720}"
-p fps:="${SITE_D435_FPS:-30}"
-p enable_depth:=true
-p enable_infra:=false
-p preview_stereo:=false
-p auto_exposure:=true
-p ae_limit_us:=8000
-p emitter_enabled:=true
)
if [[ "${PREVIEW}" == "1" ]]; then
pkill -f 'lib/arm_application/realsense_view' 2>/dev/null || true
ros2 run arm_application realsense_color_pub "${CAM_ARGS[@]}" -p show_preview:=false &
CAM_PID=$!
trap 'kill "${CAM_PID}" 2>/dev/null || true' EXIT INT TERM
ros2 run arm_application realsense_view --ros-args -p camera_name:=d435
else
exec ros2 run arm_application realsense_color_pub "${CAM_ARGS[@]}"
fi
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
# View URDF only (joint_state_publisher_gui + RViz)
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck disable=SC1091
source "${ROOT}/install/setup.bash"
ros2 launch arm_description display.launch.py
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# Both DH grippers (left PGEA + right RGI) as one ROS 2 node.
# bash ~/ros2/scripts/run_dual_gripper.sh
# Topics: /dual_gripper_driver/left|right/{command,status}
# Services: /dual_gripper_driver/left|right/execute (arm_msgs/srv/GripperExecute)
set -eo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck disable=SC1091
source "${ROOT}/scripts/lib/parse_arm_args.sh"
parse_arm_args "$@"
set -- "${PARSE_ARM_REST[@]}"
# shellcheck disable=SC1091
source "${ROOT}/scripts/lib/load_site_config.sh"
# shellcheck disable=SC1091
source "${ROOT}/scripts/env.sh"
pkill -f 'arm_application/dual_gripper_node.py' 2>/dev/null || true
pkill -f 'lib/arm_application/dual_gripper' 2>/dev/null || true
sleep 0.3
echo "=== dual gripper PGEA ${SITE_GRIP_PORT:-/dev/ttyUSB0} + RGI (site.yaml robots[]) ==="
exec ros2 launch arm_application dual_gripper.launch.py sides:=left,right allow_offline:=true
+131
View File
@@ -0,0 +1,131 @@
#!/usr/bin/env bash
# DH grippers on the IPC (Modbus RTU). Not Flexiv Studio / RDK.
#
# Left 89 PGEA parallel /dev/ttyUSB0
# Right 91 RGI rotary /dev/ttyUSB1
#
# bash ~/ros2/scripts/run_gripper.sh --arm 89 --cli probe
# bash ~/ros2/scripts/run_gripper.sh --arm 89 --cli init
# bash ~/ros2/scripts/run_gripper.sh --arm 89 --cli open
# bash ~/ros2/scripts/run_gripper.sh --arm 89 --cli close
# bash ~/ros2/scripts/run_gripper.sh --arm 89 --cli pos 500
# bash ~/ros2/scripts/run_gripper.sh --arm 91 --cli open
# bash ~/ros2/scripts/run_gripper.sh --arm 91 --cli close
# bash ~/ros2/scripts/run_gripper.sh --arm 91 --cli rotate 90
# bash ~/ros2/scripts/run_gripper.sh --arm 91 --cli revolutions 2
# bash ~/ros2/scripts/run_gripper.sh --arm 89 # ROS node PGEA
# bash ~/ros2/scripts/run_gripper.sh --arm 91 # ROS node RGI
# bash ~/ros2/scripts/run_dual_gripper.sh # both ROS nodes
set -eo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CLI=()
DUAL=0
# shellcheck disable=SC1091
source "${ROOT}/scripts/lib/parse_arm_args.sh"
parse_arm_args "$@"
set -- "${PARSE_ARM_REST[@]}"
while [[ $# -gt 0 ]]; do
case "$1" in
--cli) shift; CLI=("$@"); break ;;
--dual) DUAL=1; shift ;;
-h|--help) sed -n '2,24p' "$0"; exit 0 ;;
*) echo "Unknown: $1" >&2; exit 1 ;;
esac
done
# shellcheck disable=SC1091
source "${ROOT}/scripts/lib/load_site_config.sh"
BACKEND="${SITE_GRIP_BACKEND:-usb}"
MODEL="${SITE_GRIP_MODEL:-PGEA-100-20-W}"
ARM="${SITE_ROBOT_ARM:-}"
IS_ROTARY=0
if [[ "${MODEL}" == *RGI* || "${ARM}" == "right" ]]; then
IS_ROTARY=1
fi
if [[ "${BACKEND}" == "rdk" || "${BACKEND}" == "flexiv" ]]; then
echo "ERROR: gripper.backend=${BACKEND} is Flexiv Studio. Use usb (IPC Modbus)." >&2
exit 1
fi
# shellcheck disable=SC1091
source "${ROOT}/scripts/env.sh"
if [[ ${#CLI[@]} -gt 0 ]]; then
if [[ "${IS_ROTARY}" -eq 1 ]]; then
CMD="${CLI[0]}"
REST=("${CLI[@]:1}")
ARGS=(--arm right --test "${CMD}")
case "${CMD}" in
probe|status) ARGS=(--arm right --test probe) ;;
pos|move)
ARGS=(--arm right --test pos)
if [[ -n "${REST[0]:-}" ]]; then ARGS+=(--pos "${REST[0]}"); fi
;;
rotate|rotate_to)
ARGS=(--arm right --test rotate)
if [[ -n "${REST[0]:-}" ]]; then ARGS+=(--angle "${REST[0]}"); fi
;;
revolutions)
ARGS=(--arm right --test rotate)
if [[ -n "${REST[0]:-}" ]]; then ARGS+=(--revolutions "${REST[0]}"); fi
;;
esac
echo "=== DH RGI SN=${SITE_GRIP_SN:-?} ${SITE_GRIP_PORT} arm=91 ==="
exec python3 -m arm_application.dual_gripper_cli "${ARGS[@]}"
fi
PORT="${SITE_GRIP_PORT:-/dev/ttyUSB0}"
BAUD="${SITE_GRIP_BAUD:-115200}"
SLAVE="${SITE_GRIP_SLAVE:-1}"
echo "=== DH ${MODEL} SN=${SITE_GRIP_SN:-?} ${PORT} @${BAUD} slave=${SLAVE} ==="
exec python3 "${ROOT}/src/arm_application/arm_application/dh_pgea.py" \
--port "${PORT}" --baud "${BAUD}" --slave "${SLAVE}" \
--force "${SITE_GRIP_FORCE:-50}" --speed "${SITE_GRIP_SPEED:-50}" \
--rts "${SITE_GRIP_RS485_RTS:-}" \
"${CLI[@]}"
fi
if [[ "${DUAL}" -eq 1 ]]; then
exec bash "${ROOT}/scripts/run_dual_gripper.sh"
fi
if [[ "${IS_ROTARY}" -eq 1 ]]; then
pkill -f 'arm_application/dual_gripper_node.py' 2>/dev/null || true
pkill -f 'lib/arm_application/dual_gripper' 2>/dev/null || true
sleep 0.3
echo "=== DH RGI ROS node ${SITE_GRIP_PORT:-/dev/ttyUSB1} (arm_msgs dual_gripper right) ==="
exec ros2 launch arm_application dual_gripper.launch.py sides:=right allow_offline:=true
fi
PORT="${SITE_GRIP_PORT:-/dev/ttyUSB0}"
BAUD="${SITE_GRIP_BAUD:-115200}"
SLAVE="${SITE_GRIP_SLAVE:-1}"
GSN="${SITE_GRIP_SN:-}"
PY="${ROOT}/src/arm_application/arm_application/dh_pgea.py"
NODE_PY="${ROOT}/src/arm_application/arm_application/dh_pgea_node.py"
if [[ ! -e "${PORT}" ]]; then
echo "ERROR: ${PORT} missing. Plug USB–RS485 into the IPC." >&2
exit 1
fi
pkill -f 'arm_application/dh_pgea_node.py' 2>/dev/null || true
pkill -f 'lib/arm_application/dh_pgea' 2>/dev/null || true
sleep 0.4
echo "=== DH ${MODEL} SN=${GSN:-?} ${PORT} @${BAUD} ROS node ==="
exec python3 "${NODE_PY}" --ros-args \
-p port:="${PORT}" \
-p baud:="${BAUD}" \
-p slave_id:="${SLAVE}" \
-p stroke_mm:="${SITE_GRIP_STROKE_MM:-20.0}" \
-p force_pct:="${SITE_GRIP_FORCE:-50}" \
-p speed_pct:="${SITE_GRIP_SPEED:-50}" \
-p auto_init:="${SITE_GRIP_AUTO_INIT:-true}" \
-p model:="${MODEL}" \
-p "serial_number:='${GSN}'" \
-p "rs485_rts:='${SITE_GRIP_RS485_RTS:-}'"
+254
View File
@@ -0,0 +1,254 @@
#!/usr/bin/env bash
# Hand-eye calibration helpers.
# Scripts: ~/ros2/scripts/run_handeye.sh
# Package YAML: ~/ros2/src/arm_application/config/handeye_*.yaml
# Site overrides (board / motion): ~/ros2/config/site.yaml → handeye:
# Runtime output: /tmp/handeye_calib/
#
# Usage:
# bash ~/ros2/scripts/run_handeye.sh calibrate
# bash ~/ros2/scripts/run_handeye.sh waypoints
# bash ~/ros2/scripts/run_handeye.sh teach # RDK 软拖拽(推荐)
# bash ~/ros2/scripts/run_handeye.sh teach --append # 接着上次点位继续采
# bash ~/ros2/scripts/run_handeye.sh teach --ros # 示教器 Free Drive
# bash ~/ros2/scripts/run_handeye.sh merge [taught.txt]
# bash ~/ros2/scripts/run_handeye.sh --arm 91 teach
# bash ~/ros2/scripts/run_handeye.sh --arm 91 calibrate
#
# calibrate / waypoints 需要:
# T1: bash ~/ros2/scripts/run_real.sh --arm 89|91
# T2: bash ~/ros2/scripts/run_camera_d405.sh --arm 89|91 --preview
# teach(拖拽)需要: 先停掉 run_real.sh 和相机(RDK + D405 都独占)
# 89 与 91 的结果文件、示教点、workdir 完全分开,禁止混用。
set -eo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck disable=SC1091
source "${ROOT}/scripts/lib/parse_arm_args.sh"
parse_arm_args "$@"
set -- "${PARSE_ARM_REST[@]}"
CMD="${1:-}"
shift || true
usage() {
sed -n '2,18p' "$0"
}
# shellcheck disable=SC1091
source "${ROOT}/scripts/env.sh"
# shellcheck disable=SC1091
source "${ROOT}/scripts/lib/load_site_config.sh"
# Prefer package source tree (editable); fall back to install share
APP_CFG="${ROOT}/src/arm_application/config"
if [[ ! -d "${APP_CFG}" ]]; then
APP_CFG="$(ros2 pkg prefix arm_application 2>/dev/null)/share/arm_application/config" || true
fi
WP="${SITE_HANDEYE_WAYPOINTS:-${APP_CFG}/handeye_poses.yaml}"
CAL="${SITE_HANDEYE_CALIBRATE:-${APP_CFG}/handeye_calibrate.yaml}"
TOUR="${SITE_HANDEYE_TOUR:-${APP_CFG}/handeye_waypoints.yaml}"
RES="${SITE_HANDEYE_FILE:-${APP_CFG}/handeye_result.yaml}"
WORKDIR="${SITE_HANDEYE_WORKDIR:-/tmp/handeye_calib}"
TAUGHT="${WORKDIR}/taught_poses.txt"
PREFIX="${SITE_JOINT_PREFIX:-Rizon4-063989_}"
CAM="${SITE_CAM_NAME:-d405}"
_kill_servo() {
pkill -f 'visual_servo.launch' 2>/dev/null || true
pkill -f 'lib/arm_application/servo_node' 2>/dev/null || true
pkill -f 'lib/arm_application/perception_node' 2>/dev/null || true
sleep 1
}
_need_files() {
local f
for f in "$@"; do
if [[ ! -f "$f" ]]; then
echo "ERROR: missing $f" >&2
exit 2
fi
done
}
_need_robot() {
if ! ros2 service list 2>/dev/null | grep -q '/set_named_pose'; then
echo "ERROR: start run_real.sh first" >&2
exit 1
fi
}
_need_camera() {
if ! ros2 topic list 2>/dev/null | grep -q "/${CAM}/color/image_raw"; then
echo "ERROR: no /${CAM}/color/image_raw — start run_camera_d405.sh" >&2
exit 1
fi
}
cmd_show() {
echo "=== hand-eye (package + site) ==="
echo " arm : ${SITE_ROBOT_SN:-} ${SITE_ROBOT_LABEL:-}"
echo " package cfg : ${APP_CFG}"
echo " calibrate : ${CAL}"
echo " poses : ${WP}"
echo " tour : ${TOUR}"
echo " result : ${RES}"
echo " workdir : ${WORKDIR}"
echo " board : ${SITE_HANDEYE_BOARD_COLS:-?}x${SITE_HANDEYE_BOARD_ROWS:-?} inner, square=${SITE_HANDEYE_SQUARE_M:-?} m"
python3 "${ROOT}/scripts/lib/export_site_env.py" --print || true
if [[ -f "${WP}" ]]; then
python3 - <<PY
import re
from pathlib import Path
t = Path("${WP}").read_text(encoding="utf-8")
m = re.search(r"num_poses:\s*(\d+)", t)
n = int(m.group(1)) if m else "?"
print(f" num_poses in poses yaml: {n}")
PY
fi
}
cmd_merge() {
local src="${1:-${TAUGHT}}"
_need_files "${src}" "${WP}"
python3 "${ROOT}/scripts/merge_handeye_poses.py" "${src}" "${WP}"
echo "=== next: bash ~/ros2/scripts/run_handeye.sh calibrate ==="
}
cmd_calibrate() {
_need_files "${CAL}" "${WP}"
_need_robot
_need_camera
echo "=== kill visual_servo (avoid traj fight) ==="
_kill_servo
mkdir -p "${WORKDIR}"
local cols="${SITE_HANDEYE_BOARD_COLS:-11}"
local rows="${SITE_HANDEYE_BOARD_ROWS:-16}"
local sq="${SITE_HANDEYE_SQUARE_M:-0.011}"
local vel="${SITE_HANDEYE_VEL_SCALE:-0.05}"
local mt="${SITE_HANDEYE_MOVE_T:-8.0}"
local st="${SITE_HANDEYE_SETTLE_SEC:-1.5}"
local qd="${SITE_HANDEYE_SETTLE_QD:-0.015}"
local ss="${SITE_HANDEYE_SETTLE_STABLE:-0.6}"
local sto="${SITE_HANDEYE_SETTLE_TO:-8.0}"
local ph="${SITE_HANDEYE_PIN_HOLDS:-2}"
local ps="${SITE_HANDEYE_PIN_SEC:-0.45}"
local skip="${SITE_HANDEYE_SKIP_UNSTABLE:-true}"
echo "=== calibrate mode=${SITE_HANDEYE_MODE:-eye_in_hand} ==="
echo " board ${cols}x${rows} inner, square=${sq} m (from site.yaml)"
echo " motion v=${vel} T≥${mt}s settle=${st}s qd≤${qd} pin=${ph}x${ps}s"
echo " poses=${WP}"
echo " result → ${RES}"
exec ros2 run arm_application handeye_calibrate --ros-args \
--params-file "${CAL}" \
--params-file "${WP}" \
-p joint_prefix:="${PREFIX}" \
-p camera_source:="${CAM}" \
-p output_dir:="${WORKDIR}" \
-p result_file:="${RES}" \
-p mode:="${SITE_HANDEYE_MODE:-eye_in_hand}" \
-p board_cols:="${cols}" \
-p board_rows:="${rows}" \
-p square_size_m:="${sq}" \
-p velocity_scale:="${vel}" \
-p move_time_sec:="${mt}" \
-p settle_sec:="${st}" \
-p settle_qd_max:="${qd}" \
-p settle_stable_sec:="${ss}" \
-p settle_timeout_sec:="${sto}" \
-p pin_holds:="${ph}" \
-p pin_hold_sec:="${ps}" \
-p skip_unstable_poses:="${skip}"
}
cmd_waypoints() {
_need_files "${TOUR}" "${WP}"
_need_robot
_need_camera
echo "=== kill visual_servo ==="
_kill_servo
mkdir -p "${WORKDIR}"
echo "=== waypoint tour (Enter=confirm, q=quit) ==="
echo " saves confirmed joints → ${TAUGHT}"
echo " then: bash ~/ros2/scripts/run_handeye.sh merge"
exec ros2 run arm_application handeye_waypoints --ros-args \
--params-file "${TOUR}" \
--params-file "${WP}" \
-p joint_prefix:="${PREFIX}" \
-p camera_source:="${CAM}" \
-p append_poses_file:="${TAUGHT}"
}
cmd_teach() {
local backend="rdk"
local append=false
while [[ $# -gt 0 ]]; do
case "$1" in
--rdk) backend="rdk"; shift ;;
--ros|--pendant) backend="ros"; shift ;;
--append|--keep) append=true; shift ;;
*) echo "Unknown teach arg: $1" >&2; exit 1 ;;
esac
done
echo "=== kill visual_servo + camera (D405 exclusive) ==="
_kill_servo
pkill -f 'run_camera_d405' 2>/dev/null || true
pkill -f 'realsense_color_pub' 2>/dev/null || true
pkill -f 'realsense2_camera_node' 2>/dev/null || true
sleep 1
if [[ "${backend}" == "rdk" ]]; then
if ros2 service list 2>/dev/null | grep -q '/set_named_pose'; then
echo "ERROR: RDK 拖拽与 run_real.sh 互斥。请先在那个终端 Ctrl-C 停掉 run_real.sh" >&2
exit 1
fi
else
_need_robot
fi
mkdir -p "${WORKDIR}/images"
if [[ "${append}" == true ]]; then
echo "=== append: keep existing ${TAUGHT} ==="
else
# 重新设点:丢掉上次 taught,避免 merge 混进旧位姿
: > "${TAUGHT}"
fi
echo "=== interactive teach backend=${backend} → ${WORKDIR} ==="
echo " 窗口按键: d=拖拽开 s=拖拽关 Enter=拍照记点 q=结束"
echo " 建议 8–12 个姿态,棋盘占画面约 30–70%"
echo " 结束后: bash ~/ros2/scripts/run_handeye.sh merge"
echo " bash ~/ros2/scripts/run_handeye.sh calibrate"
TEACH_ARGS=(
-p backend:="${backend}"
-p robot_sn:="${SITE_ROBOT_SN:-Rizon4-063989}"
-p joint_prefix:="${PREFIX}"
-p camera_source:="${CAM}"
-p output_dir:="${WORKDIR}"
)
if [[ -n "${SITE_CAM_SERIAL:-}" ]]; then
TEACH_ARGS+=(-p camera_serial:="${SITE_CAM_SERIAL}")
fi
if [[ -n "${SITE_CAM_WIDTH:-}" ]]; then
TEACH_ARGS+=(-p width:="${SITE_CAM_WIDTH}" -p height:="${SITE_CAM_HEIGHT:-480}" -p fps:="${SITE_CAM_FPS:-30}")
fi
TEACH_ARGS+=(
-p auto_exposure:="$([ "${SITE_CAM_AUTO_EXPOSURE:-1}" = "1" ] && echo true || echo false)"
-p ae_limit_us:="${SITE_CAM_AE_LIMIT_US:-1800}"
-p emitter_enabled:="$([ "${SITE_CAM_EMITTER:-0}" = "1" ] && echo true || echo false)"
-p exposure_us:="${SITE_CAM_EXPOSURE_US:-0}"
-p gain:="${SITE_CAM_GAIN:-0}"
)
exec ros2 run arm_application handeye_teach --ros-args "${TEACH_ARGS[@]}"
}
case "${CMD}" in
calibrate|calib) cmd_calibrate ;;
waypoints|tour) cmd_waypoints "$@" ;;
teach) cmd_teach "$@" ;;
merge) cmd_merge "$@" ;;
show) cmd_show ;;
-h|--help|help|"") usage; [[ -n "${CMD}" ]] || exit 0; exit 0 ;;
*) echo "Unknown: ${CMD}" >&2; usage >&2; exit 1 ;;
esac
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# Hole detection on the current-arm wrist D405. Detect only — no insert motion.
#
# bash ~/ros2/scripts/run_camera_d405.sh --arm 89 --preview
# bash ~/ros2/scripts/run_hole_detect.sh --arm 89 --preview
#
# backend from site.yaml hole_detect.backend:
# two_stage YOLO workpiece bbox + HoughCircles (camera_test hole_detect.py)
# yolo YOLO boxes treated as holes
# hough full-image HoughCircles
set -eo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PREVIEW=false
# shellcheck disable=SC1091
source "${ROOT}/scripts/lib/parse_arm_args.sh"
parse_arm_args "$@"
set -- "${PARSE_ARM_REST[@]}"
while [[ $# -gt 0 ]]; do
case "$1" in
--preview) PREVIEW=true; shift ;;
-h|--help) sed -n '2,16p' "$0"; exit 0 ;;
*) echo "Unknown: $1" >&2; exit 1 ;;
esac
done
# shellcheck disable=SC1091
source "${ROOT}/scripts/lib/load_site_config.sh"
# shellcheck disable=SC1091
source "${ROOT}/scripts/env.sh"
pkill -f 'arm_application/hole_detect_node.py' 2>/dev/null || true
pkill -f 'lib/arm_application/hole_detect' 2>/dev/null || true
sleep 0.2
BACKEND="${SITE_HOLE_BACKEND:-two_stage}"
echo "=== hole_detect arm=${SITE_ROBOT_ARM:-?} cam=${SITE_CAM_NAME} backend=${BACKEND} preview=${PREVIEW} ==="
exec ros2 launch arm_application hole_detect.launch.py \
backend:="${BACKEND}" \
preview:="${PREVIEW}"
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
source /home/cc/anaconda3/etc/profile.d/conda.sh
conda activate ros_flexiv
source "${ROOT}/install/setup.bash"
ros2 run arm_application demo_named_poses --ros-args \
-p "poses:=['home','ready','inspect','pos_left','home']" \
-p velocity_scale:=2.0 \
-p dwell_sec:=0.2 \
-p loops:=1
+3
View File
@@ -0,0 +1,3 @@
#!/usr/bin/env bash
# 三阶段串联的别名,等价于 run_all.sh。
exec "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/run_all.sh" "$@"
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env bash
# Real Rizon4 bringup — sets RDK runtime libs + launches stack backend:=real
# Note: no `set -u` — conda/RoboStack hooks reference unset vars (CONDA_BUILD, COLCON_TRACE)
#
# bash ~/ros2/scripts/run_real.sh
# bash ~/ros2/scripts/run_real.sh --arm 91
# bash ~/ros2/scripts/run_real.sh --sn Rizon4-063991 # 本次启动换臂(不改 yaml)
set -eo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
FLEXIV_PREFIX="${HOME}/flexiv_install"
LIST_ONLY=false
while [[ $# -gt 0 ]]; do
case "$1" in
--sn|--arm) export ROBOT_SN="${2:?}"; export ROBOT_ARM="${2}"; shift 2 ;;
--list|-l) LIST_ONLY=true; shift ;;
-h|--help)
sed -n '2,8p' "$0"
echo " Extra args are passed to ros2 launch (e.g. use_rviz:=false)."
exit 0
;;
*) break ;;
esac
done
source /home/cc/anaconda3/etc/profile.d/conda.sh
conda activate ros_flexiv
# shellcheck disable=SC1091
source "${ROOT}/install/setup.bash"
# shellcheck disable=SC1091
source "${ROOT}/scripts/lib/load_site_config.sh"
# flexiv_hardware must load spdlog/RBDyn from flexiv_install (not conda fmt v11)
export LD_LIBRARY_PATH="${FLEXIV_PREFIX}/lib:${LD_LIBRARY_PATH:-}"
# SITE_ROBOT_SN is the resolved Flexiv serial (89/91 aliases already mapped).
ROBOT_SN="${SITE_ROBOT_SN:-${ROBOT_SN:-Rizon4-063989}}"
ROBOT_IP="${ROBOT_IP:-${SITE_ROBOT_IP:-192.168.100.1}}"
NET_IFACE="${SITE_NET_IFACE:-}"
RDK_PYTHON="${RDK_PYTHON:-/home/cc/rdk_py310/bin/python}"
if [[ "${LIST_ONLY}" == true ]]; then
python3 "${ROOT}/scripts/lib/export_site_env.py" --print
exit 0
fi
echo "=== site: ${SITE_CONFIG:-n/a} SN=${ROBOT_SN} ${SITE_ROBOT_LABEL:-} arm=${SITE_ROBOT_ARM:-} ==="
echo "=== catalog: ${SITE_ROBOT_CATALOG:-} ==="
echo "=== handeye: ${SITE_HANDEYE_FILE:-} ==="
echo "=== camera serial: ${SITE_CAM_SERIAL:-} gripper: ${SITE_GRIP_MODEL:-} ${SITE_GRIP_PORT:-} ==="
echo "=== network iface=${NET_IFACE:-auto} robot=${ROBOT_IP} pc=${SITE_PC_IP:-} ==="
echo "=== stop leftover ros2_control (RDK exclusive) ==="
pkill -f '/controller_manager/ros2_control_node' 2>/dev/null || true
pkill -f 'arm_bringup.*stack.launch' 2>/dev/null || true
sleep 1
# 清掉把 91 的 .100 误绑到 89 MAC 上的陈旧 ARP
if [[ -n "${NET_IFACE}" ]]; then
ip neigh del "${ROBOT_IP}" dev "${NET_IFACE}" 2>/dev/null || true
fi
if ping -c 2 -W 1 "${ROBOT_IP}" >/dev/null 2>&1; then
echo "robot ${ROBOT_IP} ping OK"
else
echo "WARN: cannot ping ${ROBOT_IP} — 这一台不在网里,RDK 大概率会失败。"
echo " 本机: $(ip -4 -o addr show dev "${NET_IFACE:-enp4s0}" 2>/dev/null | awk '{print $4}' | tr '\n' ' ')"
echo " ARP ${ROBOT_IP}: $(ip neigh show "${ROBOT_IP}" 2>/dev/null | head -1 || echo none)"
MAC91="$(ip neigh show "${ROBOT_IP}" 2>/dev/null | awk '{print $5}' | head -1)"
MAC89="$(ip neigh show 192.168.2.101 2>/dev/null | awk '{print $5}' | head -1)"
if [[ -n "${MAC91}" && -n "${MAC89}" && "${MAC91}" == "${MAC89}" ]]; then
echo " ARP MAC 与 89(192.168.2.101) 相同 → .100 是误绑,91 并没有回包。"
fi
echo " 91 请接控制柜 User Port 1(出厂静态 192.168.2.100)到同一交换机,示教器 AUTO+Remote Ethernet。"
echo " 89=192.168.2.101 91=192.168.2.100"
fi
echo "=== RDK preflight + tool (${ROBOT_SN}) ==="
TOOL_NAME="${SITE_ROBOT_TOOL_NAME:-}"
RDK_TOOL_ARGS=(--sn "${ROBOT_SN}")
if [[ -n "${NET_IFACE}" ]]; then
RDK_TOOL_ARGS+=(--iface "${NET_IFACE}")
fi
if [[ -n "${TOOL_NAME}" ]]; then
RDK_TOOL_ARGS+=(--name "${TOOL_NAME}")
echo "=== Studio tool → ${TOOL_NAME} ==="
fi
if ! "${RDK_PYTHON}" "${ROOT}/scripts/rdk_switch_tool.py" "${RDK_TOOL_ARGS[@]}"; then
echo ""
echo "ERROR: RDK cannot connect / switch tool on ${ROBOT_SN}."
echo " 先确认 ping ${ROBOT_IP} 通(91=控制柜 User Port 1 → 192.168.2.100)。"
echo " Stop any other RDK client (this run_real.sh is exclusive)."
echo " On teach pendant: Settings → Remote → enable Ethernet/RDK remote mode."
echo " Test: bash ${ROOT}/scripts/test_rdk_python.sh ${ROBOT_SN}"
exit 1
fi
exec ros2 launch arm_bringup stack.launch.py backend:=real robot_sn:="${ROBOT_SN}" "$@"
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
# Quick-start: mock arm + RViz
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck disable=SC1091
source "${ROOT}/install/setup.bash"
ros2 launch arm_bringup sim.launch.py
+136
View File
@@ -0,0 +1,136 @@
#!/usr/bin/env bash
# 二阶段孔定位(一次性拍照,不做实时推理):垂直朝下 → 悬停锁高 → 拍照 →
# 解 k1/k2 孔基座坐标 → 夹爪/通止规轴线对准目标孔 → 锁定保持。
#
# 前置(各占一个终端):
# T1 bash ~/ros2/scripts/run_real.sh --arm 89 # 控制器(二阶段先只做左臂)
# T3 bash ~/ros2/scripts/run_camera_d405.sh --arm 89 --preview
# 本脚本会自动拉起触觉传感器服务(仅左臂 89 装了传感器)。
#
# 用法:
# bash ~/ros2/scripts/run_stage2_locate.sh --arm 89 # 完整定位+对准 k1
# bash ~/ros2/scripts/run_stage2_locate.sh --arm 89 --hole k2 # 对准 k2
# bash ~/ros2/scripts/run_stage2_locate.sh --arm 89 --hover-only # 只垂直+悬停锁定(调高度)
# bash ~/ros2/scripts/run_stage2_locate.sh --arm 89 --hover-z 0.28 # 临时改悬停高(不改 yaml)
# bash ~/ros2/scripts/run_stage2_locate.sh --arm 89 --no-tactile # 不起触觉服务
#
# 悬停锁高固定接口:config/site.yaml → robots[89].servo.hole_hover_z_m
set -eo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
HOVER_ONLY=false
HOLE=""
HOVER_Z=""
WITH_TACTILE=true
# shellcheck disable=SC1091
source "${ROOT}/scripts/lib/parse_arm_args.sh"
parse_arm_args "$@"
set -- "${PARSE_ARM_REST[@]}"
while [[ $# -gt 0 ]]; do
case "$1" in
--hover-only) HOVER_ONLY=true; shift ;;
--hole) HOLE="${2:?}"; shift 2 ;;
--hover-z) HOVER_Z="${2:?}"; shift 2 ;;
--no-tactile) WITH_TACTILE=false; shift ;;
-h|--help) sed -n '2,22p' "$0"; exit 0 ;;
*) echo "Unknown: $1" >&2; exit 1 ;;
esac
done
# shellcheck disable=SC1091
source "${ROOT}/scripts/env.sh"
# shellcheck disable=SC1091
source "${ROOT}/scripts/lib/load_site_config.sh"
if [[ -n "${SITE_FASTDDS_PROFILE:-}" && -f "${SITE_FASTDDS_PROFILE}" ]]; then
export FASTRTPS_DEFAULT_PROFILES_FILE="${SITE_FASTDDS_PROFILE}"
export RMW_IMPLEMENTATION="${RMW_IMPLEMENTATION:-rmw_fastrtps_cpp}"
fi
echo "=== stage2 locate arm=${SITE_ROBOT_ARM:-?} ${SITE_ROBOT_LABEL:-} (${SITE_ROBOT_SN:-?}) ==="
# ---- 1) 机械臂控制器必须在(和 run_visual_servo_rt.sh 同一套判活) ----
echo "=== waiting for rizon_arm_controller ACTIVE (T1: run_real.sh --arm ${SITE_ROBOT_ARM:-?}) ==="
ready=false
for _i in $(seq 1 30); do
if timeout 5 ros2 service call /controller_manager/list_controllers \
controller_manager_msgs/srv/ListControllers 2>/dev/null \
| grep -q "name='rizon_arm_controller', state='active'"; then
ready=true
break
fi
sleep 0.5
done
if [[ "${ready}" != true ]]; then
echo "ERROR: 机械臂控制器未激活。先跑: bash ~/ros2/scripts/run_real.sh --arm ${SITE_ROBOT_ARM:-89}" >&2
exit 1
fi
# ---- 2) 相机话题必须在发(二阶段拍照用;--hover-only 纯调高度,不需要相机) ----
CAM_NS="${SITE_CAM_NAME:-d405_left}"
IMG_TOPIC="/${CAM_NS}/color/image_raw"
if [[ "${HOVER_ONLY}" == true ]]; then
echo "=== hover-only:跳过相机/触觉检查,直接垂直+悬停 ==="
else
echo "=== check camera topic ${IMG_TOPIC} ==="
cam_ok=false
for _i in $(seq 1 24); do # 最多等 ~12s,相机可能正在起
# 以真实消息为准(topic info 的 Publisher count 依赖 DDS 发现,进程频繁重启后会假阴性)
if timeout 3 ros2 topic echo --once "${IMG_TOPIC}" 2>/dev/null | grep -q "^header:"; then
cam_ok=true
break
fi
sleep 0.5
done
if [[ "${cam_ok}" != true ]]; then
echo "ERROR: ${IMG_TOPIC} 没有发布者。先开相机:" >&2
echo " bash ~/ros2/scripts/run_camera_d405.sh --arm ${SITE_ROBOT_ARM:-89} --preview" >&2
exit 1
fi
echo "camera ${CAM_NS} publishing OK"
fi
# ---- 3) 触觉传感器服务(目前只有左臂 89 装了传感器;hover-only 不需要) ----
if [[ "${WITH_TACTILE}" == true && "${HOVER_ONLY}" != true ]]; then
if [[ "${SITE_ROBOT_ARM}" == "left" || "${SITE_ROBOT_SN}" == "Rizon4-063989" ]]; then
echo "=== start tactile sensor service (left arm) ==="
pkill -f 'lib/arm_application/tactile' 2>/dev/null || true
pkill orisys_tac_w 2>/dev/null || true # 清理孤儿 worker(占死 /dev/video*)
pkill -f 'arm_application/tactile_node.py' 2>/dev/null || true
sleep 1.0
nohup ros2 launch arm_application tactile.launch.py > /tmp/stage2_tactile.log 2>&1 &
echo " tactile pid=$! log=/tmp/stage2_tactile.log"
t0=$SECONDS
while (( SECONDS - t0 < 30 )); do
if timeout 4 ros2 topic echo /tactile/left/status --once 2>/dev/null | grep -q "connected: true"; then
echo " /tactile/left/status publishing OK"
break
fi
sleep 0.5
done
if ! timeout 4 ros2 topic echo /tactile/left/status --once 2>/dev/null | grep -q "connected: true"; then
echo "WARN: 触觉话题还没起来(传感器没接?),定位流程继续。日志: /tmp/stage2_tactile.log" >&2
fi
else
echo "=== 触觉传感器只有左臂有;当前 ${SITE_ROBOT_ARM},跳过(--no-tactile 可关此提示) ==="
fi
fi
# ---- 4) 清掉可能打架的旧进程(一阶段伺服/旧二阶段) ----
echo "=== stop old perception/servo/stage2 ==="
pkill -f 'lib/arm_application/stage2_locate' 2>/dev/null || true
pkill -f 'visual_servo.launch' 2>/dev/null || true
pkill -f 'lib/arm_application/servo_node' 2>/dev/null || true
pkill -f 'lib/arm_application/perception_node' 2>/dev/null || true
sleep 1
echo "=== 确认:左臂夹爪已夹持通止规,通止规轴线与法兰轴线一致 ==="
# ---- 5) 起二阶段定位 ----
ARGS=(hover_only:="${HOVER_ONLY}")
if [[ -n "${HOLE}" ]]; then ARGS+=(target_hole:="${HOLE}"); fi
if [[ -n "${HOVER_Z}" ]]; then ARGS+=(hover_z_m:="${HOVER_Z}"); fi
echo "=== launch stage2_locate hover_only=${HOVER_ONLY} hole=${HOLE:-site} hover_z=${HOVER_Z:-site} ==="
exec ros2 launch arm_application stage2_locate.launch.py "${ARGS[@]}"
+152
View File
@@ -0,0 +1,152 @@
#!/usr/bin/env bash
# 三阶段柔顺搜孔:触面 → 力闭环螺旋搜孔 → 十字定心 → 力限插入/旋入 → 锁定。
# 前置(各占一个终端):
# T1 bash ~/ros2/scripts/run_real.sh --arm 89 # 控制器
# —— 二阶段已完成(run_stage2_locate.sh --arm 89),臂保持悬停不动
# 本脚本会自动拉起触觉传感器服务(仅左臂 89 装了 Orisys 传感器)。
#
# 用法:
# bash ~/ros2/scripts/run_stage3_search.sh --arm 89 # 搜孔+落座(site 默认深度)
# bash ~/ros2/scripts/run_stage3_search.sh --arm 89 --hole k2 # 对 k2 孔
# bash ~/ros2/scripts/run_stage3_search.sh --arm 89 --depth 0 # 只搜孔定心,不插入
# bash ~/ros2/scripts/run_stage3_search.sh --arm 89 --depth 6 --pitch 1.0 # 螺纹规旋入(M6=1.0)
# bash ~/ros2/scripts/run_stage3_search.sh --arm 89 --r-max 5 # 临时改搜索半径
# bash ~/ros2/scripts/run_stage3_search.sh --arm 89 --resnap # 搜前用 D405 复拍对准
# bash ~/ros2/scripts/run_stage3_search.sh --arm 89 --no-tactile # 不起触觉服务(调试用)
#
# 参数固定接口:config/site.yaml → hole_detect.stage3(力阈值/螺旋/插入全部可调)
# 力阈值标定:先 bash ~/ros2/scripts/run_tactile.sh --cli 看 Orisys 读数量级
set -eo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
HOLE=""
DEPTH=""
PITCH=""
RMAX=""
RESNAP=false
WITH_TACTILE=true
# shellcheck disable=SC1091
source "${ROOT}/scripts/lib/parse_arm_args.sh"
parse_arm_args "$@"
set -- "${PARSE_ARM_REST[@]}"
while [[ $# -gt 0 ]]; do
case "$1" in
--hole) HOLE="${2:?}"; shift 2 ;;
--depth) DEPTH="${2:?}"; shift 2 ;;
--pitch) PITCH="${2:?}"; shift 2 ;;
--r-max) RMAX="${2:?}"; shift 2 ;;
--resnap) RESNAP=true; shift ;;
--no-tactile) WITH_TACTILE=false; shift ;;
-h|--help) sed -n '2,24p' "$0"; exit 0 ;;
*) echo "Unknown: $1" >&2; exit 1 ;;
esac
done
# shellcheck disable=SC1091
source "${ROOT}/scripts/env.sh"
# shellcheck disable=SC1091
source "${ROOT}/scripts/lib/load_site_config.sh"
if [[ -n "${SITE_FASTDDS_PROFILE:-}" && -f "${SITE_FASTDDS_PROFILE}" ]]; then
export FASTRTPS_DEFAULT_PROFILES_FILE="${SITE_FASTDDS_PROFILE}"
export RMW_IMPLEMENTATION="${RMW_IMPLEMENTATION:-rmw_fastrtps_cpp}"
fi
echo "=== stage3 search arm=${SITE_ROBOT_ARM:-?} ${SITE_ROBOT_LABEL:-} (${SITE_ROBOT_SN:-?}) ==="
# ---- 1) 机械臂控制器必须在 ----
echo "=== waiting for rizon_arm_controller ACTIVE (T1: run_real.sh --arm ${SITE_ROBOT_ARM:-?}) ==="
ready=false
for _i in $(seq 1 30); do
if timeout 5 ros2 service call /controller_manager/list_controllers \
controller_manager_msgs/srv/ListControllers 2>/dev/null \
| grep -q "name='rizon_arm_controller', state='active'"; then
ready=true
break
fi
sleep 0.5
done
if [[ "${ready}" != true ]]; then
echo "ERROR: 机械臂控制器未激活。先跑: bash ~/ros2/scripts/run_real.sh --arm ${SITE_ROBOT_ARM:-89}" >&2
exit 1
fi
# ---- 2) 触觉传感器服务(三阶段的核心传感器,必须在线) ----
if [[ "${WITH_TACTILE}" == true ]]; then
if [[ "${SITE_ROBOT_ARM}" == "left" || "${SITE_ROBOT_SN}" == "Rizon4-063989" ]]; then
echo "=== start tactile sensor service (left arm) ==="
pkill -f 'lib/arm_application/tactile' 2>/dev/null || true
pkill orisys_tac_w 2>/dev/null || true # 清理孤儿 worker(占死 /dev/video*)
pkill -f 'arm_application/tactile_node.py' 2>/dev/null || true
sleep 1.0
nohup ros2 launch arm_application tactile.launch.py > /tmp/stage3_tactile.log 2>&1 &
echo " tactile pid=$! log=/tmp/stage3_tactile.log"
tac_ok=false
for _i in $(seq 1 15); do
# 必须看到真实消息(Publisher count 只反映 DDS 端点存在,不代表数据在流)
# 单侧可用即可(另一指掉线时降级为单指模式,stage3 节点内会告警)
l_live=false; r_live=false
timeout 4 ros2 topic echo /tactile/left/status --once 2>/dev/null | grep -q "connected: true" && l_live=true
timeout 4 ros2 topic echo /tactile/right/status --once 2>/dev/null | grep -q "connected: true" && r_live=true
if [[ "${l_live}" == true || "${r_live}" == true ]]; then
tac_ok=true
[[ "${l_live}" == true && "${r_live}" == true ]] || \
echo " WARN: 单指触觉(左=${l_live} 右=${r_live})——另一指掉线,降级运行"
break
fi
sleep 0.5
done
if [[ "${tac_ok}" != true ]]; then
echo "ERROR: /tactile/left|right/status 双侧都没起来——三阶段必须有触觉。日志: /tmp/stage3_tactile.log" >&2
echo " 排查: bash ~/ros2/scripts/run_tactile.sh --cli;若报 Failed to open video source: pkill orisys_tac_w 后重试" >&2
exit 1
fi
echo " /tactile status publishing OK"
else
echo "ERROR: 触觉传感器只有左臂 89 有;当前 ${SITE_ROBOT_ARM} 不能跑三阶段" >&2
exit 1
fi
fi
# ---- 3) 二阶段结果参考(不强制,只提示) ----
if [[ -f /tmp/stage2_result.json ]]; then
age=$(( $(date +%s) - $(stat -c %Y /tmp/stage2_result.json) ))
echo "=== stage2 result: $(date -d @$(stat -c %Y /tmp/stage2_result.json) '+%H:%M:%S') (${age}s 前) ==="
if (( age > 900 )); then
echo "WARN: 二阶段结果已超过 15 分钟——工件若被动过请重跑二阶段" >&2
fi
else
echo "WARN: 没有 /tmp/stage2_result.json——确认二阶段已跑过且臂仍悬停在孔上方" >&2
fi
# ---- 4) 复拍模式需要相机 ----
if [[ "${RESNAP}" == true ]]; then
CAM_NS="${SITE_CAM_NAME:-d405_left}"
echo "=== resnap:check camera topic /${CAM_NS}/color/image_raw ==="
if ! timeout 5 ros2 topic info "/${CAM_NS}/color/image_raw" 2>/dev/null | grep -q "Publisher count: [1-9]"; then
echo "ERROR: 相机没开。先: bash ~/ros2/scripts/run_camera_d405.sh --arm ${SITE_ROBOT_ARM:-89} --preview" >&2
exit 1
fi
fi
# ---- 5) 清掉可能打架的旧进程 ----
echo "=== stop old perception/servo/stage2/stage3 ==="
pkill -f 'lib/arm_application/stage3_search' 2>/dev/null || true
pkill -f 'lib/arm_application/stage2_locate' 2>/dev/null || true
pkill -f 'visual_servo.launch' 2>/dev/null || true
pkill -f 'lib/arm_application/servo_node' 2>/dev/null || true
pkill -f 'lib/arm_application/perception_node' 2>/dev/null || true
sleep 1
echo "=== 确认:臂仍悬停在目标孔上方(二阶段完成后没动过),通止规夹持牢固 ==="
# ---- 6) 起三阶段搜孔 ----
ARGS=(resnap:="${RESNAP}")
if [[ -n "${HOLE}" ]]; then ARGS+=(target_hole:="${HOLE}"); fi
if [[ -n "${DEPTH}" ]]; then ARGS+=(insert_depth_mm:="${DEPTH}"); fi
if [[ -n "${PITCH}" ]]; then ARGS+=(screw_pitch_mm:="${PITCH}"); fi
if [[ -n "${RMAX}" ]]; then ARGS+=(spiral_r_max_mm:="${RMAX}"); fi
echo "=== launch stage3_search hole=${HOLE:-site} depth=${DEPTH:-site} pitch=${PITCH:-site} r_max=${RMAX:-site} resnap=${RESNAP} ==="
exec ros2 launch arm_application stage3_search.launch.py "${ARGS[@]}"
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Orisys visuo-tactile (finger cameras, not D405). Config: site.yaml tactile:
#
# bash ~/ros2/scripts/run_tactile.sh --cli
# bash ~/ros2/scripts/run_tactile.sh # ROS node /tactile/left|right/status
set -eo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CLI=0
while [[ $# -gt 0 ]]; do
case "$1" in
--cli) CLI=1; shift ;;
-h|--help) sed -n '2,10p' "$0"; exit 0 ;;
*) echo "Unknown: $1" >&2; exit 1 ;;
esac
done
# shellcheck disable=SC1091
source "${ROOT}/scripts/lib/load_site_config.sh"
# shellcheck disable=SC1091
source "${ROOT}/scripts/env.sh"
echo "=== tactile backend=${SITE_TACTILE_BACKEND:-orisys} L=${SITE_TACTILE_LEFT:-0} R=${SITE_TACTILE_RIGHT:-2} cfg=${SITE_TACTILE_CONFIG:-dd03} ==="
if [[ "${CLI}" -eq 1 ]]; then
exec python3 -m arm_application.tactile_cli
fi
pkill -f 'arm_application/tactile_node.py' 2>/dev/null || true
pkill -f 'lib/arm_application/tactile' 2>/dev/null || true
pkill orisys_tac_w 2>/dev/null || true # 清理孤儿 worker(占死 /dev/video*)
sleep 0.2
exec ros2 launch arm_application tactile.launch.py
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env bash
# Real-time visual servo (reads ALL tunables from ~/ros2/config/site.yaml).
#
# bash ~/ros2/scripts/run_visual_servo_rt.sh
# bash ~/ros2/scripts/run_visual_servo_rt.sh --control
# bash ~/ros2/scripts/run_visual_servo_rt.sh --arm 91 --control
#
# --control starts tracking from the current pose (no lift, no return-to-home).
# 89/91 手眼与相机各自独立:必须与 run_real.sh 同一 --arm。
#
# Switch YOLO weights: edit paths.yolo_weights in site.yaml
set -eo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CONTROL=false
# shellcheck disable=SC1091
source "${ROOT}/scripts/lib/parse_arm_args.sh"
parse_arm_args "$@"
set -- "${PARSE_ARM_REST[@]}"
while [[ $# -gt 0 ]]; do
case "$1" in
--control) CONTROL=true; shift ;;
-h|--help) sed -n '2,12p' "$0"; exit 0 ;;
*) echo "Unknown: $1" >&2; exit 1 ;;
esac
done
# shellcheck disable=SC1091
source "${ROOT}/scripts/env.sh"
# shellcheck disable=SC1091
source "${ROOT}/scripts/lib/load_site_config.sh"
python3 "${ROOT}/scripts/lib/export_site_env.py" --print || true
if [[ -n "${SITE_FASTDDS_PROFILE:-}" && -f "${SITE_FASTDDS_PROFILE}" ]]; then
export FASTRTPS_DEFAULT_PROFILES_FILE="${SITE_FASTDDS_PROFILE}"
export RMW_IMPLEMENTATION="${RMW_IMPLEMENTATION:-rmw_fastrtps_cpp}"
echo "=== FastDDS: ${FASTRTPS_DEFAULT_PROFILES_FILE} ==="
fi
if [[ "${CONTROL}" == true ]]; then
echo "=== waiting for rizon_arm_controller ACTIVE (T1: run_real.sh --arm ${SITE_ROBOT_ARM:-?}) ==="
# NOTE: action-list can show zombie entries from a crashed ros2_control,
# and `ros2 control` CLI is broken in this env (xmlrpc Fault). A raw
# service call only answers when the controller manager is actually alive.
ready=false
for _i in $(seq 1 40); do
if timeout 5 ros2 service call /controller_manager/list_controllers \
controller_manager_msgs/srv/ListControllers 2>/dev/null \
| grep -q "name='rizon_arm_controller', state='active'"; then
ready=true
break
fi
sleep 0.5
done
if [[ "${ready}" != true ]]; then
echo "ERROR: 机械臂控制器未激活(可能 T1 已崩溃或机器人不在网)。" >&2
echo " 先确认 ping 通机器人,再重启 T1 并等 operational:" >&2
echo " bash ~/ros2/scripts/run_real.sh --arm ${SITE_ROBOT_ARM:-91}" >&2
exit 1
fi
echo "=== stop old perception/servo ==="
pkill -f 'visual_servo.launch' 2>/dev/null || true
pkill -f 'lib/arm_application/servo_node' 2>/dev/null || true
pkill -f 'lib/arm_application/perception_node' 2>/dev/null || true
sleep 1
echo "=== start perception + servo CONTROL ON (lock_z from robots[].servo.ee_z_m) ==="
else
echo "=== launch perception + servo (observe only, no arm motion) ==="
fi
EE="${SITE_EE_FRAME:-Rizon4-063989_flange}"
HANDEYE="${SITE_HANDEYE_FILE:-${ROOT}/src/arm_application/config/handeye_result.yaml}"
echo " arm=${SITE_ROBOT_SN} ${SITE_ROBOT_LABEL:-} handeye=${HANDEYE}"
echo " weights=${SITE_YOLO_WEIGHTS} camera=${SITE_CAM_NAME:-} control=${CONTROL}"
LAUNCH_ARGS=(
enable_control:="${CONTROL}"
handeye_file:="${HANDEYE}"
ee_frame:="${EE}"
camera_frame:="${SITE_CAMERA_FRAME:-d405_color_optical_frame}"
)
exec ros2 launch arm_application visual_servo.launch.py "${LAUNCH_ARGS[@]}"
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
# Show / validate the master site config.
# bash ~/ros2/scripts/show_site_config.sh
# SITE_CONFIG=/path/to/other.yaml bash ~/ros2/scripts/show_site_config.sh
set -eo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck disable=SC1091
source "${ROOT}/scripts/env.sh"
# shellcheck disable=SC1091
source "${ROOT}/scripts/lib/parse_arm_args.sh"
parse_arm_args "$@"
python3 "${ROOT}/scripts/lib/export_site_env.py" --print ${ROBOT_SN:+--sn "$ROBOT_SN"}
echo
echo "Edit: ${SITE_CONFIG:-$ROOT/config/site.yaml}"
echo "Switch weights example:"
echo " paths.yolo_weights: \"/home/cc/ros2/models/yolo/detect_hc90.pt\""
echo "Or symlink:"
echo " ln -sfn detect_hc90.pt ~/ros2/models/yolo/active.pt"
echo " # then set paths.yolo_weights to .../active.pt"
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# Smoke-test Flexiv RDK Python against site robot (no ROS / no GitHub).
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck disable=SC1091
source "${ROOT}/scripts/lib/load_site_config.sh" 2>/dev/null || true
SN="${1:-${ROBOT_SN:-${SITE_ROBOT_SN:-Rizon4-063989}}}"
ROBOT_IP="${ROBOT_IP:-${SITE_ROBOT_IP:-192.168.100.1}}"
IFACE="${SITE_NET_IFACE:-}"
PY="${RDK_PYTHON:-/home/cc/rdk_py310/bin/python}"
echo "Using: ${PY}"
echo "Robot SN: ${SN} IP: ${ROBOT_IP} iface: ${IFACE:-auto}"
echo "Ping ${ROBOT_IP} ..."
ping -c 1 -W 1 "${ROBOT_IP}" >/dev/null && echo " network OK" || echo " network FAIL"
"${PY}" - <<PY
import flexivrdk
sn = "${SN}"
iface = "${IFACE}"
print("Connecting...", sn, "iface", iface or "auto")
robot = None
if iface:
for args in ((sn, [iface]), (sn, [iface], True), (sn, [iface], True, False)):
try:
robot = flexivrdk.Robot(*args)
break
except TypeError:
continue
if robot is None:
robot = flexivrdk.Robot(sn)
print("Connected. Clearing fault if any...")
try:
robot.ClearFault()
except Exception as e:
print("ClearFault:", e)
mode = robot.mode()
print("mode:", mode)
states = robot.states()
print("q:", list(states.q))
print("OK — RDK Python link works. Enable RDK on teach pendant if mode/fault unexpected.")
PY
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
# Wire local Flexiv trees under ~/文档/Flexiv_Rizon4 into this workspace.
# Avoids re-cloning RDK / sim plugin from GitHub.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SRC="${ROOT}/src"
TP="${ROOT}/thirdparty"
LOCAL_FLEXIV="${LOCAL_FLEXIV:-$HOME/文档/Flexiv_Rizon4}"
if [[ ! -d "${LOCAL_FLEXIV}/flexiv_rdk" ]]; then
echo "ERROR: not found: ${LOCAL_FLEXIV}/flexiv_rdk"
echo "Set LOCAL_FLEXIV to your Flexiv_Rizon4 directory."
exit 1
fi
mkdir -p "${SRC}" "${TP}"
VER_LINE=$(grep -E '^project\(flexiv_rdk' "${LOCAL_FLEXIV}/flexiv_rdk/CMakeLists.txt" | head -1 || true)
echo "Linking RDK (${VER_LINE:-unknown version})..."
ln -sfn "${LOCAL_FLEXIV}/flexiv_rdk" "${SRC}/flexiv_rdk"
touch "${LOCAL_FLEXIV}/flexiv_rdk/COLCON_IGNORE"
if [[ -d "${LOCAL_FLEXIV}/flexiv_sim_plugin" ]]; then
if [[ -d "${TP}/flexiv_sim_plugin" && ! -L "${TP}/flexiv_sim_plugin" ]]; then
mv "${TP}/flexiv_sim_plugin" "${TP}/flexiv_sim_plugin.cloned.bak.$(date +%s)"
fi
ln -sfn "${LOCAL_FLEXIV}/flexiv_sim_plugin" "${TP}/flexiv_sim_plugin"
touch "${LOCAL_FLEXIV}/flexiv_sim_plugin/COLCON_IGNORE"
echo "Linked thirdparty/flexiv_sim_plugin -> ${LOCAL_FLEXIV}/flexiv_sim_plugin"
fi
# humble-v1 ROS hardware needs RDK 1.9.x (kept separately if present)
if [[ -d "${SRC}/flexiv_rdk.bak.1787646725" ]]; then
ln -sfn "${SRC}/flexiv_rdk.bak.1787646725" "${SRC}/flexiv_rdk_v1"
touch "${SRC}/flexiv_rdk.bak.1787646725/COLCON_IGNORE"
echo "ROS stack RDK v1.9.1 available at src/flexiv_rdk_v1"
fi
cat <<EOF
Done.
src/flexiv_rdk -> ${LOCAL_FLEXIV}/flexiv_rdk (your tree, currently v2.1)
thirdparty/flexiv_sim_plugin -> local sim plugin
Note: flexiv_ros2 humble-v1 expects RDK **v1.9.1**.
Build/install src/flexiv_rdk_v1 into ~/flexiv_install for flexiv_hardware.
Use your v2.1 tree for RDK C++/Python examples and Isaac-side tooling.
EOF
+9
View File
@@ -0,0 +1,9 @@
# src/ — ROS 2 包
详细职责见 [docs/WORKSPACE.md](../docs/WORKSPACE.md)。
**跟踪/手眼相关(本仓库维护):** `arm_application` `arm_bringup` `arm_control` `arm_msgs`
**Flexiv 官方驱动(尽量不改):** `flexiv_hardware` `flexiv_bringup` `flexiv_msgs` `flexiv_description` 以及 `flexiv_ros2/` 拷贝。
编译:`./scripts/build_all.sh`(先 `source scripts/env.sh`)。
+39
View File
@@ -0,0 +1,39 @@
# arm_application
现场业务包。操作员用 `~/ros2/scripts/*.sh`,不要直接改这里的默认 yaml。
## 节点(console_scripts)
| 可执行文件 | 源文件 | 作用 |
|------------|--------|------|
| `realsense_color_pub` | `realsense_color_pub.py` | D405 发布 + 预览 |
| `perception_node` | `perception_node.py` | YOLO + 平面中心 → 点 |
| `servo_node` | `servo_node.py` | 100 Hz hover / 锁高 |
| `handeye_teach` | `handeye_teach.py` | 拖拽记点 |
| `handeye_calibrate` | `handeye_calibrate.py` | 走点 + 解 T |
| `handeye_waypoints` | `handeye_waypoints.py` | 旧巡航记点(可选) |
| `dh_pgea` / `dh_pgea_cli` | `dh_pgea_node.py` / `dh_pgea.py` | 左臂 PGEA |
| `dual_gripper` / `dual_gripper_cli` | `dual_gripper_node.py` / `dual_gripper_cli.py` | PGEA+RGI |
| `hole_detect` | `hole_detect_node.py` | 入孔检测 |
| `tactile` / `tactile_cli` | `tactile_node.py` / `tactile_cli.py` | Orisys 视触觉 |
| `goto_ee_plane` | `goto_ee_plane.py` | 可选:抬高再回 home(默认启动不再调用) |
| `demo_named_poses` | `demo_named_poses.py` | mock/实机点位示例 |
| `run_joint_trajectory` | `run_joint_trajectory.py` | 轨迹回放 |
库模块见包内 `__init__.py`。接口总表:[docs/INTERFACES.md](../../docs/INTERFACES.md)。
## 配置
| 文件 | 角色 |
|------|------|
| `config/perception.yaml` `servo.yaml` | 默认值,被 `site.yaml` 覆盖 |
| `config/hole_detect.yaml` | `two_stage` 缺省;被 `site.yaml hole_detect` 覆盖 |
| `config/hole_insert.yaml` | `yolo`/`hough` 缺省 |
| `config/orisys/dd03.json` | Orisys 厂家配置,路径写在 `site.yaml tactile.config_file` |
| `config/handeye_result_89.yaml` / `_91.yaml` | 该臂手眼 T(标定写入;禁止混用) |
| `config/handeye_poses_89.yaml` / `_91.yaml` | 该臂示教关节 |
| `launch/visual_servo.launch.py` | 感知 + 伺服 + 手眼 TF |
| `launch/dh_pgea.launch.py` | 左臂 PGEA |
| `launch/dual_gripper.launch.py` | 双爪 |
| `launch/hole_detect.launch.py` | 入孔检测 |
| `launch/tactile.launch.py` | 视触觉 |
@@ -0,0 +1,32 @@
"""arm_application — field nodes for Flexiv Rizon + D405 + DH grippers.
Public ROS executables (see setup.py console_scripts)
----------------------------------------------------
Camera: realsense_color_pub
Perception: perception_node
Servo: servo_node
Hand-eye: handeye_teach, handeye_waypoints, handeye_calibrate
Gripper: dh_pgea, dh_pgea_cli, dual_gripper, dual_gripper_cli
Hole detect: hole_detect
Tactile: tactile, tactile_cli
Helpers: goto_ee_plane, demo_named_poses, run_joint_trajectory
Reusable Python modules (import from your own nodes)
----------------------------------------------------
yolo_detector YOLO / TensorRT inference + pick_best
box_tracker pixel-space constant-velocity box coast
plane_feature stereo plane centroid inside a YOLO box
dh_pgea official Modbus RTU client for DH PGEA
dh_official DH Modbus client used by dual_gripper
dual_gripper PGEA left + RGI right
hole_detector yolo / hough / two_stage hole centers
orisys_tactile Orisys visuo-tactile adapter
geom_ik Rizon geometric Jacobian / DLS / hand-eye load
handeye_lib chessboard + OpenCV calibrateHandEye
handeye_realsense topic / serial helpers for D405
pid_controller PID with integral separation
kalman_filter 3D constant-velocity KF
Operators should use ``scripts/*.sh`` and ``config/site.yaml``, not these
imports. See ``docs/INTERFACES.md``.
"""
@@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""Constant-velocity tracker for a YOLO xyxy box (pixel space).
Used so fast workpiece motion does not drop the lock: the next-frame gate
is the *predicted* box, and brief YOLO misses coast the overlay.
"""
from __future__ import annotations
from typing import Optional, Tuple
import numpy as np
def _center(xyxy: np.ndarray) -> np.ndarray:
return np.array(
[0.5 * (float(xyxy[0]) + float(xyxy[2])), 0.5 * (float(xyxy[1]) + float(xyxy[3]))],
dtype=float,
)
def _wh(xyxy: np.ndarray) -> Tuple[float, float]:
return (
max(8.0, float(xyxy[2]) - float(xyxy[0])),
max(8.0, float(xyxy[3]) - float(xyxy[1])),
)
class BoxTracker:
def __init__(
self,
*,
vmax_px_s: float = 1800.0,
v_lpf: float = 0.70,
max_dt: float = 0.25,
) -> None:
self.vmax_px_s = float(vmax_px_s)
self.v_lpf = float(np.clip(v_lpf, 0.05, 1.0))
self.max_dt = float(max_dt)
self.xyxy: Optional[np.ndarray] = None
self.v = np.zeros(2, dtype=float) # center px/s
self.t: Optional[float] = None
def reset(self) -> None:
self.xyxy = None
self.v[:] = 0.0
self.t = None
def predict(self, t: float, img_wh: Optional[Tuple[int, int]] = None) -> Optional[np.ndarray]:
if self.xyxy is None:
return None
out = self.xyxy.copy()
if self.t is None:
return out
dt = float(np.clip(t - self.t, 0.0, self.max_dt))
if dt <= 1e-4:
return out
c = _center(out) + self.v * dt
w, h = _wh(out)
x1, y1 = c[0] - 0.5 * w, c[1] - 0.5 * h
x2, y2 = c[0] + 0.5 * w, c[1] + 0.5 * h
if img_wh is not None:
iw, ih = float(img_wh[0]), float(img_wh[1])
x1 = float(np.clip(x1, 0.0, iw - 2.0))
y1 = float(np.clip(y1, 0.0, ih - 2.0))
x2 = float(np.clip(x2, x1 + 4.0, iw))
y2 = float(np.clip(y2, y1 + 4.0, ih))
return np.array([x1, y1, x2, y2], dtype=float)
def update(self, xyxy: np.ndarray, t: float) -> None:
xyxy = np.asarray(xyxy, dtype=float).reshape(4)
c = _center(xyxy)
if self.xyxy is not None and self.t is not None:
dt = float(t - self.t)
if dt > 0.28:
self.v[:] = 0.0
elif dt > 1e-3:
inst = (c - _center(self.xyxy)) / dt
n = float(np.hypot(inst[0], inst[1]))
if n > self.vmax_px_s > 0.0:
inst = inst * (self.vmax_px_s / n)
a = self.v_lpf
self.v = a * inst + (1.0 - a) * self.v
self.xyxy = xyxy.copy()
self.t = float(t)
@@ -0,0 +1,78 @@
#!/usr/bin/env python3
"""Demo: cycle named poses via /set_named_pose (works for any backend)."""
from __future__ import annotations
import time
import rclpy
from rclpy.node import Node
from arm_msgs.srv import SetNamedPose
class DemoNamedPoses(Node):
def __init__(self) -> None:
super().__init__('demo_named_poses')
self.declare_parameter(
'poses', ['home', 'ready', 'inspect', 'pos_left', 'home']
)
self.declare_parameter('velocity_scale', 0.5)
self.declare_parameter('dwell_sec', 0.3)
self.declare_parameter('loops', 1)
self.poses = list(
self.get_parameter('poses').get_parameter_value().string_array_value
or ['home', 'ready', 'inspect', 'home']
)
self.velocity_scale = float(self.get_parameter('velocity_scale').value)
self.dwell_sec = float(self.get_parameter('dwell_sec').value)
self.loops = int(self.get_parameter('loops').value)
self._cli = self.create_client(SetNamedPose, 'set_named_pose')
def run(self) -> int:
if not self._cli.wait_for_service(timeout_sec=15.0):
self.get_logger().error('/set_named_pose not available')
return 1
for loop in range(max(self.loops, 1)):
self.get_logger().info(f'loop {loop + 1}/{self.loops}')
for pose in self.poses:
req = SetNamedPose.Request()
req.pose_name = pose
req.velocity_scale = self.velocity_scale
req.acceleration_scale = self.velocity_scale
fut = self._cli.call_async(req)
deadline = time.monotonic() + 60.0
while rclpy.ok() and not fut.done():
rclpy.spin_once(self, timeout_sec=0.05)
if time.monotonic() > deadline:
self.get_logger().error(f'timeout on {pose}')
return 2
res = fut.result()
if res is None or not res.success:
self.get_logger().error(
f'{pose} failed: {res.message if res else "no response"}'
)
return 3
self.get_logger().info(f'{pose}: {res.message}')
time.sleep(self.dwell_sec)
self.get_logger().info('demo done')
return 0
def main(args=None) -> None:
rclpy.init(args=args)
node = DemoNamedPoses()
code = 1
try:
code = node.run()
except KeyboardInterrupt:
code = 0
finally:
node.destroy_node()
if rclpy.ok():
rclpy.shutdown()
raise SystemExit(code)
if __name__ == '__main__':
main()
@@ -0,0 +1,271 @@
"""DH-Robotics 官方 Modbus RTU 控制(非 Flexiv RDK)。
覆盖两类设备:
- PGE/PGEA 平行夹爪:初始化 0x0100,力 0x0101,位置 0x0103(0~1000‰),速度 0x0104。
- RGI 旋转夹爪:在平行夹爪寄存器基础上,增加旋转角度 0x0105、旋转速度 0x0107、
旋转力 0x0108;反馈增加旋转角度 0x0208、旋转初始化状态 0x020A、旋转状态 0x020B。
说明:
- RGI 手册示例中负角度使用“反码/one's complement”:+360 -> 0x0168,-360 -> 0xFE97。
因此这里默认按 one's complement 编解码;若后续厂家确认为 two's complement,只改
`_encode_signed` / `_decode_signed` 两处即可。
- 写位置/角度寄存器后会立即运动,没有 ChangingTek SDK 的 0x0108 触发寄存器。
"""
from __future__ import annotations
import threading
import time
from typing import Iterable
# =============================================================================
# DH-Robotics Modbus register map
# =============================================================================
# Basic control registers (holding registers, function code 03/06/10)
REG_INIT = 0x0100 # 0x01 initialize, 0xA5 fully initialize
REG_FORCE = 0x0101 # gripper closing force, 20~100 %
REG_POSITION = 0x0103 # finger reference position, 0~1000 per-mille
REG_SPEED = 0x0104 # finger open/close speed, 1~100 %
# RGI rotation control registers
REG_ROTATION_ANGLE = 0x0105 # target rotation angle, signed degree value
REG_ROTATION_SPEED = 0x0107 # rotation speed, 1~100 %
REG_ROTATION_FORCE = 0x0108 # rotation force/torque, 20~100 %
# Feedback registers
REG_INIT_STATE = 0x0200 # 0 uninitialized, 1 initialized, 2 initializing
REG_GRIPPER_STATE = 0x0201 # 0 moving, 1 reached, 2 object caught, 3 object dropped
REG_CURRENT_POSITION = 0x0202 # current finger position, per-mille
REG_ROTATION_ANGLE_FB = 0x0208 # current rotation angle, signed degree value
REG_ROTATION_INIT_STATE = 0x020A # 0 uninitialized, 1 initialized, 2 initializing
REG_ROTATION_STATE = 0x020B # 0 moving, 1 reached, 2 blocking, 3 had been blocked
# Configuration registers
REG_SAVE_PARAMS = 0x0300 # write 1 to save all parameters to flash
class DHModbusError(RuntimeError):
"""DH Modbus communication or protocol error."""
def _clamp_int(value: float | int, lo: int, hi: int) -> int:
return int(max(lo, min(hi, int(round(float(value))))))
def _encode_signed(value: float | int) -> int:
"""Encode signed degree value as DH one's-complement register value.
RGI manual example: +360 -> 0x0168, -360 -> 0xFE97.
"""
value = _clamp_int(value, -32767, 32767)
if value >= 0:
return value & 0xFFFF
return (~abs(value)) & 0xFFFF
def _decode_signed(raw: int) -> int:
"""Decode DH one's-complement register value to signed integer."""
raw = int(raw) & 0xFFFF
if raw & 0x8000:
mag = (~raw) & 0x7FFF
return -mag
return raw & 0x7FFF
class DHModbusClient:
"""Minimal thread-safe Modbus RTU client for DH-Robotics grippers."""
def __init__(
self,
port: str,
slave_id: int = 1,
baudrate: int = 115200,
timeout: float = 0.2,
*,
bytesize: int = 8,
parity: str = "N",
stopbits: int = 1,
) -> None:
try:
import minimalmodbus
import serial
except ImportError as exc: # pragma: no cover - depends on runtime env
raise DHModbusError(
"缺少 minimalmodbus/pyserial。请先安装:pip install minimalmodbus pyserial"
) from exc
parity_map = {
"N": serial.PARITY_NONE,
"E": serial.PARITY_EVEN,
"O": serial.PARITY_ODD,
}
self.instrument = minimalmodbus.Instrument(port, int(slave_id))
self.instrument.serial.baudrate = int(baudrate)
self.instrument.serial.bytesize = int(bytesize)
self.instrument.serial.parity = parity_map.get(str(parity).upper(), serial.PARITY_NONE)
self.instrument.serial.stopbits = int(stopbits)
self.instrument.serial.timeout = float(timeout)
self.instrument.mode = minimalmodbus.MODE_RTU
self.instrument.clear_buffers_before_each_transaction = True
try:
self.instrument.serial.exclusive = True
except Exception:
pass
self._lock = threading.Lock()
self.port = port
def close(self) -> None:
with self._lock:
try:
self.instrument.serial.close()
except Exception:
pass
# ------------------------------------------------------------------
# Low-level register access
# ------------------------------------------------------------------
def write_register(self, address: int, value: int) -> None:
value = _clamp_int(value, 0, 0xFFFF)
last_exc: Exception | None = None
for attempt in range(3):
try:
with self._lock:
self.instrument.write_register(address, value, functioncode=6)
return
except Exception as exc:
last_exc = exc
time.sleep(0.05 * (attempt + 1))
raise DHModbusError(f"写寄存器 0x{address:04X}={value} 失败: {last_exc}") from last_exc
def read_register(self, address: int) -> int:
last_exc: Exception | None = None
for attempt in range(3):
try:
with self._lock:
return int(self.instrument.read_register(address, functioncode=3))
except Exception as exc:
last_exc = exc
time.sleep(0.05 * (attempt + 1))
hint = ""
text = str(last_exc).lower()
if "multiple access" in text or "no data" in text:
hint = f"。串口 {getattr(self, 'port', '')} 可能被其它进程占用,先执行: fuser -k {getattr(self, 'port', '/dev/ttyUSB*')}"
raise DHModbusError(f"读寄存器 0x{address:04X} 失败: {last_exc}{hint}") from last_exc
def write_registers(self, address: int, values: Iterable[int]) -> None:
vals = [_clamp_int(v, 0, 0xFFFF) for v in values]
with self._lock:
self.instrument.write_registers(address, vals)
def read_registers(self, address: int, count: int) -> list[int]:
with self._lock:
return [int(v) for v in self.instrument.read_registers(address, count, functioncode=3)]
def write_signed_register(self, address: int, value: float | int) -> None:
self.write_register(address, _encode_signed(value))
def read_signed_register(self, address: int) -> int:
return _decode_signed(self.read_register(address))
# ------------------------------------------------------------------
# Common PGE/PGEA/RGI control
# ------------------------------------------------------------------
def initialize(self, *, full: bool = False, rotation: bool = False, timeout: float = 10.0) -> None:
"""Initialize/homing the gripper and wait until ready."""
self.write_register(REG_INIT, 0xA5 if full else 0x01)
start = time.monotonic()
while time.monotonic() - start < timeout:
init_state = self.read_register(REG_INIT_STATE)
rot_ok = True
if rotation:
rot_ok = self.read_register(REG_ROTATION_INIT_STATE) == 1
if init_state == 1 and rot_ok:
return
time.sleep(0.1)
raise DHModbusError(f"夹爪初始化超时(>{timeout:.1f}s)")
def save_parameters(self) -> None:
self.write_register(REG_SAVE_PARAMS, 1)
def set_force_pct(self, force_pct: float | int) -> int:
value = _clamp_int(force_pct, 20, 100)
self.write_register(REG_FORCE, value)
return value
def set_speed_pct(self, speed_pct: float | int) -> int:
value = _clamp_int(speed_pct, 1, 100)
self.write_register(REG_SPEED, value)
return value
def set_position_per_mille(self, position: float | int) -> int:
value = _clamp_int(position, 0, 1000)
try:
cmd = _clamp_int(self.read_register(REG_POSITION), 0, 1000)
except Exception:
cmd = value
if cmd == value:
try:
cur = self.get_position_per_mille()
except Exception:
cur = value
nudge = cur + (40 if value >= cur else -40)
nudge = _clamp_int(nudge, 0, 1000)
if nudge == value:
nudge = 0 if value != 0 else 40
self.write_register(REG_POSITION, nudge)
time.sleep(0.06)
self.write_register(REG_POSITION, value)
return value
def get_position_per_mille(self) -> int:
return _clamp_int(self.read_register(REG_CURRENT_POSITION), 0, 1000)
def get_gripper_state(self) -> int:
return self.read_register(REG_GRIPPER_STATE)
def get_init_state(self) -> int:
return self.read_register(REG_INIT_STATE)
def hold_current_position(self) -> None:
"""Best-effort stop/hold for DH grippers without a dedicated stop register."""
self.set_position_per_mille(self.get_position_per_mille())
# ------------------------------------------------------------------
# RGI rotation control
# ------------------------------------------------------------------
def set_rotation_speed_pct(self, speed_pct: float | int) -> int:
value = _clamp_int(speed_pct, 1, 100)
self.write_register(REG_ROTATION_SPEED, value)
return value
def set_rotation_force_pct(self, force_pct: float | int) -> int:
value = _clamp_int(force_pct, 20, 100)
self.write_register(REG_ROTATION_FORCE, value)
return value
def set_rotation_angle_deg(self, angle_deg: float | int) -> int:
angle = _clamp_int(angle_deg, -32767, 32767)
self.write_signed_register(REG_ROTATION_ANGLE, angle)
return angle
def get_rotation_angle_raw(self) -> int:
return self.read_register(REG_ROTATION_ANGLE_FB) & 0xFFFF
def get_rotation_angle_deg(self) -> int:
return self.read_signed_register(REG_ROTATION_ANGLE_FB)
def get_rotation_init_state(self) -> int:
return self.read_register(REG_ROTATION_INIT_STATE)
def get_rotation_state(self) -> int:
return self.read_register(REG_ROTATION_STATE)
def hold_current_rotation(self) -> None:
"""Stop rotation by re-commanding the current feedback angle."""
self.write_register(REG_ROTATION_ANGLE, self.get_rotation_angle_raw())
def hold_current_all(self) -> None:
self.hold_current_position()
self.hold_current_rotation()
@@ -0,0 +1,584 @@
#!/usr/bin/env python3
"""DH Robotics PGE / PGEA Modbus RTU client.
Port of the official C++ driver:
DH-Robotics/dh_gripper_ros → DH_Modbus_Gripper
(factory maps model names containing ``PGE`` / ``PGEA`` onto this class)
Flexiv RDK ``Gripper`` cannot drive this claw: it is not a Studio Device.
Talk RS485 (USB–UART) with the same registers the DH ROS driver uses.
Registers (holding, FC03 read / FC06 write, CRC-16/Modbus LE):
0x0100 init write 0xA5
0x0101 force 20–100 %
0x0103 position 0–1000 ‰
0x0104 speed 1–100 %
0x0200 init state 0=need 1=done 2=doing
0x0201 grip state 0=moving 1=arrived 2=caught 3=dropped
0x0202 current position
"""
from __future__ import annotations
import argparse
import sys
import threading
import time
from dataclasses import dataclass
from typing import Optional
try:
import serial
except ImportError as exc: # pragma: no cover
raise SystemExit(
"pyserial missing. In conda ros_flexiv: pip install pyserial"
) from exc
# Official CRC-16/Modbus (same table as dh_modbus_gripper.cpp).
_CRC_TABLE = (
0x0000, 0xC0C1, 0xC181, 0x0140, 0xC301, 0x03C0, 0x0280, 0xC241,
0xC601, 0x06C0, 0x0780, 0xC741, 0x0500, 0xC5C1, 0xC481, 0x0440,
0xCC01, 0x0CC0, 0x0D80, 0xCD41, 0x0F00, 0xCFC1, 0xCE81, 0x0E40,
0x0A00, 0xCAC1, 0xCB81, 0x0B40, 0xC901, 0x09C0, 0x0880, 0xC841,
0xD801, 0x18C0, 0x1980, 0xD941, 0x1B00, 0xDBC1, 0xDA81, 0x1A40,
0x1E00, 0xDEC1, 0xDF81, 0x1F40, 0xDD01, 0x1DC0, 0x1C80, 0xDC41,
0x1400, 0xD4C1, 0xD581, 0x1540, 0xD701, 0x17C0, 0x1680, 0xD641,
0xD201, 0x12C0, 0x1380, 0xD341, 0x1100, 0xD1C1, 0xD081, 0x1040,
0xF001, 0x30C0, 0x3180, 0xF141, 0x3300, 0xF3C1, 0xF281, 0x3240,
0x3600, 0xF6C1, 0xF781, 0x3740, 0xF501, 0x35C0, 0x3480, 0xF441,
0x3C00, 0xFCC1, 0xFD81, 0x3D40, 0xFF01, 0x3FC0, 0x3E80, 0xFE41,
0xFA01, 0x3AC0, 0x3B80, 0xFB41, 0x3900, 0xF9C1, 0xF881, 0x3840,
0x2800, 0xE8C1, 0xE981, 0x2940, 0xEB01, 0x2BC0, 0x2A80, 0xEA41,
0xEE01, 0x2EC0, 0x2F80, 0xEF41, 0x2D00, 0xEDC1, 0xEC81, 0x2C40,
0xE401, 0x24C0, 0x2580, 0xE541, 0x2700, 0xE7C1, 0xE681, 0x2640,
0x2200, 0xE2C1, 0xE381, 0x2340, 0xE101, 0x21C0, 0x2080, 0xE041,
0xA001, 0x60C0, 0x6180, 0xA141, 0x6300, 0xA3C1, 0xA281, 0x6240,
0x6600, 0xA6C1, 0xA781, 0x6740, 0xA501, 0x65C0, 0x6480, 0xA441,
0x6C00, 0xACC1, 0xAD81, 0x6D40, 0xAF01, 0x6FC0, 0x6E80, 0xAE41,
0xAA01, 0x6AC0, 0x6B80, 0xAB41, 0x6900, 0xA9C1, 0xA881, 0x6840,
0x7800, 0xB8C1, 0xB981, 0x7940, 0xBB01, 0x7BC0, 0x7A80, 0xBA41,
0xBE01, 0x7EC0, 0x7F80, 0xBF41, 0x7D00, 0xBDC1, 0xBC81, 0x7C40,
0xB401, 0x74C0, 0x7580, 0xB541, 0x7700, 0xB7C1, 0xB681, 0x7640,
0x7200, 0xB2C1, 0xB381, 0x7340, 0xB101, 0x71C0, 0x7080, 0xB041,
0x5000, 0x90C1, 0x9181, 0x5140, 0x9301, 0x53C0, 0x5280, 0x9241,
0x9601, 0x56C0, 0x5780, 0x9741, 0x5500, 0x95C1, 0x9481, 0x5440,
0x9C01, 0x5CC0, 0x5D80, 0x9D41, 0x5F00, 0x9FC1, 0x9E81, 0x5E40,
0x5A00, 0x9AC1, 0x9B81, 0x5B40, 0x9901, 0x59C0, 0x5880, 0x9841,
0x8801, 0x48C0, 0x4980, 0x8941, 0x4B00, 0x8BC1, 0x8A81, 0x4A40,
0x4E00, 0x8EC1, 0x8F81, 0x4F40, 0x8D01, 0x4DC0, 0x4C80, 0x8C41,
0x4400, 0x84C1, 0x8581, 0x4540, 0x8701, 0x47C0, 0x4680, 0x8641,
0x8201, 0x42C0, 0x4380, 0x8341, 0x4100, 0x81C1, 0x8081, 0x4040,
)
REG_INIT = 0x0100
REG_FORCE = 0x0101
REG_POSITION = 0x0103
REG_SPEED = 0x0104
REG_INIT_STATE = 0x0200
REG_GRIP_STATE = 0x0201
REG_CUR_POSITION = 0x0202
INIT_CMD = 0xA5
S_INIT_NOT = 0
S_INIT_FINISHED = 1
S_INIT_DOING = 2
S_GRIP_MOVING = 0
S_GRIP_ARRIVED = 1
S_GRIP_CAUGHT = 2
S_GRIP_DROPPED = 3
GRIP_STATE_NAME = {
S_GRIP_MOVING: "moving",
S_GRIP_ARRIVED: "arrived",
S_GRIP_CAUGHT: "caught",
S_GRIP_DROPPED: "dropped",
}
INIT_STATE_NAME = {
S_INIT_NOT: "need_init",
S_INIT_FINISHED: "initialized",
S_INIT_DOING: "initializing",
}
def crc16_modbus(data: bytes) -> int:
"""Official DH CRC16 (Modbus, little-endian on the wire)."""
crc = 0xFFFF
for byte in data:
crc = (crc >> 8) ^ _CRC_TABLE[(crc ^ byte) & 0xFF]
return crc
def _frame(slave: int, func: int, index: int, value: int) -> bytes:
body = bytes(
[
slave & 0xFF,
func & 0xFF,
(index >> 8) & 0xFF,
index & 0xFF,
(value >> 8) & 0xFF,
value & 0xFF,
]
)
crc = crc16_modbus(body)
return body + bytes([crc & 0xFF, (crc >> 8) & 0xFF])
@dataclass
class GripperState:
init_state: int = S_INIT_NOT
grip_state: int = S_GRIP_MOVING
position: int = 0
target_position: int = 0
target_force: int = 0
stroke_mm: float = 20.0
@property
def initialized(self) -> bool:
return self.init_state == S_INIT_FINISHED
@property
def width_mm(self) -> float:
return float(self.position) / 1000.0 * float(self.stroke_mm)
def as_tuple(self) -> tuple[float, float, float, float, float]:
return (
1.0 if self.initialized else 0.0,
float(self.grip_state),
float(self.position),
float(self.target_position),
float(self.target_force),
)
def summary(self) -> str:
return (
f"init={INIT_STATE_NAME.get(self.init_state, self.init_state)} "
f"grip={GRIP_STATE_NAME.get(self.grip_state, self.grip_state)} "
f"pos={self.position}/1000 ({self.width_mm:.2f} mm) "
f"target={self.target_position} force={self.target_force}%"
)
class DhPgeaError(RuntimeError):
pass
WIRING_HINT = (
"PC 已发出 Modbus,夹爪 0 字节返回。不是力/速度参数问题。按顺序查:\n"
" 1) 夹爪 24 V DC 已上电(指示灯亮)\n"
" 2) USB 转接头必须是 RS485(A/B),不能是 TTL 的 TX/RX\n"
" 3) A↔A、B↔B;不通就对调 A/B\n"
" 4) 先 Ctrl+C 停节点,再:bash ~/ros2/scripts/run_gripper.sh --cli scan"
)
class DhPgeaGripper:
"""Thread-safe Modbus RTU client for PGE / PGEA (official register map)."""
def __init__(
self,
port: str = "/dev/ttyUSB0",
baudrate: int = 115200,
slave_id: int = 1,
timeout_s: float = 0.25,
stroke_mm: float = 20.0,
rs485_rts: str = "",
) -> None:
self.port = port
self.baudrate = int(baudrate)
self.slave_id = int(slave_id)
self.timeout_s = float(timeout_s)
self.stroke_mm = float(stroke_mm)
mode = str(rs485_rts or "").strip().lower()
if mode in ("", "off", "none", "false", "0"):
mode = ""
if mode not in ("", "tx", "tx_inv"):
raise DhPgeaError(f"rs485_rts must be '', tx, or tx_inv, got {rs485_rts!r}")
self.rs485_rts = mode
self._ser: Optional[serial.Serial] = None
self._lock = threading.Lock()
def open(self) -> None:
if self._ser is not None and self._ser.is_open:
return
try:
self._ser = serial.Serial(
port=self.port,
baudrate=self.baudrate,
bytesize=serial.EIGHTBITS,
parity=serial.PARITY_NONE,
stopbits=serial.STOPBITS_ONE,
timeout=self.timeout_s,
write_timeout=self.timeout_s,
xonxoff=False,
rtscts=False,
dsrdtr=False,
exclusive=True,
)
except Exception as exc:
raise DhPgeaError(
f"cannot open {self.port}: {exc}. "
"Need 24 V DC + RS485, and user in group dialout."
) from exc
try:
self._ser.dtr = True
except Exception:
pass
self._set_tx(False)
self._ser.reset_input_buffer()
self._ser.reset_output_buffer()
time.sleep(0.05)
def close(self) -> None:
if self._ser is not None:
try:
self._ser.close()
except Exception:
pass
self._ser = None
def __enter__(self) -> "DhPgeaGripper":
self.open()
return self
def __exit__(self, *args) -> None:
self.close()
def write_register(self, index: int, value: int, retries: int = 3) -> bool:
"""FC06 write, wait for echo (official WriteRegisterFunc)."""
frame = _frame(self.slave_id, 0x06, index, int(value) & 0xFFFF)
with self._lock:
for _ in range(max(1, retries)):
if self._transact(frame, 8) == frame:
return True
time.sleep(0.02)
return False
def read_register(self, index: int, retries: int = 8) -> int:
"""FC03 read one holding register (official ReadRegisterFunc)."""
req = _frame(self.slave_id, 0x03, index, 1)
last: Optional[bytes] = None
with self._lock:
for _ in range(max(1, retries)):
rsp = self._transact(req, 7)
last = rsp
if rsp is None or len(rsp) != 7:
time.sleep(0.02)
continue
if crc16_modbus(rsp[:-2]) != (rsp[5] | (rsp[6] << 8)):
continue
if rsp[0] != (self.slave_id & 0xFF) or rsp[1] != 0x03:
continue
return (rsp[3] << 8) | (rsp[4] & 0xFF)
hint = f" last={last.hex() if last else '(empty)'}"
raise DhPgeaError(
f"Modbus read 0x{index:04X} failed on {self.port} "
f"(no valid reply{hint}). baud={self.baudrate} slave={self.slave_id} "
f"rts={self.rs485_rts or 'off'}"
)
def probe(self) -> int:
"""One short read of init-state. Raises if the claw is silent."""
return self.read_register(REG_INIT_STATE, retries=2)
def _set_tx(self, tx: bool) -> None:
if not self.rs485_rts or self._ser is None:
return
try:
if self.rs485_rts == "tx":
self._ser.rts = bool(tx)
else:
self._ser.rts = not bool(tx)
except Exception:
pass
def _transact(self, frame: bytes, expect: int) -> Optional[bytes]:
if self._ser is None or not self._ser.is_open:
raise DhPgeaError("serial not open")
bit_s = 10.0 / max(1, self.baudrate)
self._set_tx(True)
if self.rs485_rts:
time.sleep(2.0 * bit_s)
self._ser.reset_input_buffer()
self._ser.write(frame)
self._ser.flush()
if self.rs485_rts:
time.sleep(len(frame) * bit_s + 0.0008)
self._set_tx(False)
deadline = time.monotonic() + self.timeout_s
buf = bytearray()
while time.monotonic() < deadline and len(buf) < expect:
chunk = self._ser.read(expect - len(buf))
if chunk:
buf.extend(chunk)
return bytes(buf) if buf else None
def initialization(self) -> bool:
return self.write_register(REG_INIT, INIT_CMD)
def initialize(self, timeout_s: float = 20.0, poll_s: float = 0.2) -> bool:
"""Write 0xA5 then wait until init state == finished (official bringup)."""
state = self.get_init_state()
if state == S_INIT_FINISHED:
return True
if not self.initialization():
raise DhPgeaError("init write 0x0100=0xA5 failed (no Modbus echo)")
deadline = time.monotonic() + float(timeout_s)
while time.monotonic() < deadline:
state = self.get_init_state()
if state == S_INIT_FINISHED:
return True
time.sleep(poll_s)
raise DhPgeaError(
f"init timeout after {timeout_s:.0f}s (state={state}). "
"Fingers should home once on first power-up."
)
def set_target_force(self, force_pct: int) -> bool:
force_pct = int(max(20, min(100, force_pct)))
return self.write_register(REG_FORCE, force_pct)
def set_target_speed(self, speed_pct: int) -> bool:
speed_pct = int(max(1, min(100, speed_pct)))
return self.write_register(REG_SPEED, speed_pct)
def set_target_position(self, pos: int) -> bool:
pos = int(max(0, min(1000, pos)))
# 大寰固件:0x0103 写入与当前命令相同的值不会启动新运动。
# 开/关卡在 caught 时最常见——命令已是 1000,手指却停在夹持位。
try:
cmd = self.get_target_position(retries=2)
except Exception:
cmd = pos
if cmd == pos:
try:
cur = self.get_current_position(retries=2)
except Exception:
cur = pos
nudge = cur + (40 if pos >= cur else -40)
nudge = int(max(0, min(1000, nudge)))
if nudge == pos:
nudge = 0 if pos != 0 else 40
self.write_register(REG_POSITION, nudge)
time.sleep(0.06)
return self.write_register(REG_POSITION, pos)
def wait_settle(self, target: int, timeout_s: float = 6.0) -> GripperState:
"""等到到位/夹住/松开,或超时返回最后一帧。"""
t0 = time.monotonic()
last = None
st = self.get_run_states()
while time.monotonic() - t0 < timeout_s:
st = self.get_run_states()
moved = last is not None and st.position != last
last = st.position
if st.grip_state == S_GRIP_MOVING or moved:
time.sleep(0.08)
continue
if st.grip_state in (S_GRIP_ARRIVED, S_GRIP_DROPPED) and abs(st.position - target) <= 25:
return st
if st.grip_state == S_GRIP_CAUGHT:
return st
time.sleep(0.08)
return st
def get_init_state(self, retries: int = 8) -> int:
return self.read_register(REG_INIT_STATE, retries=retries)
def get_grip_state(self, retries: int = 8) -> int:
return self.read_register(REG_GRIP_STATE, retries=retries)
def get_current_position(self, retries: int = 8) -> int:
return self.read_register(REG_CUR_POSITION, retries=retries)
def get_target_position(self, retries: int = 8) -> int:
return self.read_register(REG_POSITION, retries=retries)
def get_target_force(self, retries: int = 8) -> int:
return self.read_register(REG_FORCE, retries=retries)
def get_run_states(self, retries: int = 8) -> GripperState:
return GripperState(
init_state=self.get_init_state(retries=retries),
grip_state=self.get_grip_state(retries=retries),
position=self.get_current_position(retries=retries),
target_position=self.get_target_position(retries=retries),
target_force=self.get_target_force(retries=retries),
stroke_mm=self.stroke_mm,
)
def open_jaw(self, force: int = 50, speed: int = 50) -> bool:
ok = self.set_target_force(force)
ok = self.set_target_speed(speed) and ok
return self.set_target_position(1000) and ok
def close_jaw(self, force: int = 50, speed: int = 50) -> bool:
ok = self.set_target_force(force)
ok = self.set_target_speed(speed) and ok
return self.set_target_position(0) and ok
def move(self, position: int, force: int = 50, speed: int = 50) -> bool:
ok = self.set_target_force(force)
ok = self.set_target_speed(speed) and ok
return self.set_target_position(position) and ok
def _selfcheck_crc() -> None:
"""Official short-manual example: set position 500."""
frame = _frame(1, 0x06, 0x0103, 500)
expect = bytes([0x01, 0x06, 0x01, 0x03, 0x01, 0xF4, 0x78, 0x21])
if frame != expect:
raise AssertionError(f"CRC mismatch: {frame.hex()} != {expect.hex()}")
def _site_defaults() -> dict:
try:
from pathlib import Path
root = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(root / "scripts" / "lib"))
from export_site_env import load_site # type: ignore
g = (load_site().get("gripper") or {})
return {
"port": str(g.get("port") or "/dev/ttyUSB0"),
"baudrate": int(g.get("baud") or 115200),
"slave_id": int(g.get("slave_id") or 1),
"stroke_mm": float(g.get("stroke_mm") or 20.0),
"force": int(g.get("force_pct") or 50),
"speed": int(g.get("speed_pct") or 50),
"rs485_rts": str(g.get("rs485_rts") or ""),
}
except Exception:
return {
"port": "/dev/ttyUSB0",
"baudrate": 115200,
"slave_id": 1,
"stroke_mm": 20.0,
"force": 50,
"speed": 50,
"rs485_rts": "",
}
def _scan_bus(port: str) -> int:
"""Try baud × slave × RTS until one combination answers."""
bauds = (115200, 57600, 38400, 19200, 9600)
slaves = (1, 2, 3)
rts_modes = ("", "tx", "tx_inv")
print(f"scan {port} (stop the ROS node first — port is exclusive)")
for baud in bauds:
for slave in slaves:
for rts in rts_modes:
label = f"baud={baud} slave={slave} rts={rts or 'off'}"
g = DhPgeaGripper(
port=port,
baudrate=baud,
slave_id=slave,
timeout_s=0.08,
rs485_rts=rts,
)
try:
g.open()
state = g.probe()
print(f"OK {label} init_state={state}")
print(f" → site.yaml: baud: {baud} slave_id: {slave} rs485_rts: \"{rts}\"")
g.close()
return 0
except DhPgeaError:
print(f" miss {label}")
finally:
g.close()
print("no reply on any combination.", file=sys.stderr)
print(WIRING_HINT, file=sys.stderr)
return 1
def _cli(argv: Optional[list[str]] = None) -> int:
site = _site_defaults()
ap = argparse.ArgumentParser(
description="DH PGEA Modbus CLI (official register map, not Flexiv RDK)"
)
ap.add_argument("--port", default=site["port"])
ap.add_argument("--baud", type=int, default=site["baudrate"])
ap.add_argument("--slave", type=int, default=site["slave_id"])
ap.add_argument("--force", type=int, default=site["force"])
ap.add_argument("--speed", type=int, default=site["speed"])
ap.add_argument("--rts", default=site.get("rs485_rts") or "", help="RS485 DE: empty | tx | tx_inv")
ap.add_argument(
"cmd",
choices=["probe", "init", "status", "open", "close", "pos", "selfcheck", "scan"],
)
ap.add_argument("value", nargs="?", default="", help="position 0–1000 for 'pos'")
args = ap.parse_args(argv)
if args.cmd == "selfcheck":
_selfcheck_crc()
print("CRC self-check OK (01 06 01 03 01 F4 78 21)")
return 0
if args.cmd == "scan":
return _scan_bus(args.port)
grip = DhPgeaGripper(
port=args.port,
baudrate=args.baud,
slave_id=args.slave,
stroke_mm=float(site["stroke_mm"]),
rs485_rts=args.rts,
)
try:
grip.open()
except DhPgeaError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 1
try:
if args.cmd == "probe":
st = grip.get_run_states(retries=2)
print(st.summary())
return 0
if args.cmd == "init":
print("initializing (fingers home)…")
grip.initialize()
print(grip.get_run_states().summary())
return 0
if args.cmd == "status":
print(grip.get_run_states().summary())
return 0
if args.cmd == "open":
if not grip.open_jaw(args.force, args.speed):
raise DhPgeaError("open write failed")
print(grip.wait_settle(1000).summary())
return 0
if args.cmd == "close":
if not grip.close_jaw(args.force, args.speed):
raise DhPgeaError("close write failed")
print(grip.wait_settle(0).summary())
return 0
if args.cmd == "pos":
if args.value == "":
print("usage: pos <0-1000>", file=sys.stderr)
return 2
pos = int(float(args.value))
if not grip.move(pos, args.force, args.speed):
raise DhPgeaError("position write failed")
print(grip.wait_settle(pos).summary())
return 0
except DhPgeaError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
print(WIRING_HINT, file=sys.stderr)
return 1
finally:
grip.close()
return 0
def main(argv: Optional[list[str]] = None) -> int:
return _cli(argv)
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,254 @@
#!/usr/bin/env python3
"""ROS 2 node for DH PGEA (official Modbus RTU, not Flexiv gripper).
Topics (layout matches DH-Robotics/dh_gripper_ros):
/gripper/ctrl std_msgs/Float32MultiArray
data: [initialize, position, force, speed]
initialize>0.5 → write 0xA5; else set force/speed/position
/gripper/states std_msgs/Float32MultiArray
data: [initialized, grip_state, position, target_pos, target_force]
/gripper/joint_states sensor_msgs/JointState
Services (std_srvs/Trigger):
/gripper/initialize /gripper/open /gripper/close
Do not set Flexiv bringup load_gripper:=true — that is Flexiv-GN01 only.
"""
from __future__ import annotations
import threading
from typing import Optional
import rclpy
from rcl_interfaces.msg import ParameterDescriptor
from rclpy.node import Node
from sensor_msgs.msg import JointState
from std_msgs.msg import Float32, Float32MultiArray, MultiArrayDimension, MultiArrayLayout
from std_srvs.srv import Trigger
from arm_application.dh_pgea import (
DhPgeaError,
DhPgeaGripper,
GripperState,
WIRING_HINT,
)
def _param_as_str(node: Node, name: str, default: str = "") -> str:
"""String param that also accepts numeric CLI overrides (e.g. SN 26610799)."""
node.declare_parameter(name, default, ParameterDescriptor(dynamic_typing=True))
val = node.get_parameter(name).value
if val is None or val == "":
return ""
return str(val).strip()
def _as_ctrl_array(msg: Float32MultiArray) -> tuple[float, float, float, float]:
data = list(msg.data)
while len(data) < 4:
data.append(0.0)
return float(data[0]), float(data[1]), float(data[2]), float(data[3])
class DhPgeaNode(Node):
def __init__(self) -> None:
super().__init__("dh_pgea")
self.declare_parameter("port", "/dev/ttyUSB0")
self.declare_parameter("baud", 115200)
self.declare_parameter("slave_id", 1)
self.declare_parameter("stroke_mm", 20.0)
self.declare_parameter("force_pct", 50)
self.declare_parameter("speed_pct", 50)
self.declare_parameter("auto_init", True)
self.declare_parameter("state_hz", 20.0)
self.declare_parameter("open_position", 1000)
self.declare_parameter("close_position", 0)
self._model = _param_as_str(self, "model", "PGEA-100-20-W")
self._sn = _param_as_str(self, "serial_number", "")
self._rts = _param_as_str(self, "rs485_rts", "")
self._port = str(self.get_parameter("port").value)
self._baud = int(self.get_parameter("baud").value)
self._slave = int(self.get_parameter("slave_id").value)
self._stroke = float(self.get_parameter("stroke_mm").value)
self._force = int(self.get_parameter("force_pct").value)
self._speed = int(self.get_parameter("speed_pct").value)
_auto = self.get_parameter("auto_init").value
if isinstance(_auto, str):
self._auto_init = _auto.strip().lower() in ("1", "true", "yes")
else:
self._auto_init = bool(_auto)
self._open_pos = int(self.get_parameter("open_position").value)
self._close_pos = int(self.get_parameter("close_position").value)
hz = float(self.get_parameter("state_hz").value)
self._grip = DhPgeaGripper(
port=self._port,
baudrate=self._baud,
slave_id=self._slave,
stroke_mm=self._stroke,
rs485_rts=self._rts,
)
self._state = GripperState(stroke_mm=self._stroke)
self._cmd_lock = threading.Lock()
self._pub_state = self.create_publisher(Float32MultiArray, "/gripper/states", 10)
self._pub_js = self.create_publisher(JointState, "/gripper/joint_states", 10)
self.create_subscription(Float32MultiArray, "/gripper/ctrl", self._on_ctrl, 10)
self.create_subscription(Float32, "/gripper/set_position", self._on_set_pos, 10)
self.create_service(Trigger, "/gripper/initialize", self._srv_init)
self.create_service(Trigger, "/gripper/open", self._srv_open)
self.create_service(Trigger, "/gripper/close", self._srv_close)
self.get_logger().info(
f"DH {self._model} SN={self._sn or '?'} "
f"{self._port} @{self._baud} slave={self._slave} rts={self._rts or 'off'} "
f"(official Modbus RTU; Flexiv RDK gripper API unused)"
)
try:
self._grip.open()
except DhPgeaError as exc:
self.get_logger().error(str(exc))
raise
self._online = False
try:
self._grip.probe()
self._online = True
except DhPgeaError as exc:
self.get_logger().error(str(exc))
for line in WIRING_HINT.splitlines():
self.get_logger().error(line)
self.get_logger().error("will keep retrying; open/close will fail until the bus answers")
if self._auto_init and self._online:
try:
self._grip.initialize()
self.get_logger().info("gripper initialized")
except DhPgeaError as exc:
self.get_logger().error(f"auto_init failed: {exc}")
self._online = False
period = 1.0 / max(1.0, hz)
self._timer = self.create_timer(period, self._on_timer)
def destroy_node(self) -> bool:
try:
self._grip.close()
except Exception:
pass
return super().destroy_node()
def _apply_move(self, position: int, force: Optional[int] = None, speed: Optional[int] = None) -> str:
with self._cmd_lock:
f = self._force if force is None else int(force)
s = self._speed if speed is None else int(speed)
if force is not None:
self._force = f
if speed is not None:
self._speed = s
if not self._grip.move(int(position), f, s):
raise DhPgeaError("move write failed")
return f"pos={int(position)} force={f}% speed={s}%"
def _on_ctrl(self, msg: Float32MultiArray) -> None:
initialize, position, force, speed = _as_ctrl_array(msg)
try:
if initialize > 0.5:
self._grip.initialize()
self.get_logger().info("initialize via /gripper/ctrl")
return
f = int(force) if force > 0 else self._force
s = int(speed) if speed > 0 else self._speed
info = self._apply_move(int(position), f, s)
self.get_logger().info(f"ctrl {info}")
except DhPgeaError as exc:
self.get_logger().error(str(exc))
def _on_set_pos(self, msg: Float32) -> None:
try:
info = self._apply_move(int(msg.data))
self.get_logger().info(f"set_position {info}")
except DhPgeaError as exc:
self.get_logger().error(str(exc))
def _srv_init(self, _req, resp):
try:
self._grip.initialize()
resp.success = True
resp.message = self._grip.get_run_states().summary()
except DhPgeaError as exc:
resp.success = False
resp.message = str(exc)
return resp
def _srv_open(self, _req, resp):
try:
info = self._apply_move(self._open_pos)
resp.success = True
resp.message = info
except DhPgeaError as exc:
resp.success = False
resp.message = str(exc)
return resp
def _srv_close(self, _req, resp):
try:
info = self._apply_move(self._close_pos)
resp.success = True
resp.message = info
except DhPgeaError as exc:
resp.success = False
resp.message = str(exc)
return resp
def _on_timer(self) -> None:
try:
st = self._grip.get_run_states(retries=2 if self._online else 1)
self._state = st
if not self._online:
self._online = True
self.get_logger().info("gripper bus recovered")
except DhPgeaError as exc:
self._online = False
self.get_logger().warn(str(exc), throttle_duration_sec=5.0)
return
arr = Float32MultiArray()
arr.layout = MultiArrayLayout(
dim=[MultiArrayDimension(label="dh_pgea", size=5, stride=5)],
data_offset=0,
)
arr.data = list(st.as_tuple())
self._pub_state.publish(arr)
js = JointState()
js.header.stamp = self.get_clock().now().to_msg()
js.name = ["gripper_finger1_joint"]
# Official ROS driver maps 0–1000 → ~0–0.637 rad; we publish mm as well.
js.position = [float(st.width_mm) * 0.001]
js.effort = [float(st.target_force)]
self._pub_js.publish(js)
def main(args=None) -> None:
rclpy.init(args=args)
node = None
try:
node = DhPgeaNode()
rclpy.spin(node)
except KeyboardInterrupt:
pass
except DhPgeaError:
pass
finally:
if node is not None:
node.destroy_node()
if rclpy.ok():
rclpy.shutdown()
if __name__ == "__main__":
main()
@@ -0,0 +1,803 @@
"""双臂夹爪统一控制架构(DH-Robotics 官方 Modbus RTU,非 Flexiv RDK)。
设计目标:
- 左臂(Rizon4-063989):大寰 PGEA 平行夹爪,控制行程/夹持力/速度。
- 右臂(Rizon4-063991):大寰 RGI-100-30-O-S-M1 旋转夹爪,控制手指夹持与旋转角度/圈数/速度。
- 统一接口,便于后续扩展到 ROS2 节点和新项目脚本。
注意:DH 官方协议是“写位置/角度寄存器后立即运动”,没有 ChangingTek SDK 的 0x0108 触发寄存器。
"""
from __future__ import annotations
import time
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from enum import Enum, auto
from pathlib import Path
from typing import Any
import numpy as np
from arm_application.dh_official import DHModbusClient, DHModbusError
class GripperType(Enum):
PARALLEL = auto() # 平行夹爪
ROTARY = auto() # 旋转夹爪
VACUUM = auto() # 真空吸盘(预留)
class GripperState(Enum):
IDLE = auto()
MOVING = auto()
GRIPPING = auto()
RELEASING = auto()
ERROR = auto()
@dataclass
class GripperStatus:
"""夹爪状态反馈。"""
state: GripperState = GripperState.IDLE
position: float = 0.0 # 平行:mm;旋转:角度 deg
force: float = 0.0 # 最近一次命令力/力矩 %
velocity: float = 0.0 # 最近一次命令速度 %
is_moving: bool = False
is_gripped: bool = False # 是否夹持到物体
connected: bool = False
initialized: bool = False
finger_position_per_mille: float = 0.0
rotation_deg: float = 0.0
gripper_state: int = -1 # DH 0x0201:0 moving,1 reached,2 caught,3 dropped
rotation_state: int = -1 # RGI 0x020B:0 moving,1 reached,2 blocking,3 blocked
error_msg: str = ""
timestamp: float = field(default_factory=time.time)
@dataclass
class GripperCommand:
"""夹爪控制指令。"""
target_position: float | None = None # 平行:mm;旋转:deg
target_force: float | None = None # 目标力/力矩 %
target_velocity: float | None = None # 目标速度 %
wait_complete: bool = True # 是否等待完成
timeout: float = 10.0 # 超时时间
class BaseGripper(ABC):
"""夹爪基类,定义统一接口。"""
name: str = "base_gripper"
gripper_type: GripperType = GripperType.PARALLEL
def __init__(self, config: dict[str, Any] | None = None) -> None:
self.cfg = config or {}
self._connected = False
self._status = GripperStatus()
@abstractmethod
def connect(self) -> None: ...
@abstractmethod
def disconnect(self) -> None: ...
@abstractmethod
def execute(self, cmd: GripperCommand) -> GripperStatus: ...
@abstractmethod
def get_status(self) -> GripperStatus: ...
def is_connected(self) -> bool:
return self._connected
def open(self, **kwargs) -> GripperStatus:
raise NotImplementedError
def close(self, **kwargs) -> GripperStatus:
raise NotImplementedError
def stop(self) -> None:
raise NotImplementedError
# =============================================================================
# 大寰平行夹爪(PGE/PGEA 系列)
# =============================================================================
@dataclass
class ParallelGripperConfig:
"""大寰平行夹爪配置。"""
port: str = "/dev/ttyUSB0"
slave_id: int = 1
baudrate: int = 115200
timeout: float = 0.2
# PGEA-100-40 行程为 40mm;若实际是 PGEA-100-26,请把 stroke_max 改成 26.0。
stroke_min: float = 0.0
stroke_max: float = 40.0
# 默认参数(DH 百分比)
default_speed: int = 50
default_force: int = 50
force_min: int = 20
force_max: int = 100
# 控制行为
auto_initialize: bool = True
init_timeout: float = 10.0
position_tolerance_per_mille: int = 15
status_poll_interval: float = 0.05
class ParallelGripper(BaseGripper):
"""大寰 PGE/PGEA 平行夹爪。"""
name = "dh_pgea_parallel"
gripper_type = GripperType.PARALLEL
def __init__(self, config: ParallelGripperConfig | None = None) -> None:
super().__init__()
self.cfg = config or ParallelGripperConfig()
self._client: DHModbusClient | None = None
self._last_position_mm = float(self.cfg.stroke_min)
self._last_force = int(self.cfg.default_force)
self._last_speed = int(self.cfg.default_speed)
def connect(self) -> None:
if self._client is not None:
self.disconnect()
self._client = DHModbusClient(
self.cfg.port,
slave_id=self.cfg.slave_id,
baudrate=self.cfg.baudrate,
timeout=self.cfg.timeout,
)
self._connected = True
self._status.connected = True
self._status.state = GripperState.IDLE
self._status.error_msg = ""
if self.cfg.auto_initialize:
self.initialize()
def disconnect(self) -> None:
if self._client is not None:
self._client.close()
self._client = None
self._connected = False
self._status.connected = False
self._status.state = GripperState.IDLE
def initialize(self, full: bool = False) -> None:
self._require_client()
assert self._client is not None
self._client.initialize(full=full, rotation=False, timeout=self.cfg.init_timeout)
self._status.initialized = True
def _require_client(self) -> None:
if not self._connected or self._client is None:
self._status.state = GripperState.ERROR
self._status.error_msg = "平行夹爪未连接"
raise DHModbusError(self._status.error_msg)
def _mm_to_per_mille(self, position_mm: float) -> int:
lo = float(self.cfg.stroke_min)
hi = float(self.cfg.stroke_max)
if hi <= lo:
hi = lo + 1.0
ratio = (float(position_mm) - lo) / (hi - lo)
return int(np.clip(round(ratio * 1000.0), 0, 1000))
def _per_mille_to_mm(self, position: float | int) -> float:
lo = float(self.cfg.stroke_min)
hi = float(self.cfg.stroke_max)
return lo + (float(position) / 1000.0) * (hi - lo)
def execute(self, cmd: GripperCommand) -> GripperStatus:
try:
self._require_client()
except DHModbusError:
return self._status
assert self._client is not None
position_mm = self._last_position_mm if cmd.target_position is None else float(cmd.target_position)
position_mm = float(np.clip(position_mm, self.cfg.stroke_min, self.cfg.stroke_max))
speed = int(np.clip(cmd.target_velocity or self.cfg.default_speed, 1, 100))
force = int(np.clip(cmd.target_force or self.cfg.default_force, self.cfg.force_min, self.cfg.force_max))
self._client.set_speed_pct(speed)
self._client.set_force_pct(force)
target_per_mille = self._client.set_position_per_mille(self._mm_to_per_mille(position_mm))
self._last_position_mm = position_mm
self._last_speed = speed
self._last_force = force
self._status.state = GripperState.MOVING
self._status.error_msg = ""
if cmd.wait_complete:
self._wait_complete(target_per_mille, cmd.timeout)
return self.get_status()
def _wait_complete(self, target_per_mille: int, timeout: float) -> None:
assert self._client is not None
start = time.monotonic()
time.sleep(0.05)
while time.monotonic() - start < timeout:
state = self._client.get_gripper_state()
pos = self._client.get_position_per_mille()
self._status.gripper_state = state
self._status.finger_position_per_mille = float(pos)
self._status.position = self._per_mille_to_mm(pos)
self._status.is_moving = state == 0
self._status.is_gripped = state == 2
if state in (1, 2):
self._status.state = GripperState.GRIPPING if state == 2 else GripperState.IDLE
return
if state == 3:
self._status.state = GripperState.ERROR
self._status.error_msg = "夹持后物体掉落"
return
if abs(pos - target_per_mille) <= self.cfg.position_tolerance_per_mille and state != 0:
self._status.state = GripperState.IDLE
return
time.sleep(self.cfg.status_poll_interval)
self._status.state = GripperState.ERROR
self._status.error_msg = f"平行夹爪运动超时(>{timeout:.1f}s)"
def get_status(self) -> GripperStatus:
if not self._connected or self._client is None:
self._status.connected = False
return self._status
try:
pos = self._client.get_position_per_mille()
state = self._client.get_gripper_state()
init_state = self._client.get_init_state()
self._status.connected = True
self._status.initialized = init_state == 1
self._status.finger_position_per_mille = float(pos)
self._status.position = self._per_mille_to_mm(pos)
self._status.velocity = float(self._last_speed)
self._status.force = float(self._last_force)
self._status.gripper_state = state
self._status.is_moving = state == 0
self._status.is_gripped = state == 2
if self._status.state != GripperState.ERROR:
self._status.state = GripperState.MOVING if state == 0 else (
GripperState.GRIPPING if state == 2 else GripperState.IDLE
)
self._status.timestamp = time.time()
except Exception as exc:
self._status.state = GripperState.ERROR
self._status.error_msg = str(exc)
self._status.connected = False
return self._status
def open(self, force: int | None = None, speed: int | None = None, **kwargs) -> GripperStatus:
# DH 千分比:0‰=闭合,1000‰=张开,对应行程 0mm -> stroke_max。
return self.execute(GripperCommand(
target_position=float(self.cfg.stroke_max),
target_force=force or self.cfg.default_force,
target_velocity=speed or self.cfg.default_speed,
**kwargs,
))
def close(self, force: int | None = None, speed: int | None = None, **kwargs) -> GripperStatus:
return self.execute(GripperCommand(
target_position=float(self.cfg.stroke_min),
target_force=force or self.cfg.default_force,
target_velocity=speed or self.cfg.default_speed,
**kwargs,
))
def move_to(self, position_mm: float, force: int | None = None, speed: int | None = None,
**kwargs) -> GripperStatus:
return self.execute(GripperCommand(
target_position=position_mm,
target_force=force or self.cfg.default_force,
target_velocity=speed or self.cfg.default_speed,
**kwargs,
))
def set_force(self, force_pct: int) -> None:
self._require_client()
assert self._client is not None
self._last_force = self._client.set_force_pct(force_pct)
def set_speed(self, speed_pct: int) -> None:
self._require_client()
assert self._client is not None
self._last_speed = self._client.set_speed_pct(speed_pct)
def stop(self) -> None:
if self._client is not None:
self._client.hold_current_position()
self._status.state = GripperState.IDLE
# =============================================================================
# 大寰旋转夹爪(RGI-100-30-O-S-M1)
# =============================================================================
@dataclass
class RotaryGripperConfig:
"""大寰 RGI 旋转夹爪配置。"""
port: str = "/dev/ttyUSB1"
slave_id: int = 1
baudrate: int = 115200
timeout: float = 0.2
# RGI 旋转角度寄存器是 signed 16bit;为避免 one's-complement 负零歧义,默认限制在 ±32767。
angle_min: float = -32767.0
angle_max: float = 32767.0
max_step_deg: float = 30000.0 # rotate_revolutions 分段旋转的最大单步角度
# DH 手册:手指位置 0~1000‰。0=闭合,1000=张开。RGI-100 行程约 70mm。
finger_open_per_mille: int = 1000
finger_close_per_mille: int = 0
# 默认参数(DH 百分比)
default_speed: int = 50 # 旋转速度 %
default_torque: int = 50 # 旋转力/力矩 %
default_finger_speed: int = 50 # 手指速度 %
default_finger_force: int = 50 # 手指夹持力 %
torque_min: int = 20
torque_max: int = 100
# 控制行为
auto_initialize: bool = True
init_timeout: float = 15.0 # RGI 初始化包含旋转找零,时间更长
angle_tolerance_deg: float = 1.0
status_poll_interval: float = 0.05
class RotaryGripper(BaseGripper):
"""大寰 RGI-100-30-O-S-M1 旋转夹爪。"""
name = "dh_rgi_rotary"
gripper_type = GripperType.ROTARY
def __init__(self, config: RotaryGripperConfig | None = None) -> None:
super().__init__()
self.cfg = config or RotaryGripperConfig()
self._client: DHModbusClient | None = None
self._last_angle = 0.0
self._last_torque = int(self.cfg.default_torque)
self._last_speed = int(self.cfg.default_speed)
self._last_finger_force = int(self.cfg.default_finger_force)
self._last_finger_speed = int(self.cfg.default_finger_speed)
self._last_finger_per_mille = int(self.cfg.finger_open_per_mille)
def connect(self) -> None:
if self._client is not None:
self.disconnect()
self._client = DHModbusClient(
self.cfg.port,
slave_id=self.cfg.slave_id,
baudrate=self.cfg.baudrate,
timeout=self.cfg.timeout,
)
self._connected = True
self._status.connected = True
self._status.state = GripperState.IDLE
self._status.error_msg = ""
if self.cfg.auto_initialize:
self.initialize()
def disconnect(self) -> None:
if self._client is not None:
self._client.close()
self._client = None
self._connected = False
self._status.connected = False
self._status.state = GripperState.IDLE
def initialize(self, full: bool = False) -> None:
self._require_client()
assert self._client is not None
self._client.initialize(full=full, rotation=True, timeout=self.cfg.init_timeout)
self._status.initialized = True
def _require_client(self) -> None:
if not self._connected or self._client is None:
self._status.state = GripperState.ERROR
self._status.error_msg = "旋转夹爪未连接"
raise DHModbusError(self._status.error_msg)
def execute(self, cmd: GripperCommand) -> GripperStatus:
"""执行旋转指令;cmd.target_position 单位为度。"""
return self.rotate_to(
self._last_angle if cmd.target_position is None else float(cmd.target_position),
speed=cmd.target_velocity,
torque=cmd.target_force,
wait_complete=cmd.wait_complete,
timeout=cmd.timeout,
)
def _wait_complete(self, target_angle: float, timeout: float) -> None:
assert self._client is not None
start = time.monotonic()
start_angle = float(self._client.get_rotation_angle_deg())
expected = abs(float(target_angle) - start_angle) + 30.0
settled = 0
last_angle = start_angle
time.sleep(0.05)
while time.monotonic() - start < timeout:
rot_state = self._client.get_rotation_state()
angle = float(self._client.get_rotation_angle_deg())
self._status.rotation_state = rot_state
self._status.rotation_deg = angle
self._status.position = angle
self._status.is_moving = rot_state == 0
if abs(angle - start_angle) > expected:
self._client.hold_current_rotation()
self._status.state = GripperState.ERROR
self._status.error_msg = (
f"旋转超程保护:目标 {target_angle:.1f}°,从 {start_angle:.1f}° 跑到 {angle:.1f}°,已发停止"
)
return
if rot_state in (1, 2):
self._status.state = GripperState.IDLE if rot_state == 1 else GripperState.GRIPPING
if rot_state == 2:
self._status.error_msg = "旋转受阻(blocking)"
return
if rot_state == 3:
self._status.state = GripperState.ERROR
self._status.error_msg = "旋转曾受阻且未到目标位"
return
if abs(angle - float(target_angle)) <= self.cfg.angle_tolerance_deg:
settled += 1
if settled >= 3:
self._status.state = GripperState.IDLE
self._status.is_moving = False
return
elif abs(angle - last_angle) <= self.cfg.angle_tolerance_deg:
settled += 1
if settled >= 8 and abs(angle - float(target_angle)) <= 15.0:
self._status.state = GripperState.IDLE
self._status.is_moving = False
return
else:
settled = 0
last_angle = angle
time.sleep(self.cfg.status_poll_interval)
self._client.hold_current_rotation()
self._status.state = GripperState.ERROR
self._status.error_msg = f"旋转运动超时(>{timeout:.1f}s),已发停止"
def get_status(self) -> GripperStatus:
if not self._connected or self._client is None:
self._status.connected = False
return self._status
try:
finger_pos = self._client.get_position_per_mille()
finger_state = self._client.get_gripper_state()
init_state = self._client.get_init_state()
rot_angle = self._client.get_rotation_angle_deg()
rot_state = self._client.get_rotation_state()
rot_init = self._client.get_rotation_init_state()
self._status.connected = True
self._status.initialized = init_state == 1 and rot_init == 1
self._status.finger_position_per_mille = float(finger_pos)
self._status.rotation_deg = float(rot_angle)
self._status.position = float(rot_angle)
self._status.velocity = float(self._last_speed)
self._status.force = float(self._last_torque)
self._status.gripper_state = finger_state
self._status.rotation_state = rot_state
self._status.is_moving = finger_state == 0 or rot_state == 0
self._status.is_gripped = finger_state == 2
if self._status.state != GripperState.ERROR:
if finger_state == 2 or rot_state == 2:
self._status.state = GripperState.GRIPPING
elif finger_state == 0 or rot_state == 0:
self._status.state = GripperState.MOVING
else:
self._status.state = GripperState.IDLE
self._status.timestamp = time.time()
except Exception as exc:
self._status.state = GripperState.ERROR
self._status.error_msg = str(exc)
self._status.connected = False
return self._status
# -----------------------------
# 手指夹持
# -----------------------------
def move_fingers(self, position_per_mille: int, force: int | None = None,
speed: int | None = None, wait_complete: bool = True,
timeout: float = 10.0) -> GripperStatus:
try:
self._require_client()
except DHModbusError:
return self._status
assert self._client is not None
target = int(np.clip(position_per_mille, 0, 1000))
f = int(np.clip(force or self.cfg.default_finger_force, 20, 100))
s = int(np.clip(speed or self.cfg.default_finger_speed, 1, 100))
self._client.set_speed_pct(s)
self._client.set_force_pct(f)
self._client.set_position_per_mille(target)
self._last_finger_per_mille = target
self._last_finger_force = f
self._last_finger_speed = s
if wait_complete:
start = time.monotonic()
time.sleep(0.05)
while time.monotonic() - start < timeout:
state = self._client.get_gripper_state()
pos = self._client.get_position_per_mille()
self._status.gripper_state = state
self._status.finger_position_per_mille = float(pos)
if state in (1, 2):
break
if state == 3:
self._status.state = GripperState.ERROR
self._status.error_msg = "夹持后物体掉落"
break
time.sleep(self.cfg.status_poll_interval)
return self.get_status()
def open_fingers(self, **kwargs) -> GripperStatus:
return self.move_fingers(self.cfg.finger_open_per_mille, **kwargs)
def close_fingers(self, **kwargs) -> GripperStatus:
return self.move_fingers(self.cfg.finger_close_per_mille, **kwargs)
# -----------------------------
# 旋转
# -----------------------------
def rotate_to(self, angle_deg: float, speed: int | None = None, torque: int | None = None,
wait_complete: bool = True, timeout: float = 10.0, **kwargs) -> GripperStatus:
try:
self._require_client()
except DHModbusError:
return self._status
assert self._client is not None
angle = float(np.clip(angle_deg, self.cfg.angle_min, self.cfg.angle_max))
s = int(np.clip(speed or self.cfg.default_speed, 1, 100))
t = int(np.clip(torque or self.cfg.default_torque, self.cfg.torque_min, self.cfg.torque_max))
self._client.set_rotation_speed_pct(s)
self._client.set_rotation_force_pct(t)
target = self._client.set_rotation_angle_deg(angle)
self._last_angle = float(target)
self._last_speed = s
self._last_torque = t
self._status.state = GripperState.MOVING
self._status.error_msg = ""
if wait_complete:
self._wait_complete(float(target), timeout)
return self.get_status()
def rotate_by(self, delta_deg: float, **kwargs) -> GripperStatus:
current = self.get_status().rotation_deg
return self.rotate_to(current + float(delta_deg), **kwargs)
def rotate_revolutions(self, revolutions: float, **kwargs) -> GripperStatus:
"""旋转指定圈数;超过单步角度范围时自动分段,并按实际反馈角度结算。"""
remaining = float(revolutions) * 360.0
status = self.get_status()
max_step = abs(float(self.cfg.max_step_deg)) or 30000.0
while abs(remaining) > 1e-6:
before = float(self.get_status().rotation_deg)
step = float(np.clip(remaining, -max_step, max_step))
status = self.rotate_to(before + step, **kwargs)
if status.state == GripperState.ERROR:
return status
after = float(status.rotation_deg)
achieved = after - before
if abs(achieved) < max(1.0, abs(step) * 0.2):
status.state = GripperState.ERROR
status.error_msg = (
"旋转未达到预期步进,可能超出角度寄存器范围或反馈异常:"
f"step={step:.3f}, before={before:.3f}, after={after:.3f}"
)
return status
remaining -= achieved
return status
def home_rotation(self, **kwargs) -> GripperStatus:
return self.rotate_to(0.0, **kwargs)
def set_rotation_speed(self, speed_pct: int) -> None:
self._require_client()
assert self._client is not None
self._last_speed = self._client.set_rotation_speed_pct(speed_pct)
def set_rotation_torque(self, torque_pct: int) -> None:
self._require_client()
assert self._client is not None
self._last_torque = self._client.set_rotation_force_pct(torque_pct)
def set_finger_speed(self, speed_pct: int) -> None:
self._require_client()
assert self._client is not None
self._last_finger_speed = self._client.set_speed_pct(speed_pct)
def set_finger_force(self, force_pct: int) -> None:
self._require_client()
assert self._client is not None
self._last_finger_force = self._client.set_force_pct(force_pct)
def open(self, **kwargs) -> GripperStatus:
"""对于 RGI,open 定义为张开手指;旋转请用 rotate_to/rotate_revolutions。"""
return self.open_fingers(**kwargs)
def close(self, **kwargs) -> GripperStatus:
"""对于 RGI,close 定义为闭合手指;旋转请用 rotate_to/rotate_revolutions。"""
return self.close_fingers(**kwargs)
def stop(self) -> None:
if self._client is not None:
self._client.hold_current_all()
self._status.state = GripperState.IDLE
# =============================================================================
# 双臂夹爪管理器
# =============================================================================
@dataclass
class DualArmGripperConfig:
"""双臂夹爪配置。"""
left_arm_sn: str = "Rizon4-063989"
right_arm_sn: str = "Rizon4-063991"
left_gripper: ParallelGripperConfig = field(default_factory=ParallelGripperConfig)
right_gripper: RotaryGripperConfig = field(default_factory=RotaryGripperConfig)
class DualArmGripperManager:
"""双臂夹爪管理器:统一控制左右臂夹爪。"""
def __init__(self, config: DualArmGripperConfig | None = None) -> None:
self.cfg = config or DualArmGripperConfig()
self._left: ParallelGripper | None = None
self._right: RotaryGripper | None = None
@property
def left(self) -> ParallelGripper:
if self._left is None:
self._left = ParallelGripper(self.cfg.left_gripper)
return self._left
@property
def right(self) -> RotaryGripper:
if self._right is None:
self._right = RotaryGripper(self.cfg.right_gripper)
return self._right
def connect_all(self) -> None:
self.left.connect()
self.right.connect()
def disconnect_all(self) -> None:
if self._left:
self._left.disconnect()
if self._right:
self._right.disconnect()
def initialize_all(self, full: bool = False) -> None:
self.left.initialize(full=full)
self.right.initialize(full=full)
def get_all_status(self) -> dict[str, GripperStatus]:
return {
"left": self.left.get_status(),
"right": self.right.get_status(),
}
def emergency_stop(self) -> None:
self.left.stop()
self.right.stop()
def load_dual_arm_gripper_config(path: str | Path) -> DualArmGripperConfig:
"""从 configs/dual_arm_robot.yaml 加载双臂夹爪配置。"""
import yaml
data = yaml.safe_load(Path(path).read_text(encoding="utf-8")) or {}
left = data.get("left_arm", {}) or {}
right = data.get("right_arm", {}) or {}
grip = data.get("gripper", {}) or {}
parallel = grip.get("parallel", {}) or {}
rotary = grip.get("rotary", {}) or {}
left_cfg = ParallelGripperConfig(
port=left.get("gripper_port", "/dev/ttyUSB0"),
slave_id=int(left.get("gripper_slave_id", 1)),
baudrate=int(left.get("gripper_baudrate", 115200)),
stroke_min=float(parallel.get("stroke_min", 0.0)),
stroke_max=float(parallel.get("stroke_max", 40.0)),
default_speed=int(parallel.get("default_speed", 50)),
default_force=int(parallel.get("default_force", 50)),
force_min=int(parallel.get("force_min", 20)),
force_max=int(parallel.get("force_max", 100)),
auto_initialize=bool(parallel.get("auto_initialize", True)),
)
right_cfg = RotaryGripperConfig(
port=right.get("gripper_port", "/dev/ttyUSB1"),
slave_id=int(right.get("gripper_slave_id", 1)),
baudrate=int(right.get("gripper_baudrate", 115200)),
angle_min=float(rotary.get("angle_min", -32767.0)),
angle_max=float(rotary.get("angle_max", 32767.0)),
max_step_deg=float(rotary.get("max_step_deg", 30000.0)),
finger_open_per_mille=int(rotary.get("finger_open_per_mille", 1000)),
finger_close_per_mille=int(rotary.get("finger_close_per_mille", 0)),
default_speed=int(rotary.get("default_speed", 50)),
default_torque=int(rotary.get("default_torque", 50)),
default_finger_speed=int(rotary.get("default_finger_speed", 50)),
default_finger_force=int(rotary.get("default_finger_force", 50)),
torque_min=int(rotary.get("torque_min", 20)),
torque_max=int(rotary.get("torque_max", 100)),
auto_initialize=bool(rotary.get("auto_initialize", True)),
)
return DualArmGripperConfig(
left_arm_sn=left.get("robot_sn", "Rizon4-063989"),
right_arm_sn=right.get("robot_sn", "Rizon4-063991"),
left_gripper=left_cfg,
right_gripper=right_cfg,
)
def load_from_site(data: dict) -> DualArmGripperConfig:
"""Build DualArmGripperConfig from ~/ros2/config/site.yaml."""
robots = list((data.get("robot") or {}).get("robots") or [])
left = next((r for r in robots if str(r.get("arm") or "").lower() == "left"), {})
right = next((r for r in robots if str(r.get("arm") or "").lower() == "right"), {})
if not left:
left = next((r for r in robots if "063989" in str(r.get("sn") or "")), {})
if not right:
right = next((r for r in robots if "063991" in str(r.get("sn") or "")), {})
g = data.get("gripper") or {}
parallel = g.get("parallel") or {}
rotary = g.get("rotary") or {}
lg = left.get("gripper") or {}
rg = right.get("gripper") or {}
stroke = float(lg.get("stroke_mm") or parallel.get("stroke_max") or g.get("stroke_mm") or 20.0)
left_cfg = ParallelGripperConfig(
port=str(lg.get("port") or "/dev/ttyUSB0"),
slave_id=int(lg.get("slave_id") or g.get("slave_id") or 1),
baudrate=int(lg.get("baud") or g.get("baud") or 115200),
stroke_min=float(parallel.get("stroke_min") or 0.0),
stroke_max=stroke,
default_speed=int(parallel.get("default_speed") or g.get("speed_pct") or 50),
default_force=int(parallel.get("default_force") or g.get("force_pct") or 50),
force_min=int(parallel.get("force_min") or 20),
force_max=int(parallel.get("force_max") or 100),
auto_initialize=bool(lg.get("auto_init", parallel.get("auto_initialize", True))),
init_timeout=float(parallel.get("init_timeout") or 10.0),
)
right_cfg = RotaryGripperConfig(
port=str(rg.get("port") or "/dev/ttyUSB1"),
slave_id=int(rg.get("slave_id") or 1),
baudrate=int(rg.get("baud") or g.get("baud") or 115200),
angle_min=float(rotary.get("angle_min") or -32767.0),
angle_max=float(rotary.get("angle_max") or 32767.0),
max_step_deg=float(rotary.get("max_step_deg") or 30000.0),
finger_open_per_mille=int(rotary.get("finger_open_per_mille") or 1000),
finger_close_per_mille=int(rotary.get("finger_close_per_mille") or 0),
default_speed=int(rotary.get("default_speed") or 50),
default_torque=int(rotary.get("default_torque") or 50),
default_finger_speed=int(rotary.get("default_finger_speed") or 50),
default_finger_force=int(rotary.get("default_finger_force") or 50),
torque_min=int(rotary.get("torque_min") or 20),
torque_max=int(rotary.get("torque_max") or 100),
auto_initialize=bool(rg.get("auto_init", rotary.get("auto_initialize", False))),
init_timeout=float(rotary.get("init_timeout") or 15.0),
)
return DualArmGripperConfig(
left_arm_sn=str(left.get("sn") or "Rizon4-063989"),
right_arm_sn=str(right.get("sn") or "Rizon4-063991"),
left_gripper=left_cfg,
right_gripper=right_cfg,
)
@@ -0,0 +1,142 @@
#!/usr/bin/env python3
"""CLI for DH PGEA (left) and RGI (right). Loads ports from site.yaml."""
from __future__ import annotations
import argparse
import os
import sys
import time
from pathlib import Path
import yaml
from arm_application.dual_gripper import DualArmGripperManager, load_from_site
def _site_path() -> Path:
env = os.environ.get("SITE_CONFIG", "").strip()
if env:
return Path(env).expanduser()
return Path.home() / "ros2" / "config" / "site.yaml"
def _print_status(label: str, status) -> None:
print(
f" {label}: state={status.state.name} "
f"pos={status.position:.2f} finger={status.finger_position_per_mille:.0f}‰ "
f"rot={status.rotation_deg:.2f}° moving={status.is_moving} "
f"gripped={status.is_gripped} init={status.initialized} "
f"err={status.error_msg or '-'}"
)
def _normalize_arm(raw: str) -> str:
t = (raw or "").strip().lower()
if t in ("89", "left", "l", "063989"):
return "left"
if t in ("91", "right", "r", "063991"):
return "right"
if t in ("both", "dual", "all"):
return "both"
raise SystemExit(f"unknown --arm {raw!r} (use 89|91|left|right|both)")
def test_parallel(manager: DualArmGripperManager, args: argparse.Namespace) -> None:
gripper = manager.left
print(f"=== PGEA parallel ({gripper.cfg.port}) ===")
gripper.connect()
_print_status("connected", gripper.get_status())
if args.test in ("init", "probe", "all"):
if args.test != "probe":
gripper.initialize(full=args.init_full)
_print_status("init", gripper.get_status())
if args.test in ("open", "all"):
_print_status("open", gripper.open(force=args.force, speed=args.speed))
if args.test in ("close", "all"):
_print_status("close", gripper.close(force=args.force, speed=args.speed))
if args.test in ("move", "pos") and args.pos is not None:
_print_status("move", gripper.move_to(args.pos, force=args.force, speed=args.speed))
if args.test in ("status", "probe"):
_print_status("status", gripper.get_status())
def test_rotary(manager: DualArmGripperManager, args: argparse.Namespace) -> None:
gripper = manager.right
print(f"=== RGI rotary ({gripper.cfg.port}) ===")
gripper.connect()
_print_status("connected", gripper.get_status())
if args.test in ("init", "all"):
gripper.initialize(full=args.init_full)
_print_status("init", gripper.get_status())
if args.test in ("open", "all"):
_print_status("open", gripper.open_fingers(force=args.force, speed=args.speed))
if args.test in ("close", "all"):
_print_status("close", gripper.close_fingers(force=args.force, speed=args.speed))
if args.test in ("move", "pos") and args.pos is not None:
_print_status("fingers", gripper.move_fingers(int(args.pos), force=args.force, speed=args.speed))
if args.test in ("rotate", "rotate_to", "all"):
if args.revolutions is not None:
_print_status(
"revolutions",
gripper.rotate_revolutions(args.revolutions, speed=args.speed, torque=args.force),
)
else:
angle = args.angle if args.angle is not None else 90.0
_print_status("rotate", gripper.rotate_to(angle, speed=args.speed, torque=args.force))
if args.test == "rotate_by":
delta = args.angle if args.angle is not None else 45.0
_print_status("rotate_by", gripper.rotate_by(delta, speed=args.speed, torque=args.force))
if args.test in ("status", "probe"):
_print_status("status", gripper.get_status())
def main() -> int:
parser = argparse.ArgumentParser(description="DH PGEA / RGI CLI (site.yaml ports)")
parser.add_argument("--arm", default=os.environ.get("ROBOT_ARM") or os.environ.get("ROBOT_SN") or "89")
parser.add_argument(
"--test",
default="status",
help="probe|init|open|close|move|pos|rotate|rotate_by|status",
)
parser.add_argument("--pos", type=float, default=None, help="PGEA mm or RGI finger 0-1000")
parser.add_argument("--angle", type=float, default=None)
parser.add_argument("--revolutions", type=float, default=None)
parser.add_argument("--speed", type=int, default=25)
parser.add_argument("--force", type=int, default=33)
parser.add_argument("--init-full", action="store_true")
parser.add_argument("--site", default="")
args = parser.parse_args()
site = Path(args.site).expanduser() if args.site else _site_path()
if not site.is_file():
print(f"ERROR: site.yaml missing: {site}", file=sys.stderr)
return 1
data = yaml.safe_load(site.read_text(encoding="utf-8")) or {}
manager = DualArmGripperManager(load_from_site(data))
arm = _normalize_arm(str(args.arm))
print(f"site={site} arm={arm} test={args.test}")
try:
if arm == "left":
test_parallel(manager, args)
elif arm == "right":
test_rotary(manager, args)
else:
test_parallel(manager, args)
test_rotary(manager, args)
except KeyboardInterrupt:
print("interrupt — stop")
manager.emergency_stop()
return 130
except Exception as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 1
finally:
manager.disconnect_all()
time.sleep(0.05)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,318 @@
"""ROS 2 dual DH gripper node (PGEA left + RGI right). Uses arm_msgs, not Flexiv RDK."""
from __future__ import annotations
import os
import threading
from pathlib import Path
import rclpy
import yaml
from rclpy.callback_groups import ReentrantCallbackGroup
from rclpy.executors import MultiThreadedExecutor
from rclpy.node import Node
from arm_application.dual_gripper import (
DualArmGripperManager,
GripperState,
load_dual_arm_gripper_config,
load_from_site,
)
from arm_msgs.msg import GripperCommand as GripperCommandMsg
from arm_msgs.msg import GripperStatus as GripperStatusMsg
from arm_msgs.srv import GripperExecute
def _site_path() -> Path:
env = os.environ.get("SITE_CONFIG", "").strip()
if env:
return Path(env).expanduser()
return Path.home() / "ros2" / "config" / "site.yaml"
def _as_bool(val, default: bool = True) -> bool:
if val is None:
return default
if isinstance(val, str):
return val.strip().lower() in ("1", "true", "yes", "on")
return bool(val)
class DualGripperNode(Node):
def __init__(self) -> None:
super().__init__("dual_gripper_driver")
self.declare_parameter("config_file", "")
self.declare_parameter("auto_connect", True)
self.declare_parameter("allow_offline", True)
self.declare_parameter("publish_hz", 10.0)
self.declare_parameter("sides", "left,right")
self._cb_group = ReentrantCallbackGroup()
self._locks = {"left": threading.Lock(), "right": threading.Lock()}
sides_raw = str(self.get_parameter("sides").value or "left,right")
self._sides = [s.strip().lower() for s in sides_raw.split(",") if s.strip()]
if not self._sides:
self._sides = ["left", "right"]
cfg_path = self.get_parameter("config_file").get_parameter_value().string_value.strip()
if cfg_path:
self._manager = DualArmGripperManager(load_dual_arm_gripper_config(cfg_path))
else:
site = _site_path()
data = yaml.safe_load(site.read_text(encoding="utf-8")) or {}
self._manager = DualArmGripperManager(load_from_site(data))
self.get_logger().info(f"gripper config from site.yaml: {site}")
self._connect_errors: dict[str, str] = {}
if _as_bool(self.get_parameter("auto_connect").value, True):
self._connect_all()
self._left_pub = self.create_publisher(GripperStatusMsg, "~/left/status", 10)
self._right_pub = self.create_publisher(GripperStatusMsg, "~/right/status", 10)
if "left" in self._sides:
self.create_subscription(
GripperCommandMsg,
"~/left/command",
lambda msg: self._on_command("left", msg),
10,
callback_group=self._cb_group,
)
self.create_service(
GripperExecute,
"~/left/execute",
lambda req, resp: self._on_execute("left", req, resp),
callback_group=self._cb_group,
)
if "right" in self._sides:
self.create_subscription(
GripperCommandMsg,
"~/right/command",
lambda msg: self._on_command("right", msg),
10,
callback_group=self._cb_group,
)
self.create_service(
GripperExecute,
"~/right/execute",
lambda req, resp: self._on_execute("right", req, resp),
callback_group=self._cb_group,
)
period = 1.0 / max(1e-3, float(self.get_parameter("publish_hz").value))
self.create_timer(period, self._publish_status, callback_group=self._cb_group)
self.get_logger().info(
f"dual_gripper_driver ready sides={self._sides} "
"~/left|right/command status execute"
)
def _connect_all(self) -> None:
mapping = (("left", self._manager.left), ("right", self._manager.right))
for side, gripper in mapping:
if side not in self._sides:
continue
try:
gripper.connect()
self.get_logger().info(f"{side} gripper connected: {gripper.cfg.port}")
except Exception as exc:
self._connect_errors[side] = str(exc)
self.get_logger().error(f"{side} gripper connect failed: {exc}")
if self._connect_errors and not _as_bool(self.get_parameter("allow_offline").value, True):
raise RuntimeError(f"gripper connect failed: {self._connect_errors}")
def _gripper(self, side: str):
return self._manager.left if side == "left" else self._manager.right
def _execute(self, side: str, msg: GripperCommandMsg):
gripper = self._gripper(side)
command = (msg.command or "").strip().lower()
timeout = float(msg.timeout or 10.0)
wait = bool(msg.wait_complete)
if side in self._connect_errors and not gripper.is_connected():
status = gripper.get_status()
status.error_msg = self._connect_errors[side]
return status
if command == "connect":
gripper.connect()
self._connect_errors.pop(side, None)
return gripper.get_status()
if command == "init":
gripper.initialize(full=False)
return gripper.get_status()
if command == "init_full":
gripper.initialize(full=True)
return gripper.get_status()
if command == "stop":
gripper.stop()
return gripper.get_status()
if side == "left":
if command == "open":
return gripper.open(
force=int(msg.force) if msg.force else None,
speed=int(msg.speed) if msg.speed else None,
wait_complete=wait,
timeout=timeout,
)
if command == "close":
return gripper.close(
force=int(msg.force) if msg.force else None,
speed=int(msg.speed) if msg.speed else None,
wait_complete=wait,
timeout=timeout,
)
if command == "move":
return gripper.move_to(
float(msg.position),
force=int(msg.force) if msg.force else None,
speed=int(msg.speed) if msg.speed else None,
wait_complete=wait,
timeout=timeout,
)
if command == "set_force":
gripper.set_force(int(msg.force))
return gripper.get_status()
if command == "set_speed":
gripper.set_speed(int(msg.speed))
return gripper.get_status()
else:
if command == "open":
return gripper.open_fingers(
force=int(msg.force) if msg.force else None,
speed=int(msg.speed) if msg.speed else None,
wait_complete=wait,
timeout=timeout,
)
if command == "close":
return gripper.close_fingers(
force=int(msg.force) if msg.force else None,
speed=int(msg.speed) if msg.speed else None,
wait_complete=wait,
timeout=timeout,
)
if command == "move":
return gripper.move_fingers(
int(msg.position),
force=int(msg.force) if msg.force else None,
speed=int(msg.speed) if msg.speed else None,
wait_complete=wait,
timeout=timeout,
)
if command in ("rotate", "rotate_to"):
return gripper.rotate_to(
float(msg.position),
speed=int(msg.speed) if msg.speed else None,
torque=int(msg.force) if msg.force else None,
wait_complete=wait,
timeout=timeout,
)
if command == "rotate_by":
return gripper.rotate_by(
float(msg.position),
speed=int(msg.speed) if msg.speed else None,
torque=int(msg.force) if msg.force else None,
wait_complete=wait,
timeout=timeout,
)
if command == "rotate_revolutions":
return gripper.rotate_revolutions(
float(msg.position),
speed=int(msg.speed) if msg.speed else None,
torque=int(msg.force) if msg.force else None,
wait_complete=wait,
timeout=timeout,
)
if command == "home_rotation":
return gripper.home_rotation(
speed=int(msg.speed) if msg.speed else None,
torque=int(msg.force) if msg.force else None,
wait_complete=wait,
timeout=timeout,
)
if command == "set_force":
gripper.set_rotation_torque(int(msg.force))
return gripper.get_status()
if command == "set_speed":
gripper.set_rotation_speed(int(msg.speed))
return gripper.get_status()
if command == "set_finger_force":
gripper.set_finger_force(int(msg.force))
return gripper.get_status()
if command == "set_finger_speed":
gripper.set_finger_speed(int(msg.speed))
return gripper.get_status()
status = gripper.get_status()
status.state = GripperState.ERROR
status.error_msg = f"unsupported command {command!r} (side={side})"
return status
def _on_command(self, side: str, msg: GripperCommandMsg) -> None:
with self._locks[side]:
status = self._execute(side, msg)
if status.state == GripperState.ERROR:
self.get_logger().warn(f"{side} command {msg.command!r} failed: {status.error_msg}")
def _on_execute(self, side: str, request: GripperExecute.Request, response: GripperExecute.Response):
with self._locks[side]:
status = self._execute(side, request.command)
response.success = status.state != GripperState.ERROR and status.connected
response.message = status.error_msg or "OK"
response.status = self._to_status_msg(side, status)
return response
def _to_status_msg(self, side: str, status) -> GripperStatusMsg:
gripper = self._gripper(side)
msg = GripperStatusMsg()
msg.header.stamp = self.get_clock().now().to_msg()
msg.header.frame_id = f"{side}_gripper"
msg.name = gripper.name
msg.gripper_type = gripper.gripper_type.name.lower()
msg.state = status.state.name
msg.connected = bool(status.connected)
msg.initialized = bool(status.initialized)
msg.is_moving = bool(status.is_moving)
msg.is_gripped = bool(status.is_gripped)
msg.position = float(status.position)
msg.finger_position_per_mille = float(status.finger_position_per_mille)
msg.rotation_deg = float(status.rotation_deg)
msg.gripper_state = int(status.gripper_state)
msg.rotation_state = int(status.rotation_state)
msg.velocity = float(status.velocity)
msg.force = float(status.force)
msg.error_msg = status.error_msg or ""
return msg
def _publish_status(self) -> None:
if "left" in self._sides:
left_status = self._manager.left.get_status()
if "left" in self._connect_errors and not left_status.connected:
left_status.error_msg = self._connect_errors["left"]
self._left_pub.publish(self._to_status_msg("left", left_status))
if "right" in self._sides:
right_status = self._manager.right.get_status()
if "right" in self._connect_errors and not right_status.connected:
right_status.error_msg = self._connect_errors["right"]
self._right_pub.publish(self._to_status_msg("right", right_status))
def destroy_node(self) -> bool:
try:
self._manager.disconnect_all()
finally:
return super().destroy_node()
def main() -> None:
rclpy.init()
node = DualGripperNode()
executor = MultiThreadedExecutor(num_threads=4)
try:
rclpy.spin(node, executor=executor)
finally:
node.destroy_node()
rclpy.shutdown()
if __name__ == "__main__":
main()
@@ -0,0 +1,135 @@
#!/usr/bin/env python3
"""TF-based geometric Jacobian + damped least-squares position IK step."""
from __future__ import annotations
from typing import List, Optional, Sequence, Tuple
import numpy as np
# URDF axes expressed in each child link frame (Rizon4).
RIZON_LINK_AXES = [
(0.0, 0.0, 1.0), # joint1 -> link1
(0.0, 1.0, 0.0), # joint2 -> link2
(0.0, 0.0, 1.0),
(0.0, 1.0, 0.0),
(0.0, 0.0, 1.0),
(0.0, 1.0, 0.0),
(0.0, 0.0, 1.0),
]
def quat_xyzw_to_R(q: Sequence[float]) -> np.ndarray:
x, y, z, w = [float(v) for v in q]
n = np.sqrt(x * x + y * y + z * z + w * w) + 1e-12
x, y, z, w = x / n, y / n, z / n, w / n
return np.array(
[
[1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w)],
[2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w)],
[2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y)],
],
dtype=float,
)
def geometric_jacobian(
link_R: Sequence[np.ndarray],
link_p: Sequence[np.ndarray],
ee_p: np.ndarray,
axes_local: Sequence[Tuple[float, float, float]] = RIZON_LINK_AXES,
) -> np.ndarray:
"""Return 6x7 geometric Jacobian in base frame (linear then angular)."""
n = len(axes_local)
J = np.zeros((6, n), dtype=float)
pee = np.asarray(ee_p, dtype=float).reshape(3)
for i in range(n):
R = np.asarray(link_R[i], dtype=float).reshape(3, 3)
p = np.asarray(link_p[i], dtype=float).reshape(3)
z = R @ np.asarray(axes_local[i], dtype=float).reshape(3)
J[:3, i] = np.cross(z, pee - p)
J[3:, i] = z
return J
def position_jacobian(
link_R: Sequence[np.ndarray],
link_p: Sequence[np.ndarray],
ee_p: np.ndarray,
axes_local: Sequence[Tuple[float, float, float]] = RIZON_LINK_AXES,
) -> np.ndarray:
"""Return 3x7 translational Jacobian in base frame."""
return geometric_jacobian(link_R, link_p, ee_p, axes_local)[:3, :]
def dls_position_step(
J: np.ndarray,
dp_base: np.ndarray,
*,
damping: float = 0.05,
max_dq: float = 0.05,
) -> np.ndarray:
"""Solve J dq = dp with damped least squares; clip |dq|."""
dp = np.asarray(dp_base, dtype=float).reshape(3)
JT = J.T
A = J @ JT + (damping ** 2) * np.eye(3)
dq = JT @ np.linalg.solve(A, dp)
dq = np.clip(dq, -abs(max_dq), abs(max_dq))
return dq
def dls_twist(
J6: np.ndarray,
v_base: np.ndarray,
*,
w_base: Optional[np.ndarray] = None,
damping: float = 0.08,
max_qdot: float = 0.80,
ori_weight: float = 1.2,
) -> np.ndarray:
"""Solve J qdot = [v; w] with orientation held (w≈0 by default)."""
dx = np.zeros(6, dtype=float)
dx[:3] = np.asarray(v_base, dtype=float).reshape(3)
if w_base is not None:
dx[3:] = np.asarray(w_base, dtype=float).reshape(3)
w = np.array([1.0, 1.0, 1.0, ori_weight, ori_weight, ori_weight], dtype=float)
Jw = J6 * w[:, None]
dxw = dx * w
JT = Jw.T
A = Jw @ JT + (damping ** 2) * np.eye(6)
qdot = JT @ np.linalg.solve(A, dxw)
return np.clip(qdot, -abs(max_qdot), abs(max_qdot))
def dls_translate_hold_ori(
J6: np.ndarray,
dp_base: np.ndarray,
*,
damping: float = 0.08,
max_dq: float = 0.05,
ori_weight: float = 1.2,
) -> np.ndarray:
"""Translate EE by dp while keeping orientation (wx,wy,wz ≈ 0)."""
dx = np.zeros(6, dtype=float)
dx[:3] = np.asarray(dp_base, dtype=float).reshape(3)
w = np.array([1.0, 1.0, 1.0, ori_weight, ori_weight, ori_weight], dtype=float)
Jw = J6 * w[:, None]
dxw = dx * w
JT = Jw.T
A = Jw @ JT + (damping ** 2) * np.eye(6)
dq = JT @ np.linalg.solve(A, dxw)
return np.clip(dq, -abs(max_dq), abs(max_dq))
def load_handeye_T(path: str) -> np.ndarray:
"""Load 4x4 from handeye_result.yaml (T_4x4 list)."""
import yaml
with open(path, 'r', encoding='utf-8') as f:
data = yaml.safe_load(f)
if not isinstance(data, dict) or 'T_4x4' not in data:
raise ValueError(f'No T_4x4 in hand-eye file: {path}')
T = np.array(data['T_4x4'], dtype=float)
if T.shape != (4, 4):
raise ValueError(f'T_4x4 shape {T.shape}, expected (4,4)')
return T
@@ -0,0 +1,451 @@
#!/usr/bin/env python3
"""Startup return-to-initial before perception/servo starts.
Sequence:
[A] if the flange is below servo.safe_z_m, lift it STRAIGHT UP first
(closed-loop Z hops) so the home move can't sweep walls/fixtures;
[B] call /set_named_pose (Flexiv named pose, default "home") — the arm
returns to the Flexiv initial pose with Flexiv's own planner;
[C] hold (pin) so the servo takes over from a clean, stationary pose.
"""
from __future__ import annotations
import sys
import time
from pathlib import Path
from typing import List, Optional, Tuple
import numpy as np
import rclpy
from arm_msgs.srv import SetNamedPose
from control_msgs.action import FollowJointTrajectory
from rclpy.action import ActionClient
from rclpy.node import Node
from sensor_msgs.msg import JointState
from tf2_ros import Buffer, TransformListener
from trajectory_msgs.msg import JointTrajectory, JointTrajectoryPoint
from arm_application.geom_ik import (
RIZON_LINK_AXES,
dls_translate_hold_ori,
dls_twist,
geometric_jacobian,
quat_xyzw_to_R,
)
def _load_ee_cfg() -> dict:
root = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(root / "scripts" / "lib"))
from export_site_env import load_site, resolve_robot # type: ignore
data = load_site()
servo = (data.get("servo") or {})
robot = resolve_robot(data)
ee_z = float(servo.get("ee_z_m") or 0.24)
return {
"prefix": str(robot.get("joint_prefix") or "Rizon4-063989_"),
"base": str(robot.get("base_frame") or "Rizon4-063989_base_link"),
"ee": str(robot.get("ee_frame") or "Rizon4-063989_flange"),
"z": ee_z,
# Lift straight up to this Z before the home move (avoid hitting
# walls/fixtures); then return to the Flexiv named initial pose.
"safe_z": float(servo.get("safe_z_m") or max(0.40, ee_z + 0.10)),
"home_pose": str(servo.get("home_pose_name") or "home"),
"home_speed": float(servo.get("home_pose_speed") or 0.30),
"v_z": float(servo.get("v_max_z") or 0.04),
# only used by the (currently unused) verticalize planner path
"kp": float(servo.get("ee_hold_kp") or 2.0),
"w_max": float(servo.get("ee_w_max") or 0.50),
}
def _pt(q: np.ndarray, qdot: np.ndarray, t_sec: float) -> JointTrajectoryPoint:
pt = JointTrajectoryPoint()
pt.positions = [float(v) for v in q.tolist()]
pt.velocities = [float(v) for v in qdot.tolist()]
pt.accelerations = [0.0] * int(q.size)
ns = int(round(max(0.0, float(t_sec)) * 1e9))
pt.time_from_start.sec = ns // 1_000_000_000
pt.time_from_start.nanosec = ns % 1_000_000_000
return pt
class GotoEePlane(Node):
def __init__(self, cfg: dict) -> None:
super().__init__("goto_ee_plane")
self.cfg = cfg
prefix = cfg["prefix"]
self.joint_names = [f"{prefix}joint{i}" for i in range(1, 8)]
self.link_frames = [f"{prefix}link{i}" for i in range(1, 8)]
self._js: Optional[JointState] = None
self.create_subscription(JointState, "joint_states", self._on_js, 10)
self._tf = Buffer()
TransformListener(self._tf, self, spin_thread=True)
self._cli = ActionClient(
self, FollowJointTrajectory, "rizon_arm_controller/follow_joint_trajectory"
)
def _on_js(self, msg: JointState) -> None:
self._js = msg
def _spin(self, dt: float = 0.05) -> None:
rclpy.spin_once(self, timeout_sec=dt)
def _lookup(self, parent: str, child: str) -> Optional[np.ndarray]:
try:
tf = self._tf.lookup_transform(
parent, child, rclpy.time.Time(),
timeout=rclpy.duration.Duration(nanoseconds=80_000_000),
)
except Exception:
return None
t = tf.transform.translation
o = tf.transform.rotation
T = np.eye(4)
T[:3, :3] = quat_xyzw_to_R([o.x, o.y, o.z, o.w])
T[:3, 3] = [t.x, t.y, t.z]
return T
def _q(self) -> Optional[np.ndarray]:
if self._js is None:
return None
m = {n: p for n, p in zip(self._js.name, self._js.position)}
try:
return np.array([float(m[n]) for n in self.joint_names], dtype=float)
except KeyError:
return None
def _jacobian(self, p_ee: np.ndarray) -> Optional[np.ndarray]:
link_R: List[np.ndarray] = []
link_p: List[np.ndarray] = []
for fr in self.link_frames:
Tl = self._lookup(self.cfg["base"], fr)
if Tl is None:
return None
link_R.append(Tl[:3, :3])
link_p.append(Tl[:3, 3])
return geometric_jacobian(link_R, link_p, p_ee, RIZON_LINK_AXES)
def _w_down(self, R: np.ndarray) -> Tuple[np.ndarray, float]:
z_now = R[:, 2]
z_des = np.array([0.0, 0.0, -1.0])
c = float(np.clip(np.dot(z_now, z_des), -1.0, 1.0))
ang = float(np.arccos(c))
if ang < 0.02:
return np.zeros(3), ang
axis = np.cross(z_now, z_des)
n = float(np.linalg.norm(axis))
axis = axis / n if n > 1e-9 else np.array([1.0, 0.0, 0.0])
mag = float(np.clip(self.cfg["kp"] * ang, -self.cfg["w_max"], self.cfg["w_max"]))
return axis * mag, ang
def _target_err(
self, p: np.ndarray, R: np.ndarray, target: np.ndarray, verticalize: bool
) -> Tuple[np.ndarray, float, float]:
dp = np.asarray(target, dtype=float).reshape(3) - np.asarray(p, dtype=float).reshape(3)
_, ang = (self._w_down(R) if verticalize else (np.zeros(3), 0.0))
xy = float(np.hypot(dp[0], dp[1]))
return dp, ang, xy
def _wait_state(self, timeout_s: float) -> Tuple[Optional[np.ndarray], Optional[np.ndarray]]:
t0 = time.monotonic()
q = None
Tee = None
while time.monotonic() - t0 < timeout_s:
self._spin(0.05)
q = self._q()
Tee = self._lookup(self.cfg["base"], self.cfg["ee"])
if q is not None and Tee is not None and self._jacobian(Tee[:3, 3]) is not None:
return q, Tee
return q, Tee
def _send_action(self, traj: JointTrajectory, wait_s: float) -> bool:
goal = FollowJointTrajectory.Goal()
goal.trajectory = traj
fut = self._cli.send_goal_async(goal)
t0 = time.monotonic()
while not fut.done() and time.monotonic() - t0 < 4.0:
self._spin(0.05)
handle = fut.result() if fut.done() else None
if handle is None or not handle.accepted:
print(" ERROR: JTC rejected the height trajectory", flush=True)
return False
res = handle.get_result_async()
t0 = time.monotonic()
last = 0.0
while not res.done() and time.monotonic() - t0 < wait_s:
self._spin(0.05)
now = time.monotonic()
if now - last > 0.4:
Tee = self._lookup(self.cfg["base"], self.cfg["ee"])
if Tee is not None:
p = Tee[:3, 3]
print(
f" moving ee=({p[0]:.3f},{p[1]:.3f},{p[2]:.3f}) "
f"z_err={(self.cfg['z'] - p[2]) * 1000:+.0f}mm",
flush=True,
)
last = now
if not res.done():
print(" WARN: JTC result timeout — checking TF anyway", flush=True)
return False
return True
def _plan_and_send(
self,
q0: np.ndarray,
Tee: np.ndarray,
target: np.ndarray,
verticalize: bool,
tag: str = "",
max_seg: float = 0.08,
v: float = 0.05,
) -> bool:
"""Plan and execute ONE short cartesian segment (≤ max_seg) toward
target. Long moves are handled by the caller in closed-loop hops,
because the Jacobian is frozen at the current pose — over >10 cm the
linearization error makes single-shot plans land far off target."""
p = Tee[:3, 3].copy()
R = Tee[:3, :3]
dp, ang, _ = self._target_err(p, R, target, verticalize)
dist = float(np.linalg.norm(dp))
if dist > max_seg: # clamp this hop; caller loops with fresh TF
dp = dp * (max_seg / dist)
dist = max_seg
print(
f" plan {tag} Δxyz=({dp[0]*1000:+.0f},{dp[1]*1000:+.0f},{dp[2]*1000:+.0f})mm "
f"tilt={ang * 180 / np.pi:.1f}deg",
flush=True,
)
J = self._jacobian(p)
if J is None:
print("ERROR: missing link TF for Jacobian", flush=True)
return False
q = q0.copy()
waypoints = [q.copy()]
remain = dp.copy()
w_step, _ = (self._w_down(R) if verticalize else (np.zeros(3), 0.0))
n_max = int(np.ceil(dist / 0.012)) + 4
for _ in range(n_max):
if float(np.linalg.norm(remain)) < 0.0015 and (
(not verticalize) or ang < 0.03
):
break
nseg = max(1, int(np.ceil(float(np.linalg.norm(remain)) / 0.012)))
step = remain / nseg
if verticalize and ang > 0.03:
qdot = dls_twist(
J, step / 0.12, w_base=w_step, damping=0.08, max_qdot=0.45
)
dq = np.clip(qdot * 0.12, -0.06, 0.06)
else:
dq = dls_translate_hold_ori(J, step, damping=0.08, max_dq=0.06)
if float(np.max(np.abs(dq))) < 1e-5:
break
q = q + dq
waypoints.append(q.copy())
remain = remain - (J[:3, :] @ dq)
ang = max(0.0, ang - 0.05)
planned = dist - float(np.linalg.norm(remain))
if len(waypoints) < 2 or planned < 0.4 * dist:
print(
f" WARN {tag}: planner stalled "
f"(planned {planned*1000:.0f}/{dist*1000:.0f}mm) — skip hop",
flush=True,
)
return False
# joint-excursion sanity gate: refuse wild plans
if float(np.max(np.abs(waypoints[-1] - q0))) > 1.0:
print(f" ERROR {tag}: joint excursion >1 rad — abort hop", flush=True)
return False
duration = max(1.6, dist / max(min(v, 0.06), 1e-3))
n = len(waypoints) - 1
traj = JointTrajectory()
traj.header.stamp.sec = 0
traj.header.stamp.nanosec = 0
traj.joint_names = list(self.joint_names)
traj.points.append(_pt(waypoints[0], np.zeros(7), 0.08))
zeros = np.zeros(7)
for i, qi in enumerate(waypoints[1:], start=1):
t = 0.08 + duration * (i / n)
qdot = zeros if i == n else (waypoints[min(i + 1, n)] - qi) / max(
duration / n, 0.05
)
qdot = np.clip(qdot, -0.40, 0.40)
traj.points.append(_pt(qi, qdot, t))
# force zero velocity at the last point
traj.points[-1].velocities = [0.0] * 7
print(
f" send JTC action {tag} {n} steps T={duration:.1f}s",
flush=True,
)
return self._send_action(traj, wait_s=duration + 6.0)
def _goto(
self,
q: np.ndarray,
Tee: np.ndarray,
target: np.ndarray,
verticalize: bool,
tol_m: float,
tol_tilt: float,
tag: str,
v: float,
max_hops: int = 8,
) -> Tuple[bool, np.ndarray, np.ndarray]:
"""Closed-loop point-to-point: plan ≤8 cm hop → execute → re-measure
TF → repeat, until within tolerance. Robust to Jacobian linearization
error over long distances."""
for hop in range(max_hops):
p = Tee[:3, 3]
dp, ang, _ = self._target_err(p, Tee[:3, :3], target, verticalize)
dist = float(np.linalg.norm(dp))
if dist < tol_m and ((not verticalize) or ang < tol_tilt):
return True, q, Tee
print(
f" {tag} hop{hop + 1} remain={dist*1000:.0f}mm "
f"tilt={ang * 180 / np.pi:.1f}deg",
flush=True,
)
if not self._plan_and_send(q, Tee, target, verticalize, tag, v=v):
return False, q, Tee
t_end = time.monotonic() + 0.5
while time.monotonic() < t_end:
self._spin(0.05)
q2, Tee2 = self._wait_state(2.0)
if q2 is None or Tee2 is None:
print("ERROR: lost state during move", flush=True)
return False, q, Tee
q, Tee = q2, Tee2
p = Tee[:3, 3]
dp, ang, _ = self._target_err(p, Tee[:3, :3], target, verticalize)
ok = float(np.linalg.norm(dp)) < (tol_m * 2.0) and (
(not verticalize) or ang < (tol_tilt * 2.0)
)
return ok, q, Tee
def _pin(self, q: np.ndarray) -> None:
if not self._cli.wait_for_server(timeout_sec=3.0):
return
traj = JointTrajectory()
traj.header.stamp.sec = 0
traj.header.stamp.nanosec = 0
traj.joint_names = list(self.joint_names)
traj.points = [_pt(q, np.zeros(7), 0.08), _pt(q, np.zeros(7), 0.45)]
self._send_action(traj, wait_s=3.0)
def _named_pose(self, name: str, speed: float) -> bool:
"""Call Flexiv /set_named_pose and wait until the arm is stationary."""
cli = self.create_client(SetNamedPose, "/set_named_pose")
if not cli.wait_for_server(timeout_sec=8.0):
print("ERROR: /set_named_pose unavailable — start run_real.sh", flush=True)
return False
req = SetNamedPose.Request()
req.pose_name = name
req.velocity_scale = float(min(max(speed, 0.05), 1.0))
req.acceleration_scale = 0.0
print(f" [B] set_named_pose '{name}' speed={req.velocity_scale:.2f}", flush=True)
fut = cli.call_async(req)
t0 = time.monotonic()
while not fut.done() and time.monotonic() - t0 < 90.0:
self._spin(0.1)
if not fut.done():
print("ERROR: set_named_pose timeout", flush=True)
return False
resp = fut.result()
print(
f" [B] resp success={resp.success} msg={resp.message}",
flush=True,
)
if not resp.success:
return False
# wait until the flange stops moving (Δp < 1 mm over ~0.8 s)
last = None
still = 0.0
t0 = time.monotonic()
while time.monotonic() - t0 < 30.0:
self._spin(0.2)
Tee = self._lookup(self.cfg["base"], self.cfg["ee"])
if Tee is not None:
p = Tee[:3, 3]
if last is not None and float(np.linalg.norm(p - last)) < 0.001:
still += 0.2
if still >= 0.8:
return True
else:
still = 0.0
last = p.copy()
print(" WARN: still-moving wait timeout — continue anyway", flush=True)
return True
def run(self, timeout_s: float = 25.0) -> int:
cfg = self.cfg
q, Tee = self._wait_state(4.0)
if q is None or Tee is None:
print("ERROR: no joint_states / TF — start run_real.sh", flush=True)
return 2
if not self._cli.wait_for_server(timeout_sec=8.0):
print("ERROR: JTC action unavailable — start run_real.sh", flush=True)
return 3
p = Tee[:3, 3]
safe_z = float(cfg["safe_z"])
print(
f"=== return to Flexiv initial pose '{cfg['home_pose']}' "
f"safe_z={safe_z*1000:.0f}mm ===",
flush=True,
)
print(
f" now ee=({p[0]:.3f},{p[1]:.3f},{p[2]:.3f})",
flush=True,
)
# --- Phase A: pure-Z lift to the safe height before the home move ---
if p[2] < safe_z - 0.01:
lift_tgt = np.array([p[0], p[1], safe_z], dtype=float)
print(f" [A] lift Z {p[2]*1000:.0f} → {safe_z*1000:.0f}mm", flush=True)
ok, q, Tee = self._goto(
q, Tee, lift_tgt, False, 0.010, 0.0, "[A]lift", v=0.05
)
if not ok:
print("ERROR: lift phase failed — pin here", flush=True)
self._pin(q)
return 4
# --- Phase B: Flexiv named pose → real initial pose, good posture ---
if not self._named_pose(cfg["home_pose"], cfg["home_speed"]):
print("ERROR: named-pose return failed — pin here", flush=True)
self._pin(q)
return 4
# --- Phase C: hold so the servo takes over from a stationary pose ---
q, Tee = self._wait_state(2.0)
if q is not None:
self._pin(q)
if Tee is not None:
p = Tee[:3, 3]
print(
f" at initial pose ee=({p[0]:.3f},{p[1]:.3f},{p[2]:.3f})",
flush=True,
)
return 0
def main() -> int:
cfg = _load_ee_cfg()
rclpy.init()
node = GotoEePlane(cfg)
try:
return node.run()
finally:
node.destroy_node()
if rclpy.ok():
rclpy.shutdown()
if __name__ == "__main__":
raise SystemExit(main())

Some files were not shown because too many files have changed in this diff Show More