55e5f8f20b
`--arm 89 --sim` 是个**合法且有意的**用法(run_gauge_assembly.sh 里
_HAD_ARM 那段写着"想用仿真核对某台真机的点位时会这么用"),但手眼真值导出
传的是 ${ROBOT_SN} —— 于是帧名成了 Rizon4-063989_flange,而仿真 URDF 里是
sim_flange。导出先干等 20s,再报"仿真栈起来了吗?",把人引去重启一个明明在
跑的仿真。文档 §7 里写的就是这条命令,所以照着敲 100% 失败。
真值必须按**仿真台**的帧名导出,与"用哪台臂的点位"无关 → 固定用仿真台别名
(SIM_ARM_ALIAS 可覆盖,默认 sim)。
顺带把 §7 那组验收命令补成真能跑的:
· 以前只写"先编",没说**怎么起仿真**,也没说**起之前要清干净** ——
这次就是在这两步上栽的,新增 §6.5 把三步(编 / 清 / 起)写全;
· "arm_application 子集 454 passed"配的那条裸 `PYTHONPATH=... pytest`
命令实测已经收集阶段就 error(导入不到 ament),改成走 run_unit_tests.sh
并更新数字;总测试数 1118 → 1128。
验证:`bash scripts/run_gauge_assembly.sh --arm 89 --sim --dry-run` 现在
state=DONE ok=True 2/2 孔、退出码 0(修改前必然退出码 1)。
Co-authored-by: Cursor <cursoragent@cursor.com>
205 lines
11 KiB
Bash
Executable File
205 lines
11 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
# 通止规装配(看板 S00–S11):自检 → 回 home+极限角 → 跟踪 → 定位 → 悬停 →
|
||
# 柔顺接触 → 棘轮拧入 → 反向拧出 → 换孔 → 复位。
|
||
#
|
||
# 前置(本脚本**不**替你起这些,只让 S00 自检告诉你缺什么):
|
||
# T1 bash ~/ros2/scripts/run_real.sh --arm 89 # 机械臂控制器 + /home
|
||
# T2 bash ~/ros2/scripts/run_camera_d405.sh --arm 89 # 腕部 D405 预览
|
||
# T2 bash ~/ros2/scripts/run_tactile.sh # Orisys 触觉三向力
|
||
# T3 bash ~/ros2/scripts/run_gripper.sh # 夹爪服务(张开/夹紧/状态)
|
||
# —— 手眼标定产物 handeye/results/<SN>_eye_in_hand.yaml(S05 定位的前提)
|
||
#
|
||
# 为什么不在脚本里检查/拉起这些:判断标准只能有一处。脚本里查一遍、节点里
|
||
# 再查一遍,就会出现"脚本说没问题、节点说没数据"(发布者不在的假阳性),
|
||
# 而 S00 的报告是打印给人看的,带"缺了会怎样"。
|
||
#
|
||
# 用法:
|
||
# bash ~/ros2/scripts/run_gauge_assembly.sh --arm 89 # 完整装配(2 个孔)
|
||
# bash ~/ros2/scripts/run_gauge_assembly.sh --arm 89 --holes 1 # 只装 k1
|
||
# bash ~/ros2/scripts/run_gauge_assembly.sh --arm 89 --dry-run # 只验编排/配置,不动臂
|
||
# bash ~/ros2/scripts/run_gauge_assembly.sh --sim --dry-run # 仿真环境跑干跑
|
||
# bash ~/ros2/scripts/run_gauge_assembly.sh --arm 89 --backend legacy # 强制真机夹爪后端
|
||
#
|
||
# 参数接口:config/site.yaml → gauge_assembly(力阈值/角度/轮数/超时全部可调)
|
||
# 力阈值标定:先 bash ~/ros2/scripts/run_tactile.sh --cli 看读数量级
|
||
# 掉件重放:bash ~/ros2/scripts/run_gauge_assembly.sh 之外另开一个终端
|
||
# ros2 topic pub -1 /gauge/reload_ok std_msgs/Bool '{data: true}'
|
||
#
|
||
# 退出码:0=成功(结论文件 ok=true),1=失败(读的是结论文件,不是 ros2 launch 的
|
||
# `$?` —— 后者恒为 0,见 docs/FRAMEWORK_RULES.md R6.9)。
|
||
# 结论文件:/tmp/gauge_assembly_<SN>.json(节点写,本脚本读)
|
||
set -eo pipefail
|
||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||
|
||
USE_SIM=false
|
||
DRY_RUN=false
|
||
HOLES=""
|
||
BACKEND="auto"
|
||
|
||
# 用户**这一条命令**里有没有显式指定臂?要看原始 argv:
|
||
# `parse_arm_args` 只设 ROBOT_SN,而调用者的 shell 可能已经 export 了 ROBOT_SN
|
||
#(比如先 source 过 env.sh)—— 那种情况下"仿真要不要默认成 sim 台"就分不清了。
|
||
_HAD_ARM=false
|
||
for _a in "$@"; do
|
||
if [[ "${_a}" == "--arm" || "${_a}" == "--sn" ]]; then _HAD_ARM=true; fi
|
||
done
|
||
unset _a
|
||
|
||
# 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
|
||
--sim) USE_SIM=true; shift ;;
|
||
--dry-run) DRY_RUN=true; shift ;;
|
||
--holes) HOLES="${2:?}"; shift 2 ;;
|
||
--backend) BACKEND="${2:?}"; shift 2 ;;
|
||
-h|--help) sed -n '2,26p' "$0"; exit 0 ;;
|
||
*) echo "Unknown: $1" >&2; exit 1 ;;
|
||
esac
|
||
done
|
||
|
||
case "${BACKEND}" in
|
||
auto|contract|legacy) ;;
|
||
*) echo "--backend 只能是 auto|contract|legacy,收到 ${BACKEND}" >&2; exit 1 ;;
|
||
esac
|
||
|
||
if [[ "${USE_SIM}" == true && "${_HAD_ARM}" == false ]]; then
|
||
# 仿真默认选「仿真台」——就是 site.yaml 里 robots[] 的那条 `sn: sim`
|
||
#(TF/关节名是 sim_* 前缀,相机是 sim_d405)。**必须在 source sim_env.sh 之前**:
|
||
# 那里会把 site.yaml 导成 SITE_*,之后再改就晚了。
|
||
# 显式 --arm 仍然优先:想用仿真核对某台真机的点位时会这么用。
|
||
export ROBOT_SN=sim
|
||
export ROBOT_ARM=sim
|
||
fi
|
||
|
||
if [[ "${USE_SIM}" == true ]]; then
|
||
echo "=== 仿真环境(install_sim + ROS_DOMAIN_ID=77) ==="
|
||
# shellcheck disable=SC1091
|
||
source "${ROOT}/scripts/sim_env.sh"
|
||
else
|
||
# shellcheck disable=SC1091
|
||
source "${ROOT}/scripts/env.sh"
|
||
fi
|
||
|
||
if [[ -z "${SITE_ROBOT_SN:-}" ]]; then
|
||
echo "ERROR: 没选出机械臂。用 --arm 89 指定,或填 config/site.yaml 的 robot.sn" >&2
|
||
exit 1
|
||
fi
|
||
|
||
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 "=== 通止规装配 arm=${SITE_ROBOT_ARM:-?} ${SITE_ROBOT_LABEL:-} (${SITE_ROBOT_SN}) ==="
|
||
echo " 孔数=${HOLES:-site} 夹爪后端=${BACKEND} 干跑=${DRY_RUN} 仿真时钟=${USE_SIM}"
|
||
|
||
# 清掉上一轮可能残留的装配节点(同一时间只允许一个进程在指挥这条臂)
|
||
# 不 pkill 整个 arm_application:会连相机/触觉服务一起带走(那些是本脚本的前置)
|
||
pkill -f 'lib/arm_application/gauge_assembly' 2>/dev/null || true
|
||
sleep 0.5
|
||
|
||
# 结论落盘路径:**由本脚本决定**(节点只负责写到这里),脚本末尾再读回来判成败。
|
||
RESULT_FILE="/tmp/gauge_assembly_${SITE_ROBOT_SN}.json"
|
||
rm -f "${RESULT_FILE}"
|
||
|
||
ARGS=(gripper_backend:="${BACKEND}" result_file:="${RESULT_FILE}")
|
||
if [[ -n "${HOLES}" ]]; then ARGS+=(holes_total:="${HOLES}"); fi
|
||
if [[ "${DRY_RUN}" == true ]]; then ARGS+=(dry_run:=true); fi
|
||
# 仿真必须 use_sim_time:=true:本流程的"帧新不新"和 TF 查询都按节点时钟比对。
|
||
# 忘了带的现场是"相机 13Hz 明明在发,S00 却报腕部相机无数据(过期 1.79e9s)" ——
|
||
# 报出来的原因是假的,人只会去查相机。
|
||
if [[ "${USE_SIM}" == true ]]; then ARGS+=(use_sim_time:=true); fi
|
||
# 仿真里手眼是"URDF 的真值导出成文件"(不标定)。没有这份文件,S04 的
|
||
# visual_servo 会直接拒绝启动("手眼标定结果缺失"),整条流程卡在 S04↔S03 循环。
|
||
# 所以这里先把它刷成当前真值(已是最新则什么都不做)。
|
||
# 手眼静态 TF 由 URDF 提供 → 本流程**不**发(否则同一对父子两个发布者)。
|
||
if [[ "${USE_SIM}" == true ]]; then
|
||
ARGS+=(publish_handeye_tf:=false)
|
||
echo "=== 刷新仿真手眼真值(sim_eye_in_hand.yaml / sim_d435_eye_to_hand.yaml) ==="
|
||
# ⚠️ 这里**必须**用仿真台的别名,不能用 ${ROBOT_SN}。
|
||
# 真值要按**仿真 URDF 的帧名**导出(sim_flange / sim_d405_color_optical_frame)。
|
||
# 而 `--arm 89 --sim` 是个**合法且有意的**用法(见上面 _HAD_ARM 那段:
|
||
# "想用仿真核对某台真机的点位时会这么用")——那时 ROBOT_SN=Rizon4-063989,
|
||
# 帧名就成了 Rizon4-063989_flange,仿真里根本没有这个帧:
|
||
# 导出会先等 20s 再报"仿真栈起来了吗?",把人引去重启一个明明在跑的仿真。
|
||
# 实测(2026-09-28)就是这么踩的 —— 文档 §7 里写的就是 `--arm 89 --sim`,
|
||
# 照着敲必然失败。
|
||
if ! python3 "${ROOT}/scripts/gen_sim_handeye.py" --arm "${SIM_ARM_ALIAS:-sim}"; then
|
||
echo "ERROR: 仿真手眼真值导出失败(上面有原因)。仿真栈起来了吗?" >&2
|
||
echo " bash scripts/run_sim_gz.sh 之后重试。" >&2
|
||
exit 1
|
||
fi
|
||
# 仿真没有"人把规塞进夹爪"这一步:不装规,整条流程会在 S07 才暴露
|
||
#(下了 25mm 什么也没碰到,看着像力阈值问题)。所以这里上前先上料,
|
||
# 装不上就直接停 —— 不允许带着空夹爪往下跑。
|
||
# 真机不需要:现场由人装夹,S02 的夹持确认是预留跳过项(grip_check)。
|
||
echo "=== 仿真上料:把通止规装进夹爪(真机由人工装夹) ==="
|
||
if ! python3 "${ROOT}/scripts/sim_load_gauge.py"; then
|
||
echo "ERROR: 仿真上料失败(上面有原因)。可以单独重跑:" >&2
|
||
echo " python3 scripts/sim_load_gauge.py # 装规" >&2
|
||
echo " python3 scripts/sim_load_gauge.py --dry-run # 只看算出来的位姿" >&2
|
||
exit 1
|
||
fi
|
||
# 工具长**用实测值**,不用 site.yaml 的标称值(2026-09-24 的教训,完整说明见
|
||
# sim_load_gauge._write_measured_geometry):仿真里规会在指间下滑到落座位置,
|
||
# 而下滑量随装夹变化(实测见过 243 / 254mm)。工具长写小了,二阶段悬停就会把
|
||
# 规尖停在工件**里面** —— 接着 S05 的横向对准变成拿规尖拨工件(工件被推走、
|
||
# 残差恒不收敛),日志却报"检查手眼/相机安装";写大了则 S07 提前判"台肩落面"。
|
||
# 真机没有这个文件(现场装夹后把实测值写进 site.yaml),所以这条只在仿真里走。
|
||
GEOM_FILE="${ROOT}/devices/sim/gazebo/config/sim_tool_geometry.yaml"
|
||
if [[ -f "${GEOM_FILE}" ]]; then
|
||
TOOL_LEN="$(python3 -c 'import sys,yaml;print(yaml.safe_load(open(sys.argv[1],encoding="utf-8"))["tool_tip_below_flange_m"])' "${GEOM_FILE}")"
|
||
if [[ -n "${TOOL_LEN}" ]]; then
|
||
ARGS+=(tool_tip_below_flange_m:="${TOOL_LEN}")
|
||
echo "=== 工具长用**实测值** ${TOOL_LEN}m(${GEOM_FILE#"${ROOT}/"})==="
|
||
fi
|
||
else
|
||
echo "⚠ 没有 ${GEOM_FILE#"${ROOT}/"}:工具长退回 site.yaml 的标称值。" >&2
|
||
echo " 上料脚本每次都会重写它 —— 没有这个文件说明上料没走完(或没落盘)。" >&2
|
||
fi
|
||
fi
|
||
|
||
echo "=== launch gauge_assembly(自检不过会自动报缺什么) ==="
|
||
# 不用 exec:exec 之后 Ctrl-C 直接打给 ros2 launch,这里的清理就没了;
|
||
# gauge_assembly 自己也会在收到 KeyboardInterrupt 时退到安全高度。
|
||
ros2 launch arm_application gauge_assembly.launch.py "${ARGS[@]}"
|
||
|
||
# ---- 判成败:**不能靠 `$?`** ----
|
||
# 实测(Humble):`ros2 launch` 的子进程 exit 3 时,ros2 launch 仍返回 0。
|
||
# 所以这里读节点落盘的结论文件 —— 否则自动化会把"装配失败"记成"成功"。
|
||
python3 - "${RESULT_FILE}" <<'PY'
|
||
import json
|
||
import sys
|
||
|
||
path = sys.argv[1]
|
||
try:
|
||
with open(path, encoding="utf-8") as fh:
|
||
res = json.load(fh)
|
||
except FileNotFoundError:
|
||
sys.exit(f"失败:没有结论文件 {path}\n"
|
||
" 节点在写出结论前就退出了(构造失败 / 崩溃 / 被 Ctrl-C)—— 看上面的日志。")
|
||
except (OSError, json.JSONDecodeError) as exc:
|
||
sys.exit(f"失败:结论文件读不了({path}):{exc}")
|
||
|
||
ok = bool(res.get("ok"))
|
||
print(f"结论:state={res.get('state')} ok={ok} "
|
||
f"已装 {res.get('holes_done')}/{res.get('holes_total')} 孔"
|
||
f"{'(干跑)' if res.get('dry_run') else ''} 掉件重放 {res.get('relocations')} 次")
|
||
if res.get("failures"):
|
||
print(" 未就绪(必检):")
|
||
for item in res["failures"]:
|
||
print(f" - {item}")
|
||
if res.get("notes"):
|
||
print(" 备注:")
|
||
for item in res["notes"]:
|
||
print(f" · {item}")
|
||
if res.get("reason"):
|
||
print(f" 原因:{res['reason']}")
|
||
if not ok:
|
||
sys.exit(f"失败:state={res.get('state')}(exit_code={res.get('exit_code', 1)})")
|
||
PY
|