#!/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/_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_.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) ===" if ! python3 "${ROOT}/scripts/gen_sim_handeye.py" --arm "${ROBOT_SN:-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