diff --git a/config/site.example.yaml b/config/site.example.yaml index 116f291..23a87d0 100644 --- a/config/site.example.yaml +++ b/config/site.example.yaml @@ -10,8 +10,8 @@ # 用法:复制本文件为 config/site.yaml,然后把所有 <占位符> 换成现场实际值。 # 现场值包括:机械臂 SN / IP、相机序列号、夹爪串口、传感器 USB 路径。 # -# 说明:仓库里的文件用相对仓库根的路径(例如 models/yolo/...)。 -# 不要改回某个用户的绝对家目录,换台电脑就会找不到文件。 +# 说明:site.yaml 里的路径出现 /home/USER 是因为生成时匿名化了用户名; +# 部署时请改成实际用户的 home 目录。 # ============================================================================= # ============================================================================= @@ -61,17 +61,15 @@ robot: # (status 里的 init=need_init 只说明"绝对位置标定没做",不影响夹持) # 想让开机必初始化(无人值守的上电流程)就显式改 true,并确认爪里是空的。 auto_init: false - named_poses_file: "src/arm_control/config/named_poses_89.yaml" + named_poses_file: "/home/USER/ros2/src/arm_control/config/named_poses_89.yaml" paths: - handeye_file: "handeye/results/89_eye_in_hand.yaml" - handeye_waypoints: "handeye/poses/89_eye_in_hand.yaml" - handeye_workdir: "handeye/tmp/89" + handeye_file: "/home/USER/ros2/handeye/results/89_eye_in_hand.yaml" + handeye_waypoints: "/home/USER/ros2/handeye/poses/89_eye_in_hand.yaml" + handeye_workdir: "/home/USER/ros2/handeye/tmp/89" handeye: # Studio tcp_location(法兰系,米 + qwxyz)。与 rdk Tool.params 必须一致。 - # 眼在手上:AX=XB 的手是这份 TCP,再折成 cam→法兰写 T_4x4。 - # 眼在手外:AX=XB 的手同样是这份 TCP(板夹在工具上);T_4x4 仍是 cam→基座,不折。 gripper_tool: "tool10120938" - gripper_tcp_xyz: [-0.0005, 0.0067, 0.2245] + gripper_tcp_xyz: [0.000537, -0.000264, 0.226587] gripper_tcp_qwxyz: [1.0, 0.0, 0.0, 0.0] # 左臂跟踪高度(只在 --arm 89 时生效,改 91 的 servo 不影响这里) # 锚点 `&servo_left`:下面的"仿真台"条目直接引用它 —— 仿真建的就是左臂, @@ -179,9 +177,8 @@ robot: # 落在本文件下方注释记录的合理区间 0.204~0.233 内。 # 复核:python3 scripts/sense_tool_length.py --sn Rizon4-XXXXXX # python3 scripts/lib/real_geometry_check.py --sn Rizon4-XXXXXX - # 2026-10-12:下臂安全距离改按 Studio 工具 tool10120938 的 TCP +Z。 - # 上面 0.223 是旧 tool1(夹爪 TCP 0.147 + 规露出 0.076),这把工具不再用。 - tool_tip_below_flange_m: 0.2245 + # 下臂安全距离按 tool10120938 的 TCP +Z,不再用旧 tool1 的 0.223。 + tool_tip_below_flange_m: 0.226587 # >>> 仿真那台臂(Gazebo)也登记成 robots[] 里的一台 <<< # 为什么必须登记:应用是按"当前臂"的名字去查 frame / joint / 相机话题的 #(`sim_env.sh` 注释里说的"仿真与真机共用同一套 site.yaml")。仿真的 TF 与关节 @@ -211,7 +208,7 @@ robot: # 为什么必须给文件而不能留空:visual_servo 的 servo_node 需要 # T_flange_cam 矩阵做像素→笛卡尔换算;留空它就直接拒绝启动 #("手眼标定结果缺失"),S04 跟踪起不来。 - handeye_file: "handeye/results/sim_eye_in_hand.yaml" + handeye_file: "/home/USER/ros2/handeye/results/sim_eye_in_hand.yaml" # 运动参数:**大部分**与 89 共用(锚点),但**高度类**必须按仿真台的几何改写。 # # 为什么不能直接共用:真机是"机械臂装在台面同一高度",所以"法兰相对基座" @@ -305,11 +302,11 @@ robot: port: "/dev/ttyUSB1" stroke_mm: 30.0 auto_init: false - named_poses_file: "src/arm_control/config/named_poses_91.yaml" + named_poses_file: "/home/USER/ros2/src/arm_control/config/named_poses_91.yaml" paths: - handeye_file: "handeye/results/91_eye_in_hand.yaml" - handeye_waypoints: "handeye/poses/91_eye_in_hand.yaml" - handeye_workdir: "handeye/tmp/91" + handeye_file: "/home/USER/ros2/handeye/results/91_eye_in_hand.yaml" + handeye_waypoints: "/home/USER/ros2/handeye/poses/91_eye_in_hand.yaml" + handeye_workdir: "/home/USER/ros2/handeye/tmp/91" # >>> 右臂跟踪高度:只改这里,89 仍用上面那段 <<< # 法兰 Z;RGI 比平行爪高,通常要比 89 略抬一点。改完重启: # bash ~/ros2/scripts/run_visual_servo_rt.sh --arm 91 --control @@ -330,7 +327,7 @@ robot: paths: # >>> YOLO 权重(标准放法,不必改代码)<<< - # 1) 把 .onnx / .pt 放到 models/yolo/ + # 1) 把 .onnx / .pt 放到 /home/USER/ros2/models/yolo/ # 2) 这一行改成那个绝对路径(写 .onnx 即可;TensorRT 会找同名 .engine) # 3) backend=tensorrt 时必须先导出引擎(只需做一次): # bash ~/ros2/scripts/weights.sh convert <源文件> --name <名字> --group yolo @@ -344,18 +341,18 @@ paths: # ultralytics 解不了 pose 的 TensorRT engine,运行时走 # arm_application/pose_detector.py 的自解码(见该模块头)。 # 仍然用 .engine(实测 ~2.6 ms/帧;onnx 只有 CPU provider,约 27 ms)。 - yolo_weights: "models/yolo/gongjian0929.onnx" + yolo_weights: "/home/USER/ros2/models/yolo/gongjian0929.onnx" # 换模型:把 models/yolo/ 下别的 .onnx / .pt 绝对路径填进来即可。 # 未在用的模型已移出仓库(体积大且干扰 `weights.sh list`),要恢复见 models/README.md。 # 入孔模型(backend=yolo 时把孔当框)。two_stage 用下面的工件权重。 # pose 模型两处共用同一份 —— 它既能框工件,又直接给 k1/k2 关键点。 # 查悬空引用:bash ~/ros2/scripts/weights.sh doctor - hole_weights: "models/yolo/gongjian0929.onnx" + hole_weights: "/home/USER/ros2/models/yolo/gongjian0929.onnx" # 下面是缺省(未匹配到 robots[] 时)。真机以 robots[].paths 为准:89 / 91 各一份。 - handeye_file: "handeye/results/89_eye_in_hand.yaml" - handeye_waypoints: "handeye/poses/89_eye_in_hand.yaml" - handeye_calibrate: "handeye/calibrate.yaml" - handeye_workdir: "handeye/tmp/89" + handeye_file: "/home/USER/ros2/handeye/results/89_eye_in_hand.yaml" + handeye_waypoints: "/home/USER/ros2/handeye/poses/89_eye_in_hand.yaml" + handeye_calibrate: "/home/USER/ros2/handeye/calibrate.yaml" + handeye_workdir: "/home/USER/ros2/handeye/tmp/89" # 空=默认 DDS(SHM);勿强制 UDP-only,否则相机预览收不到 /workpiece/detection fastdds_profile: "" @@ -595,9 +592,8 @@ servo: lock_hold_sec: 0.7 # 旧死区驻留(混合模式不再用它进二阶段) # 混合模式必填。交回 D405 的等待不再用它,见 d435_handback_sec。 lock_still_sec: 3.0 - # 进程内 MoveL 到位后,法兰 XY 连续停稳这么久才交回 D405。 - # 旧 3s 是杀栈冷启动的垫时间;不杀栈后只留机械停稳。晃了把数加大,不许再杀栈。 - d435_handback_sec: 0.5 + # 厂家 MoveL 结束后,法兰连续这么久没再动,才打开 D405 框心。 + d435_handback_sec: 2.0 # 框心对准并且法兰停稳这么久,才精拍一次小孔关键点、进对孔。实时点到此为止。 wrist_align_sec: 0.5 lock_still_move_m: 0.015 # 15 mm;超过就重计时,避免"晃一下刚好满 3 秒" @@ -679,10 +675,10 @@ gripper: hole_detect: backend: "two_stage" # two_stage | yolo | hough # backend=yolo 时用。 - weights: "models/yolo/gongjian0929.onnx" + weights: "/home/USER/ros2/models/yolo/gongjian0929.onnx" # 二阶段工件检测权重。pose 模型既框工件、又直接给 k1/k2 关键点, # 所以和上面的跟踪权重是同一份。 - workpiece_weights: "models/yolo/gongjian0929.onnx" + workpiece_weights: "/home/USER/ros2/models/yolo/gongjian0929.onnx" # pose 关键点优先:检测器给了 k1/k2 就直接用,不用再找圆。 # 关键点不可信(低于 keypoint_min_conf / 两点几乎重合)时自动回落到 # 亮环 + Hough 那套老路径。 @@ -690,7 +686,7 @@ hole_detect: keypoint_min_conf: 0.30 keypoint_min_dist_px: 15.0 keypoint_hole_radius_px: 12.0 # 关键点没有半径;反投影时按这个半径取孔周深度环 - config: "src/arm_application/config/hole_detect.yaml" + config: "/home/USER/ros2/src/arm_application/config/hole_detect.yaml" conf: 0.25 iou: 0.7 imgsz: 640 @@ -768,26 +764,22 @@ hole_detect: # bbox=(0,142,97,222) 反投影出盒外的 (0.280,-0.747)(偏差 211.5mm),旧逻辑 # **不判误检、只把目标截断到盒边**,臂照样横移 56mm 离开工件 ⇒ 精定位再也 # 看不到 k1/k2 ⇒ exit=4。判据改成"盒外/偏移过大 ⇒ 判误检、原地不动"。 - coarse_max_offset_m: 0.12 # 目标离夹爪轴线超过此值就不像粗居中量 [m] + coarse_max_offset_m: 0.08 # 目标离夹爪轴线超过此值就不像粗居中量 [m];0.12 放行了 2026-10-16 的 110mm 回撤 coarse_border_px: 2 # bbox 贴边(被画面切)即弃用 [px];见 _bbox_on_border # —— 柔顺运动(夹爪持规,防急加速/急停/过冲把规震脱):速度放慢 + 平滑刹车 —— align_speed: 0.014 # 对准段笛卡尔速度 [m/s](原0.03,放缓防冲) move_speed: 0.022 # 升降/粗居中段速度 [m/s](原0.05,放缓防震规) - # 对小孔关键点:只用像素射线交到 workpiece_surface_z_m,不读深度图。 hole_visual_only: true - # 本分支实验(exp/hole-keypoint-once):true=悬停拍一次,框心→k1 一次走完 - # (40mm 上限)。main 保存点是 false(多走多拍)。 + # 本分支实验:true=悬停拍一次并走完到孔;main 保存点应保持 false。 hole_keypoint_once: true - # true=悬停走完后一条厂家 MoveL 贴面(不补拍)。对照 37b85aa 改回 false。 + # true=悬停走完后一条厂家 MoveL 贴面。对照观察高补交时改 false。 hole_once_lock_xy_descend: true - # 贴面相对悬停:沿 -look 走 k×剩余下降 [m/m]。0=笛卡尔锁 XY 只降 Z。 - # 0.018 在框心改平面交点后两条链路都 +1.3mm X 过补,插不进。 + # 0=笛卡尔锁 XY 只降 Z。k>0 才沿 -look 加前馈。 once_ff_look_per_m: 0.0 - final_gap_mm: 2.0 # 对准后贴面目标:规尖离面 [mm](不接触)。笛卡尔锁 XY - # 时法兰 Z = 工具长 + 工件面 + 本值。CLI --final-gap 覆盖。 + final_gap_mm: 2.0 # 对准后贴面目标:规尖离面 [mm]。CLI --final-gap 覆盖。 observe_gap_mm: 15.0 # --no-descent 时的观测下降:只降到离面这么高 [mm], # 方便看规尖偏没偏。必须 ≥ final_gap_mm。 - # 左臂89:悬停间隙约 73.5mm(0.320−0.2245−0.022)⇒ 观测下降约 58mm。 + # 左臂89:悬停间隙约 75mm(0.320−0.223−0.022)⇒ 大约下降 60mm。 final_descent_speed: 0.005 # 最后轻降速度 [m/s](很慢,柔顺) # ✅ 2026-10-08 定稿:下降量**只由几何决定**(法兰 FK − 工具长 − 工件面 − final_gap), # 深度读数只做交叉核对、不再参与任何决策。两段真机事故方向相反、都指向这条: @@ -800,7 +792,7 @@ hole_detect: # ⇒ 读数往**小**错一次就永远到不了目标。 # 同一套读数两个方向各错一次、幅度 150mm 量级、随位姿乱跳 —— 它对"能降多少" # 和"该不该停"都没有发言权。现在它只在偏差 >10mm 时喊一句"深度测距与几何不符"。 - # 现行几何:悬停 0.320 − 工具长 0.2245 − 工件面 0.022 = 73.5mm 间隙 ⇒ 降 71.5mm 到 2mm。 + # 现行几何:悬停 0.320 − 工具长 0.223 − 工件面 0.022 = 75mm 间隙 ⇒ 降 73mm 到 2mm。 # 三道**物理/几何**保险:descent_budget_m(配置上限)+ 腕部 FT 接触检测(停+退 1mm) # + 任一步 _goto 走不到位就停。 # 缺几何(工具长/工件面没配)时**拒绝下降**并报错(保持悬停),绝不拿测距去赌。 @@ -812,9 +804,11 @@ hole_detect: max_depth_m: 0.60 linger_sec: 8.0 # 完成后继续发布结果/叠图的时长 - # ===== 旋转装配仍读这里的力阈值(stage3_search_node)===== - # 搜孔轻压已取消:不带 --screw 的入口会直接拒绝,整体流程也不再进入这一步。 - # bash ~/ros2/scripts/run_stage3_search.sh --arm 89 --screw + # ===== 三阶段柔顺搜孔(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,看日志「触觉基线/派生阈值」两行)—— @@ -841,12 +835,9 @@ hole_detect: drop_frames: 2 # 跌落需持续的帧数(防毛刺) center_scan: false # 入孔后十字微扫;默认关(0.8mm 扫会把已入孔的规刮偏) center_scan_mm: 0.3 - insert_depth_mm: 0.0 # 默认只搜孔+轻压,不拧入。旋转装配不读这项 + insert_depth_mm: 0.0 # 默认只搜孔+轻压,不拧入;拧入阶段再用 --depth insert_retry: 3 - screw_pitch_mm: 0.0 # 搜孔轻压不旋。旋转装配不读这项 - # 控制页「旋转装配」单独用。数值就是手册里的 M6 旋入:深度 6 mm、螺距 1.0 mm/圈。 - screw_stage_depth_mm: 6.0 - screw_stage_pitch_mm: 1.0 + screw_pitch_mm: 0.0 # 螺纹通止规螺距:>0 右旋 / <0 左旋 / 0=不旋(光面规) search_speed: 0.03 # 搜孔笛卡尔速度 [m/s] settle_sec: 0.25 # 每个微步后的静止时间 tactile_timeout_s: 3.0 # 触觉话题断流多久中止 @@ -1102,7 +1093,7 @@ tactile: # 传感器型号。必须与 devices/sensors/orisys/models/ 下的子目录名一致。 model: "dd03" # 型号配置随 SDK 一起放:devices/sensors/orisys/models/<型号>/config.json - config_file: "devices/sensors/orisys/models/dd03/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" @@ -1129,8 +1120,7 @@ go_home: network: # 2026-09-29 改线:两台臂 → 交换机 → USB-C 千兆网卡(ASIX AX88179) - # MAC 6c:1f:f7:d0:6f:17。内核名会在 eth0 和 enxXXXXXXXXXXXX 之间变, - # NetworkManager 按 MAC 绑定,不按名字。 + # MAC 6c:1f:f7:d0:6f:17。内核现在叫 eth0(不再用 enxXXXXXXXXXXXX)。 # 本机 ↔ <左臂SN>(左89)=.101 / <右臂SN>(右91)=.100 # NM 配置名 robot-usb-lan(静态 .10/24,不当默认路由)。 # 说明:enp4s0 仍是办公网(192.168.1.x);两台臂已不在 enp4s0 上。 diff --git a/config/site.yaml b/config/site.yaml index 1a84117..4942fa3 100644 --- a/config/site.yaml +++ b/config/site.yaml @@ -23,7 +23,7 @@ robot: # 本臂装了 Orisys 视触觉(只有左臂有)。脚本靠这一项决定要不要拉触觉服务, # 而不是靠"SN 等于某个具体值"这种写死的判断(换台臂就会失效)。 tactile: true - # 2026-10-12 示教的工具 TCP(tool + 1012 + 0938)。眼在手上 AX=XB 用这份 TCP。 + # 2026-10-16 下午 RDK 重读 Studio tool10120938(用户再次更新 TCP)。 tool_name: "tool10120938" camera: name: d405_left # 话题 /d405_left/... 与 91 分开 @@ -45,17 +45,17 @@ robot: # (status 里的 init=need_init 只说明"绝对位置标定没做",不影响夹持) # 想让开机必初始化(无人值守的上电流程)就显式改 true,并确认爪里是空的。 auto_init: false - named_poses_file: "src/arm_control/config/named_poses_89.yaml" + named_poses_file: "/home/cc/ros2/src/arm_control/config/named_poses_89.yaml" paths: - handeye_file: "handeye/results/89_eye_in_hand.yaml" - handeye_waypoints: "handeye/poses/89_eye_in_hand.yaml" - handeye_workdir: "handeye/tmp/89" + handeye_file: "/home/cc/ros2/handeye/results/89_eye_in_hand.yaml" + handeye_waypoints: "/home/cc/ros2/handeye/poses/89_eye_in_hand.yaml" + handeye_workdir: "/home/cc/ros2/handeye/tmp/89" handeye: # Studio tcp_location(法兰系,米 + qwxyz)。与 rdk Tool.params 必须一致。 # 眼在手上:AX=XB 的手是这份 TCP,再折成 cam→法兰写 T_4x4。 # 眼在手外:AX=XB 的手同样是这份 TCP(板夹在工具上);T_4x4 仍是 cam→基座,不折。 gripper_tool: "tool10120938" - gripper_tcp_xyz: [-0.0005, 0.0067, 0.2245] + gripper_tcp_xyz: [0.000537, -0.000264, 0.226587] gripper_tcp_qwxyz: [1.0, 0.0, 0.0, 0.0] # 左臂跟踪高度(只在 --arm 89 时生效,改 91 的 servo 不影响这里) # 锚点 `&servo_left`:下面的"仿真台"条目直接引用它 —— 仿真建的就是左臂, @@ -163,9 +163,11 @@ robot: # 落在本文件下方注释记录的合理区间 0.204~0.233 内。 # 复核:python3 scripts/sense_tool_length.py --sn Rizon4-063989 # python3 scripts/lib/real_geometry_check.py --sn Rizon4-063989 - # 2026-10-12:下臂安全距离改按 Studio 工具 tool10120938 的 TCP +Z。 - # 上面 0.223 是旧 tool1(夹爪 TCP 0.147 + 规露出 0.076),这把工具不再用。 - tool_tip_below_flange_m: 0.2245 + # 2026-10-16 下午:RDK Tool.params("tool10120938").tcp_location + # [x,y,z,qw,qx,qy,qz] = [0.000537, -0.000264, 0.226587, 1, 0, 0, 0] + # 示教器拟合误差:平均 0.58 / 最大 0.99 / 最小 0.29 mm。 + # 下臂安全距离用 TCP +Z。上午 0.226274;再往前 0.2245 是 10-12;0.223 是 tool1。 + tool_tip_below_flange_m: 0.226587 # >>> 仿真那台臂(Gazebo)也登记成 robots[] 里的一台 <<< # 为什么必须登记:应用是按"当前臂"的名字去查 frame / joint / 相机话题的 #(`sim_env.sh` 注释里说的"仿真与真机共用同一套 site.yaml")。仿真的 TF 与关节 @@ -195,7 +197,7 @@ robot: # 为什么必须给文件而不能留空:visual_servo 的 servo_node 需要 # T_flange_cam 矩阵做像素→笛卡尔换算;留空它就直接拒绝启动 #("手眼标定结果缺失"),S04 跟踪起不来。 - handeye_file: "handeye/results/sim_eye_in_hand.yaml" + handeye_file: "/home/cc/ros2/handeye/results/sim_eye_in_hand.yaml" # 运动参数:**大部分**与 89 共用(锚点),但**高度类**必须按仿真台的几何改写。 # # 为什么不能直接共用:真机是"机械臂装在台面同一高度",所以"法兰相对基座" @@ -289,11 +291,11 @@ robot: port: "/dev/ttyUSB1" stroke_mm: 30.0 auto_init: false - named_poses_file: "src/arm_control/config/named_poses_91.yaml" + named_poses_file: "/home/cc/ros2/src/arm_control/config/named_poses_91.yaml" paths: - handeye_file: "handeye/results/91_eye_in_hand.yaml" - handeye_waypoints: "handeye/poses/91_eye_in_hand.yaml" - handeye_workdir: "handeye/tmp/91" + handeye_file: "/home/cc/ros2/handeye/results/91_eye_in_hand.yaml" + handeye_waypoints: "/home/cc/ros2/handeye/poses/91_eye_in_hand.yaml" + handeye_workdir: "/home/cc/ros2/handeye/tmp/91" # >>> 右臂跟踪高度:只改这里,89 仍用上面那段 <<< # 法兰 Z;RGI 比平行爪高,通常要比 89 略抬一点。改完重启: # bash ~/ros2/scripts/run_visual_servo_rt.sh --arm 91 --control @@ -314,7 +316,7 @@ robot: paths: # >>> YOLO 权重(标准放法,不必改代码)<<< - # 1) 把 .onnx / .pt 放到 models/yolo/ + # 1) 把 .onnx / .pt 放到 /home/cc/ros2/models/yolo/ # 2) 这一行改成那个绝对路径(写 .onnx 即可;TensorRT 会找同名 .engine) # 3) backend=tensorrt 时必须先导出引擎(只需做一次): # bash ~/ros2/scripts/weights.sh convert <源文件> --name <名字> --group yolo @@ -328,18 +330,18 @@ paths: # ultralytics 解不了 pose 的 TensorRT engine,运行时走 # arm_application/pose_detector.py 的自解码(见该模块头)。 # 仍然用 .engine(实测 ~2.6 ms/帧;onnx 只有 CPU provider,约 27 ms)。 - yolo_weights: "models/yolo/gongjian0929.onnx" + yolo_weights: "/home/cc/ros2/models/yolo/gongjian0929.onnx" # 换模型:把 models/yolo/ 下别的 .onnx / .pt 绝对路径填进来即可。 # 未在用的模型已移出仓库(体积大且干扰 `weights.sh list`),要恢复见 models/README.md。 # 入孔模型(backend=yolo 时把孔当框)。two_stage 用下面的工件权重。 # pose 模型两处共用同一份 —— 它既能框工件,又直接给 k1/k2 关键点。 # 查悬空引用:bash ~/ros2/scripts/weights.sh doctor - hole_weights: "models/yolo/gongjian0929.onnx" + hole_weights: "/home/cc/ros2/models/yolo/gongjian0929.onnx" # 下面是缺省(未匹配到 robots[] 时)。真机以 robots[].paths 为准:89 / 91 各一份。 - handeye_file: "handeye/results/89_eye_in_hand.yaml" - handeye_waypoints: "handeye/poses/89_eye_in_hand.yaml" - handeye_calibrate: "handeye/calibrate.yaml" - handeye_workdir: "handeye/tmp/89" + handeye_file: "/home/cc/ros2/handeye/results/89_eye_in_hand.yaml" + handeye_waypoints: "/home/cc/ros2/handeye/poses/89_eye_in_hand.yaml" + handeye_calibrate: "/home/cc/ros2/handeye/calibrate.yaml" + handeye_workdir: "/home/cc/ros2/handeye/tmp/89" # 空=默认 DDS(SHM);勿强制 UDP-only,否则相机预览收不到 /workpiece/detection fastdds_profile: "" @@ -579,9 +581,8 @@ servo: lock_hold_sec: 0.7 # 旧死区驻留(混合模式不再用它进二阶段) # 混合模式必填。交回 D405 的等待不再用它,见 d435_handback_sec。 lock_still_sec: 3.0 - # 进程内 MoveL 到位后,法兰 XY 连续停稳这么久才交回 D405。 - # 旧 3s 是杀栈冷启动的垫时间;不杀栈后只留机械停稳。晃了把数加大,不许再杀栈。 - d435_handback_sec: 0.5 + # 厂家 MoveL 结束后,法兰连续这么久没再动,才打开 D405 框心。 + d435_handback_sec: 2.0 # 框心对准并且法兰停稳这么久,才精拍一次小孔关键点、进对孔。实时点到此为止。 wrist_align_sec: 0.5 lock_still_move_m: 0.015 # 15 mm;超过就重计时,避免"晃一下刚好满 3 秒" @@ -663,10 +664,10 @@ gripper: hole_detect: backend: "two_stage" # two_stage | yolo | hough # backend=yolo 时用。 - weights: "models/yolo/gongjian0929.onnx" + weights: "/home/cc/ros2/models/yolo/gongjian0929.onnx" # 二阶段工件检测权重。pose 模型既框工件、又直接给 k1/k2 关键点, # 所以和上面的跟踪权重是同一份。 - workpiece_weights: "models/yolo/gongjian0929.onnx" + workpiece_weights: "/home/cc/ros2/models/yolo/gongjian0929.onnx" # pose 关键点优先:检测器给了 k1/k2 就直接用,不用再找圆。 # 关键点不可信(低于 keypoint_min_conf / 两点几乎重合)时自动回落到 # 亮环 + Hough 那套老路径。 @@ -674,7 +675,7 @@ hole_detect: keypoint_min_conf: 0.30 keypoint_min_dist_px: 15.0 keypoint_hole_radius_px: 12.0 # 关键点没有半径;反投影时按这个半径取孔周深度环 - config: "src/arm_application/config/hole_detect.yaml" + config: "/home/cc/ros2/src/arm_application/config/hole_detect.yaml" conf: 0.25 iou: 0.7 imgsz: 640 @@ -752,7 +753,7 @@ hole_detect: # bbox=(0,142,97,222) 反投影出盒外的 (0.280,-0.747)(偏差 211.5mm),旧逻辑 # **不判误检、只把目标截断到盒边**,臂照样横移 56mm 离开工件 ⇒ 精定位再也 # 看不到 k1/k2 ⇒ exit=4。判据改成"盒外/偏移过大 ⇒ 判误检、原地不动"。 - coarse_max_offset_m: 0.12 # 目标离夹爪轴线超过此值就不像粗居中量 [m] + coarse_max_offset_m: 0.08 # 目标离夹爪轴线超过此值就不像粗居中量 [m];0.12 放行了 2026-10-16 的 110mm 回撤 coarse_border_px: 2 # bbox 贴边(被画面切)即弃用 [px];见 _bbox_on_border # —— 柔顺运动(夹爪持规,防急加速/急停/过冲把规震脱):速度放慢 + 平滑刹车 —— align_speed: 0.014 # 对准段笛卡尔速度 [m/s](原0.03,放缓防冲) @@ -771,7 +772,7 @@ hole_detect: # 时法兰 Z = 工具长 + 工件面 + 本值。CLI --final-gap 覆盖。 observe_gap_mm: 15.0 # --no-descent 时的观测下降:只降到离面这么高 [mm], # 方便看规尖偏没偏。必须 ≥ final_gap_mm。 - # 左臂89:悬停间隙约 73.5mm(0.320−0.2245−0.022)⇒ 观测下降约 58mm。 + # 左臂89:悬停间隙约 71.4mm(0.320−0.226587−0.022)⇒ 观测下降约 56.4mm。 final_descent_speed: 0.005 # 最后轻降速度 [m/s](很慢,柔顺) # ✅ 2026-10-08 定稿:下降量**只由几何决定**(法兰 FK − 工具长 − 工件面 − final_gap), # 深度读数只做交叉核对、不再参与任何决策。两段真机事故方向相反、都指向这条: @@ -784,7 +785,7 @@ hole_detect: # ⇒ 读数往**小**错一次就永远到不了目标。 # 同一套读数两个方向各错一次、幅度 150mm 量级、随位姿乱跳 —— 它对"能降多少" # 和"该不该停"都没有发言权。现在它只在偏差 >10mm 时喊一句"深度测距与几何不符"。 - # 现行几何:悬停 0.320 − 工具长 0.2245 − 工件面 0.022 = 73.5mm 间隙 ⇒ 降 71.5mm 到 2mm。 + # 现行几何:悬停 0.320 − 工具长 0.226587 − 工件面 0.022 = 71.4mm 间隙 ⇒ 降 69.4mm 到 2mm。 # 三道**物理/几何**保险:descent_budget_m(配置上限)+ 腕部 FT 接触检测(停+退 1mm) # + 任一步 _goto 走不到位就停。 # 缺几何(工具长/工件面没配)时**拒绝下降**并报错(保持悬停),绝不拿测距去赌。 @@ -796,9 +797,11 @@ hole_detect: max_depth_m: 0.60 linger_sec: 8.0 # 完成后继续发布结果/叠图的时长 - # ===== 旋转装配仍读这里的力阈值(stage3_search_node)===== - # 搜孔轻压已取消:不带 --screw 的入口会直接拒绝,整体流程也不再进入这一步。 - # bash ~/ros2/scripts/run_stage3_search.sh --arm 89 --screw + # ===== 三阶段柔顺搜孔(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,看日志「触觉基线/派生阈值」两行)—— @@ -825,12 +828,9 @@ hole_detect: drop_frames: 2 # 跌落需持续的帧数(防毛刺) center_scan: false # 入孔后十字微扫;默认关(0.8mm 扫会把已入孔的规刮偏) center_scan_mm: 0.3 - insert_depth_mm: 0.0 # 默认只搜孔+轻压,不拧入。旋转装配不读这项 + insert_depth_mm: 0.0 # 默认只搜孔+轻压,不拧入;拧入阶段再用 --depth insert_retry: 3 - screw_pitch_mm: 0.0 # 搜孔轻压不旋。旋转装配不读这项 - # 控制页「旋转装配」单独用。数值就是手册里的 M6 旋入:深度 6 mm、螺距 1.0 mm/圈。 - screw_stage_depth_mm: 6.0 - screw_stage_pitch_mm: 1.0 + screw_pitch_mm: 0.0 # 螺纹通止规螺距:>0 右旋 / <0 左旋 / 0=不旋(光面规) search_speed: 0.03 # 搜孔笛卡尔速度 [m/s] settle_sec: 0.25 # 每个微步后的静止时间 tactile_timeout_s: 3.0 # 触觉话题断流多久中止 @@ -1086,7 +1086,7 @@ tactile: # 传感器型号。必须与 devices/sensors/orisys/models/ 下的子目录名一致。 model: "dd03" # 型号配置随 SDK 一起放:devices/sensors/orisys/models/<型号>/config.json - config_file: "devices/sensors/orisys/models/dd03/config.json" + config_file: "/home/cc/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:3.4.1:1.0-video-index0" @@ -1113,8 +1113,7 @@ go_home: network: # 2026-09-29 改线:两台臂 → 交换机 → USB-C 千兆网卡(ASIX AX88179) - # MAC 6c:1f:f7:d0:6f:17。内核名会在 eth0 和 enx6c1ff7d06f17 之间变, - # NetworkManager 按 MAC 绑定,不按名字。 + # MAC 6c:1f:f7:d0:6f:17。内核现在叫 eth0(不再用 enx6c1ff7d06f17)。 # 本机 192.168.2.10 ↔ 063989(左89)=.101 / 063991(右91)=.100 # NM 配置名 robot-usb-lan(静态 .10/24,不当默认路由)。 # 说明:enp4s0 仍是办公网(192.168.1.x);两台臂已不在 enp4s0 上。 diff --git a/docs/HANDBOOK.md b/docs/HANDBOOK.md index ab2832b..1440686 100644 --- a/docs/HANDBOOK.md +++ b/docs/HANDBOOK.md @@ -34,7 +34,7 @@ ### 1.3 一次完整任务分几步 -代码里跟踪和对孔串成一条命令。搜孔轻压已取消,`run_all.sh` 不会再进入那一步,也不能用参数打开。旋入是单独的 `run_stage3_search.sh --screw`。 +代码里用 **stage1 / stage2 / stage3** 三段串起来,一条命令跑完: ``` run_all.sh @@ -46,13 +46,18 @@ run_all.sh │ YOLO 找工件外框 → Hough 找左右两个孔心 → │ 深度 + 内参 + 手眼 → 换算出孔的基座坐标 → │ 移动夹爪轴线到目标孔正上方 → 复拍验证 → 锁定 + │ + └─ stage3_search 柔顺搜孔:力闭环下的螺旋搜索,直到规的尖端"掉进"孔里, + 再十字微扫定心,最后在力限内下压(可选按螺距旋入) ``` -跟踪和对孔在同一个进程里交接。盯日志: +三段之间**在同一个进程里交接**,所以相机、触觉、手眼 TF 只初始化一次。 +盯日志就能知道跑到哪了 —— 三行关键输出: ``` [pipeline] === stage1_track === → 在等跟踪锁定 [pipeline] === stage2_locate === → 在拍照对孔 +[pipeline] === stage3_search === → 触面 → ★ 入孔 → 轻压到位 ``` 日常入口(三个终端,**必须同一个 `--arm`**): @@ -60,7 +65,7 @@ run_all.sh ```bash bash ~/ros2/scripts/run_real.sh --arm 89 # T1 控制栈,一直开着 bash ~/ros2/scripts/run_camera_d405.sh --arm 89 --preview # T2 相机预览,一直开着 -bash ~/ros2/scripts/run_all.sh --arm 89 # T3 跟踪 + 对孔(不含搜孔、不含旋入) +bash ~/ros2/scripts/run_all.sh --arm 89 # T3 三段串联(默认不拧入) bash ~/ros2/scripts/run_all.sh --arm 89 --init-z 0.35 --hover-z 0.34 ``` @@ -68,15 +73,15 @@ bash ~/ros2/scripts/run_all.sh --arm 89 --init-z 0.35 --hover-z 0.34 `D405_MISS_BEFORE_D435_S`(0.5 s)没有 conf≥`perception.wrist_handoff_conf`(0.5) 的目标,才用顶视 D435 **拍一张**,把点用眼在手外 T 变到基座(不再乘法兰)。 这一段运动是进程内厂家 `MoveL`(不杀 `run_real.sh`):当前厂家 `tcp_pose` 加 -ROS 相对 XY,`keep_z`。硬件在 MoveL 期间不灌关节指令。到位后法兰 XY 连续停稳 -`servo.d435_handback_sec`(0.5 s)才交回 D405。赶路期间不下发 D405(两套控制互抢会晃)。 -停稳之后改回 D405,跟踪点是工件框的矩形中心,不追 k0。 +ROS 相对 XY,`keep_z`。硬件在 MoveL 期间不灌关节指令。D435 这一点变到基座系后,只下发厂家 `MoveL`。赶路期间 D405 推理开着, +控制点截断。法兰连续停稳 `servo.d435_handback_sec`(2 s)后才打开 D405, +追工件框的矩形中心。框心对准并停稳后才精拍 k1/k2、下臂。 启动高度用 `--init-z`(法兰 Z,米),对孔悬停用 `--hover-z`。两个都是法兰高度。 下臂贴面用 `--final-gap`(规尖离面,毫米;site 默认 `final_gap_mm` 2)。笛卡尔锁 XY 时只改 Z:法兰 = 工具长 + 工件面 + 间隙。不能和 `--no-descent` 一起写。 会让工具尖进入工件、或超出工作盒的值直接拒绝,不会改成另一个高度再往下走。 不写则一阶段锁高仍按 site,对孔悬停仍是 `hole_hover_z_m`(89 为 0.320)。 -框心对准连续 `servo.wrist_align_sec`(0.5 s)→ `/visual_servo/locked`。 +框心对准连续 `servo.wrist_align_sec`(0.5 s)才 `/visual_servo/locked`,然后才精拍下臂。 本分支(`exp/hole-keypoint-once`)二阶段是**单次精拍**:悬停拍一次 k1/k2, 把框心到目标孔一次走完(步长上限 40mm)。现行实验 `hole_once_lock_xy_descend: true`: 走完后不补拍,一条厂家 MoveL 收到贴面。`once_ff_look_per_m: 0` 时 MoveL 只降 Z @@ -84,7 +89,8 @@ ROS 相对 XY,`keep_z`。硬件在 MoveL 期间不灌关节指令。到位后 不是 ROS 绝对坐标。保存点 `37b85aa` 是观察高 15mm 再斜线补 XY。看 `/tmp/stage2_result.json`。 `main` 保存点仍是多走多拍。 孔坐标把关键点像素交到 `workpiece_surface_z_m`,不读深度图。下臂停在 -`tool10120938` 的 TCP 长度(0.2245 m)加上 `final_gap_mm` 之上,不按旧 tool1 的 0.223 m。 +`tool10120938` 的 TCP 长度(0.226587 m,2026-10-16 下午 RDK 重读)加上 `final_gap_mm` 之上,不按旧 tool1 的 0.223 m。 +贴面之后就停在这个法兰高度,不抬回 `hole_hover_z_m`。没有接触传感器,到位只认 `final_gap_mm`,不另设一个 Z。 只测 D435 这一段位移时加 `--d435-only`:不交 D405、不进二阶段,并且要求先停 `run_real.sh`。 ```bash @@ -107,8 +113,8 @@ bash ~/ros2/scripts/run_visual_servo_rt.sh --arm 89 --control | 能力 | 状态 | 怎么自己确认 | |------|------|--------------| -| 跟踪 → 对孔 | ✅ 真机跑通过。搜孔轻压已取消,这条命令不再下压搜孔 | `run_all.sh --arm 89` | -| 按螺距旋入(螺纹规) | ✅ 单独阶段。不带 `--screw` 的旧搜孔命令会直接拒绝 | `run_stage3_search.sh --arm 89 --screw` | +| 三段串联(跟踪 → 对孔 → 搜孔轻压) | ✅ 真机跑通 | `run_all.sh --arm 89` | +| 按螺距旋入(螺纹规) | ✅ 有参数,`--depth`/`--pitch` 控制 | `run_stage3_search.sh --arm 89 --depth 6 --pitch 1.0` | | Orisys 双指触觉(法向 + 两路切向) | ⚠️ 能读,但**轴向映射尚未实测确认** | 见 [`TACTILE.md`](TACTILE.md) | | Orisys 的深度图 / 力矢量图 | ⚠️ 消息结构与话题已在,真机值待确认 | 同上 | | D435 手外相机(一阶段一次性 + S03) | ⚠️ 89 已按 `tool10120938` 重标(24 视角,10-09 旧结果在 `results/backup/`);S03 粗定位真机未对过 | `handeye/results/89_d435_eye_to_hand.yaml` | @@ -505,7 +511,7 @@ bash scripts/run_unit_tests.sh -k gauge # 只跑名字带 gauge 的 | **89 / 91** | 左臂 / 右臂的现场编号,**各有独立的手眼、相机、夹爪、锁高,禁止混用** | | **法兰** | 机械臂末端安装面;手眼 `T_4x4` 仍是相机→法兰。左臂 89 现行按 Studio `tool10120938` 的 TCP 求解,再折回法兰 | | **eye-in-hand / eye-to-hand** | 相机装在手上 / 装在手上之外(D405 在手上,D435 在外部) | -| **stage1/2** | 跟踪 / 对孔,见 1.3。搜孔轻压已取消 | +| **stage1/2/3** | 跟踪 / 对孔 / 搜孔,见 1.3 | | **S03 / S04 / S07 / S08** | 整体装配任务书里的阶段编号(S03 粗定位、S04 跟踪、S07 柔顺下压、S08 夹爪旋转循环)。代码里对应的是 stage1/2/3 的拆分实现,**没有一一对应**,别硬套 | | **基线** | 触觉静止时的读数均值。力的语义是"相对基线的增量",不是绝对牛顿值 | | **R1.1 / M1 …** | [`FRAMEWORK_RULES.md`](FRAMEWORK_RULES.md) 里的规则编号,文档和提交信息里直接引用 | diff --git a/docs/HOLE_DETECT.md b/docs/HOLE_DETECT.md index 5c984a3..fba4c2a 100644 --- a/docs/HOLE_DETECT.md +++ b/docs/HOLE_DETECT.md @@ -57,7 +57,8 @@ hole_detect: ## 三阶段串联(推荐日常入口) -一条命令跑完:**视觉跟踪锁定 → 拍照对孔**。搜孔轻压已取消,这条命令不再下压,也不能用参数重新打开。旋入是后面的单独命令。 +一条命令跑完:**视觉跟踪锁定 → 拍照对孔 → 搜孔并轻轻按压**。默认**不拧入** +(拧入是后面单独阶段)。三个阶段在同一个节点里交接,相机 / 触觉 / 手眼 TF 只起一次。 ```bash # 终端 1 —— 控制栈(一直开着) @@ -66,12 +67,12 @@ 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 # 跟踪 + 对孔 +# 终端 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_stage3_search.sh --arm 89 --screw +# 若这一次就要拧入(一般留给后续阶段): +bash ~/ros2/scripts/run_all.sh --arm 89 --depth 6 --pitch 1.0 ``` `run_pipeline.sh` 是 `run_all.sh` 的别名。 @@ -84,9 +85,9 @@ bash ~/ros2/scripts/run_stage3_search.sh --arm 89 --screw 1. `[pipeline] === stage1_track ===` → 等跟踪锁定 2. `一阶段已锁定 — 停跟踪、钉住当前关节,直接进二阶段` 3. `[pipeline] === stage2_locate ===` → 降到孔悬停高,拍照对准 -对孔结束后臂停在当前高度。搜孔轻压不再接着跑。 +4. `[pipeline] === stage3_search ===` → `触面` → `★ 入孔` → `轻压到位`(不拧入) -急停:终端 3 Ctrl-C。失败时臂保持当前位姿,看 +急停:终端 3 Ctrl-C。搜孔中会先抬 5mm。失败时臂保持当前位姿,看 `/tmp/pipeline_stage1.log`、`/tmp/stage2_result.json`、`/tmp/stage3_result.json`。 高度接口:`robots[89].servo.ee_z_m`(跟踪锁高)、 @@ -148,10 +149,7 @@ bash ~/ros2/scripts/run_all.sh --arm 89 --final-gap 8 # 锁 XY,规 | `/stage2/result` | `std_msgs/String` JSON:k1/k2 基座坐标、残差、悬停高 | | `/stage2/overlay` | `sensor_msgs/Image` 标注图(同时落盘 /tmp/stage2_overlay.png) | -## 旋转装配(原三阶段节点;搜孔轻压入口已取消) - -不带 `--screw` 时 `run_stage3_search.sh` 直接退出,不会触面,也不会螺旋搜孔。 -下面是这个节点在旋入时仍会走的力控步骤,不是一条还能单独打开的搜孔命令。 +## 三阶段柔顺搜孔(stage3,Orisys 触觉 + 力闭环螺旋) 前置:二阶段刚跑完(臂悬停在目标孔上方、垂直朝下、夹持通止规)。流程: @@ -169,9 +167,12 @@ bash ~/ros2/scripts/run_all.sh --arm 89 --final-gap 8 # 锁 XY,规 7. **hold** — 锁定关节,发布结果 ```bash -# 前置:T1 run_real.sh --arm 89;对孔已完成(臂保持不动) -# 不带 --screw 会直接退出,不会搜孔。 -bash ~/ros2/scripts/run_stage3_search.sh --arm 89 --screw +# 前置: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 读数是形变任意单位且 diff --git a/handeye/poses/89_eye_in_hand.yaml b/handeye/poses/89_eye_in_hand.yaml index c4642b3..67b189b 100644 --- a/handeye/poses/89_eye_in_hand.yaml +++ b/handeye/poses/89_eye_in_hand.yaml @@ -8,49 +8,59 @@ /**: ros__parameters: - num_poses: 21 - # 2026-09-02 通过 handeye_teach 示教(RDK 自由驱动) + num_poses: 26 + # 2026-10-16 重新示教 26 点(旧 21 点在 poses/backup/);A5 11×8,tool10120938 waypoint_positions: [ # pose 0 - -0.928421, -1.237477, 1.528823, 1.526334, -1.230146, 0.098562, 0.538531, + -0.211261, -0.571428, 0.247301, 1.552793, -0.119748, 0.614676, -0.023410, # pose 1 - -0.970203, -1.315554, 1.553230, 1.669383, -1.407869, 0.125907, 0.578173, + -0.169531, -0.647538, 0.404777, 1.568624, -0.268686, 0.700171, 0.395947, # pose 2 - -0.967819, -1.525572, 1.706057, 1.357062, -1.211051, 0.023232, 0.193541, + -0.759204, -0.846289, 0.735872, 1.387307, -0.539553, 0.576374, -0.060241, # pose 3 - -1.065106, -1.624218, 1.793644, 1.279402, -1.120929, 0.019151, 0.139658, + -0.925104, -1.394748, 1.532423, 0.826872, -0.686369, -0.074315, -0.145523, # pose 4 - -0.386279, -1.551838, 1.563578, 1.552444, -1.775337, -0.341974, 0.957445, + -1.004515, -1.491910, 1.596406, 1.245659, -0.864236, 0.212990, 0.460589, # pose 5 - -0.227779, -1.219414, 1.026882, 0.828801, -0.916809, -0.166985, 0.034154, + -0.373052, -1.441561, 1.624783, 0.325602, -1.361228, -0.404116, 0.511430, # pose 6 - -0.324968, -1.273082, 1.092344, 0.723132, -0.922095, -0.206413, 0.152082, + -0.281212, -1.452318, 1.627866, 0.517637, -1.412135, -0.420614, 0.217023, # pose 7 - -0.421966, -1.309101, 1.143065, 0.606456, -0.906673, -0.219282, 0.237953, + -0.184868, -1.473244, 1.670229, 0.827772, -1.522330, -0.529014, -0.212158, # pose 8 - -0.851598, -1.260600, 1.302895, 1.127033, -0.969809, 0.032856, 0.642673, + -0.246577, -1.532828, 1.679171, 1.204498, -1.697638, -0.607779, -0.517785, # pose 9 - -0.951418, -0.550124, 0.848598, 2.139787, -1.025778, 1.184418, 0.598394, + -0.463435, -1.549157, 1.650964, 1.948746, -1.841124, -0.638016, -0.842437, # pose 10 - -0.834498, -0.635767, 0.970038, 2.058410, -1.035138, 1.032040, 0.738619, + -1.573539, -0.359801, 1.261534, 2.423352, -0.405671, 1.620875, -0.045014, # pose 11 - -0.519528, -0.883321, 1.135035, 1.830274, -1.100901, 0.234065, -0.069068, + -1.549088, -0.710364, 1.347227, 2.111776, -0.606133, 1.116345, -0.567788, # pose 12 - -0.347358, -1.081426, 1.192233, 1.514557, -1.244594, -0.116668, -0.564362, + -1.512218, -0.983202, 1.445454, 1.808168, -0.602205, 0.699420, -0.803771, # pose 13 - -0.364832, -1.102689, 1.224844, 1.520320, -1.265760, -0.136855, -0.582459, + -1.023248, -1.342205, 1.465909, 1.092911, -0.843418, 0.099672, 1.287399, # pose 14 - -1.130796, -1.316186, 1.711089, 1.821409, -1.278003, 0.010885, -1.337210, + -0.870257, -1.473283, 1.657987, 0.831271, -1.034030, -0.144608, 1.263203, # pose 15 - -0.732222, -1.376363, 1.812339, 1.293854, -1.410826, -0.226771, -0.217230, + -0.384368, -1.417984, 1.403442, 2.075614, -1.952106, -0.394139, 1.022592, # pose 16 - -0.885017, -1.493416, 1.894070, 1.379021, -1.520015, -0.257115, 1.025331, + -0.320712, -1.513967, 1.508547, 1.652983, -1.854378, -0.409816, 1.024834, # pose 17 - -0.911769, -1.532695, 1.944123, 1.427787, -1.560513, -0.267425, 1.004398, + -0.046066, -1.630857, 1.639221, 1.105078, -1.971509, -0.601654, 1.076452, # pose 18 - -1.436271, -1.768595, 1.559397, 2.244717, -2.057112, 0.341785, 0.749883, + -0.183628, -1.588663, 1.989621, 0.918793, -1.873979, -0.629910, 0.092012, # pose 19 - -1.511828, -1.759337, 1.620075, 2.131574, -1.946595, 0.328227, 0.584657, + -1.300709, -1.159500, 1.611790, 1.761984, -0.963027, 0.391619, -0.468980, # pose 20 - -1.180482, -1.643241, 1.865851, 1.597699, -1.464982, 0.097605, -0.217780, + -0.750548, -1.221231, 1.566283, 1.128755, -1.163176, -0.102841, 0.958420, + # pose 21 + -0.874185, -1.297230, 1.655940, 1.303348, -1.334815, -0.047425, 2.047690, + # pose 22 + -0.671314, -1.510973, 1.813908, 1.235398, -1.778996, -0.462958, 2.899902, + # pose 23 + -0.710795, -1.522124, 1.653705, 1.316372, -1.574330, 0.086268, 1.453877, + # pose 24 + -0.533642, -1.545775, 1.837151, 1.467081, -1.463448, -0.161183, 0.387563, + # pose 25 + -0.850451, -1.523689, 1.982673, 1.082314, -1.771622, -0.354609, 0.583659, ] diff --git a/handeye/poses/89_eye_to_hand.yaml b/handeye/poses/89_eye_to_hand.yaml index 7620bbd..2ef7485 100644 --- a/handeye/poses/89_eye_to_hand.yaml +++ b/handeye/poses/89_eye_to_hand.yaml @@ -7,48 +7,38 @@ /**: ros__parameters: - num_poses: 21 + num_poses: 16 waypoint_positions: [ # pose 0 - -0.798667, -0.611079, 0.054912, 2.246988, 0.049199, 1.389219, 0.196135, + -0.850450, -1.523690, 1.982676, 1.082314, -1.771616, -0.354603, 0.583665, # pose 1 - -0.787304, -0.531383, 0.240108, 2.183440, 0.367026, 1.370311, 0.472489, + -2.098137, -0.606148, 1.213612, 2.290328, -0.795715, 0.618633, 0.213293, # pose 2 - -1.615896, -0.687070, 1.187600, 2.243693, -0.162789, 1.650045, 1.026121, + -2.115870, -0.711154, 1.204674, 2.128285, -0.937034, 0.514766, -0.356714, # pose 3 - -1.787431, -0.668004, 1.408195, 2.025431, -0.064718, 0.565415, 1.324635, + -2.017820, -0.798105, 1.516945, 2.002096, -0.392371, -0.035888, 0.657789, # pose 4 - -2.024343, -0.863793, 1.349640, 2.359469, -0.720629, 0.084467, 0.821412, + -2.021874, -0.805058, 1.533201, 2.001407, -0.399671, -0.045924, 0.653138, # pose 5 - -1.884100, -1.117584, 1.183093, 2.127937, -1.258013, 0.151934, 0.985555, + -2.135665, -1.020864, 1.346306, 2.334297, -1.268378, 0.406336, 0.897832, # pose 6 - -1.866465, -1.194137, 1.143263, 1.872811, -1.320028, 0.280719, 0.777663, + -1.793944, -1.143422, 1.244557, 1.942177, -1.315611, 0.473616, 1.619695, # pose 7 - -1.729402, -1.139685, 1.127574, 1.913544, -1.329630, -0.194162, 1.383834, + -2.075950, -1.215149, 1.322761, 2.142359, -1.372597, 0.398777, 1.008202, # pose 8 - -1.611937, -1.158158, 1.064049, 1.918313, -1.480676, -0.392994, 1.833894, + -2.153500, -1.215892, 1.345656, 2.306931, -1.412319, 0.415145, 0.997235, # pose 9 - -2.252978, -1.387014, 1.686186, 2.101298, -1.333349, -0.111994, 0.036817, + -2.127777, -1.256654, 1.370562, 2.009440, -1.369584, 0.219094, -0.132912, # pose 10 - -2.179722, -1.297659, 1.497171, 2.167675, -1.272158, 0.100548, 0.673603, + -2.138686, -1.270867, 1.591854, 2.150744, -0.898729, 0.079835, 0.840791, # pose 11 - -1.921897, -1.543024, 1.000863, 1.897273, -1.455972, 1.437388, -0.140968, + -2.063605, -1.300562, 1.582835, 2.080463, -0.904827, 0.243915, 1.110180, # pose 12 - -1.884059, -1.063347, 1.439248, 2.253200, -1.164963, -0.423306, 0.907197, + -1.867772, -1.316320, 1.376713, 1.888199, -1.274029, 0.284609, 1.405712, # pose 13 - -1.987022, -1.235459, 1.354108, 2.370862, -1.354044, 0.065378, 0.923079, + -1.829092, -1.355347, 1.315742, 1.808403, -1.413826, 0.338349, 1.459711, # pose 14 - -1.770869, -1.333861, 1.241996, 1.800723, -1.395819, -0.255590, 1.389660, + -2.192461, -1.374082, 1.330037, 2.154137, -1.528020, 0.279283, 0.657702, # pose 15 - -1.761759, -1.369860, 1.213137, 1.791639, -1.467956, -0.170537, 1.473557, - # pose 16 - -1.504294, -1.346227, 1.206550, 1.660186, -1.320782, -0.393676, 1.657215, - # pose 17 - -1.846546, -1.476930, 0.946255, 1.910615, -2.435429, 1.535168, 2.151544, - # pose 18 - -1.700067, -1.452760, 0.909035, 1.758222, -2.103930, 1.445249, 2.174743, - # pose 19 - -2.173713, -0.983647, 1.399849, 2.384939, -1.379509, -0.216471, 0.253561, - # pose 20 - -2.160187, -0.967904, 1.376288, 2.451713, -1.393524, -0.212278, 0.473606, + -2.137444, -1.468886, 1.525289, 2.348419, -1.025385, 1.011855, 1.148855, ] diff --git a/handeye/results/89_d435_eye_to_hand.yaml b/handeye/results/89_d435_eye_to_hand.yaml index 8039d08..f58c7a3 100644 --- a/handeye/results/89_d435_eye_to_hand.yaml +++ b/handeye/results/89_d435_eye_to_hand.yaml @@ -1,22 +1,22 @@ # 手眼标定结果 # mode: eye_to_hand # camera_source: d435 -# views: 24 -# utc: 2026-10-12T05:20:30.137123+00:00 +# views: 15 +# utc: 2026-10-16T03:02:49.214344+00:00 # board: 11x8 square_m=0.02 # gripper_tool: tool10120938 # T_4x4 = cam→基座(AX=XB 的手是 TCP,不折到法兰) # 旧结果备份在 results/backup/ gripper_tool: tool10120938 T_flange_to_tool: - - [1.00000000, 0.00000000, 0.00000000, -0.00050000] - - [0.00000000, 1.00000000, 0.00000000, 0.00670000] - - [0.00000000, 0.00000000, 1.00000000, 0.22450000] + - [1.00000000, 0.00000000, 0.00000000, 0.00075900] + - [0.00000000, 1.00000000, 0.00000000, -0.00008800] + - [0.00000000, 0.00000000, 1.00000000, 0.22627400] - [0.00000000, 0.00000000, 0.00000000, 1.00000000] # T_cam_to_base (eye_to_hand) -translation_m: [0.37094529, -0.52805928, 0.73745585] +translation_m: [0.38078822, -0.52208142, 0.70359964] T_4x4: - - [-0.00990083, 0.99909706, -0.04131632, 0.37094529] - - [0.99991380, 0.01024832, 0.00820703, -0.52805928] - - [0.00862304, -0.04123150, -0.99911241, 0.73745585] + - [-0.03086969, 0.99833255, -0.04877684, 0.38078822] + - [0.99951823, 0.03067553, -0.00472440, -0.52208142] + - [-0.00322027, -0.04889918, -0.99879853, 0.70359964] - [0.00000000, 0.00000000, 0.00000000, 1.00000000] diff --git a/handeye/results/89_eye_in_hand.yaml b/handeye/results/89_eye_in_hand.yaml index 9cfa770..fd4f670 100644 --- a/handeye/results/89_eye_in_hand.yaml +++ b/handeye/results/89_eye_in_hand.yaml @@ -1,28 +1,27 @@ # 手眼标定结果 # mode: eye_in_hand # camera_source: d405_left -# views: 21 -# utc: 2026-10-12T03:29:51.243487+00:00 +# views: 25 +# utc: 2026-10-16T02:29:07.572249+00:00 # board: 11x8 square_m=0.015 # gripper_tool: tool10120938 # T_4x4 = cam→法兰(AX=XB 在 TCP 上解,再左乘 T_flange_to_tool) # T_cam_to_tool 是原始求解;旧的法兰/tool1 备份在 results/backup/ -# 2026-10-12 第一次写出的 T_4x4 乘序反了(tz≈-296 mm),已按同一份 T_cam_to_tool 重折。 gripper_tool: tool10120938 T_flange_to_tool: - - [1.00000000, 0.00000000, 0.00000000, -0.00050000] - - [0.00000000, 1.00000000, 0.00000000, 0.00670000] - - [0.00000000, 0.00000000, 1.00000000, 0.22450000] + - [1.00000000, 0.00000000, 0.00000000, 0.00075900] + - [0.00000000, 1.00000000, 0.00000000, -0.00008800] + - [0.00000000, 0.00000000, 1.00000000, 0.22627400] - [0.00000000, 0.00000000, 0.00000000, 1.00000000] T_cam_to_tool: - - [-0.03435184, -0.86951953, 0.49270249, -0.05961995] - - [0.99932767, -0.03620511, 0.00577974, -0.01523625] - - [0.01281276, 0.49256977, 0.87017863, -0.09761517] + - [0.01117708, -0.86715374, 0.49791511, -0.06192365] + - [0.99992647, 0.01203574, -0.00148504, -0.00762539] + - [-0.00470501, 0.49789509, 0.86722450, -0.09779711] - [0.00000000, 0.00000000, 0.00000000, 1.00000000] # T_cam_to_flange (eye_in_hand,由工具 TCP 折算) -translation_m: [-0.06011995, -0.00853625, 0.12688483] +translation_m: [-0.06116465, -0.00771339, 0.12847689] T_4x4: - - [-0.03435184, -0.86951953, 0.49270249, -0.06011995] - - [0.99932767, -0.03620511, 0.00577974, -0.00853625] - - [0.01281276, 0.49256977, 0.87017863, 0.12688483] + - [0.01117708, -0.86715374, 0.49791511, -0.06116465] + - [0.99992647, 0.01203574, -0.00148504, -0.00771339] + - [-0.00470501, 0.49789509, 0.86722450, 0.12847689] - [0.00000000, 0.00000000, 0.00000000, 1.00000000] diff --git a/install/arm_application/lib/arm_application/assembly_pipeline b/install/arm_application/lib/arm_application/assembly_pipeline index 0b324dd..83e04c4 100755 --- a/install/arm_application/lib/arm_application/assembly_pipeline +++ b/install/arm_application/lib/arm_application/assembly_pipeline @@ -1,10 +1,10 @@ -#!/usr/bin/env python3 -# EASY-INSTALL-ENTRY-SCRIPT: 'arm-application','console_scripts','assembly_pipeline' +#!/home/cc/anaconda3/envs/ros_flexiv/bin/python3.11 +# EASY-INSTALL-ENTRY-SCRIPT: 'arm-application==0.1.0','console_scripts','assembly_pipeline' import re import sys # for compatibility with easy_install; see #2198 -__requires__ = 'arm-application' +__requires__ = 'arm-application==0.1.0' try: from importlib.metadata import distribution @@ -30,4 +30,4 @@ globals().setdefault('load_entry_point', importlib_load_entry_point) if __name__ == '__main__': sys.argv[0] = re.sub(r'(-script\.pyw?|\.exe)?$', '', sys.argv[0]) - sys.exit(load_entry_point('arm-application', 'console_scripts', 'assembly_pipeline')()) + sys.exit(load_entry_point('arm-application==0.1.0', 'console_scripts', 'assembly_pipeline')()) diff --git a/install/arm_application/lib/arm_application/demo_named_poses b/install/arm_application/lib/arm_application/demo_named_poses index f7c52f6..7c827cb 100755 --- a/install/arm_application/lib/arm_application/demo_named_poses +++ b/install/arm_application/lib/arm_application/demo_named_poses @@ -1,10 +1,10 @@ -#!/usr/bin/env python3 -# EASY-INSTALL-ENTRY-SCRIPT: 'arm-application','console_scripts','demo_named_poses' +#!/home/cc/anaconda3/envs/ros_flexiv/bin/python3.11 +# EASY-INSTALL-ENTRY-SCRIPT: 'arm-application==0.1.0','console_scripts','demo_named_poses' import re import sys # for compatibility with easy_install; see #2198 -__requires__ = 'arm-application' +__requires__ = 'arm-application==0.1.0' try: from importlib.metadata import distribution @@ -30,4 +30,4 @@ globals().setdefault('load_entry_point', importlib_load_entry_point) if __name__ == '__main__': sys.argv[0] = re.sub(r'(-script\.pyw?|\.exe)?$', '', sys.argv[0]) - sys.exit(load_entry_point('arm-application', 'console_scripts', 'demo_named_poses')()) + sys.exit(load_entry_point('arm-application==0.1.0', 'console_scripts', 'demo_named_poses')()) diff --git a/install/arm_application/lib/arm_application/dh_pgea b/install/arm_application/lib/arm_application/dh_pgea index cd342cb..ca6dd44 100755 --- a/install/arm_application/lib/arm_application/dh_pgea +++ b/install/arm_application/lib/arm_application/dh_pgea @@ -1,10 +1,10 @@ -#!/usr/bin/env python3 -# EASY-INSTALL-ENTRY-SCRIPT: 'arm-application','console_scripts','dh_pgea' +#!/home/cc/anaconda3/envs/ros_flexiv/bin/python3.11 +# EASY-INSTALL-ENTRY-SCRIPT: 'arm-application==0.1.0','console_scripts','dh_pgea' import re import sys # for compatibility with easy_install; see #2198 -__requires__ = 'arm-application' +__requires__ = 'arm-application==0.1.0' try: from importlib.metadata import distribution @@ -30,4 +30,4 @@ globals().setdefault('load_entry_point', importlib_load_entry_point) if __name__ == '__main__': sys.argv[0] = re.sub(r'(-script\.pyw?|\.exe)?$', '', sys.argv[0]) - sys.exit(load_entry_point('arm-application', 'console_scripts', 'dh_pgea')()) + sys.exit(load_entry_point('arm-application==0.1.0', 'console_scripts', 'dh_pgea')()) diff --git a/install/arm_application/lib/arm_application/dh_pgea_cli b/install/arm_application/lib/arm_application/dh_pgea_cli index d7cfa18..81ecdd2 100755 --- a/install/arm_application/lib/arm_application/dh_pgea_cli +++ b/install/arm_application/lib/arm_application/dh_pgea_cli @@ -1,10 +1,10 @@ -#!/usr/bin/env python3 -# EASY-INSTALL-ENTRY-SCRIPT: 'arm-application','console_scripts','dh_pgea_cli' +#!/home/cc/anaconda3/envs/ros_flexiv/bin/python3.11 +# EASY-INSTALL-ENTRY-SCRIPT: 'arm-application==0.1.0','console_scripts','dh_pgea_cli' import re import sys # for compatibility with easy_install; see #2198 -__requires__ = 'arm-application' +__requires__ = 'arm-application==0.1.0' try: from importlib.metadata import distribution @@ -30,4 +30,4 @@ globals().setdefault('load_entry_point', importlib_load_entry_point) if __name__ == '__main__': sys.argv[0] = re.sub(r'(-script\.pyw?|\.exe)?$', '', sys.argv[0]) - sys.exit(load_entry_point('arm-application', 'console_scripts', 'dh_pgea_cli')()) + sys.exit(load_entry_point('arm-application==0.1.0', 'console_scripts', 'dh_pgea_cli')()) diff --git a/install/arm_application/lib/arm_application/dual_gripper b/install/arm_application/lib/arm_application/dual_gripper index fbbbaf9..bf6c529 100755 --- a/install/arm_application/lib/arm_application/dual_gripper +++ b/install/arm_application/lib/arm_application/dual_gripper @@ -1,10 +1,10 @@ -#!/usr/bin/env python3 -# EASY-INSTALL-ENTRY-SCRIPT: 'arm-application','console_scripts','dual_gripper' +#!/home/cc/anaconda3/envs/ros_flexiv/bin/python3.11 +# EASY-INSTALL-ENTRY-SCRIPT: 'arm-application==0.1.0','console_scripts','dual_gripper' import re import sys # for compatibility with easy_install; see #2198 -__requires__ = 'arm-application' +__requires__ = 'arm-application==0.1.0' try: from importlib.metadata import distribution @@ -30,4 +30,4 @@ globals().setdefault('load_entry_point', importlib_load_entry_point) if __name__ == '__main__': sys.argv[0] = re.sub(r'(-script\.pyw?|\.exe)?$', '', sys.argv[0]) - sys.exit(load_entry_point('arm-application', 'console_scripts', 'dual_gripper')()) + sys.exit(load_entry_point('arm-application==0.1.0', 'console_scripts', 'dual_gripper')()) diff --git a/install/arm_application/lib/arm_application/dual_gripper_cli b/install/arm_application/lib/arm_application/dual_gripper_cli index 2189632..1a713e1 100755 --- a/install/arm_application/lib/arm_application/dual_gripper_cli +++ b/install/arm_application/lib/arm_application/dual_gripper_cli @@ -1,10 +1,10 @@ -#!/usr/bin/env python3 -# EASY-INSTALL-ENTRY-SCRIPT: 'arm-application','console_scripts','dual_gripper_cli' +#!/home/cc/anaconda3/envs/ros_flexiv/bin/python3.11 +# EASY-INSTALL-ENTRY-SCRIPT: 'arm-application==0.1.0','console_scripts','dual_gripper_cli' import re import sys # for compatibility with easy_install; see #2198 -__requires__ = 'arm-application' +__requires__ = 'arm-application==0.1.0' try: from importlib.metadata import distribution @@ -30,4 +30,4 @@ globals().setdefault('load_entry_point', importlib_load_entry_point) if __name__ == '__main__': sys.argv[0] = re.sub(r'(-script\.pyw?|\.exe)?$', '', sys.argv[0]) - sys.exit(load_entry_point('arm-application', 'console_scripts', 'dual_gripper_cli')()) + sys.exit(load_entry_point('arm-application==0.1.0', 'console_scripts', 'dual_gripper_cli')()) diff --git a/install/arm_application/lib/arm_application/gauge_assembly b/install/arm_application/lib/arm_application/gauge_assembly index cdeb1f2..5270ad3 100755 --- a/install/arm_application/lib/arm_application/gauge_assembly +++ b/install/arm_application/lib/arm_application/gauge_assembly @@ -1,10 +1,10 @@ -#!/usr/bin/env python3 -# EASY-INSTALL-ENTRY-SCRIPT: 'arm-application','console_scripts','gauge_assembly' +#!/home/cc/anaconda3/envs/ros_flexiv/bin/python3.11 +# EASY-INSTALL-ENTRY-SCRIPT: 'arm-application==0.1.0','console_scripts','gauge_assembly' import re import sys # for compatibility with easy_install; see #2198 -__requires__ = 'arm-application' +__requires__ = 'arm-application==0.1.0' try: from importlib.metadata import distribution @@ -30,4 +30,4 @@ globals().setdefault('load_entry_point', importlib_load_entry_point) if __name__ == '__main__': sys.argv[0] = re.sub(r'(-script\.pyw?|\.exe)?$', '', sys.argv[0]) - sys.exit(load_entry_point('arm-application', 'console_scripts', 'gauge_assembly')()) + sys.exit(load_entry_point('arm-application==0.1.0', 'console_scripts', 'gauge_assembly')()) diff --git a/install/arm_application/lib/arm_application/goto_ee_plane b/install/arm_application/lib/arm_application/goto_ee_plane index 6cd4bbe..9b2bf55 100755 --- a/install/arm_application/lib/arm_application/goto_ee_plane +++ b/install/arm_application/lib/arm_application/goto_ee_plane @@ -1,10 +1,10 @@ -#!/usr/bin/env python3 -# EASY-INSTALL-ENTRY-SCRIPT: 'arm-application','console_scripts','goto_ee_plane' +#!/home/cc/anaconda3/envs/ros_flexiv/bin/python3.11 +# EASY-INSTALL-ENTRY-SCRIPT: 'arm-application==0.1.0','console_scripts','goto_ee_plane' import re import sys # for compatibility with easy_install; see #2198 -__requires__ = 'arm-application' +__requires__ = 'arm-application==0.1.0' try: from importlib.metadata import distribution @@ -30,4 +30,4 @@ globals().setdefault('load_entry_point', importlib_load_entry_point) if __name__ == '__main__': sys.argv[0] = re.sub(r'(-script\.pyw?|\.exe)?$', '', sys.argv[0]) - sys.exit(load_entry_point('arm-application', 'console_scripts', 'goto_ee_plane')()) + sys.exit(load_entry_point('arm-application==0.1.0', 'console_scripts', 'goto_ee_plane')()) diff --git a/install/arm_application/lib/arm_application/handeye_calibrate b/install/arm_application/lib/arm_application/handeye_calibrate index e089854..05964cc 100755 --- a/install/arm_application/lib/arm_application/handeye_calibrate +++ b/install/arm_application/lib/arm_application/handeye_calibrate @@ -1,10 +1,10 @@ -#!/usr/bin/env python3 -# EASY-INSTALL-ENTRY-SCRIPT: 'arm-application','console_scripts','handeye_calibrate' +#!/home/cc/anaconda3/envs/ros_flexiv/bin/python3.11 +# EASY-INSTALL-ENTRY-SCRIPT: 'arm-application==0.1.0','console_scripts','handeye_calibrate' import re import sys # for compatibility with easy_install; see #2198 -__requires__ = 'arm-application' +__requires__ = 'arm-application==0.1.0' try: from importlib.metadata import distribution @@ -30,4 +30,4 @@ globals().setdefault('load_entry_point', importlib_load_entry_point) if __name__ == '__main__': sys.argv[0] = re.sub(r'(-script\.pyw?|\.exe)?$', '', sys.argv[0]) - sys.exit(load_entry_point('arm-application', 'console_scripts', 'handeye_calibrate')()) + sys.exit(load_entry_point('arm-application==0.1.0', 'console_scripts', 'handeye_calibrate')()) diff --git a/install/arm_application/lib/arm_application/handeye_teach b/install/arm_application/lib/arm_application/handeye_teach index 3ff575f..cb30156 100755 --- a/install/arm_application/lib/arm_application/handeye_teach +++ b/install/arm_application/lib/arm_application/handeye_teach @@ -1,10 +1,10 @@ -#!/usr/bin/env python3 -# EASY-INSTALL-ENTRY-SCRIPT: 'arm-application','console_scripts','handeye_teach' +#!/home/cc/anaconda3/envs/ros_flexiv/bin/python3.11 +# EASY-INSTALL-ENTRY-SCRIPT: 'arm-application==0.1.0','console_scripts','handeye_teach' import re import sys # for compatibility with easy_install; see #2198 -__requires__ = 'arm-application' +__requires__ = 'arm-application==0.1.0' try: from importlib.metadata import distribution @@ -30,4 +30,4 @@ globals().setdefault('load_entry_point', importlib_load_entry_point) if __name__ == '__main__': sys.argv[0] = re.sub(r'(-script\.pyw?|\.exe)?$', '', sys.argv[0]) - sys.exit(load_entry_point('arm-application', 'console_scripts', 'handeye_teach')()) + sys.exit(load_entry_point('arm-application==0.1.0', 'console_scripts', 'handeye_teach')()) diff --git a/install/arm_application/lib/arm_application/handeye_waypoints b/install/arm_application/lib/arm_application/handeye_waypoints index 89dbb86..461792e 100755 --- a/install/arm_application/lib/arm_application/handeye_waypoints +++ b/install/arm_application/lib/arm_application/handeye_waypoints @@ -1,10 +1,10 @@ -#!/usr/bin/env python3 -# EASY-INSTALL-ENTRY-SCRIPT: 'arm-application','console_scripts','handeye_waypoints' +#!/home/cc/anaconda3/envs/ros_flexiv/bin/python3.11 +# EASY-INSTALL-ENTRY-SCRIPT: 'arm-application==0.1.0','console_scripts','handeye_waypoints' import re import sys # for compatibility with easy_install; see #2198 -__requires__ = 'arm-application' +__requires__ = 'arm-application==0.1.0' try: from importlib.metadata import distribution @@ -30,4 +30,4 @@ globals().setdefault('load_entry_point', importlib_load_entry_point) if __name__ == '__main__': sys.argv[0] = re.sub(r'(-script\.pyw?|\.exe)?$', '', sys.argv[0]) - sys.exit(load_entry_point('arm-application', 'console_scripts', 'handeye_waypoints')()) + sys.exit(load_entry_point('arm-application==0.1.0', 'console_scripts', 'handeye_waypoints')()) diff --git a/install/arm_application/lib/arm_application/hole_detect b/install/arm_application/lib/arm_application/hole_detect index c68accd..f2b2bc9 100755 --- a/install/arm_application/lib/arm_application/hole_detect +++ b/install/arm_application/lib/arm_application/hole_detect @@ -1,10 +1,10 @@ -#!/usr/bin/env python3 -# EASY-INSTALL-ENTRY-SCRIPT: 'arm-application','console_scripts','hole_detect' +#!/home/cc/anaconda3/envs/ros_flexiv/bin/python3.11 +# EASY-INSTALL-ENTRY-SCRIPT: 'arm-application==0.1.0','console_scripts','hole_detect' import re import sys # for compatibility with easy_install; see #2198 -__requires__ = 'arm-application' +__requires__ = 'arm-application==0.1.0' try: from importlib.metadata import distribution @@ -30,4 +30,4 @@ globals().setdefault('load_entry_point', importlib_load_entry_point) if __name__ == '__main__': sys.argv[0] = re.sub(r'(-script\.pyw?|\.exe)?$', '', sys.argv[0]) - sys.exit(load_entry_point('arm-application', 'console_scripts', 'hole_detect')()) + sys.exit(load_entry_point('arm-application==0.1.0', 'console_scripts', 'hole_detect')()) diff --git a/install/arm_application/lib/arm_application/move_to_pose_client b/install/arm_application/lib/arm_application/move_to_pose_client index 1ce10d4..73cf946 100755 --- a/install/arm_application/lib/arm_application/move_to_pose_client +++ b/install/arm_application/lib/arm_application/move_to_pose_client @@ -1,10 +1,10 @@ -#!/usr/bin/env python3 -# EASY-INSTALL-ENTRY-SCRIPT: 'arm-application','console_scripts','move_to_pose_client' +#!/home/cc/anaconda3/envs/ros_flexiv/bin/python3.11 +# EASY-INSTALL-ENTRY-SCRIPT: 'arm-application==0.1.0','console_scripts','move_to_pose_client' import re import sys # for compatibility with easy_install; see #2198 -__requires__ = 'arm-application' +__requires__ = 'arm-application==0.1.0' try: from importlib.metadata import distribution @@ -30,4 +30,4 @@ globals().setdefault('load_entry_point', importlib_load_entry_point) if __name__ == '__main__': sys.argv[0] = re.sub(r'(-script\.pyw?|\.exe)?$', '', sys.argv[0]) - sys.exit(load_entry_point('arm-application', 'console_scripts', 'move_to_pose_client')()) + sys.exit(load_entry_point('arm-application==0.1.0', 'console_scripts', 'move_to_pose_client')()) diff --git a/install/arm_application/lib/arm_application/perception_node b/install/arm_application/lib/arm_application/perception_node index d1e25ad..8679a28 100755 --- a/install/arm_application/lib/arm_application/perception_node +++ b/install/arm_application/lib/arm_application/perception_node @@ -1,10 +1,10 @@ -#!/usr/bin/env python3 -# EASY-INSTALL-ENTRY-SCRIPT: 'arm-application','console_scripts','perception_node' +#!/home/cc/anaconda3/envs/ros_flexiv/bin/python3.11 +# EASY-INSTALL-ENTRY-SCRIPT: 'arm-application==0.1.0','console_scripts','perception_node' import re import sys # for compatibility with easy_install; see #2198 -__requires__ = 'arm-application' +__requires__ = 'arm-application==0.1.0' try: from importlib.metadata import distribution @@ -30,4 +30,4 @@ globals().setdefault('load_entry_point', importlib_load_entry_point) if __name__ == '__main__': sys.argv[0] = re.sub(r'(-script\.pyw?|\.exe)?$', '', sys.argv[0]) - sys.exit(load_entry_point('arm-application', 'console_scripts', 'perception_node')()) + sys.exit(load_entry_point('arm-application==0.1.0', 'console_scripts', 'perception_node')()) diff --git a/install/arm_application/lib/arm_application/realsense_color_pub b/install/arm_application/lib/arm_application/realsense_color_pub index c640e0c..1453e5a 100755 --- a/install/arm_application/lib/arm_application/realsense_color_pub +++ b/install/arm_application/lib/arm_application/realsense_color_pub @@ -1,10 +1,10 @@ -#!/usr/bin/env python3 -# EASY-INSTALL-ENTRY-SCRIPT: 'arm-application','console_scripts','realsense_color_pub' +#!/home/cc/anaconda3/envs/ros_flexiv/bin/python3.11 +# EASY-INSTALL-ENTRY-SCRIPT: 'arm-application==0.1.0','console_scripts','realsense_color_pub' import re import sys # for compatibility with easy_install; see #2198 -__requires__ = 'arm-application' +__requires__ = 'arm-application==0.1.0' try: from importlib.metadata import distribution @@ -30,4 +30,4 @@ globals().setdefault('load_entry_point', importlib_load_entry_point) if __name__ == '__main__': sys.argv[0] = re.sub(r'(-script\.pyw?|\.exe)?$', '', sys.argv[0]) - sys.exit(load_entry_point('arm-application', 'console_scripts', 'realsense_color_pub')()) + sys.exit(load_entry_point('arm-application==0.1.0', 'console_scripts', 'realsense_color_pub')()) diff --git a/install/arm_application/lib/arm_application/realsense_view b/install/arm_application/lib/arm_application/realsense_view index 83717c6..48049cf 100755 --- a/install/arm_application/lib/arm_application/realsense_view +++ b/install/arm_application/lib/arm_application/realsense_view @@ -1,10 +1,10 @@ -#!/usr/bin/env python3 -# EASY-INSTALL-ENTRY-SCRIPT: 'arm-application','console_scripts','realsense_view' +#!/home/cc/anaconda3/envs/ros_flexiv/bin/python3.11 +# EASY-INSTALL-ENTRY-SCRIPT: 'arm-application==0.1.0','console_scripts','realsense_view' import re import sys # for compatibility with easy_install; see #2198 -__requires__ = 'arm-application' +__requires__ = 'arm-application==0.1.0' try: from importlib.metadata import distribution @@ -30,4 +30,4 @@ globals().setdefault('load_entry_point', importlib_load_entry_point) if __name__ == '__main__': sys.argv[0] = re.sub(r'(-script\.pyw?|\.exe)?$', '', sys.argv[0]) - sys.exit(load_entry_point('arm-application', 'console_scripts', 'realsense_view')()) + sys.exit(load_entry_point('arm-application==0.1.0', 'console_scripts', 'realsense_view')()) diff --git a/install/arm_application/lib/arm_application/run_joint_trajectory b/install/arm_application/lib/arm_application/run_joint_trajectory index 442cb60..19b0f14 100755 --- a/install/arm_application/lib/arm_application/run_joint_trajectory +++ b/install/arm_application/lib/arm_application/run_joint_trajectory @@ -1,10 +1,10 @@ -#!/usr/bin/env python3 -# EASY-INSTALL-ENTRY-SCRIPT: 'arm-application','console_scripts','run_joint_trajectory' +#!/home/cc/anaconda3/envs/ros_flexiv/bin/python3.11 +# EASY-INSTALL-ENTRY-SCRIPT: 'arm-application==0.1.0','console_scripts','run_joint_trajectory' import re import sys # for compatibility with easy_install; see #2198 -__requires__ = 'arm-application' +__requires__ = 'arm-application==0.1.0' try: from importlib.metadata import distribution @@ -30,4 +30,4 @@ globals().setdefault('load_entry_point', importlib_load_entry_point) if __name__ == '__main__': sys.argv[0] = re.sub(r'(-script\.pyw?|\.exe)?$', '', sys.argv[0]) - sys.exit(load_entry_point('arm-application', 'console_scripts', 'run_joint_trajectory')()) + sys.exit(load_entry_point('arm-application==0.1.0', 'console_scripts', 'run_joint_trajectory')()) diff --git a/install/arm_application/lib/arm_application/servo_node b/install/arm_application/lib/arm_application/servo_node index 55b7cc0..66bfece 100755 --- a/install/arm_application/lib/arm_application/servo_node +++ b/install/arm_application/lib/arm_application/servo_node @@ -1,10 +1,10 @@ -#!/usr/bin/env python3 -# EASY-INSTALL-ENTRY-SCRIPT: 'arm-application','console_scripts','servo_node' +#!/home/cc/anaconda3/envs/ros_flexiv/bin/python3.11 +# EASY-INSTALL-ENTRY-SCRIPT: 'arm-application==0.1.0','console_scripts','servo_node' import re import sys # for compatibility with easy_install; see #2198 -__requires__ = 'arm-application' +__requires__ = 'arm-application==0.1.0' try: from importlib.metadata import distribution @@ -30,4 +30,4 @@ globals().setdefault('load_entry_point', importlib_load_entry_point) if __name__ == '__main__': sys.argv[0] = re.sub(r'(-script\.pyw?|\.exe)?$', '', sys.argv[0]) - sys.exit(load_entry_point('arm-application', 'console_scripts', 'servo_node')()) + sys.exit(load_entry_point('arm-application==0.1.0', 'console_scripts', 'servo_node')()) diff --git a/install/arm_application/lib/arm_application/stage2_locate b/install/arm_application/lib/arm_application/stage2_locate index 29e1b3f..56a842a 100755 --- a/install/arm_application/lib/arm_application/stage2_locate +++ b/install/arm_application/lib/arm_application/stage2_locate @@ -1,10 +1,10 @@ -#!/usr/bin/env python3 -# EASY-INSTALL-ENTRY-SCRIPT: 'arm-application','console_scripts','stage2_locate' +#!/home/cc/anaconda3/envs/ros_flexiv/bin/python3.11 +# EASY-INSTALL-ENTRY-SCRIPT: 'arm-application==0.1.0','console_scripts','stage2_locate' import re import sys # for compatibility with easy_install; see #2198 -__requires__ = 'arm-application' +__requires__ = 'arm-application==0.1.0' try: from importlib.metadata import distribution @@ -30,4 +30,4 @@ globals().setdefault('load_entry_point', importlib_load_entry_point) if __name__ == '__main__': sys.argv[0] = re.sub(r'(-script\.pyw?|\.exe)?$', '', sys.argv[0]) - sys.exit(load_entry_point('arm-application', 'console_scripts', 'stage2_locate')()) + sys.exit(load_entry_point('arm-application==0.1.0', 'console_scripts', 'stage2_locate')()) diff --git a/install/arm_application/lib/arm_application/stage3_search b/install/arm_application/lib/arm_application/stage3_search index e08aa2d..f1a3a02 100755 --- a/install/arm_application/lib/arm_application/stage3_search +++ b/install/arm_application/lib/arm_application/stage3_search @@ -1,10 +1,10 @@ -#!/usr/bin/env python3 -# EASY-INSTALL-ENTRY-SCRIPT: 'arm-application','console_scripts','stage3_search' +#!/home/cc/anaconda3/envs/ros_flexiv/bin/python3.11 +# EASY-INSTALL-ENTRY-SCRIPT: 'arm-application==0.1.0','console_scripts','stage3_search' import re import sys # for compatibility with easy_install; see #2198 -__requires__ = 'arm-application' +__requires__ = 'arm-application==0.1.0' try: from importlib.metadata import distribution @@ -30,4 +30,4 @@ globals().setdefault('load_entry_point', importlib_load_entry_point) if __name__ == '__main__': sys.argv[0] = re.sub(r'(-script\.pyw?|\.exe)?$', '', sys.argv[0]) - sys.exit(load_entry_point('arm-application', 'console_scripts', 'stage3_search')()) + sys.exit(load_entry_point('arm-application==0.1.0', 'console_scripts', 'stage3_search')()) diff --git a/install/arm_application/lib/arm_application/tactile b/install/arm_application/lib/arm_application/tactile index 3f600f9..952fc9f 100755 --- a/install/arm_application/lib/arm_application/tactile +++ b/install/arm_application/lib/arm_application/tactile @@ -1,10 +1,10 @@ -#!/usr/bin/env python3 -# EASY-INSTALL-ENTRY-SCRIPT: 'arm-application','console_scripts','tactile' +#!/home/cc/anaconda3/envs/ros_flexiv/bin/python3.11 +# EASY-INSTALL-ENTRY-SCRIPT: 'arm-application==0.1.0','console_scripts','tactile' import re import sys # for compatibility with easy_install; see #2198 -__requires__ = 'arm-application' +__requires__ = 'arm-application==0.1.0' try: from importlib.metadata import distribution @@ -30,4 +30,4 @@ globals().setdefault('load_entry_point', importlib_load_entry_point) if __name__ == '__main__': sys.argv[0] = re.sub(r'(-script\.pyw?|\.exe)?$', '', sys.argv[0]) - sys.exit(load_entry_point('arm-application', 'console_scripts', 'tactile')()) + sys.exit(load_entry_point('arm-application==0.1.0', 'console_scripts', 'tactile')()) diff --git a/install/arm_application/lib/arm_application/tactile_cli b/install/arm_application/lib/arm_application/tactile_cli index 85e500c..570ae0f 100755 --- a/install/arm_application/lib/arm_application/tactile_cli +++ b/install/arm_application/lib/arm_application/tactile_cli @@ -1,10 +1,10 @@ -#!/usr/bin/env python3 -# EASY-INSTALL-ENTRY-SCRIPT: 'arm-application','console_scripts','tactile_cli' +#!/home/cc/anaconda3/envs/ros_flexiv/bin/python3.11 +# EASY-INSTALL-ENTRY-SCRIPT: 'arm-application==0.1.0','console_scripts','tactile_cli' import re import sys # for compatibility with easy_install; see #2198 -__requires__ = 'arm-application' +__requires__ = 'arm-application==0.1.0' try: from importlib.metadata import distribution @@ -30,4 +30,4 @@ globals().setdefault('load_entry_point', importlib_load_entry_point) if __name__ == '__main__': sys.argv[0] = re.sub(r'(-script\.pyw?|\.exe)?$', '', sys.argv[0]) - sys.exit(load_entry_point('arm-application', 'console_scripts', 'tactile_cli')()) + sys.exit(load_entry_point('arm-application==0.1.0', 'console_scripts', 'tactile_cli')()) diff --git a/install/arm_application/lib/python3.11/site-packages/arm_application/dh_pgea.py b/install/arm_application/lib/python3.11/site-packages/arm_application/dh_pgea.py index 3cfbbbf..117d46a 100644 --- a/install/arm_application/lib/python3.11/site-packages/arm_application/dh_pgea.py +++ b/install/arm_application/lib/python3.11/site-packages/arm_application/dh_pgea.py @@ -213,6 +213,34 @@ WIRING_HINT = ( ) +def open_failure_text(port: str, exc: BaseException) -> str: + """串口打不开时把「被占用」和「没应答」分开。 + + 独占锁失败(errno 11)以前和真没上电写成同一句「需要 24 V DC」, + 界面上看起来像缺自动上电,实际是别的进程还握着这个口。 + """ + text = str(exc).lower() + errno = getattr(exc, "errno", None) + busy = errno in (11, 16) or any( + token in text + for token in ( + "temporarily unavailable", + "device or resource busy", + "could not exclusively lock", + "resource busy", + ) + ) + if busy: + return ( + f"无法独占打开 {port}:{exc}。" + "串口已被别的进程占用(夹爪驱动或状态读取)。" + ) + return ( + f"无法打开 {port}:{exc}。" + "需要 24 V DC 供电 + RS485 接线,且当前用户在 dialout 组内。" + ) + + class DhPgeaGripper: """面向 PGE / PGEA 的线程安全 Modbus RTU 客户端(官方寄存器映射)。""" @@ -257,10 +285,7 @@ class DhPgeaGripper: exclusive=True, ) except Exception as exc: - raise DhPgeaError( - f"无法打开 {self.port}:{exc}。" - "需要 24 V DC 供电 + RS485 接线,且当前用户在 dialout 组内。" - ) from exc + raise DhPgeaError(open_failure_text(self.port, exc)) from exc try: self._ser.dtr = True except Exception: @@ -562,6 +587,36 @@ def _scan_bus(port: str) -> int: return 1 +def ctrl_values( + cmd: str, + value: str = "", + force: int = 50, + speed: int = 50, +) -> Optional[tuple[float, float, float, float]]: + """节点已经占着串口时,改发 ``/gripper/ctrl`` 的四个数。 + + 顺序是初始化、位置千分比、力度、速度。``probe`` / ``status`` 只读,返回 None。 + """ + name = str(cmd or "").strip().lower() + if name in ("probe", "status"): + return None + if name == "init": + return (1.0, 0.0, float(force), float(speed)) + if name == "open": + pos = 1000 + elif name == "close": + pos = 0 + elif name in ("pos", "move"): + if str(value).strip() == "": + raise ValueError("pos 需要 0–1000") + pos = int(round(float(value))) + if pos < 0 or pos > 1000: + raise ValueError("位置只接受 0–1000") + else: + raise ValueError(f"节点在线时不支持 {name}") + return (0.0, float(pos), float(force), float(speed)) + + def _cli(argv: Optional[list[str]] = None) -> int: site = _site_defaults() ap = argparse.ArgumentParser( diff --git a/install/arm_application/lib/python3.11/site-packages/arm_application/handeye_calibrate.py b/install/arm_application/lib/python3.11/site-packages/arm_application/handeye_calibrate.py index a04c653..e7e827d 100644 --- a/install/arm_application/lib/python3.11/site-packages/arm_application/handeye_calibrate.py +++ b/install/arm_application/lib/python3.11/site-packages/arm_application/handeye_calibrate.py @@ -137,9 +137,7 @@ class HandeyeCalibrate(TolerantNode): self.declare_parameter('pin_hold_sec', 0.45) # 每拍多长 self.declare_parameter('skip_unstable_poses', True) - from arm_application.site_io import ros2_root - - self.declare_parameter('output_dir', str(ros2_root() / 'handeye' / 'tmp')) + self.declare_parameter('output_dir', '/home/cc/ros2/handeye/tmp') self.declare_parameter('result_file', '') self.declare_parameter('cleanup_images', True) # 眼在手上:AX=XB 的 gripper 用 Studio 工具 TCP,再折成 cam→法兰写 T_4x4。 diff --git a/install/arm_application/lib/python3.11/site-packages/arm_application/handeye_teach.py b/install/arm_application/lib/python3.11/site-packages/arm_application/handeye_teach.py index c657a18..a78b0fc 100644 --- a/install/arm_application/lib/python3.11/site-packages/arm_application/handeye_teach.py +++ b/install/arm_application/lib/python3.11/site-packages/arm_application/handeye_teach.py @@ -108,12 +108,10 @@ class RdkBridge: self.robot_sn = robot_sn self._last_q: Optional[List[float]] = None self._io = threading.Lock() - from arm_application.site_io import ros2_root - - py = os.environ.get('RDK_PYTHON', '').strip() or str( - Path.home() / 'rdk_py310' / 'bin' / 'python' - ) - bridge = ros2_root() / 'scripts' / 'rdk_drag_bridge.py' + py = os.environ.get('RDK_PYTHON', '/home/cc/rdk_py310/bin/python') + bridge = Path.home() / 'ros2' / 'scripts' / 'rdk_drag_bridge.py' + if not bridge.is_file(): + bridge = Path('/home/cc/ros2/scripts/rdk_drag_bridge.py') if not bridge.is_file(): raise FileNotFoundError(f'找不到 rdk_drag_bridge.py:{bridge}') @@ -222,13 +220,6 @@ class RdkBridge: return self._last_q return self._last_q - def move_joints(self, joints: List[float], speed: float = 0.15) -> dict: - """按关节阻抗把臂缓慢送到已记录的 7 个关节角。speed 单位 rad/s。""" - return self._ask( - {'cmd': 'move_joints', 'q': [float(v) for v in joints[:7]], 'speed': float(speed)}, - timeout=180.0, - ) - def close(self) -> None: try: self._ask({'cmd': 'quit'}, timeout=3.0) @@ -248,9 +239,7 @@ class RdkBridge: class HandeyeTeach(TolerantNode): def __init__(self) -> None: super().__init__('handeye_teach') - from arm_application.site_io import ros2_root - - self.declare_parameter('output_dir', str(ros2_root() / 'handeye' / 'tmp')) + self.declare_parameter('output_dir', '/home/cc/ros2/handeye/tmp') self.declare_parameter('width', 1280) self.declare_parameter('height', 720) self.declare_parameter('fps', 15) diff --git a/install/arm_application/lib/python3.11/site-packages/arm_application/operator_app.py b/install/arm_application/lib/python3.11/site-packages/arm_application/operator_app.py index 2c38a27..0e93ba1 100644 --- a/install/arm_application/lib/python3.11/site-packages/arm_application/operator_app.py +++ b/install/arm_application/lib/python3.11/site-packages/arm_application/operator_app.py @@ -11,39 +11,69 @@ import sys import threading from pathlib import Path -from PySide6.QtCore import Qt, QSize, QThread, QTimer, Signal -from PySide6.QtGui import QColor, QFont, QPainter, QPen, QPixmap +from PySide6.QtCore import QPoint, QRect, QRectF, QSize, Qt, QThread, QTimer, Signal +from PySide6.QtGui import ( + QBrush, + QColor, + QFont, + QFontMetrics, + QIcon, + QImage, + QMatrix3x3, + QPainter, + QPainterPath, + QPen, + QPixmap, + QPolygon, + QSurfaceFormat, + QVector3D, +) +from PySide6.QtOpenGL import QOpenGLBuffer, QOpenGLShader, QOpenGLShaderProgram, QOpenGLVertexArrayObject +from PySide6.QtOpenGLWidgets import QOpenGLWidget from PySide6.QtWidgets import ( QApplication, + QCheckBox, QDoubleSpinBox, + QSpinBox, QFrame, QGridLayout, QHBoxLayout, QLabel, + QLayout, QLineEdit, QListWidget, QListWidgetItem, QMainWindow, - QMessageBox, QPlainTextEdit, QPushButton, QScrollArea, QSizePolicy, QSlider, + QStyle, + QStyleOptionButton, QStackedWidget, QVBoxLayout, QWidget, ) +from arm_application.site_io import arm_short_id, listed_arms, load_site_dict from arm_application.operator_console import ( _poll_tactile, catalog, + command_blocked_message, + command_lane, run_action, + stop_started, run_gripper_tune, status, ) +from arm_application import operator_arm_mesh as arm_mesh +from arm_application import operator_gripper_mesh as gripper_mesh +from arm_application import operator_arm_state as arm_state +from arm_application import operator_gripper_state as grip_state from arm_application import operator_teach as teach from arm_application import operator_camera_view as camera_view +from arm_application.operator_handeye_board import BoardWatch from arm_application import operator_tactile_view as tactile_view _LOGO = Path(__file__).resolve().parent / "operator_console_logo.png" @@ -83,14 +113,21 @@ class ValueTune(QWidget): decimals: int = 0, slider_low: float | None = None, slider_high: float | None = None, + compact: bool = False, ) -> None: super().__init__() self._scale = 10 ** decimals self._guard = False row = QHBoxLayout(self) row.setContentsMargins(0, 0, 0, 0) + row.setSpacing(4) label = QLabel(title) - label.setFixedWidth(88) + label.setFixedWidth(36 if compact else 88) + label.setAlignment( + Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter + if compact + else Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter + ) self.slider = QSlider(Qt.Orientation.Horizontal) slo = low if slider_low is None else slider_low shi = high if slider_high is None else slider_high @@ -105,8 +142,10 @@ class ValueTune(QWidget): self.spin.valueChanged.connect(self._from_spin) row.addWidget(label) row.addWidget(self.slider, 1) - self.spin.setFixedWidth(120) + self.spin.setFixedWidth(72 if compact else 120) row.addWidget(self.spin) + if compact: + self.setMaximumHeight(28) def _clamp_slider(self, value: float) -> int: raw = int(round(value * self._scale)) @@ -133,62 +172,212 @@ class ValueTune(QWidget): self.spin.setValue(value) -class FrameLabel(QLabel): - """预览框。尺寸不跟图像走,否则每贴一帧标签就更大,画面会慢慢放大。""" +class _PictureRow(QWidget): + """并排画面的高度跟格子宽度走,按 16:9 把画面铺满。 - def __init__(self, text: str) -> None: + 彩色是 848×480 和 1280×720,都接近 16:9。格子若被纵向拉高, + 画面上下会留一条黑边,那块空白本该给触觉。 + """ + + def __init__(self, columns: int) -> None: + super().__init__() + self._columns = max(1, columns) + self._lock = False + policy = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + policy.setHeightForWidth(True) + self.setSizePolicy(policy) + self.setMinimumHeight(96) + + def hasHeightForWidth(self) -> bool: # noqa: N802 + return True + + def heightForWidth(self, width: int) -> int: # noqa: N802 + gap = 6 * max(0, self._columns - 1) + cell = max(1, (max(1, width) - gap) // self._columns) + return max(96, int(round(cell * 9 / 16))) + + def resizeEvent(self, event) -> None: # noqa: N802 + super().resizeEvent(event) + if self._lock or self.width() < 40: + return + want = self.heightForWidth(self.width()) + if abs(self.minimumHeight() - want) <= 1 and abs(self.maximumHeight() - want) <= 1: + return + self._lock = True + self.setMinimumHeight(want) + self.setMaximumHeight(want) + self._lock = False + + +class FrameLabel(QLabel): + """预览框。尺寸不跟图像走,否则每贴一帧标签就更大,画面会慢慢放大。 + + 画面自己画。不调用 ``setPixmap``:那一下会 ``updateGeometry``, + 六路画面每帧都把整页布局重算一遍,窗口就卡。 + """ + + def __init__(self, text: str, *, compact: bool = False) -> None: super().__init__(text) + self._compact = compact self.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.setMinimumSize(320, 200) + self.setMinimumSize(96, 64) if compact else self.setMinimumSize(320, 200) self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) self.setObjectName("shot") self._source = QPixmap() + self._scaled = QPixmap() self._token = 0 self._drawn = QSize() self._busy = False + self._badge_title = "" + self._badge_detail = "" def sizeHint(self) -> QSize: - return QSize(480, 270) + return QSize(200, 112) if self._compact else QSize(480, 270) def minimumSizeHint(self) -> QSize: - return QSize(320, 200) + return QSize(96, 64) if self._compact else QSize(320, 200) + + def setText(self, text: str) -> None: # noqa: N802 + self._source = QPixmap() + self._scaled = QPixmap() + self._token = 0 + self._drawn = QSize() + super().setText(text) + + def set_badge(self, title: str, detail: str = "") -> None: + """画在画面底部的名字和分辨率、帧率。文字变了才重画。""" + title = str(title or "") + detail = str(detail or "") + if title == self._badge_title and detail == self._badge_detail: + return + self._badge_title = title + self._badge_detail = detail + self.update() def show_jpeg(self, blob: bytes) -> None: token = id(blob) - if token != self._token: - pix = QPixmap() - if not pix.loadFromData(blob, "JPG"): - return - self._source = pix - self._token = token - self._drawn = QSize() + if token == self._token and not self._source.isNull(): + self._draw(False) + return + pix = QPixmap() + if not pix.loadFromData(blob, "JPG"): + return + self._source = pix + self._token = token + self._drawn = QSize() self._draw(False) - def resizeEvent(self, event) -> None: + def show_rgb(self, blob: bytes, width: int, height: int) -> None: + """贴一块已经缩好的 RGB。不再在界面线程里解 JPEG。""" + token = id(blob) + if token == self._token and not self._source.isNull(): + self._draw(False) + return + if width < 2 or height < 2 or len(blob) < width * height * 3: + return + image = QImage(blob, width, height, width * 3, QImage.Format.Format_RGB888).copy() + if image.isNull(): + return + self._source = QPixmap.fromImage(image) + self._token = token + self._drawn = QSize() + self._draw(False) + + def resizeEvent(self, event) -> None: # noqa: N802 super().resizeEvent(event) self._drawn = QSize() self._draw(True) - def _draw(self, smooth: bool) -> None: + def paintEvent(self, event) -> None: # noqa: N802 + if self._scaled.isNull(): + super().paintEvent(event) + if self._badge_title or self._badge_detail: + painter = QPainter(self) + self._clip_round(painter) + self._paint_badge(painter) + painter.end() + return + painter = QPainter(self) + self._clip_round(painter) + painter.fillRect(self.rect(), QColor("#090c11")) + pix = self._scaled + frame = self._picture_rect() + painter.drawPixmap( + frame.x() + (frame.width() - pix.width()) // 2, + frame.y() + (frame.height() - pix.height()) // 2, + pix, + ) + self._paint_badge(painter) + painter.end() + + def _clip_round(self, painter: QPainter) -> None: + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + path = QPainterPath() + path.addRoundedRect(QRectF(self.rect()), 10, 10) + painter.setClipPath(path) + + def _paint_badge(self, painter: QPainter) -> None: + if not self._badge_title and not self._badge_detail: + return + bar = self._caption_h() + top = max(0, self.height() - bar) + painter.fillRect(0, top, self.width(), bar, QColor("#0c1016")) + font = QFont(painter.font()) + font.setPixelSize(12) + painter.setFont(font) + painter.setPen(QColor("#f4f7fb")) + detail = self._badge_detail + if detail: + title_w = max(0, self.width() // 2 - 14) + detail_w = max(0, self.width() // 2 - 14) + else: + title_w = max(0, self.width() - 16) + detail_w = 0 + title = QFontMetrics(font).elidedText(self._badge_title, Qt.TextElideMode.ElideRight, title_w) + painter.drawText( + 8, top, title_w, bar, + int(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft), + title, + ) + if detail_w: + detail_font = QFont("DejaVu Sans Mono") + detail_font.setPixelSize(11) + painter.setFont(detail_font) + painter.setPen(QColor("#d5deea")) + shown = QFontMetrics(detail_font).elidedText(detail, Qt.TextElideMode.ElideLeft, detail_w) + painter.drawText( + self.width() // 2, top, detail_w, bar, + int(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignRight), + shown, + ) + + def _caption_h(self) -> int: + return 24 if (self._badge_title or self._badge_detail) else 0 + + def _picture_rect(self) -> QRect: + """画面停在标题条上面。标题条是实底,图像不再从字下面透出来。""" + bar = self._caption_h() + return QRect(0, 0, max(1, self.width()), max(1, self.height() - bar)) + + def _draw(self, _smooth: bool) -> None: if self._busy or self._source.isNull(): return - size = self.contentsRect().size() + size = self._picture_rect().size() if size.width() < 8 or size.height() < 8 or size == self._drawn: return self._busy = True try: - mode = ( - Qt.TransformationMode.SmoothTransformation - if smooth - else Qt.TransformationMode.FastTransformation - ) - self.setPixmap(self._source.scaled( + # 缩放用平滑。快缩放会把原图重新弄糊,帧率并不靠它省出来。 + mode = Qt.TransformationMode.SmoothTransformation + self._scaled = self._source.scaled( size, Qt.AspectRatioMode.KeepAspectRatio, mode, - )) - self.setText("") + ) self._drawn = QSize(size) + if self.text(): + super().setText("") + self.update() finally: self._busy = False @@ -273,21 +462,753 @@ def _title(text: str) -> QLabel: return label +def _section(text: str) -> QWidget: + """一小节的标题。左侧一条色标,用来把相机、触觉、操作分开。""" + row = QWidget() + row.setFixedHeight(36) + lay = QHBoxLayout(row) + lay.setContentsMargins(0, 0, 0, 0) + lay.setSpacing(6) + lay.setAlignment(Qt.AlignmentFlag.AlignVCenter) + mark = QFrame() + mark.setObjectName("sectionMark") + mark.setFixedSize(3, 12) + lay.addWidget(mark) + label = QLabel(text) + label.setObjectName("section") + lay.addWidget(label) + lay.addStretch(1) + return row + + +def _segment_box() -> QWidget: + """一对开关。外框固定高度,里面两颗按钮不再各自被拉成一条。""" + box = QWidget() + box.setObjectName("switch") + box.setFixedHeight(36) + box.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) + lay = QHBoxLayout(box) + lay.setContentsMargins(3, 3, 3, 3) + lay.setSpacing(2) + return box + + +def _segment_button(button: QPushButton) -> None: + button.setObjectName("seg") + button.setFixedHeight(30) + button.setMinimumWidth(52) + button.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) + button.setCursor(Qt.CursorShape.PointingHandCursor) + + def _btn(text: str, danger: bool = False) -> QPushButton: button = QPushButton(text) button.setObjectName("danger" if danger else "action") + button.setCursor(Qt.CursorShape.PointingHandCursor) return button +def _fit_text(label: QLabel) -> QLabel: + """长状态文字不能把窗口撑得比屏幕宽,否则标题栏拖不动。""" + label.setMinimumWidth(0) + label.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred) + return label + + +def _action_icon(name: str) -> QPixmap: + """操作键上的图案。自己画,不靠系统图标主题,也比一个符号字清楚。""" + pix = QPixmap(64, 64) + pix.setDevicePixelRatio(2.0) + pix.fill(Qt.GlobalColor.transparent) + painter = QPainter(pix) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + ink = QColor("#e8eef6") + accent = QColor("#8eb7ff") + painter.setPen(QPen(ink, 1.8, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap, Qt.PenJoinStyle.RoundJoin)) + painter.setBrush(Qt.BrushStyle.NoBrush) + if name in ("real_up",): + painter.drawEllipse(QRectF(7, 7, 18, 18)) + painter.setPen(QPen(accent, 2.2, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap)) + painter.drawLine(16, 8, 16, 16) + elif name in ("camera_d405", "camera_d435"): + painter.setBrush(QColor(94, 168, 255, 56)) + painter.drawRoundedRect(QRectF(4, 10, 24, 14), 3, 3) + painter.setBrush(QColor("#10141b")) + painter.drawEllipse(QRectF(11, 12, 10, 10)) + painter.setBrush(accent) + painter.drawEllipse(QRectF(14.2, 15.2, 3.6, 3.6)) + painter.setBrush(Qt.BrushStyle.NoBrush) + painter.setPen(QPen(ink, 1.6, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap, Qt.PenJoinStyle.RoundJoin)) + if name == "camera_d435": + painter.drawLine(10, 24, 8, 28) + painter.drawLine(22, 24, 24, 28) + painter.drawLine(8, 28, 24, 28) + else: + painter.drawRoundedRect(QRectF(12, 6, 8, 4), 1.2, 1.2) + elif name == "tactile_up": + painter.setBrush(QColor(94, 168, 255, 56)) + painter.drawRoundedRect(QRectF(10, 4, 12, 18), 6, 6) + painter.setBrush(accent) + painter.setPen(Qt.PenStyle.NoPen) + for row in (9, 15): + for col in (13, 18): + painter.drawEllipse(QRectF(col, row, 2.4, 2.4)) + painter.setPen(QPen(ink, 1.6, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap)) + painter.setBrush(Qt.BrushStyle.NoBrush) + painter.drawLine(12, 22, 12, 28) + painter.drawLine(20, 22, 20, 28) + painter.drawLine(10, 28, 22, 28) + elif name in ("gripper_up", "gripper_open", "gripper_close", "gripper_probe", "gripper_rotate"): + painter.drawRoundedRect(QRectF(6, 13, 20, 6), 1.5, 1.5) + left = 8 if name != "gripper_open" else 5 + right = 20 if name != "gripper_open" else 23 + if name == "gripper_close": + left, right = 12, 16 + painter.setBrush(QColor(94, 168, 255, 80)) + painter.drawRoundedRect(QRectF(left, 6, 4, 16), 1.2, 1.2) + painter.drawRoundedRect(QRectF(right, 6, 4, 16), 1.2, 1.2) + painter.setBrush(Qt.BrushStyle.NoBrush) + if name == "gripper_probe": + painter.setPen(QPen(accent, 1.6, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap)) + painter.drawArc(QRectF(18, 18, 10, 10), 40 * 16, 220 * 16) + elif name == "gripper_rotate": + painter.setPen(QPen(accent, 1.8, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap)) + painter.drawArc(QRectF(8, 20, 16, 10), 20 * 16, 200 * 16) + painter.drawLine(22, 24, 25, 22) + painter.drawLine(22, 24, 24, 27) + elif name == "pipeline": + painter.setBrush(accent) + painter.setPen(Qt.PenStyle.NoPen) + path = QPainterPath() + path.moveTo(11, 7) + path.lineTo(24, 16) + path.lineTo(11, 25) + path.closeSubpath() + painter.drawPath(path) + elif name == "stage_track": + painter.drawEllipse(QRectF(6, 6, 20, 20)) + painter.drawLine(16, 4, 16, 10) + painter.drawLine(16, 22, 16, 28) + painter.drawLine(4, 16, 10, 16) + painter.drawLine(22, 16, 28, 16) + painter.setBrush(accent) + painter.setPen(Qt.PenStyle.NoPen) + painter.drawEllipse(QRectF(14, 14, 4, 4)) + elif name == "stage_locate": + painter.drawEllipse(QRectF(5, 5, 22, 22)) + painter.drawEllipse(QRectF(10, 10, 12, 12)) + painter.setBrush(accent) + painter.setPen(Qt.PenStyle.NoPen) + painter.drawEllipse(QRectF(14, 14, 4, 4)) + elif name == "stage_screw": + painter.setPen(QPen(ink, 1.8, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap)) + painter.drawArc(QRectF(6, 6, 20, 20), 40 * 16, 280 * 16) + painter.setPen(QPen(accent, 1.8, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap)) + painter.drawLine(23, 8, 26, 6) + painter.drawLine(23, 8, 20, 5) + painter.setPen(QPen(ink, 1.6, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap)) + painter.drawLine(16, 12, 16, 20) + elif name in ("reset_home", "nav_main"): + path = QPainterPath() + path.moveTo(16, 5) + path.lineTo(27, 14) + path.lineTo(24, 14) + path.lineTo(24, 26) + path.lineTo(8, 26) + path.lineTo(8, 14) + path.lineTo(5, 14) + path.closeSubpath() + painter.setBrush(QColor(94, 168, 255, 56)) + painter.drawPath(path) + painter.setBrush(accent) + painter.drawRect(QRectF(13, 18, 6, 8)) + elif name == "nav_eye": + painter.drawEllipse(QRectF(4, 10, 24, 12)) + painter.setBrush(accent) + painter.drawEllipse(QRectF(12, 12, 8, 8)) + painter.setBrush(QColor("#10141b")) + painter.drawEllipse(QRectF(14.5, 14.5, 3, 3)) + elif name == "nav_pen": + painter.drawLine(8, 24, 20, 8) + painter.setPen(QPen(accent, 1.8, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap)) + painter.drawLine(20, 8, 24, 6) + painter.setPen(QPen(ink, 1.8, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap)) + painter.drawLine(6, 26, 12, 22) + elif name == "gauge_dry": + painter.drawRoundedRect(QRectF(6, 6, 20, 20), 3, 3) + painter.setBrush(accent) + painter.setPen(Qt.PenStyle.NoPen) + path = QPainterPath() + path.moveTo(13, 11) + path.lineTo(21, 16) + path.lineTo(13, 21) + path.closeSubpath() + painter.drawPath(path) + elif name == "gauge_run": + painter.setBrush(accent) + painter.setPen(Qt.PenStyle.NoPen) + painter.drawRoundedRect(QRectF(6, 6, 20, 20), 4, 4) + painter.setBrush(QColor("#10141b")) + path = QPainterPath() + path.moveTo(13, 11) + path.lineTo(21, 16) + path.lineTo(13, 21) + path.closeSubpath() + painter.drawPath(path) + else: + painter.drawEllipse(QRectF(8, 8, 16, 16)) + painter.end() + return pix + + +class CommandButton(QPushButton): + """操作键。背景自己画成圆角矩形,图标和文字都落在这块矩形里面。 + + ``bar`` 用在相机、触觉那一行:图标在左、字在右,高度跟旁边的开关一样。 + 操作区用上下排,避免和系统按钮样式叠出两层边、再把邻居挤变形。 + """ + + def __init__(self, text: str, kind: str, mark: str, *, bar: bool = False) -> None: + super().__init__(text) + self._bar = bar + self._pix = _action_icon(mark) + self.setIcon(QIcon(self._pix)) + self.setObjectName(kind) + self.setCursor(Qt.CursorShape.PointingHandCursor) + self.setMinimumWidth(0) + self.setFixedHeight(36 if bar else 60) + self.setSizePolicy( + QSizePolicy.Policy.Expanding if not bar else QSizePolicy.Policy.Preferred, + QSizePolicy.Policy.Fixed, + ) + + def sizeHint(self) -> QSize: + if self._bar: + width = QFontMetrics(self.font()).horizontalAdvance(self.text()) + 46 + return QSize(max(96, width), 36) + return QSize(108, 60) + + def minimumSizeHint(self) -> QSize: + return QSize(88 if self._bar else 96, self.height()) + + def paintEvent(self, _event) -> None: # noqa: N802 + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + option = QStyleOptionButton() + self.initStyleOption(option) + rect = QRectF(self.rect()).adjusted(0.5, 0.5, -0.5, -0.5) + path = QPainterPath() + path.addRoundedRect(rect, 8, 8) + painter.setClipPath(path) + hover = bool(option.state & QStyle.StateFlag.State_MouseOver) + down = bool(option.state & QStyle.StateFlag.State_Sunken) + kind = self.objectName() + if kind == "primary": + fill = QColor("#2460ad" if hover else "#1d4f91") + border = QColor("#6ea2ea") + ink = QColor("#f4f7fb") + elif kind == "danger": + fill = QColor("#3a1c20" if hover else "#241416") + border = QColor("#c46a6a" if hover else "#8a4a4a") + ink = QColor("#f6d5d5") + else: + fill = QColor("#263041" if hover else "#1c2430") + border = QColor("#5c6b82" if hover else "#3d4b60") + ink = QColor("#e7ebf2") + if down: + fill = fill.darker(115) + painter.fillPath(path, fill) + painter.setPen(QPen(border, 1)) + painter.setBrush(Qt.BrushStyle.NoBrush) + painter.drawPath(path) + font = QFont(self.font()) + if self._bar: + icon_box = QRect(8, (self.height() - 18) // 2, 18, 18) + painter.drawPixmap(icon_box, self._pix) + font.setPixelSize(13) + text_rect = QRect(32, 0, max(0, self.width() - 40), self.height()) + align = Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft + else: + icon_box = QRect((self.width() - 22) // 2, 6, 22, 22) + painter.drawPixmap(icon_box, self._pix) + font.setPixelSize(12) + text_rect = QRect(6, 32, max(0, self.width() - 12), 22) + align = Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignVCenter + painter.setFont(font) + painter.setPen(ink) + shown = QFontMetrics(font).elidedText( + self.text(), Qt.TextElideMode.ElideRight, text_rect.width(), + ) + painter.drawText(text_rect, int(align), shown) + painter.end() + + +# Qt 的 QOpenGLFunctions 只带函数,不带这些枚举。数值是 OpenGL 规范里的固定值。 +_GL_TRIANGLES = 0x0004 +_GL_DEPTH_BUFFER_BIT = 0x00000100 +_GL_CULL_FACE = 0x0B44 +_GL_DEPTH_TEST = 0x0B71 +_GL_FLOAT = 0x1406 +_GL_COLOR_BUFFER_BIT = 0x00004000 +_AXIS_X = QColor("#e24b4b") +_AXIS_Y = QColor("#3dbe6a") +_AXIS_Z = QColor("#4c86d4") +# 打开界面时的观察角。双击只回到这里,不改关节。 +_VIEW_YAW = 0.9 +_VIEW_PITCH = -0.35 + + +_MESH_VERT = """ +#version 330 core +layout(location = 0) in vec3 a_pos; +layout(location = 1) in vec3 a_color; +uniform mat3 u_rot; +uniform vec3 u_center; +uniform float u_sx; +uniform float u_sy; +uniform float u_radius; +out vec3 v_color; +void main() { + vec3 p = u_rot * (a_pos - u_center); + gl_Position = vec4(p.x * u_sx, p.z * u_sy, p.y / (u_radius * 2.0), 1.0); + v_color = a_color; +} +""" + +_MESH_FRAG = """ +#version 330 core +in vec3 v_color; +uniform float u_tint; +out vec4 frag_color; +void main() { + frag_color = vec4(v_color * u_tint, 1.0); +} +""" + + +class MeshView(QOpenGLWidget): + """三角网格。左键拖拽绕模型转视角,三角面交给显卡画。""" + + def __init__(self, min_w: int, min_h: int) -> None: + super().__init__() + fmt = QSurfaceFormat() + fmt.setRenderableType(QSurfaceFormat.RenderableType.OpenGL) + fmt.setVersion(3, 3) + fmt.setProfile(QSurfaceFormat.OpenGLContextProfile.CoreProfile) + fmt.setSamples(4) + fmt.setDepthBufferSize(24) + self.setFormat(fmt) + self._tris: list = [] + self._note = "" + self._live = False + self._yaw = _VIEW_YAW + self._pitch = _VIEW_PITCH + self._press: QPoint | None = None + self._cache_key: tuple | None = None + self._batch: list[tuple[QColor, QPolygon]] = [] + self._gen = 0 + self._sig: tuple | None = None + self._gl_blob = b"" + self._gl_count = 0 + self._gl_center = (0.0, 0.0, 0.0) + self._gl_radius = 1.0 + self._gl_dirty = False + self._gl_program: QOpenGLShaderProgram | None = None + self._gl_buffer: QOpenGLBuffer | None = None + self._gl_vao: QOpenGLVertexArrayObject | None = None + self._frames: list = [] + self._thin_caption = False + self.setMinimumSize(min_w, min_h) + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + self.setCursor(Qt.CursorShape.OpenHandCursor) + + def set_mesh(self, triangles: list, note: str, live: bool) -> None: + live = bool(live) + sig = (len(triangles), note, live) + if triangles: + sig = sig + (triangles[0][0], triangles[-1][0]) + if sig == self._sig: + return + self._sig = sig + self._tris = triangles + self._note = note + self._live = live + self._gen += 1 + self._gl_count = 0 + self.update() + + def set_gl_mesh( + self, + blob: bytes, + count: int, + center: tuple[float, float, float], + radius: float, + note: str, + live: bool, + ) -> None: + """大网格走显卡顶点缓冲。拖视角不再在 CPU 上重算每个三角。""" + self._gl_blob = blob + self._gl_count = count + self._gl_center = center + self._gl_radius = radius + self._gl_dirty = True + self._tris = [] + self._batch = [] + self._note = note + self._live = bool(live) + self._sig = None + self.update() + + def initializeGL(self) -> None: # noqa: N802 + program = QOpenGLShaderProgram(self) + ok = program.addShaderFromSourceCode(QOpenGLShader.ShaderTypeBit.Vertex, _MESH_VERT) + ok = program.addShaderFromSourceCode(QOpenGLShader.ShaderTypeBit.Fragment, _MESH_FRAG) and ok + program.bindAttributeLocation("a_pos", 0) + program.bindAttributeLocation("a_color", 1) + ok = program.link() and ok + if not ok: + self._note = "显卡着色器没链上,网格画不出来。" + self._gl_program = None + return + self._gl_program = program + self._gl_loc = { + name: program.uniformLocation(name.encode()) + for name in ("u_rot", "u_center", "u_sx", "u_sy", "u_radius", "u_tint") + } + buffer = QOpenGLBuffer(QOpenGLBuffer.Type.VertexBuffer) + buffer.create() + self._gl_buffer = buffer + vao = QOpenGLVertexArrayObject(self) + vao.create() + self._gl_vao = vao + + def mousePressEvent(self, event) -> None: # noqa: N802 + if event.button() == Qt.MouseButton.LeftButton: + self._press = event.position().toPoint() + self.setCursor(Qt.CursorShape.ClosedHandCursor) + event.accept() + return + super().mousePressEvent(event) + + def mouseMoveEvent(self, event) -> None: # noqa: N802 + if self._press is None: + super().mouseMoveEvent(event) + return + now = event.position().toPoint() + self._yaw += (now.x() - self._press.x()) * 0.012 + self._pitch = max(-1.35, min(1.35, self._pitch + (now.y() - self._press.y()) * 0.010)) + self._press = now + self.update() + event.accept() + + def mouseReleaseEvent(self, event) -> None: # noqa: N802 + self._press = None + self.setCursor(Qt.CursorShape.OpenHandCursor) + self.update() + event.accept() + + def paintGL(self) -> None: # noqa: N802 + if self._gl_count and self._gl_program is not None and self._gl_buffer is not None: + self._paint_gpu() + return + # 夹爪面少,仍用画笔。机械臂不走这里。 + key = (self.width(), self.height(), round(self._yaw, 3), round(self._pitch, 3), self._gen) + if key != self._cache_key: + self._batch = self._project() + self._cache_key = key + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + painter.fillRect(self.rect(), QColor("#0c0e12")) + if not self._batch and not self._tris: + painter.setPen(QColor("#8b929d")) + painter.drawText(self.rect(), int(Qt.AlignmentFlag.AlignCenter), self._note or "没有网格") + painter.end() + return + painter.setPen(Qt.PenStyle.NoPen) + for color, poly in self._batch: + painter.setBrush(QBrush(color)) + painter.drawPolygon(poly) + self._paint_caption(painter) + painter.end() + + def _caption(self) -> str: + return "左键拖拽改视角。 " + self._note + + def _paint_gpu(self) -> None: + """一次画出全部三角。视角只改 uniform,不把面片搬回 CPU。""" + painter = QPainter(self) + painter.beginNativePainting() + functions = self.context().functions() + functions.glClearColor(12 / 255, 14 / 255, 18 / 255, 1.0) + functions.glClear(_GL_COLOR_BUFFER_BIT | _GL_DEPTH_BUFFER_BIT) + functions.glEnable(_GL_DEPTH_TEST) + functions.glDisable(_GL_CULL_FACE) + program = self._gl_program + buffer = self._gl_buffer + vao = self._gl_vao + assert program is not None and buffer is not None and vao is not None + vao.bind() + program.bind() + buffer.bind() + if self._gl_dirty: + buffer.allocate(self._gl_blob, len(self._gl_blob)) + self._gl_dirty = False + program.setAttributeBuffer(0, _GL_FLOAT, 0, 3, 24) + program.setAttributeBuffer(1, _GL_FLOAT, 12, 3, 24) + program.enableAttributeArray(0) + program.enableAttributeArray(1) + rot = arm_mesh.view_rotation(self._yaw, self._pitch) + # QMatrix3x3 按行读入。GLSL 再按列使用,和 view_rotation 的行向量乘法一致。 + flat = [rot[row][col] for row in range(3) for col in range(3)] + loc = self._gl_loc + program.setUniformValue(loc["u_rot"], QMatrix3x3(flat)) + cx, cy, cz = self._gl_center + program.setUniformValue(loc["u_center"], QVector3D(cx, cy, cz)) + width = max(self.width(), 1) + height = max(self.height(), 1) + sx, sy = arm_mesh.view_scale(self._gl_radius, width, height) + program.setUniformValue1f(loc["u_sx"], sx) + program.setUniformValue1f(loc["u_sy"], sy) + program.setUniformValue1f(loc["u_radius"], self._gl_radius) + program.setUniformValue1f(loc["u_tint"], 1.0 if self._live else 0.55) + functions.glDrawArrays(_GL_TRIANGLES, 0, self._gl_count) + buffer.release() + program.release() + vao.release() + painter.endNativePainting() + self._paint_ground(painter) + self._paint_frames(painter) + self._paint_caption(painter) + painter.end() + + def _caption_band(self) -> int: + if self._thin_caption: + return 16 + return 14 if self.height() < 160 else 60 + + def _paint_caption(self, painter: QPainter) -> None: + band = self._caption_band() + if band <= 16: + rect = QRect(6, self.height() - 16, self.width() - 12, 14) + else: + rect = QRect(12, self.height() - 72, self.width() - 24, 60) + painter.setPen(QColor("#8b929d")) + painter.drawText( + rect, + int(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignBottom | Qt.TextFlag.TextWordWrap), + self._caption(), + ) + + def _paint_ground(self, painter: QPainter) -> None: + """地平面格子。只在机械臂仿真上画,线数固定,不跟模型一起重算。""" + if not self._frames or self.width() < 8 or self.height() < 8: + return + pen = QPen(QColor("#3a4150")) + pen.setWidth(1) + painter.setPen(pen) + width = self.width() + height = self.height() + for start, end in arm_mesh.ground_grid(): + x0, y0 = arm_mesh.screen_point( + start, self._yaw, self._pitch, self._gl_center, self._gl_radius, width, height, + ) + x1, y1 = arm_mesh.screen_point( + end, self._yaw, self._pitch, self._gl_center, self._gl_radius, width, height, + ) + painter.drawLine(int(x0), int(y0), int(x1), int(y1)) + + def _paint_frames(self, painter: QPainter) -> None: + """坐标系画在地面格子上面。轴长 0.15 m,和模型同一套米制。""" + if not self._frames or self.width() < 8 or self.height() < 8: + return + length = 0.15 + width = self.width() + height = self.height() + for frame in self._frames: + origin = frame["pos"] + rot = frame["rot"] + ox, oy = arm_mesh.screen_point( + origin, self._yaw, self._pitch, self._gl_center, self._gl_radius, width, height, + ) + for axis, color in enumerate((_AXIS_X, _AXIS_Y, _AXIS_Z)): + tip = arm_mesh.axis_tip(origin, rot, axis, length) + tx, ty = arm_mesh.screen_point( + tip, self._yaw, self._pitch, self._gl_center, self._gl_radius, width, height, + ) + pen = QPen(color) + pen.setWidth(3) + painter.setPen(pen) + painter.drawLine(int(ox), int(oy), int(tx), int(ty)) + painter.setPen(QColor("#e7ebf0")) + painter.drawText(int(ox) + 8, int(oy) - 8, frame["name"]) + + def _project(self) -> list[tuple[QColor, QPolygon]]: + triangles = self._tris + if not triangles or self.width() < 8 or self.height() < 8: + return [] + rot = arm_mesh.view_rotation(self._yaw, self._pitch) + projected = [] + for item in triangles: + a, b, c = item[0], item[1], item[2] + shade = float(item[3]) + rgb = item[4] if len(item) > 4 else (0.75, 0.76, 0.78) + pts = [arm_mesh._apply(rot, p) for p in (a, b, c)] + screen = [(p[0], -p[2]) for p in pts] + depth = pts[0][1] + pts[1][1] + pts[2][1] + projected.append((depth, shade, rgb, screen)) + projected.sort(key=lambda row: row[0], reverse=True) + xs = [p[0] for _d, _s, _c, pts in projected for p in pts] + ys = [p[1] for _d, _s, _c, pts in projected for p in pts] + span = max(max(xs) - min(xs), max(ys) - min(ys), 1e-4) + band = self._caption_band() + scale = min(self.width() - 20, max(8, self.height() - band - 8)) / span + cx = (min(xs) + max(xs)) / 2 + cy = (min(ys) + max(ys)) / 2 + origin_y = (self.height() - band) / 2 + tint = 1.0 if self._live else 0.55 + batch = [] + for _depth, shade, rgb, pts in projected: + light = (0.55 + 0.45 * max(0.0, min(1.0, shade))) * tint + color = QColor( + int(max(0, min(255, rgb[0] * 255 * light))), + int(max(0, min(255, rgb[1] * 255 * light))), + int(max(0, min(255, rgb[2] * 255 * light))), + ) + poly = QPolygon([ + QPoint( + int(self.width() / 2 + (point[0] - cx) * scale), + int(origin_y + (point[1] - cy) * scale), + ) + for point in pts + ]) + batch.append((color, poly)) + return batch + + +class ArmSketch(MeshView): + """用非夕 Rizon4 的外壳网格画当前姿态。没有关节角时摆在零位,并标明。 + + 双击把观察角放回打开界面时的角度。关节仍是实时的,真机不动。 + """ + + def __init__(self) -> None: + super().__init__(240, 180) + self._workpiece = None + self._pose_key = None + + def _caption(self) -> str: + return "左键拖拽改视角。双击把视角复位。 " + self._note + + def mouseDoubleClickEvent(self, event) -> None: # noqa: N802 + if event.button() != Qt.MouseButton.LeftButton: + return + self._yaw = _VIEW_YAW + self._pitch = _VIEW_PITCH + self._press = None + self.update() + event.accept() + + def set_pose(self, q: list | None, live: bool, workpiece: tuple | None = None) -> None: + self._workpiece = workpiece + if isinstance(q, list) and len(q) >= 7: + joints = [round(float(v), 3) for v in q[:7]] + draw_live = bool(live) + else: + joints = [0.0] * 7 + draw_live = False + try: + pose_key = (tuple(joints), draw_live, _workpiece_key(workpiece)) + if pose_key == self._pose_key: + return + self._pose_key = pose_key + blob, count, center, radius = arm_mesh.posed_gl(joints) + self._frames = arm_state.display_frames(joints, workpiece) + except Exception as exc: # noqa: BLE001 —— 网格缺失时把原因画出来,不退回另一套模型 + self._pose_key = None + self._frames = [] + self.set_mesh([], f"厂家网格读失败:{exc}", False) + return + names = "、".join(item["name"] for item in self._frames) + if not draw_live: + note = f"非夕 Rizon4 外壳,零位。还没有关节角,这不是当前姿态。坐标系:{names}。" + else: + note = f"非夕 Rizon4 外壳,按实时关节角摆放。白壳和灰蓝关节是厂家材质。坐标系:{names}。" + self.set_gl_mesh(blob, count, center, radius, note, draw_live) + + +def _workpiece_key(workpiece: tuple | None) -> tuple | None: + if workpiece is None: + return None + rot, pos = workpiece + return ( + tuple(round(float(v), 5) for row in rot for v in row), + tuple(round(float(v), 5) for v in pos), + ) + + +def _workpieces_by_arm(site: dict) -> dict[str, tuple | None]: + """每台真机臂的工件系。没配完整坐标系就是 None,法兰继续画在法兰上。""" + found: dict[str, tuple | None] = {} + robots = ((site or {}).get("robot") or {}).get("robots") or [] + for robot in robots: + if not isinstance(robot, dict): + continue + sn = str(robot.get("sn") or "") + if sn.lower() == "sim": + continue + arm_no = arm_short_id(sn) + if arm_no: + found[arm_no] = arm_state.workpiece_frame(robot) + return found + + +class GripperSketch(MeshView): + """按尺寸图画大寰夹爪。开口和旋转跟寄存器走。""" + + def __init__(self, min_w: int = 360, min_h: int = 280) -> None: + super().__init__(min_w, min_h) + self._thin_caption = True + + def _caption(self) -> str: + return "拖拽改视角" + + def set_pose( + self, + permille: float | None, + rotation_deg: float | None, + rotary: bool, + live: bool, + stroke_mm: float | None = None, + ) -> None: + if isinstance(permille, (int, float)): + permille = round(float(permille)) + if isinstance(rotation_deg, (int, float)): + rotation_deg = round(float(rotation_deg), 1) + pose_key = (bool(rotary), permille, rotation_deg, bool(live), stroke_mm) + if pose_key == getattr(self, "_pose_key", None): + return + self._pose_key = pose_key + triangles, note = gripper_mesh.triangles(bool(rotary), permille, rotation_deg, stroke_mm) + if live and permille is not None: + note = f"开口 {float(permille):.0f}‰ " + note + if rotary and isinstance(rotation_deg, (int, float)): + note = f"转角 {float(rotation_deg):.1f}° " + note + self.set_mesh(triangles, note, bool(live) and permille is not None) + + class StationApp(QMainWindow): def __init__(self) -> None: super().__init__() self.setWindowTitle("ORISYS 通止规工位") self.resize(1440, 900) - self._arm = "89" + site = load_site_dict() + self._catalog = listed_arms(site) + self._workpieces = _workpieces_by_arm(site) + self._board = BoardWatch() + self._arm = self._catalog[0]["id"] if self._catalog else "" self._mode = "eye_in_hand" self._snap: dict = {} - self._job: _Job | None = None + self._jobs: dict[str, _Job] = {} self._teach_sig: tuple | None = None self._pages: dict[str, QWidget] = {} self._nav: list[tuple[str, QPushButton]] = [] @@ -297,7 +1218,8 @@ class StationApp(QMainWindow): self._timer.start(800) self._images = QTimer(self) self._images.timeout.connect(self._paint_images) - self._images.start(200) + # 和相机预览同一档:大约 20 帧,推理框才跟得上画面。 + self._images.start(16) self._stack.currentChanged.connect(lambda _index: self._paint_images()) self._refresh() @@ -307,93 +1229,105 @@ class StationApp(QMainWindow): layout = QHBoxLayout(root) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) + # 子控件的最小宽度不许写进窗口。否则一串长状态会把窗口撑过屏幕,最大化后拖不动。 + layout.setSizeConstraint(QLayout.SizeConstraint.SetNoConstraint) layout.addWidget(self._sidebar()) right = QVBoxLayout() right.setContentsMargins(0, 0, 0, 0) right.setSpacing(0) right.addWidget(self._header()) self._stack = QStackedWidget() - for key, _label in ( - ("overview", "总览"), - ("tactile", "视触觉"), - ("handeye", "手眼"), - ("camera", "相机"), - ("gripper", "夹爪"), - ("control", "控制"), - ("teach", "示教"), - ("assembly", "装配"), - ("operate", "操作"), - ): - page = getattr(self, f"_page_{key}")() + handeye = self._page_handeye() + self._he_out.setFixedHeight(72) + built = ( + ("main", self._page_overview()), + ("handeye", self._with_back(handeye, "手眼")), + ("teach", self._with_back(self._scroll(self._page_teach()), "示教")), + ) + for key, page in built: self._pages[key] = page self._stack.addWidget(page) right.addWidget(self._stack, 1) self._detail = QPlainTextEdit() + self._detail.setObjectName("log") self._detail.setReadOnly(True) - self._detail.setFixedHeight(120) + self._detail.setFixedHeight(56) self._detail.setPlaceholderText("命令输出显示在这里") right.addWidget(self._detail) holder = QWidget() holder.setLayout(right) layout.addWidget(holder, 1) - self._show_page("overview") + self._show_page("main") + self.setMinimumSize(1100, 680) + + def _with_back(self, inner: QWidget, title: str) -> QWidget: + page = QWidget() + lay = QVBoxLayout(page) + lay.setContentsMargins(12, 8, 12, 8) + bar = QHBoxLayout() + back = _btn("← 返回主界面") + back.clicked.connect(lambda: self._show_page("main")) + bar.addWidget(back) + bar.addWidget(_title(title)) + bar.addStretch(1) + lay.addLayout(bar) + lay.addWidget(inner, 1) + return page def _sidebar(self) -> QWidget: bar = QWidget() bar.setObjectName("side") - bar.setFixedWidth(220) + bar.setFixedWidth(120) lay = QVBoxLayout(bar) + lay.setContentsMargins(10, 12, 10, 12) + lay.setSpacing(6) logo = QLabel() if _LOGO.is_file(): pix = QPixmap(str(_LOGO)) - logo.setPixmap(pix.scaledToWidth(180, Qt.TransformationMode.SmoothTransformation)) + logo.setPixmap(pix.scaledToWidth(88, Qt.TransformationMode.SmoothTransformation)) logo.setAlignment(Qt.AlignmentFlag.AlignCenter) lay.addWidget(logo) - station = QLabel("通止规工位") - station.setObjectName("sideNote") - station.setAlignment(Qt.AlignmentFlag.AlignCenter) - lay.addWidget(station) + brand = QLabel("通止规工位") + brand.setObjectName("brand") + brand.setAlignment(Qt.AlignmentFlag.AlignCenter) + lay.addWidget(brand) items = ( - ("overview", "总览", "设备与流程"), - ("tactile", "视触觉", "切向、热成像、曲线"), - ("handeye", "手眼", "眼在手上 / 手外"), - ("camera", "相机", "D405 与 D435"), - ("gripper", "夹爪", "位置、力度、速度"), - ("control", "控制", "分步、整体、复位"), - ("teach", "示教", "软拖拽记点"), - ("assembly", "装配", "S00 到 S11"), - ("operate", "操作", "启动与确认"), + ("main", "主界面", "nav_main"), + ("handeye", "手眼", "nav_eye"), + ("teach", "示教", "nav_pen"), ) - for key, name, hint in items: - button = QPushButton(f"{name}\n{hint}") + for key, name, mark in items: + button = QPushButton(name) + button.setIcon(QIcon(_action_icon(mark))) + button.setIconSize(QSize(18, 18)) button.setObjectName("nav") + button.setFixedHeight(36) button.clicked.connect(lambda _checked=False, k=key: self._show_page(k)) self._nav.append((key, button)) lay.addWidget(button) lay.addStretch(1) - note = QLabel("读的是本机 USB、进程和话题。动臂、张合夹爪都要再确认一次。") - note.setWordWrap(True) - note.setObjectName("sideNote") - lay.addWidget(note) return bar def _header(self) -> QWidget: bar = QWidget() bar.setObjectName("head") - bar.setFixedHeight(64) + bar.setFixedHeight(52) lay = QHBoxLayout(bar) - self._arm_line = QLabel("左臂 89") + first = next((row for row in self._catalog if row["id"] == self._arm), None) + self._arm_line = QLabel(first["label"] if first else "没有机械臂") self._arm_line.setObjectName("armLine") lay.addWidget(self._arm_line) lay.addStretch(1) self._chips = QLabel("") self._chips.setObjectName("chips") - lay.addWidget(self._chips) + _fit_text(self._chips) + lay.addWidget(self._chips, 1) self._arm_buttons: dict[str, QPushButton] = {} - for arm, text in (("89", "左臂89"), ("91", "右臂91")): - button = _btn(text) - button.clicked.connect(lambda _checked=False, a=arm: self._set_arm(a)) - self._arm_buttons[arm] = button + for row in self._catalog: + button = _btn(row["label"]) + button.setObjectName("seg") + button.clicked.connect(lambda _checked=False, a=row["id"]: self._set_arm(a)) + self._arm_buttons[row["id"]] = button lay.addWidget(button) self._clock = QLabel("--:--:--") self._clock.setObjectName("clock") @@ -408,53 +1342,429 @@ class StationApp(QMainWindow): return area def _page_overview(self) -> QWidget: + """主界面。状态、仿真、相机、触觉和操作键都在这一屏,不放滚动区。""" + page = QWidget() + root = QHBoxLayout(page) + root.setContentsMargins(8, 6, 8, 6) + root.setSpacing(8) + root.addWidget(self._main_arm_column(), 2) + root.addWidget(self._main_work_column(), 5) + return page + + def _main_arm_column(self) -> QWidget: page = QWidget() lay = QVBoxLayout(page) - self._lead = QLabel("") - self._lead.setObjectName("lead") - self._lead.setWordWrap(True) - lay.addWidget(self._lead) - grid = QGridLayout() - self._usb_labels: list[tuple[QLabel, QLabel, QLabel]] = [] - for index, name in enumerate(("腕部 D405", "手外 D435", "视触觉", "夹爪串口")): + lay.setContentsMargins(0, 0, 0, 0) + lay.setSpacing(6) + self._arm_banner = _fit_text(QLabel("")) + self._arm_banner.setObjectName("warn") + self._arm_banner.setFixedHeight(22) + lay.addWidget(self._arm_banner) + usb = QHBoxLayout() + usb.setSpacing(6) + self._usb_labels: list[tuple[QLabel, QLabel]] = [] + for name in ("腕部", "手外", "触觉", "串口"): card = _card() + card.setFixedHeight(48) box = QVBoxLayout(card) - box.addWidget(_title(name)) + box.setContentsMargins(8, 4, 8, 4) + box.setSpacing(0) + title = _title(name) + title.setAlignment(Qt.AlignmentFlag.AlignCenter) + box.addWidget(title) count = QLabel("—") - count.setObjectName("stat") + count.setObjectName("count") + count.setAlignment(Qt.AlignmentFlag.AlignCenter) flag = QLabel("") - detail = QLabel("") - detail.setObjectName("muted") - detail.setWordWrap(True) - box.addWidget(count) - box.addWidget(flag) - box.addWidget(detail) - grid.addWidget(card, index // 2, index % 2) - self._usb_labels.append((count, flag, detail)) - self._mini = [QLabel("左指 —"), QLabel("右指 —")] - for index, label in enumerate(self._mini): - label.setObjectName("muted") - label.setWordWrap(True) + flag.setAlignment(Qt.AlignmentFlag.AlignCenter) + line = QHBoxLayout() + line.setSpacing(4) + line.addStretch(1) + line.addWidget(count) + line.addWidget(flag) + line.addStretch(1) + box.addLayout(line) + usb.addWidget(card, 1) + self._usb_labels.append((count, flag)) + lay.addLayout(usb) + self._arm_prompt = _fit_text(QLabel("")) + self._arm_prompt.setObjectName("warn") + self._arm_prompt.setFixedHeight(22) + self._arm_prompt.setVisible(False) + lay.addWidget(self._arm_prompt) + self._arm_sketch = ArmSketch() + lay.addWidget(self._arm_sketch, 1) + cards = QHBoxLayout() + cards.setSpacing(6) + self._arm_cards: dict[str, QLabel] = {} + for key, title in ( + ("enable", "使能"), + ("mode", "模式"), + ("fault", "故障"), + ("source", "数据"), + ): card = _card() + card.setFixedHeight(48) box = QVBoxLayout(card) - box.addWidget(_title("左指" if index == 0 else "右指")) + box.setContentsMargins(6, 4, 6, 4) + box.setSpacing(0) + cap = _title(title) + cap.setAlignment(Qt.AlignmentFlag.AlignCenter) + box.addWidget(cap) + label = _fit_text(QLabel("—")) + label.setObjectName("count") + label.setAlignment(Qt.AlignmentFlag.AlignCenter) box.addWidget(label) - grid.addWidget(card, index, 2) - lay.addLayout(grid) - flow = QHBoxLayout() + self._arm_cards[key] = label + cards.addWidget(card, 1) + lay.addLayout(cards) + self._arm_joints = _fit_text(QLabel("")) + self._arm_joints.setObjectName("muted") + self._arm_joints.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._arm_joints.setFixedHeight(22) + lay.addWidget(self._arm_joints) + lay.addWidget(self._grip_panel()) + return page + + def _grip_panel(self) -> QWidget: + """主界面上的夹爪仿真和下发。状态只留一行,滑条上下对齐。""" + panel = _card() + lay = QVBoxLayout(panel) + lay.setContentsMargins(8, 6, 8, 8) + lay.setSpacing(6) + head = QHBoxLayout() + head.setSpacing(8) + head.addWidget(_section("夹爪")) + self._grip_summary = _fit_text(QLabel("还没读到")) + self._grip_summary.setObjectName("warn") + self._grip_summary.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) + head.addWidget(self._grip_summary, 1) + lay.addLayout(head) + self._grip_sketch = GripperSketch(160, 96) + self._grip_sketch.setFixedHeight(128) + self._grip_sketch.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + lay.addWidget(self._grip_sketch) + self._pos = ValueTune("位置", 0, 1000, 500, compact=True) + self._force = ValueTune("力度", 20, 100, 33, compact=True) + self._speed = ValueTune("速度", 1, 100, 25, compact=True) + for widget in (self._pos, self._force, self._speed): + lay.addWidget(widget) + row = QHBoxLayout() + row.setSpacing(6) + apply_btn = _btn("按此夹持", True) + open_btn = _btn("张开", True) + close_btn = _btn("闭合", True) + apply_btn.clicked.connect(lambda: self._grip_move("pos")) + open_btn.clicked.connect(lambda: self._grip_preset(1000)) + close_btn.clicked.connect(lambda: self._grip_preset(0)) + for button in (apply_btn, open_btn, close_btn): + button.setFixedHeight(36) + button.setMinimumWidth(0) + button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + row.addWidget(button, 1) + lay.addLayout(row) + + self._rotary = QWidget() + rot = QVBoxLayout(self._rotary) + rot.setContentsMargins(0, 0, 0, 0) + rot.setSpacing(4) + rot.addWidget(_section("旋转")) + self._angle = ValueTune( + "绝对角", -32767, 32767, 0, step=0.1, decimals=1, + slider_low=-3600, slider_high=3600, compact=True, + ) + self._delta = ValueTune( + "相对角", -32767, 32767, 15, step=0.1, decimals=1, + slider_low=-720, slider_high=720, compact=True, + ) + self._turns = ValueTune( + "圈数", -50, 50, 0.25, step=0.01, decimals=2, + slider_low=-10, slider_high=10, compact=True, + ) + self._rot_speed = ValueTune("转速", 1, 100, 25, compact=True) + self._torque = ValueTune("力矩", 20, 100, 33, compact=True) + for widget in (self._angle, self._delta, self._turns, self._rot_speed, self._torque): + rot.addWidget(widget) + rot_grid = QGridLayout() + rot_grid.setSpacing(6) + for index, (title, kind) in enumerate(( + ("转到绝对角", "rotate"), + ("相对转动", "rotate_by"), + ("按圈数转", "revolutions"), + ("旋转回零", "rotate_home"), + )): + button = _btn(title, True) + button.setFixedHeight(36) + button.setMinimumWidth(0) + button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + button.clicked.connect(lambda _checked=False, k=kind: self._grip_move(k)) + rot_grid.addWidget(button, index // 2, index % 2) + rot.addLayout(rot_grid) + self._rotary.setVisible(False) + lay.addWidget(self._rotary) + return panel + + def _main_work_column(self) -> QWidget: + page = QWidget() + lay = QVBoxLayout(page) + lay.setContentsMargins(0, 0, 0, 0) + lay.setSpacing(6) + cam_bar = QHBoxLayout() + cam_bar.setSpacing(6) + cam_bar.addWidget(_section("相机")) + source = _segment_box() + self._real_mode = QPushButton("真机") + self._sim_mode = QPushButton("仿真") + self._cam_source = "real" + for button, sim in ((self._real_mode, False), (self._sim_mode, True)): + _segment_button(button) + button.clicked.connect(lambda _checked=False, use_sim=sim: self._set_source(use_sim)) + source.layout().addWidget(button) + self._real_mode.setProperty("active", True) + cam_bar.addWidget(source) + switch = _segment_box() + self._color_mode = QPushButton("彩色") + self._depth_mode = QPushButton("深度") + self._depth_on = False + for button, on in ((self._color_mode, False), (self._depth_mode, True)): + _segment_button(button) + button.clicked.connect(lambda _checked=False, depth=on: self._set_depth(depth)) + switch.layout().addWidget(button) + self._color_mode.setProperty("active", True) + cam_bar.addWidget(switch) + for action in ("camera_d405", "camera_d435"): + button = self._node_button(action, bar=True) + cam_bar.addWidget(button) + cam_bar.addStretch(1) + self._infer_line = _fit_text(QLabel("腕部没有框 手外没有框")) + self._infer_line.setObjectName("ok") + self._infer_line.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) + cam_bar.addWidget(self._infer_line) + lay.addLayout(cam_bar) + self._cam_note = _fit_text(QLabel("")) + self._cam_note.setObjectName("warn") + self._cam_note.setFixedHeight(22) + self._cam_note.setVisible(False) + lay.addWidget(self._cam_note) + self._cam_pic: dict[str, FrameLabel] = {} + cams = camera_view.targets() + self._slot_role = {cam["id"]: cam["role"] for cam in cams} + sim_rows = camera_view.sim_targets() + self._sim_id = {item["role"]: item["id"] for item in sim_rows} + self._sim_title = { + item["role"]: {"color": item["label"], "depth": f"{item['label']} 深度"} + for item in sim_rows + } + colors = QHBoxLayout() + colors.setSpacing(6) + colors.setContentsMargins(0, 0, 0, 0) + self._cam_title: dict[str, str] = {} + for cam in cams: + shot = FrameLabel("", compact=True) + shot.set_badge(cam["label"], "等待画面") + self._cam_pic[f"{cam['id']}_color"] = shot + self._cam_pic[f"{cam['id']}_depth"] = shot + self._cam_title[f"{cam['id']}_color"] = cam["label"] + self._cam_title[f"{cam['id']}_depth"] = f"{cam['label']} 深度" + colors.addWidget(shot, 1) + color_box = _PictureRow(len(cams) or 1) + color_box.setLayout(colors) + self._color_box = color_box + lay.addWidget(color_box, 0) + tac_bar = QHBoxLayout() + tac_bar.setSpacing(6) + tac_bar.addWidget(_section("视触觉")) + see = _btn("开始成像") + hide = _btn("停止成像") + for button in (see, hide): + button.setFixedHeight(36) + see.clicked.connect(lambda: self._run("tactile_view_on", False)) + hide.clicked.connect(lambda: self._run("tactile_view_off", False)) + tac_bar.addWidget(see) + tac_bar.addWidget(hide) + tac_bar.addWidget(self._node_button("tactile_up", bar=True)) + tac_bar.addStretch(1) + lay.addLayout(tac_bar) + self._view_note = _fit_text(QLabel("")) + self._view_note.setObjectName("warn") + self._view_note.setFixedHeight(22) + self._view_note.setVisible(False) + lay.addWidget(self._view_note) + self._charts: dict[str, ForceChart] = {} + self._pic: dict[str, FrameLabel] = {} + self._mini = [] + pics = QGridLayout() + pics.setSpacing(6) + pics.setContentsMargins(0, 0, 0, 0) + for side_index, (side, name) in enumerate((("left", "左指"), ("right", "右指"))): + head = QLabel(name) + head.setObjectName("finger") + head.setAlignment(Qt.AlignmentFlag.AlignCenter) + head.setFixedHeight(22) + pics.addWidget(head, 0, side_index * 2, 1, 2) + for kind_index, (kind, caption) in enumerate((("shear", "切向"), ("thermal", "热像"))): + column = side_index * 2 + kind_index + shot = FrameLabel("", compact=True) + shot.set_badge(caption, "") + self._pic[f"{side}_{kind}"] = shot + pics.addWidget(shot, 1, column) + force = _fit_text(QLabel("—")) + force.setObjectName("muted") + force.setFixedHeight(22) + force.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._mini.append(force) + pics.addWidget(force, 2, side_index * 2, 1, 2) + for column in range(4): + pics.setColumnStretch(column, 1) + pics.setRowStretch(1, 1) + pics.setRowMinimumHeight(0, 22) + pics.setRowMinimumHeight(2, 22) + tac_box = QWidget() + tac_box.setLayout(pics) + tac_box.setMinimumHeight(160) + tac_box.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + self._tac_box = tac_box + lay.addWidget(tac_box, 1) self._flow_labels: list[QLabel] = [] - for name in ("跟踪", "对孔", "搜孔轻压"): + flow = QHBoxLayout() + flow.setSpacing(6) + for index, name in enumerate(("跟踪", "对孔", "旋转装配"), start=1): + label = _fit_text(QLabel(f"{index} {name}")) + label.setObjectName("step") + label.setAlignment(Qt.AlignmentFlag.AlignCenter) + label.setFixedHeight(34) + self._flow_labels.append(label) + flow.addWidget(label, 1) + lay.addLayout(flow) + ops = _card() + ops_lay = QVBoxLayout(ops) + ops_lay.setContentsMargins(8, 6, 8, 8) + ops_lay.setSpacing(6) + head = QHBoxLayout() + head.setSpacing(6) + head.addWidget(_section("操作")) + head.addStretch(1) + stop = _btn("停止命令", True) + stop.setFixedHeight(36) + stop.clicked.connect(lambda: self._start(stop_started, "stop")) + head.addWidget(stop) + ops_lay.addLayout(head) + grid = QGridLayout() + grid.setSpacing(6) + actions = ( + "real_up", "gripper_up", "gripper_probe", "gripper_open", + "gripper_close", "gripper_rotate", "pipeline", "stage_track", + "stage_locate", "stage_screw", "reset_home", "gauge_dry", + "gauge_run", + ) + for index, action in enumerate(actions): + grid.addWidget(self._node_button(action), index // 4, index % 4) + for column in range(4): + grid.setColumnStretch(column, 1) + ops_lay.addLayout(grid) + lay.addWidget(ops) + return page + + def _node_button(self, action: str, *, bar: bool = False) -> QPushButton: + spec = self._spec(action) or {} + if action == "pipeline": + kind = "primary" + elif spec.get("moves"): + kind = "danger" + else: + kind = "node" + button = CommandButton(str(spec.get("title") or action), kind, action, bar=bar) + button.clicked.connect(lambda _checked=False, a=action: self._ask(a)) + return button + + def _set_depth(self, on: bool) -> None: + """彩色和深度共用同一排画面,点过去就换,不往下再展开一排。""" + self._depth_on = bool(on) + for button, active in ((self._color_mode, not on), (self._depth_mode, on)): + button.setProperty("active", active) + button.style().unpolish(button) + button.style().polish(button) + suffix = "depth" if on else "color" + seen: set[int] = set() + for key, label in self._cam_pic.items(): + if not key.endswith("_" + suffix) or id(label) in seen: + continue + seen.add(id(label)) + _view_key, title = self._view_key(key) + label.setText("") + label.set_badge(title or self._cam_title.get(key, ""), "等待画面") + self._sync_camera_watch() + self._paint_images() + + def _set_source(self, sim: bool) -> None: + """真机和仿真共用这一排画面。仿真图像也进这里,不另开窗口。""" + self._cam_source = "sim" if sim else "real" + for button, active in ((self._real_mode, not sim), (self._sim_mode, sim)): + button.setProperty("active", active) + button.style().unpolish(button) + button.style().polish(button) + self._set_depth(self._depth_on) + + def _view_key(self, real_key: str) -> tuple[str, str]: + """这一格现在该订哪路话题,以及画面上写的名字。""" + suffix = "_depth" if real_key.endswith("_depth") else "_color" + real_id = real_key[: -len(suffix)] + role = self._slot_role.get(real_id, "") + if self._cam_source == "sim": + sim_id = self._sim_id.get(role) + if sim_id: + kind = "depth" if suffix == "_depth" else "color" + return f"{sim_id}{suffix}", self._sim_title.get(role, {}).get(kind, "") + return real_key, self._cam_title.get(real_key, "") + + def _page_arm(self) -> QWidget: + page = QWidget() + lay = QVBoxLayout(page) + self._arm_lead = QLabel( + "只读机械臂状态,不会在这里自动使能。Remote 只能在示教器上开。" + ) + self._arm_lead.setObjectName("lead") + self._arm_lead.setWordWrap(True) + lay.addWidget(self._arm_lead) + self._arm_prompt = QLabel(arm_state.REMOTE_PROMPT) + self._arm_prompt.setObjectName("warn") + self._arm_prompt.setWordWrap(True) + lay.addWidget(self._arm_prompt) + grid = QGridLayout() + self._arm_cards: dict[str, QLabel] = {} + for index, (key, title) in enumerate(( + ("enable", "使能"), + ("mode", "模式"), + ("fault", "故障 / 急停"), + ("source", "数据从哪来"), + )): card = _card() box = QVBoxLayout(card) - box.addWidget(_title(name)) - label = QLabel("") - label.setObjectName("muted") + box.addWidget(_title(title)) + label = QLabel("—") + label.setObjectName("stat" if key == "enable" else "muted") label.setWordWrap(True) box.addWidget(label) - self._flow_labels.append(label) - flow.addWidget(card) - lay.addLayout(flow) - lay.addStretch(1) + self._arm_cards[key] = label + grid.addWidget(card, 0, index) + lay.addLayout(grid) + body = QHBoxLayout() + self._arm_sketch = ArmSketch() + body.addWidget(self._arm_sketch, 1) + side = QVBoxLayout() + self._arm_summary = QLabel("") + self._arm_summary.setWordWrap(True) + self._arm_summary.setObjectName("muted") + side.addWidget(self._arm_summary) + self._arm_joints = QLabel("") + self._arm_joints.setWordWrap(True) + self._arm_joints.setObjectName("muted") + side.addWidget(self._arm_joints) + side.addStretch(1) + holder = QWidget() + holder.setLayout(side) + body.addWidget(holder, 1) + lay.addLayout(body, 1) return page def _page_tactile(self) -> QWidget: @@ -523,9 +1833,10 @@ class StationApp(QMainWindow): page = QWidget() lay = QVBoxLayout(page) lead = QLabel( - "眼在手上用腕部 D405 和 A5 板,结果是相机到法兰。" - "眼在手外用顶置 D435 和 A4 板,结果是相机到基座。两份文件不能混用。" - "示教和求解会动臂。" + "眼在手上用腕部相机和 A5 板,结果是相机到法兰。" + "眼在手外用手外相机和 A4 板,结果是相机到基座。两份文件不能混用。" + "先在这一页看画面,棋盘约占 30%–70% 再采点。示教时相机会被采点程序独占," + "求解前要重新拉起这路相机。示教和求解会动臂。" ) lead.setWordWrap(True) lead.setObjectName("lead") @@ -539,13 +1850,46 @@ class StationApp(QMainWindow): row.addWidget(button) row.addStretch(1) lay.addLayout(row) + body = QHBoxLayout() + preview = _card() + preview_box = QVBoxLayout(preview) + preview_box.addWidget(_title("当前相机")) + self._he_shot = FrameLabel("预览没开") + self._he_shot.setMinimumHeight(280) + preview_box.addWidget(self._he_shot, 1) + self._he_board = QLabel("还没有画面") + self._he_board.setWordWrap(True) + self._he_board.setObjectName("muted") + preview_box.addWidget(self._he_board) + body.addWidget(preview, 1) + info = _card() + info_box = QVBoxLayout(info) self._he_body = QLabel("") self._he_body.setWordWrap(True) self._he_body.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) - card = _card() - box = QVBoxLayout(card) - box.addWidget(self._he_body) - lay.addWidget(card) + info_box.addWidget(self._he_body) + info_box.addStretch(1) + body.addWidget(info, 1) + lay.addLayout(body, 1) + tools = QHBoxLayout() + open_cam = _btn("拉起这路相机") + open_cam.clicked.connect(self._handeye_camera) + see = _btn("看画面") + see.clicked.connect(self._handeye_preview_on) + hide = _btn("关掉画面") + hide.clicked.connect(self._handeye_preview_off) + tools.addWidget(open_cam) + tools.addWidget(see) + tools.addWidget(hide) + tools.addWidget(QLabel("目标点数")) + self._he_points = QSpinBox() + self._he_points.setRange(4, 30) + self._he_points.setValue(12) + tools.addWidget(self._he_points) + self._he_append = QCheckBox("接着上次的点") + tools.addWidget(self._he_append) + tools.addStretch(1) + lay.addLayout(tools) actions = QHBoxLayout() for action, danger in ( ("handeye_show", False), @@ -555,14 +1899,18 @@ class StationApp(QMainWindow): ): spec = self._spec(action) button = _btn(spec["title"] if spec else action, danger) - button.clicked.connect(lambda _checked=False, a=action: self._ask(a)) + if action == "handeye_teach": + button.clicked.connect(self._handeye_teach) + else: + button.clicked.connect(lambda _checked=False, a=action: self._ask(a)) actions.addWidget(button) actions.addStretch(1) lay.addLayout(actions) self._he_out = QPlainTextEdit() self._he_out.setReadOnly(True) - self._he_out.setPlaceholderText("查看配置后,脚本输出显示在这里。") - lay.addWidget(self._he_out, 1) + self._he_out.setPlaceholderText("查看配置、合并和求解的输出显示在这里。") + self._he_out.setFixedHeight(140) + lay.addWidget(self._he_out) return page def _page_camera(self) -> QWidget: @@ -603,94 +1951,19 @@ class StationApp(QMainWindow): lay.addWidget(card, 1) return self._scroll(page) - def _page_gripper(self) -> QWidget: - page = QWidget() - lay = QVBoxLayout(page) - self._grip_lead = QLabel("") - self._grip_lead.setWordWrap(True) - self._grip_lead.setObjectName("lead") - lay.addWidget(self._grip_lead) - tools = QHBoxLayout() - for action in ("gripper_probe", "gripper_up"): - spec = self._spec(action) - button = _btn(spec["title"] if spec else action, False) - button.clicked.connect(lambda _checked=False, a=action: self._ask(a)) - tools.addWidget(button) - tools.addStretch(1) - lay.addLayout(tools) - - hold = _card() - box = QVBoxLayout(hold) - box.addWidget(_title("夹持")) - note = QLabel("位置 0 是合拢,1000 是张开,单位是千分比。力度最低 20%。滑条和数字框是同一个值。") - note.setObjectName("muted") - note.setWordWrap(True) - box.addWidget(note) - self._pos = ValueTune("位置", 0, 1000, 500) - self._force = ValueTune("力度 %", 20, 100, 33) - self._speed = ValueTune("速度 %", 1, 100, 25) - box.addWidget(self._pos) - box.addWidget(self._force) - box.addWidget(self._speed) - row = QHBoxLayout() - apply_btn = _btn("按此夹持", True) - open_btn = _btn("张开", True) - close_btn = _btn("闭合", True) - apply_btn.clicked.connect(lambda: self._grip_move("pos")) - open_btn.clicked.connect(lambda: self._grip_preset(1000)) - close_btn.clicked.connect(lambda: self._grip_preset(0)) - row.addWidget(apply_btn) - row.addWidget(open_btn) - row.addWidget(close_btn) - row.addStretch(1) - box.addLayout(row) - lay.addWidget(hold) - - self._rotary = _card() - rot = QVBoxLayout(self._rotary) - rot.addWidget(_title("旋转")) - rot_note = QLabel( - "绝对角和相对角可以打到 ±32767°。滑条只覆盖常用段,数字框是完整范围。" - "圈数可以到小数。力矩和旋转速度跟上面的夹持力度、速度是分开的。" - ) - rot_note.setObjectName("muted") - rot_note.setWordWrap(True) - rot.addWidget(rot_note) - self._angle = ValueTune("绝对角 °", -32767, 32767, 0, step=0.1, decimals=1, slider_low=-3600, slider_high=3600) - self._delta = ValueTune("相对角 °", -32767, 32767, 15, step=0.1, decimals=1, slider_low=-720, slider_high=720) - self._turns = ValueTune("圈数", -50, 50, 0.25, step=0.01, decimals=2, slider_low=-10, slider_high=10) - self._rot_speed = ValueTune("转速 %", 1, 100, 25) - self._torque = ValueTune("力矩 %", 20, 100, 33) - for widget in (self._angle, self._delta, self._turns, self._rot_speed, self._torque): - rot.addWidget(widget) - rot_row = QHBoxLayout() - for title, kind in ( - ("转到绝对角", "rotate"), - ("相对转动", "rotate_by"), - ("按圈数转", "revolutions"), - ("旋转回零", "rotate_home"), - ): - button = _btn(title, True) - button.clicked.connect(lambda _checked=False, k=kind: self._grip_move(k)) - rot_row.addWidget(button) - rot_row.addStretch(1) - rot.addLayout(rot_row) - lay.addWidget(self._rotary) - lay.addStretch(1) - return self._scroll(page) - def _page_control(self) -> QWidget: page = QWidget() lay = QVBoxLayout(page) lead = QLabel( - "分步是单独拉起跟踪、对孔或搜孔。整体是三段一次跑完。" - "复位先停跟踪,再回到命名位姿 home,前提是控制栈已经起来。都会动臂。" + "分步是单独拉起跟踪、对孔或旋转装配。搜孔轻压已取消,整体只跑跟踪和对孔。" + "旋转装配按现场配置里的拧入深度和螺距走。复位先停跟踪,再回到命名位姿 home," + "前提是控制栈已经起来。都会动臂。" ) lead.setWordWrap(True) lead.setObjectName("lead") lay.addWidget(lead) groups = ( - ("分步测试", ("stage_track", "stage_locate", "stage_search")), + ("分步测试", ("stage_track", "stage_locate", "stage_screw")), ("整体测试", ("pipeline",)), ("复位", ("reset_home",)), ) @@ -708,14 +1981,14 @@ class StationApp(QMainWindow): button.clicked.connect(lambda _checked=False, a=action: self._ask(a)) box.addWidget(button) lay.addWidget(card) - lay.addStretch(1) - return self._scroll(page) + return page def _page_teach(self) -> QWidget: page = QWidget() lay = QVBoxLayout(page) lead = QLabel( - "软拖拽占用 RDK,要先停掉控制栈。记录的是当时的 7 个关节角,单位弧度。" + "软拖拽会先停下关节轨迹控制器,关掉后再恢复,不再另开一条去抢位置环。" + "记录的是当时的 7 个关节角,单位弧度。" "回到点位会先关掉拖拽,再按下面的关节速度走过去。和 home 复位不是同一条路。" ) lead.setWordWrap(True) @@ -760,8 +2033,7 @@ class StationApp(QMainWindow): back.addStretch(1) list_box.addLayout(back) lay.addWidget(listed) - lay.addStretch(1) - return self._scroll(page) + return page def _page_assembly(self) -> QWidget: page = QWidget() @@ -789,9 +2061,21 @@ class StationApp(QMainWindow): row.addWidget(real) row.addStretch(1) lay.addLayout(row) - lay.addStretch(1) return page + def _page_job(self) -> QWidget: + page = QWidget() + lay = QVBoxLayout(page) + for title, builder in ( + ("示教", self._page_teach), + ("装配", self._page_assembly), + ("控制", self._page_control), + ("操作", self._page_operate), + ): + lay.addWidget(_title(title)) + lay.addWidget(builder()) + return self._scroll(page) + def _page_operate(self) -> QWidget: page = QWidget() lay = QVBoxLayout(page) @@ -802,7 +2086,7 @@ class StationApp(QMainWindow): skip = { "gripper_open", "gripper_close", "gripper_rotate", "gripper_probe", "handeye_show", "handeye_teach", "handeye_merge", "handeye_calibrate", - "pipeline", "stage_track", "stage_locate", "stage_search", "reset_home", + "pipeline", "stage_track", "stage_locate", "stage_screw", "reset_home", } shown = [spec for spec in catalog()["actions"] if spec["id"] not in skip] for index, spec in enumerate(shown): @@ -813,13 +2097,12 @@ class StationApp(QMainWindow): blurb.setWordWrap(True) blurb.setObjectName("muted") box.addWidget(blurb) - button = _btn("确认后执行" if spec.get("moves") else "执行", bool(spec.get("moves"))) + button = _btn("执行", bool(spec.get("moves"))) button.clicked.connect(lambda _checked=False, a=spec["id"]: self._ask(a)) box.addWidget(button) grid.addWidget(card, index // 3, index % 3) lay.addLayout(grid) - lay.addStretch(1) - return self._scroll(page) + return page def _show_page(self, key: str) -> None: self._stack.setCurrentWidget(self._pages[key]) @@ -827,6 +2110,27 @@ class StationApp(QMainWindow): button.setProperty("active", name == key) button.style().unpolish(button) button.style().polish(button) + self._sync_camera_watch() + + def _sync_camera_watch(self) -> None: + """只有正在看的那一页才处理图像。别的页上相机回调直接丢掉。""" + page = self._stack.currentWidget() + if page is self._pages.get("main"): + suffix = "_depth" if self._depth_on else "_color" + keys = set() + seen: set[int] = set() + for key, label in self._cam_pic.items(): + if not key.endswith(suffix) or id(label) in seen: + continue + seen.add(id(label)) + keys.add(self._view_key(key)[0]) + camera_view.watch(keys, set()) + elif page is self._pages.get("handeye"): + name = str(self._handeye_card().get("preview_camera") or "") + keys = {f"{name}_color"} if name else set() + camera_view.watch(set(), keys) + else: + camera_view.watch(set(), set()) def _set_arm(self, arm: str) -> None: self._arm = arm @@ -840,35 +2144,6 @@ class StationApp(QMainWindow): def _spec(self, action: str) -> dict | None: return next((item for item in catalog()["actions"] if item["id"] == action), None) - def _cmd(self, action: str) -> str: - spec = self._spec(action) - if not spec: - return action - args = " ".join(part.replace("{arm}", self._arm).replace("{mode}", self._mode) for part in spec.get("args") or []) - script = { - "real_up": "run_real.sh", - "camera_d405": "run_camera_d405.sh", - "camera_d435": "run_camera_d435.sh", - "tactile_up": "run_tactile.sh", - "gripper_up": "run_gripper.sh", - "gripper_probe": "run_gripper.sh", - "gripper_open": "run_gripper.sh", - "gripper_close": "run_gripper.sh", - "gripper_rotate": "run_gripper.sh", - "pipeline": "run_all.sh", - "stage_track": "run_visual_servo_rt.sh", - "stage_locate": "run_stage2_locate.sh", - "stage_search": "run_stage3_search.sh", - "reset_home": "go_home.sh", - "gauge_dry": "run_gauge_assembly.sh", - "gauge_run": "run_gauge_assembly.sh", - "handeye_show": "run_handeye.sh", - "handeye_teach": "run_handeye.sh", - "handeye_merge": "run_handeye.sh", - "handeye_calibrate": "run_handeye.sh", - }.get(action, "") - return f"bash scripts/{script} {args}".strip() - def _grip_preset(self, pos: int) -> None: self._pos.set_value(pos) self._grip_move("pos") @@ -885,22 +2160,13 @@ class StationApp(QMainWindow): "torque": self._torque.value(), } send_kind = "rotate" if kind == "rotate_home" else kind - if send_kind != "pos" and self._arm != "91": - self.statusBar().showMessage("旋转只对右臂", 4000) - return - if send_kind == "pos": - text = f"位置 {numbers['pos']:.0f} 力度 {numbers['force']:.0f}% 速度 {numbers['speed']:.0f}%" - elif send_kind == "rotate": - text = f"绝对角 {numbers['angle']:.1f}° 力矩 {numbers['torque']:.0f}% 转速 {numbers['rot_speed']:.0f}%" - elif send_kind == "rotate_by": - text = f"相对 {numbers['delta']:.1f}° 力矩 {numbers['torque']:.0f}% 转速 {numbers['rot_speed']:.0f}%" - else: - text = f"{numbers['revolutions']:.2f} 圈 力矩 {numbers['torque']:.0f}% 转速 {numbers['rot_speed']:.0f}%" - if not self._confirm("夹爪动作", f"臂 {self._arm}。{text}\n夹爪会动。", ""): + current = next((row for row in self._catalog if row["id"] == self._arm), None) + if send_kind != "pos" and not (current and current.get("rotary")): + self.statusBar().showMessage("这台夹爪没有旋转轴", 4000) return captured = dict(numbers) arm = self._arm - self._start(lambda: run_gripper_tune(arm, send_kind, captured)) + self._start(lambda: run_gripper_tune(arm, send_kind, captured), "grip") def _selected_point(self) -> str: item = self._teach_list.currentItem() @@ -913,16 +2179,14 @@ class StationApp(QMainWindow): if not sn: self.statusBar().showMessage("这台臂没有序列号", 4000) return - if not self._confirm("开启软拖拽", f"臂 {self._arm}。会占用 RDK,控制栈必须先停掉。臂可以被手推动。", ""): - return - self._start(lambda: teach.drag_on(sn)) + self._start(lambda: teach.drag_on(sn), "teach") def _teach_off(self) -> None: sn = self._sn() if not sn: self.statusBar().showMessage("这台臂没有序列号", 4000) return - self._start(lambda: teach.drag_off(sn)) + self._start(lambda: teach.drag_off(sn), "teach") def _teach_record(self) -> None: sn = self._sn() @@ -931,7 +2195,7 @@ class StationApp(QMainWindow): self.statusBar().showMessage("这台臂没有序列号", 4000) return arm = self._arm - self._start(lambda: teach.record(sn, arm, name)) + self._start(lambda: teach.record(sn, arm, name), "teach") def _teach_delete(self) -> None: name = self._selected_point() @@ -940,7 +2204,7 @@ class StationApp(QMainWindow): return arm = self._arm self._teach_sig = None - self._start(lambda: teach.delete_point(arm, name)) + self._start(lambda: teach.delete_point(arm, name), "teach") def _teach_goto(self) -> None: sn = self._sn() @@ -949,48 +2213,54 @@ class StationApp(QMainWindow): if not sn or not name: self.statusBar().showMessage("先选臂和点位", 3000) return - if not self._confirm( - "回到示教点", - f"臂 {self._arm} 回到 {name}。关节速度 {speed:.2f} rad/s。会先关拖拽再动臂。", - "", - ): - return arm = self._arm - self._start(lambda: teach.goto(sn, arm, name, speed)) + self._start(lambda: teach.goto(sn, arm, name, speed), "teach") def _sn(self) -> str: arms = (self._snap or {}).get("arms") or [] current = next((arm for arm in arms if arm.get("id") == self._arm), None) return str((current or {}).get("sn") or "") - def _confirm(self, title: str, text: str, command: str) -> bool: - box = QMessageBox(self) - box.setWindowTitle(title) - box.setText(text) - if command: - box.setInformativeText(command) - box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No) - box.button(QMessageBox.StandardButton.Yes).setText("确认执行") - box.button(QMessageBox.StandardButton.No).setText("取消") - return box.exec() == QMessageBox.StandardButton.Yes + def _handeye_card(self) -> dict: + return ((self._snap.get("handeye") or {}).get(self._arm) or {}).get(self._mode) or {} + + def _handeye_camera(self) -> None: + action = "camera_d405" if self._mode == "eye_in_hand" else "camera_d435" + self._ask(action) + self.statusBar().showMessage(camera_view.start(), 4000) + + def _handeye_preview_on(self) -> None: + self.statusBar().showMessage(camera_view.start(), 4000) + + def _handeye_preview_off(self) -> None: + camera_view.stop() + self._he_shot.setText("预览没开") + self._he_board.setText("画面已关") + + def _handeye_teach(self) -> None: + spec = self._spec("handeye_teach") + confirm = bool(spec and spec.get("moves")) + arm, mode = self._arm, self._mode + extras = {"points": self._he_points.value(), "append": self._he_append.isChecked()} + self._start(lambda: run_action("handeye_teach", arm, confirm, mode, extras), "teach") def _ask(self, action: str) -> None: + """点下去就下发。动臂仍带 confirm,后台才肯执行,界面不再弹窗。""" spec = self._spec(action) - confirm = False - if spec and spec.get("moves"): - if not self._confirm(spec["title"], f"{spec['blurb']}\n当前臂 {self._arm}。", self._cmd(action)): - return - confirm = True + confirm = bool(spec and spec.get("moves")) arm, mode = self._arm, self._mode - self._start(lambda: run_action(action, arm, confirm, mode)) + self._start(lambda: run_action(action, arm, confirm, mode), command_lane(action)) - def _start(self, fn) -> None: - if self._job is not None and self._job.isRunning(): - self.statusBar().showMessage("上一个命令还没返回", 3000) + def _start(self, fn, lane: str) -> None: + running = {name for name, job in self._jobs.items() if job.isRunning()} + blocked = command_blocked_message(running, lane) + if blocked: + self.statusBar().showMessage(blocked, 3000) return - self._job = _Job(fn) - self._job.finished_ok.connect(self._ran) - self._job.start() + job = _Job(fn) + self._jobs[lane] = job + job.finished_ok.connect(self._ran) + job.start() def _ran(self, result: dict) -> None: text = result.get("message") or ("已发出" if result.get("ok") else "失败") @@ -1017,74 +2287,120 @@ class StationApp(QMainWindow): button.style().unpolish(button) button.style().polish(button) chips = [] + live = (self._snap.get("arm_live") or {}).get(self._arm) or {} + chips.append("已使能" if live.get("enabled") else "未使能") + grip = (self._snap.get("gripper_live") or {}).get(self._arm) or {} + if grip.get("connected") and grip.get("permille") is not None: + chips.append(f"夹爪 {grip['permille']:.0f}‰") + else: + chips.append("夹爪未连接") for service in self._snap.get("services") or []: chips.append(f"{service['label']} {'在跑' if service['up'] else '未开'}") self._chips.setText(" ".join(chips)) self._fill_overview(current) + self._fill_arm(live) self._fill_tactile() self._fill_handeye() self._fill_camera() - self._fill_gripper() + self._sync_camera_watch() + self._fill_gripper(grip) self._fill_teach() - self._op_lead.setText(f"当前臂 {self._arm}。红色按钮会动设备,先确认再执行。") def _fill_overview(self, current: dict | None) -> None: - if current: - tactile = "本臂有视触觉" if current.get("tactile") else "本臂无视触觉" - self._lead.setText( - f"{current.get('label')} · {current.get('sn') or ''} · 相机 {current.get('camera') or '—'} · " - f"夹爪 {current.get('gripper') or '—'} · {tactile}" - ) + del current + live = (self._snap.get("arm_live") or {}).get(self._arm) or {} + enable_word = "已使能" if live.get("enabled") else "未使能" + self._arm_banner.setText(f"{enable_word} {live.get('summary') or ''}".strip()) + self._arm_banner.setObjectName("ok" if live.get("enabled") and not live.get("prompt_hot") else "warn") + self._arm_banner.style().unpolish(self._arm_banner) + self._arm_banner.style().polish(self._arm_banner) usb = list(self._snap.get("usb") or []) ports = self._snap.get("grippers") or [] usb.append({"id": "tty", "items": [{"port": name, "speed": ""} for name in ports], "expect": 1}) - for (count, flag, detail), dev in zip(self._usb_labels, usb): + for (count, flag), dev in zip(self._usb_labels, usb): items = dev.get("items") or [] if dev.get("id") == "tty": count.setText(str(len(items))) ok = len(items) > 0 else: - count.setText(f"{len(items)} / {dev.get('expect')}") + count.setText(f"{len(items)}/{dev.get('expect')}") ok = len(items) >= int(dev.get("expect") or 0) - flag.setText("齐" if ok else ("缺一" if items else "缺失")) - detail.setText("\n".join( - f"{item['port']} · {item['speed']} Mbps" if item.get("speed") else item["port"] - for item in items - ) or "未检测到") + flag.setText("齐" if ok else "缺") + flag.setObjectName("ok" if ok else "warn") + flag.style().unpolish(flag) + flag.style().polish(flag) tactile = self._snap.get("tactile") or {} for label, side, name in ((self._mini[0], "left", "左指"), (self._mini[1], "right", "右指")): row = tactile.get(side) or {} label.setText(self._force_line(row)) pipe = next((s for s in self._snap.get("services") or [] if s["id"] == "pipeline"), None) - texts = ( - "D405 看住工件,锁住悬停", - "拍照找孔,移到孔上方", - "力闭环入孔,再轻压", + running = bool(pipe and pipe.get("up")) + for index, (label, name) in enumerate( + zip(self._flow_labels, ("跟踪", "对孔", "旋转装配")), start=1 + ): + label.setText(f"{index} {name}") + label.setProperty("on", running) + label.style().unpolish(label) + label.style().polish(label) + + def _fill_arm(self, live: dict) -> None: + enabled = bool(live.get("enabled")) + self._arm_cards["enable"].setText("已使能" if enabled else "未使能") + self._arm_cards["mode"].setText(str(live.get("mode") or "—")) + fault = live.get("fault") + estop = live.get("estop_released") + if estop is False: + fault_text = "急停" + elif fault: + fault_text = "有故障" + elif fault is False: + fault_text = "正常" + else: + fault_text = "—" + self._arm_cards["fault"].setText(fault_text) + source = str(live.get("source") or "—") + if source.startswith("RDK"): + source = "RDK" + elif source == "拖拽桥": + source = "拖拽" + self._arm_cards["source"].setText(source) + hot = bool(live.get("prompt_hot", True)) + prompt = str(live.get("prompt") or "") + sentence = prompt.split("。")[0] + self._arm_prompt.setVisible(hot and bool(sentence)) + self._arm_prompt.setText(f"{sentence}。" if sentence else "") + q = live.get("q") + if isinstance(q, list) and len(q) >= 7: + degrees = " ".join( + f"J{i + 1} {v * 180.0 / 3.141592653589793:.0f}°" for i, v in enumerate(q[:7]) + ) + else: + degrees = "零位" + self._arm_joints.setText(degrees) + self._arm_sketch.set_pose( + q if isinstance(q, list) else None, + bool(live.get("pose_live")), + self._workpieces.get(self._arm), ) - state = "进程在跑,具体阶段看终端日志。" if pipe and pipe.get("up") else "流程未启动。" - for label, text in zip(self._flow_labels, texts): - label.setText(f"{text}\n{state}") def _force_line(self, row: dict) -> str: def num(key: str) -> str: value = row.get(key) return f"{value:.1f}" if isinstance(value, (int, float)) else "—" - return f"法向 {num('fnormal')} 切向 X {num('fshearx')} 切向 Y {num('fsheary')} {num('fps')} fps" + return f"法向 {num('fnormal')} 切向 {num('fshearx')} / {num('fsheary')}" def _fill_tactile(self) -> None: view = self._snap.get("view") or {} - self._view_note.setText( - view.get("error") - or view.get("note") - or ("成像已开,等第一帧。" if view.get("on") else "成像没开。点开始成像后这里出切向力和热成像。") - ) + err = str(view.get("error") or "") + self._view_note.setVisible(bool(err)) + self._view_note.setText(err) history = self._snap.get("history") or {} for side, chart in self._charts.items(): chart.set_samples(history.get(side)) def _paint_images(self) -> None: current = self._stack.currentWidget() - if current is self._pages.get("tactile"): + if current is self._pages.get("main"): view = tactile_view.state() frames = view.get("frames") or {} if view.get("on") else {} for side in ("left", "right"): @@ -1095,15 +2411,35 @@ class StationApp(QMainWindow): blob = tactile_view.frame(side, kind) if blob: label.show_jpeg(blob) - if current is self._pages.get("camera"): - frames = camera_view.state().get("frames") or {} + shots = camera_view.previews() + meta = camera_view.preview_meta() + suffix = "_depth" if self._depth_on else "_color" + seen: set[int] = set() for key, label in self._cam_pic.items(): - if not frames.get(key): + if not key.endswith(suffix) or id(label) in seen: continue - name, _, kind = key.rpartition("_") - blob = camera_view.frame(name, kind) - if blob: - label.show_jpeg(blob) + seen.add(id(label)) + view_key, title = self._view_key(key) + info = meta.get(view_key) + if not title: + title = self._cam_title.get(key, "") + if info and info[2] >= 1.0: + detail = f"{info[0]}×{info[1]} {info[2]:.0f} 帧/秒" + elif info and info[0] > 0: + detail = f"{info[0]}×{info[1]}" + else: + detail = "等待画面" + label.set_badge(title, detail) + item = shots.get(view_key) + if not item: + continue + blob, width, height = item + label.show_rgb(blob, width, height) + infer = camera_view.inference_status() + if infer != self._infer_line.text(): + self._infer_line.setText(infer) + if current is self._pages.get("handeye"): + self._paint_handeye() def _fill_handeye(self) -> None: for mode, button in self._mode_buttons.items(): @@ -1119,40 +2455,71 @@ class StationApp(QMainWindow): mm = "—" name = "眼在手上" if self._mode == "eye_in_hand" else "眼在手外" poses = "没有位姿文件" if card.get("poses") is None else f"{card.get('poses')} 个" + target = self._he_points.value() + preview = card.get("preview_label") or "相机" + cam_id = card.get("preview_camera") or "—" self._he_body.setText( f"{name} {'有结果' if card.get('exists') else '还没有结果文件'}\n" - f"{card.get('meaning') or ''}\n" + f"{card.get('meaning') or '还没有求解结果'}\n" + f"预览 {preview} {cam_id}\n" f"文件 {card.get('file') or '—'}\n" f"视角 {card.get('views') or '—'} 时间 {card.get('utc') or '—'}\n" - f"相机 {card.get('camera') or '—'} 工具 {card.get('tool') or '—'}\n" + f"结果里的相机 {card.get('camera') or '—'} 工具 {card.get('tool') or '—'}\n" f"平移 X / Y / Z {mm}\n" - f"示教点 {poses}\n" + f"已有示教点 {poses} 这次目标 {target} 个\n" f"板 {board.get('cols') or '—'}×{board.get('rows') or '—'} " - f"方格 {board.get('square_m') if board.get('square_m') is not None else '—'} m" + f"方格 {board.get('square_m') if board.get('square_m') is not None else '—'} m\n" + "顺序:看画面 → 停控制栈后采点 → 合并 → 再开控制栈和相机 → 求解" ) + if not camera_view.state().get("on"): + self._he_board.setText("预览没开。点「看画面」。相机节点没起来时再点「拉起这路相机」。") + + def _paint_handeye(self) -> None: + if not camera_view.state().get("on"): + return + card = self._handeye_card() + name = str(card.get("preview_camera") or "") + blob = camera_view.frame(name, "color") if name else None + if not blob: + if not camera_view.state().get("on"): + self._he_board.setText("预览没开。点「看画面」。相机节点没起来时再点「拉起这路相机」。") + else: + self._he_board.setText(f"还没有 {card.get('preview_label') or '这路相机'} 的彩色帧") + return + cols = int((card.get("board") or {}).get("cols") or 0) + rows = int((card.get("board") or {}).get("rows") or 0) + self._board.submit(id(blob), blob, cols, rows) + drawn, text, token = self._board.latest() + if drawn and token == id(blob): + self._he_shot.show_jpeg(drawn) + self._he_board.setText(text) + else: + self._he_shot.show_jpeg(blob) + self._he_board.setText(text or "正在找棋盘") def _fill_camera(self) -> None: view = camera_view.state() - usb = {item["id"]: item for item in self._snap.get("usb") or []} - bits = [] - for key, name in (("d405", "腕部"), ("d435", "手外")): - dev = usb.get(key) or {"items": []} - bits.append(f"{name} {len(dev.get('items') or [])} 台") - err = view.get("error") or "" - ready = "、".join(key for key, ok in (view.get("frames") or {}).items() if ok) or "还没有帧" - self._cam_note.setText(f"{' '.join(bits)}。画面:{ready}。{err}") + err = str(view.get("error") or "") + self._cam_note.setVisible(bool(err)) + if err != self._cam_note.text(): + self._cam_note.setText(err) - def _fill_gripper(self) -> None: - ports = " ".join(self._snap.get("grippers") or []) or "没有 ttyUSB" - self._rotary.setVisible(self._arm == "91") - if self._arm == "91": - self._grip_lead.setText( - f"当前是右臂旋转爪。串口 {ports}。位置、力度、速度以及转角都会按数字下发。夹爪节点占着串口时,命令行会失败。" - ) - else: - self._grip_lead.setText( - f"当前是左臂平行爪。串口 {ports}。位置 0–1000,力度、速度按滑条下发。探测只读状态。" - ) + def _fill_gripper(self, live: dict) -> None: + current = next((row for row in self._catalog if row["id"] == self._arm), None) + rotary = bool(current and current.get("rotary")) + self._rotary.setVisible(rotary) + connected = bool(live.get("connected")) + self._grip_summary.setText(live.get("summary") or "没连上") + self._grip_summary.setObjectName("ok" if connected else "warn") + self._grip_summary.style().unpolish(self._grip_summary) + self._grip_summary.style().polish(self._grip_summary) + self._grip_sketch.set_pose( + live.get("permille"), + live.get("rotation_deg"), + bool(live.get("rotary")), + bool(live.get("pose_live")), + live.get("stroke_mm"), + ) def _fill_teach(self) -> None: listed = teach.list_points(self._arm) @@ -1177,42 +2544,85 @@ def main() -> None: atexit.register(camera_view.stop) atexit.register(teach.close) threading.Thread(target=_poll_tactile, daemon=True, name="tactile-poll").start() + arm_state.start() + grip_state.start() camera_view.start() + QApplication.setAttribute(Qt.ApplicationAttribute.AA_ShareOpenGLContexts) + QApplication.setAttribute(Qt.ApplicationAttribute.AA_UseDesktopOpenGL) app = QApplication(sys.argv) app.setFont(QFont("Noto Sans SC", 11)) app.setStyleSheet(_STYLE) window = StationApp() - window.showMaximized() + screen = app.primaryScreen() + if screen is not None: + avail = screen.availableGeometry() + window.resize(min(1600, avail.width()), min(960, avail.height())) + # 不用最大化。桌面如果还记着上次的最大化,晚一拍取消,标题栏才能拖。 + window.showNormal() + QTimer.singleShot(300, window.showNormal) sys.exit(app.exec()) _STYLE = """ -QMainWindow, QWidget { background: #070708; color: #f3f4f6; } -QWidget#side, QWidget#head { background: #0b0c0e; } -QWidget#side { border-right: 1px solid #2a2e36; } -QWidget#head { border-bottom: 1px solid #2a2e36; } -QLabel#sideNote, QLabel#lead, QLabel#muted, QLabel#chips, QLabel#clock { color: #8b929d; } -QLabel#armLine { font-size: 16px; } -QLabel#cardTitle { color: #9aa3b2; letter-spacing: 1px; } -QLabel#stat { font-size: 28px; } -QLabel#shot { background: #0c0e12; border: 1px solid #243044; border-radius: 8px; color: #8b929d; } -QFrame#card { background: #101216; border: 1px solid #2a2e36; border-radius: 12px; } +QMainWindow, QWidget { background: #0c0f14; color: #e7ebf2; } +QWidget#side { background: #10141b; border-right: 1px solid #2a3240; } +QWidget#head { background: #10141b; border-bottom: 1px solid #2a3240; } +QLabel#brand { color: #8b95a7; font-size: 11px; letter-spacing: 1px; } +QLabel#sideNote, QLabel#lead, QLabel#muted, QLabel#chips, QLabel#clock { color: #8b95a7; } +QLabel#clock { padding: 0 8px; } +QLabel#warn { color: #e2b657; } +QLabel#ok { color: #7dcea0; } +QLabel#armLine { font-size: 16px; font-weight: 600; color: #f4f7fb; } +QLabel#count { color: #f4f7fb; font-weight: 600; } +QLabel#cardTitle { color: #8b95a7; font-size: 11px; } +QLabel#section { color: #d5dbe6; font-weight: 600; } +QLabel#finger { color: #f4f7fb; font-weight: 600; background: transparent; } +QFrame#sectionMark { background: #5b9bff; border: none; border-radius: 1px; } +QLabel#step { + background: #151a22; color: #9aa3b2; border: 1px solid #2a3240; + border-radius: 8px; padding: 2px 8px; +} +QLabel#step[on="true"] { background: #15263c; color: #d6e6ff; border-color: #2f5f99; } +QLabel#shot { background: #090c11; border: 1px solid #243044; border-radius: 10px; color: #8b95a7; } +QFrame#card { background: #161b24; border: 1px solid #2c3544; border-radius: 12px; } QPushButton { - background: #171a20; color: #f3f4f6; border: 1px solid #2a2e36; - border-radius: 8px; padding: 8px 12px; text-align: left; + background: #1a1f27; color: #e7ebf2; border: 1px solid #313844; + border-radius: 8px; padding: 0px 12px; font-size: 13px; text-align: left; } -QPushButton#nav { text-align: left; background: transparent; border: 1px solid transparent; } -QPushButton#nav[active="true"], QPushButton[active="true"] { background: #12315d; border-color: #1a4580; } -QPushButton#danger { background: #6e2a2a; border-color: #8a3a3a; } +QWidget#switch { background: #12171f; border: 1px solid #2c3544; border-radius: 8px; } +QPushButton:hover { background: #222833; border-color: #4a5568; } +QPushButton#nav { + text-align: left; background: transparent; border: none; border-radius: 8px; + padding: 0px 8px; color: #b7c0cc; font-size: 13px; +} +QPushButton#nav:hover { background: #1a2030; } +QPushButton#nav[active="true"] { + background: #182438; color: #f4f7fb; border-left: 3px solid #5b9bff; +} +QPushButton#node, QPushButton#danger, QPushButton#primary, QPushButton#action { text-align: center; padding: 0; } +QPushButton#node { background: #1c222c; border-color: #3a4558; } +QPushButton#seg { + text-align: center; padding: 0 12px; border: none; border-radius: 6px; + background: transparent; min-width: 0; min-height: 0; +} +QPushButton#seg[active="true"] { background: #1d4f91; color: #f4f7fb; } +QPushButton#primary { background: #1d4f91; border-color: #3d7ed4; color: #f4f7fb; font-weight: 600; } +QPushButton#primary:hover { background: #2460ad; } +QPushButton#danger { background: #241416; border: 1px solid #6e3a3a; color: #f0d0d0; } +QPushButton#danger:hover { background: #3a1c20; border-color: #a05050; } QPlainTextEdit, QLineEdit, QListWidget, QDoubleSpinBox { - background: #0c0e12; color: #f3f4f6; border: 1px solid #2a2e36; border-radius: 8px; padding: 4px 8px; + background: #10141a; color: #e7ebf2; border: 1px solid #2a3240; border-radius: 8px; padding: 4px 8px; } -QSlider::groove:horizontal { height: 6px; background: #2a2e36; border-radius: 3px; } +QPlainTextEdit#log { + background: #10141a; color: #b7c0cc; border: none; border-top: 1px solid #2a3240; border-radius: 0; +} +QDoubleSpinBox:focus, QLineEdit:focus { border-color: #3d7ed4; } +QSlider::groove:horizontal { height: 4px; background: #2a3240; border-radius: 2px; } QSlider::handle:horizontal { - width: 16px; margin: -6px 0; background: #4c86d4; border-radius: 8px; + width: 14px; margin: -6px 0; background: #5b9bff; border-radius: 7px; } -QScrollArea { background: transparent; } -QMessageBox { background: #12141a; } +QScrollArea { background: transparent; border: none; } +QStatusBar { background: #10141b; color: #8b95a7; } """ diff --git a/install/arm_application/lib/python3.11/site-packages/arm_application/operator_arm_mesh.py b/install/arm_application/lib/python3.11/site-packages/arm_application/operator_arm_mesh.py new file mode 100644 index 0000000..20a7634 --- /dev/null +++ b/install/arm_application/lib/python3.11/site-packages/arm_application/operator_arm_mesh.py @@ -0,0 +1,339 @@ +"""用非夕给的 Rizon4 外壳网格摆出当前姿态。 + +网格和安装位姿来自 ``flexiv_description`` 的 ``visual_parameters.yaml`` +与 ``meshes/Rizon4/visual/*.obj``,不另画一套简图。面数按固定步长抽稀, +只为了界面里能转得动,几何仍是厂家的。 +""" + +from __future__ import annotations + +import math +from pathlib import Path + +import numpy as np +import yaml + +from arm_application.operator_arm_state import link_frames +from arm_application.site_io import ros2_root + +# 单个连杆原始面数大约 5000。再按 700 抽稀会在壳上留出三角洞, +# 白壳和灰蓝关节分不出来。这里保留全部面。 +_FACES_PER_LINK = 8000 +_MODELS: dict[str, list[tuple[list[tuple[float, float, float]], tuple]]] | None = None + + +def posed_triangles(q: list[float]) -> list[tuple]: + """返回基座系下的三角面 ``(a, b, c, 明暗, rgb)``。 + + ``rgb`` 来自同目录 ``.mtl`` 的 ``Kd``。外壳是白的,关节壳体是灰蓝, + 这是厂家导出的两种材质,不是另涂的颜色。 + """ + world, shade, rgb = _posed_arrays(q) + return [ + ( + (float(world[i, 0, 0]), float(world[i, 0, 1]), float(world[i, 0, 2])), + (float(world[i, 1, 0]), float(world[i, 1, 1]), float(world[i, 1, 2])), + (float(world[i, 2, 0]), float(world[i, 2, 1]), float(world[i, 2, 2])), + float(shade[i]), + (float(rgb[i, 0]), float(rgb[i, 1]), float(rgb[i, 2])), + ) + for i in range(shade.shape[0]) + ] + + +def posed_gl(q: list[float]) -> tuple[bytes, int, tuple[float, float, float], float]: + """给显卡的一份网格:交错 ``xyzrgb``、顶点数、中心、半径。 + + 拖视角只改观察矩阵,不再把四万个面在界面线程里重投影。 + """ + world, shade, rgb = _posed_arrays(q) + light = (0.55 + 0.45 * shade)[:, None] + colored = (rgb * light).astype(np.float32) + pos = world.reshape(-1, 3).astype(np.float32) + col = np.repeat(colored, 3, axis=0) + center = pos.mean(axis=0) + radius = float(np.linalg.norm(pos - center, axis=1).max()) + packed = np.concatenate([pos, col], axis=1).astype(np.float32, copy=False) + return ( + packed.tobytes(), + int(pos.shape[0]), + (float(center[0]), float(center[1]), float(center[2])), + max(radius, 1e-4), + ) + + +def _posed_arrays(q: list[float]): + """每个连杆做一次矩阵乘法,不再对每个顶点走 Python 循环。""" + frames = link_frames(q) + worlds: list[np.ndarray] = [] + shades: list[np.ndarray] = [] + colors: list[np.ndarray] = [] + for link, parts in _models().items(): + frame = frames.get(link) + if frame is None: + continue + rot = np.asarray(frame[0], dtype=np.float64) + origin = np.asarray(frame[1], dtype=np.float64) + for part in parts: + local = np.einsum("ij,fvj->fvi", part["erot"], part["verts"]) + part["epos"] + world = np.einsum("ij,fvj->fvi", rot, local) + origin + ab = world[:, 1] - world[:, 0] + ac = world[:, 2] - world[:, 0] + normal = np.cross(ab, ac) + norm = np.linalg.norm(normal, axis=1) + norm = np.where(norm > 0.0, norm, 1.0) + facing = np.abs(normal[:, 0] + normal[:, 1]) / (norm * math.sqrt(2.0)) + worlds.append(world) + shades.append(0.28 + 0.72 * facing) + colors.append(part["rgb"]) + if not worlds: + empty = np.zeros((0, 3, 3), dtype=np.float64) + return empty, np.zeros((0,), dtype=np.float64), np.zeros((0, 3), dtype=np.float64) + return np.concatenate(worlds), np.concatenate(shades), np.concatenate(colors) + + +def view_rotation(yaw: float, pitch: float): + """先绕竖直轴转,再俯仰。拖拽视角用这一份,不在界面里另写一套。""" + return _mul(_rx(pitch), _rz(yaw)) + + +def view_scale(radius: float, width: int, height: int) -> tuple[float, float]: + """和着色器里的 ``u_sx`` / ``u_sy`` 同一套缩放。宽屏时横向再缩一档。""" + fit = 0.85 / max(float(radius), 1e-4) + width = max(int(width), 1) + height = max(int(height), 1) + if width >= height: + return fit * height / width, fit + return fit, fit * width / height + + +def screen_point( + point: tuple[float, float, float], + yaw: float, + pitch: float, + center: tuple[float, float, float], + radius: float, + width: int, + height: int, +) -> tuple[float, float]: + """基座系的点投到控件像素。纵向和着色器里的 ``p.z`` 是同一套,+Z 朝画面上方。""" + shifted = ( + float(point[0]) - float(center[0]), + float(point[1]) - float(center[1]), + float(point[2]) - float(center[2]), + ) + viewed = _apply(view_rotation(yaw, pitch), shifted) + sx, sy = view_scale(radius, width, height) + ndc_x = viewed[0] * sx + # 着色器写的是 ``p.z * u_sy``。OpenGL 的 y 朝上,这里再换成 Qt 的像素 y。 + ndc_y = viewed[2] * sy + width = max(int(width), 1) + height = max(int(height), 1) + return (ndc_x * 0.5 + 0.5) * width, (1.0 - (ndc_y * 0.5 + 0.5)) * height + + +def ground_grid(extent: float = 1.0, step: float = 0.25) -> list[tuple[tuple[float, float, float], tuple[float, float, float]]]: + """地平面格子,z = 0,单位米。以前这张格子在 RViz 里,现在画在工位仿真上。""" + if step <= 0 or extent <= 0: + raise ValueError("地平面格子的间距和范围都要大于 0") + count = int(round(extent / step)) + lines = [] + for index in range(-count, count + 1): + coord = index * step + lines.append(((-extent, coord, 0.0), (extent, coord, 0.0))) + lines.append(((coord, -extent, 0.0), (coord, extent, 0.0))) + return lines + + +def axis_tip( + origin: tuple[float, float, float], + rot: tuple, + axis: int, + length: float, +) -> tuple[float, float, float]: + """坐标系某一轴的端点。``rot`` 把连杆坐标变到基座坐标,列向量就是三根轴。""" + basis = ((1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0))[axis] + direction = _apply(rot, basis) + return ( + origin[0] + direction[0] * length, + origin[1] + direction[1] * length, + origin[2] + direction[2] * length, + ) + + +def _place(rot, origin, erot, epos, vertex): + local = _add(epos, _apply(erot, vertex)) + return _add(origin, _apply(rot, local)) + + +def _shade(a, b, c) -> float: + ux, uy, uz = b[0] - a[0], b[1] - a[1], b[2] - a[2] + vx, vy, vz = c[0] - a[0], c[1] - a[1], c[2] - a[2] + nx = uy * vz - uz * vy + ny = uz * vx - ux * vz + nz = ux * vy - uy * vx + norm = math.sqrt(nx * nx + ny * ny + nz * nz) or 1.0 + facing = abs(nx + ny) / (norm * math.sqrt(2.0)) + return 0.28 + 0.72 * facing + + +def _models() -> dict[str, list]: + global _MODELS + if _MODELS is not None: + return _MODELS + root = _description_root() + visual = yaml.safe_load((root / "config/Rizon4/visual_parameters.yaml").read_text(encoding="utf-8")) + params = (visual or {}).get("visual_parameters") or {} + loaded: dict[str, list] = {} + for link, spec in params.items(): + shell = ((spec or {}).get("visual") or {}) + parts = [] + for kind in ("shell", "ring"): + item = shell.get(kind) + if not isinstance(item, dict): + continue + mesh = item.get("mesh") or {} + path = root / str(mesh.get("path") or "") + if not path.is_file(): + continue + scale = _scale(mesh.get("scale")) + origin = item.get("origin") or {} + erot = _rpy( + float(origin.get("roll") or 0.0), + float(origin.get("pitch") or 0.0), + float(origin.get("yaw") or 0.0), + ) + epos = ( + float(origin.get("x") or 0.0), + float(origin.get("y") or 0.0), + float(origin.get("z") or 0.0), + ) + parts.append(_part_arrays(_load_obj(path, scale, _materials(path)), erot, epos)) + if parts: + loaded[str(link)] = parts + if "base_link" not in loaded or "link7" not in loaded: + raise FileNotFoundError(f"Rizon4 外壳网格不完整:{root}") + _MODELS = loaded + return loaded + + +def _part_arrays(faces: list[tuple], erot, epos) -> dict: + """把一个零件收成数组,姿态更新时整块相乘。""" + if not faces: + verts = np.zeros((0, 3, 3), dtype=np.float64) + rgb = np.zeros((0, 3), dtype=np.float64) + else: + verts = np.asarray([[a, b, c] for a, b, c, _color in faces], dtype=np.float64) + rgb = np.asarray([color for _a, _b, _c, color in faces], dtype=np.float64) + return { + "verts": verts, + "rgb": rgb, + "erot": np.asarray(erot, dtype=np.float64), + "epos": np.asarray(epos, dtype=np.float64), + } + + +def _description_root() -> Path: + root = ros2_root() + candidates = ( + root / "src/flexiv_description", + root / "install/flexiv_description/share/flexiv_description", + ) + for item in candidates: + if (item / "config/Rizon4/visual_parameters.yaml").is_file(): + return item + raise FileNotFoundError("找不到 flexiv_description 的 Rizon4 外观") + + +def _materials(path: Path) -> dict[str, tuple[float, float, float]]: + mtl = path.with_suffix(".mtl") + colors: dict[str, tuple[float, float, float]] = {} + name = "" + if not mtl.is_file(): + return colors + for line in mtl.read_text(encoding="utf-8", errors="replace").splitlines(): + if line.startswith("newmtl "): + name = line.split(None, 1)[1].strip() + elif name and line.startswith("Kd "): + parts = line.split() + if len(parts) >= 4: + colors[name] = (float(parts[1]), float(parts[2]), float(parts[3])) + return colors + + +def _load_obj(path: Path, scale: tuple[float, float, float], materials: dict) -> list[tuple]: + fallback = (0.75, 0.76, 0.78) + rgb = fallback + vertices: list[tuple[float, float, float]] = [] + faces: list[tuple[int, int, int, tuple]] = [] + for line in path.read_text(encoding="utf-8", errors="replace").splitlines(): + if line.startswith("usemtl "): + rgb = materials.get(line.split(None, 1)[1].strip(), fallback) + elif line.startswith("v "): + _v, x, y, z, *_rest = line.split() + vertices.append((float(x) * scale[0], float(y) * scale[1], float(z) * scale[2])) + elif line.startswith("f "): + idx = [] + for token in line.split()[1:]: + raw = token.split("/")[0] + if not raw: + continue + number = int(raw) + idx.append(number - 1 if number > 0 else number) + for offset in range(1, len(idx) - 1): + faces.append((idx[0], idx[offset], idx[offset + 1], rgb)) + if not faces or not vertices: + return [] + step = max(1, len(faces) // _FACES_PER_LINK) + chosen = faces[::step] + out = [] + count = len(vertices) + for ia, ib, ic, color in chosen: + if not (0 <= ia < count and 0 <= ib < count and 0 <= ic < count): + continue + out.append((vertices[ia], vertices[ib], vertices[ic], color)) + return out + + +def _scale(raw) -> tuple[float, float, float]: + if raw is None: + return (1.0, 1.0, 1.0) + if isinstance(raw, (int, float)): + value = float(raw) + return (value, value, value) + text = str(raw).replace(",", " ").split() + nums = [float(item) for item in text[:3]] + while len(nums) < 3: + nums.append(nums[-1] if nums else 1.0) + return (nums[0], nums[1], nums[2]) + + +def _rpy(roll: float, pitch: float, yaw: float): + return _mul(_mul(_rz(yaw), _ry(pitch)), _rx(roll)) + + +def _rx(angle: float): + c, s = math.cos(angle), math.sin(angle) + return ((1.0, 0.0, 0.0), (0.0, c, -s), (0.0, s, c)) + + +def _ry(angle: float): + c, s = math.cos(angle), math.sin(angle) + return ((c, 0.0, s), (0.0, 1.0, 0.0), (-s, 0.0, c)) + + +def _rz(angle: float): + c, s = math.cos(angle), math.sin(angle) + return ((c, -s, 0.0), (s, c, 0.0), (0.0, 0.0, 1.0)) + + +def _mul(a, b): + return tuple(tuple(sum(a[i][k] * b[k][j] for k in range(3)) for j in range(3)) for i in range(3)) + + +def _apply(rot, vec): + return tuple(sum(rot[i][k] * vec[k] for k in range(3)) for i in range(3)) + + +def _add(a, b): + return (a[0] + b[0], a[1] + b[1], a[2] + b[2]) diff --git a/install/arm_application/lib/python3.11/site-packages/arm_application/operator_arm_state.py b/install/arm_application/lib/python3.11/site-packages/arm_application/operator_arm_state.py new file mode 100644 index 0000000..d91bd37 --- /dev/null +++ b/install/arm_application/lib/python3.11/site-packages/arm_application/operator_arm_state.py @@ -0,0 +1,595 @@ +"""工位界面用的机械臂状态:只读使能 / Remote,并用关节角画出姿态。 + +不会调用 Enable,也不会在控制栈或拖拽桥已经占着 RDK 时再连一次。 +``robot.mode()`` 看不出 Manual,必须看 ``operational_status``。 +示教器上的 Auto(Remote)没有远程切换接口,这里只负责把这句话摆在界面上。 +""" + +from __future__ import annotations + +import json +import math +import os +import re +import subprocess +import threading +import time +from pathlib import Path +from typing import Any + +import yaml + +from arm_application.site_io import listed_arms, ros2_root + +# 和 scripts/lib/rdk_status.py 的 MANUAL_HINT 同一件事,界面上始终可见。 +REMOTE_PROMPT = ( + "请在示教器上打开 Auto(Remote)。" + "RDK 没有远程拨这个开关的接口,必须人到现场拨。" +) + +_AXES = ( + (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), + (0.0, 1.0, 0.0), + (0.0, 0.0, 1.0), +) +_JOINTS = tuple(f"joint{i}" for i in range(1, 8)) +_LOCK = threading.Lock() +_CACHE: dict[str, dict] = {} +_STOP = False +_STARTED = False +_HOLD = 0 +_HOLD_LOCK = threading.Lock() + + +def _empty(arm: str) -> dict: + return { + "arm": arm, + "level": "warn", + "enabled": False, + "summary": "还没读到这台臂的状态", + "prompt": REMOTE_PROMPT, + "prompt_hot": True, + "mode": "", + "op_status": "", + "fault": None, + "estop_released": None, + "q": None, + "points": [], + "source": "", + "stack_up": False, + "stamp": 0.0, + } + + +def snapshot(arm: str) -> dict: + with _LOCK: + row = _CACHE.get(arm) + return dict(row) if row else _empty(arm) + + +def present( + arm: str, + *, + stack_up: bool, + bridge_up: bool, + probe: dict | None, + joints: list[float] | None, + error: str = "", +) -> dict: + """把一次读数收成界面直接能画的字典。纯函数,不连机器人。""" + probe = probe or {} + q = _seven(joints) or _seven(probe.get("q")) + op_status = str(probe.get("op_status") or "") + mode = str(probe.get("mode") or "") + manual = "MANUAL" in op_status.upper() + if stack_up and q is not None: + enabled = True + level = "ok" + summary = "控制栈在发关节角,这台臂已经接上。" + if probe.get("_shared_stack"): + summary = "控制栈在发关节角,但看不出是 89 还是 91,两台显示的是同一份。" + source = "控制栈 /joint_states" + elif stack_up: + enabled = False + level = "fail" + summary = "控制栈在跑,但没有关节数据。通常是还没使能,一直在等 operational。" + source = "控制栈" + elif bridge_up and not probe: + enabled = False + level = "warn" + summary = "软拖拽桥占着 RDK,界面不再另开一条连接。" + source = "拖拽桥" + q = None + elif probe.get("connected"): + enabled = bool(probe.get("operational")) + level = str(probe.get("level") or ("ok" if enabled else "fail")) + summary = str(probe.get("summary") or ("已使能" if enabled else "未使能")) + source = "RDK 直读(未使能,只查询)" + else: + enabled = False + level = "fail" if error else "warn" + summary = error or str(probe.get("summary") or probe.get("error") or "还没连上 RDK") + source = "RDK 直读" if probe else "" + hot = (not enabled) or manual + return { + "arm": arm, + "level": level, + "enabled": enabled, + "summary": summary, + "prompt": REMOTE_PROMPT, + "prompt_hot": hot, + "mode": mode, + "op_status": op_status, + "fault": probe.get("fault") if probe else None, + "estop_released": probe.get("estop_released") if probe else None, + "q": q, + "points": fk_points(q) if q else fk_points([0.0] * 7), + "pose_live": q is not None, + "source": source, + "stack_up": stack_up, + "stamp": time.time(), + } + + +def link_frames(q: list[float]) -> dict[str, tuple]: + """各连杆坐标系,和 ``rizon_arm.xacro`` 的关节顺序一致。 + + 返回 ``base_link`` 与 ``link1``…``link7``。每个值是 ``(旋转, 原点)``, + 旋转把连杆坐标变到基座坐标。厂家网格就画在这些坐标系里。 + """ + kin = _kinematics() + rot = ((1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0)) + pos = (0.0, 0.0, 0.0) + frames: dict[str, tuple] = {"base_link": (rot, pos)} + for index, angle in enumerate(q[:7]): + spec = kin[_JOINTS[index]] + pos = _add(pos, _apply(rot, (spec["x"], spec["y"], spec["z"]))) + rot = _mul(rot, _rpy(spec["roll"], spec["pitch"], spec["yaw"])) + rot = _mul(rot, _axis_rot(_AXES[index], float(angle))) + frames[f"link{index + 1}"] = (rot, pos) + return frames + + +def fk_points(q: list[float]) -> list[tuple[float, float, float]]: + """按 Rizon4 的 default_kinematics 做正运动学,返回基座到法兰的点(米)。 + + 关节轴与 ``rizon_arm.xacro`` 一致:1/3/5/7 绕 z,2/4/6 绕 y。 + URDF 顺序是先平移(父坐标系),再 rpy,再绕关节轴转 q。 + """ + frames = link_frames(q) + points = [frames["base_link"][1]] + points.extend(frames[f"link{index}"][1] for index in range(1, 8)) + _rot, pos = _flange_pose(frames) + points.append(pos) + return points + + +def display_frames(q: list[float], workpiece: tuple | None = None) -> list[dict]: + """界面要画的坐标系,都在基座系里。 + + 第一套是基座。第二套默认是法兰(旋转与 ``link7`` 相同,原点再加 + ``link7_to_flange``)。传入工件系时,第二套改画在工件上,不再画法兰。 + """ + frames = link_frames(q) + base_rot, base_pos = frames["base_link"] + shown = [{"name": "基座", "rot": base_rot, "pos": base_pos}] + if workpiece is None: + rot, pos = _flange_pose(frames) + shown.append({"name": "法兰", "rot": rot, "pos": pos}) + return shown + rot, pos = workpiece + if not isinstance(rot, tuple) or len(rot) != 3 or not isinstance(pos, tuple) or len(pos) != 3: + raise ValueError("工件坐标系要有旋转和原点") + shown.append({"name": "工件", "rot": rot, "pos": pos}) + return shown + + +def workpiece_frame(robot: dict | None) -> tuple | None: + """有完整工件系时返回 ``(旋转, 原点)``。没有就返回 None。 + + ``workpiece_surface_z_m`` 只是上表面高度,没有原点的 XY,也没有朝向, + 不能当成坐标系。配了 ``gauge_assembly.workpiece_frame`` 却缺 ``xyz`` 或 + ``rpy`` 时直接报错,不用单位阵把缺的朝向补上。 + ``xyz`` 单位米,``rpy`` 单位弧度,都写在基座系,旋转顺序与连杆 ``rpy`` 相同。 + """ + if not isinstance(robot, dict): + return None + gauge = robot.get("gauge_assembly") + if not isinstance(gauge, dict) or "workpiece_frame" not in gauge: + return None + raw = gauge.get("workpiece_frame") + if not isinstance(raw, dict): + raise ValueError("工件坐标系要写成 xyz 和 rpy,各三个数") + xyz = raw.get("xyz") + rpy = raw.get("rpy") + if not _three_floats(xyz) or not _three_floats(rpy): + raise ValueError("工件坐标系要有 xyz 和 rpy,各三个数,单位是米和弧度,写在基座系里") + return _rpy(float(rpy[0]), float(rpy[1]), float(rpy[2])), ( + float(xyz[0]), float(xyz[1]), float(xyz[2]), + ) + + +def _flange_pose(frames: dict) -> tuple: + """法兰位姿。旋转就是 ``link7`` 的旋转,平移来自 ``link7_to_flange``。""" + rot, pos = frames["link7"] + flange = _kinematics()["link7_to_flange"] + pos = _add(pos, _apply(rot, (float(flange["x"]), float(flange["y"]), float(flange["z"])))) + return rot, pos + + +def _three_floats(raw: Any) -> bool: + if not isinstance(raw, (list, tuple)) or len(raw) != 3: + return False + return all(isinstance(item, (int, float)) and not isinstance(item, bool) for item in raw) + + +def stack_running() -> bool: + return _stack_arm() is not None or _alive("ros2_control_node") + + +def control_holds_rdk() -> bool: + """有控制命令在用 RDK 时,状态轮询必须让开。""" + with _HOLD_LOCK: + return _HOLD > 0 + + +def release_for_control() -> None: + """停掉正在读状态的 RDK 子进程,把机器人交给控制命令。""" + with _HOLD_LOCK: + global _HOLD + _HOLD += 1 + _stop_status_probes() + deadline = time.time() + 3.0 + while time.time() < deadline and _status_probe_running(): + time.sleep(0.05) + + +def resume_after_control() -> None: + with _HOLD_LOCK: + global _HOLD + _HOLD = max(0, _HOLD - 1) + + +def hold_until_idle(pid: int, *, until_stack: bool = False) -> None: + """后台脚本还在时继续占着 RDK。进程退出,或控制栈已经起来,就放开。""" + def _watch() -> None: + while True: + if until_stack and stack_running(): + break + if not _pid_alive(pid): + break + time.sleep(0.3) + resume_after_control() + + threading.Thread(target=_watch, daemon=True, name="arm-rdk-hold").start() + + +def motion_blocked(action: str, *, stack_up: bool, op_status: str) -> str: + """控制栈没起来时,动臂按钮给一句能执行的原因,而不是把脚本挂在那儿。""" + needs_stack = { + "pipeline", "stage_track", "stage_locate", "stage_screw", + "reset_home", "gauge_run", "handeye_calibrate", + } + if action not in needs_stack or stack_up: + return "" + manual = "MANUAL" in str(op_status or "").upper() + tail = "示教器现在是 Manual,要先拨到 Auto(Remote)。" if manual else "" + return f"控制栈没起来,这个动作动不了臂。{tail}请先在操作页执行「上电控制栈」。" + + +def start() -> None: + global _STARTED + with _LOCK: + if _STARTED: + return + _STARTED = True + threading.Thread(target=_loop, daemon=True, name="arm-state").start() + + +def retain_pose(fresh: dict, previous: dict | None, *, now: float | None = None, hold_s: float = 15.0) -> dict: + """这一拍没读到关节角时,短时间沿用上一拍,避免模型被打回零位。 + + 超过 ``hold_s`` 仍没有新读数就放开,界面回到“没连上”,不把过期姿态一直当成当前。 + """ + if fresh.get("q") is not None or not previous or previous.get("q") is None: + return fresh + moment = time.time() if now is None else now + if moment - float(previous.get("stamp") or 0.0) > hold_s: + return fresh + kept = dict(fresh) + kept["q"] = list(previous["q"]) + kept["points"] = list(previous.get("points") or []) + kept["pose_live"] = True + note = "关节角沿用上次读数。" + summary = str(kept.get("summary") or "") + if note not in summary: + kept["summary"] = f"{summary} {note}".strip() + return kept + + +def _loop() -> None: + while not _STOP: + if control_holds_rdk(): + time.sleep(0.2) + continue + try: + arms = _arms() + if not arms: + time.sleep(2.0) + continue + stack_arm = _stack_arm() + stack_up = stack_arm is not None or _alive("ros2_control_node") + bridge_up = _alive("rdk_drag_bridge.py") + joints = _read_joints() if stack_up else None + if stack_up: + targets = [row for row in arms if stack_arm is None or row["id"] == stack_arm] + if not targets: + targets = arms + shared = stack_arm is None + for row in targets: + fresh = present( + row["id"], + stack_up=True, + bridge_up=bridge_up, + probe={"_shared_stack": True} if shared else None, + joints=joints, + ) + _store(retain_pose(fresh, snapshot(row["id"]))) + else: + for row in arms: + probe: dict | None = None + error = "" + if control_holds_rdk(): + break + if not bridge_up: + probe, error = _probe(row["sn"], row["iface"], row["pc_ip"]) + fresh = present( + row["id"], + stack_up=False, + bridge_up=bridge_up, + probe=probe, + joints=None, + error=error, + ) + _store(retain_pose(fresh, snapshot(row["id"]))) + except Exception as exc: # noqa: BLE001 —— 读状态失败不能把界面线程打死 + for row in _arms(): + _store(present( + row["id"], stack_up=False, bridge_up=False, probe=None, joints=None, error=str(exc), + )) + time.sleep(4.0) + + +def _stop_status_probes() -> None: + try: + subprocess.run( + ["pkill", "-f", "scripts/lib/rdk_status.py"], + capture_output=True, text=True, timeout=2, + ) + except (OSError, subprocess.TimeoutExpired): + return + + +def _status_probe_running() -> bool: + return "rdk_status.py" in _pgrep("scripts/lib/rdk_status.py") + + +def _pid_alive(pid: int) -> bool: + if pid <= 0: + return False + try: + os.kill(pid, 0) + except OSError: + return False + return True + + +def _store(row: dict) -> None: + with _LOCK: + _CACHE[str(row.get("arm") or "")] = row + + +def _arms() -> list[dict]: + path = ros2_root() / "config" / "site.yaml" + if not path.is_file(): + return [] + data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + return [ + {"id": row["id"], "sn": row["sn"], "iface": row["iface"], "pc_ip": row["pc_ip"]} + for row in listed_arms(data) + ] + + +def _stack_arm() -> str | None: + text = _pgrep("run_real.sh") + ids = {row["id"] for row in _arms()} + for match in re.finditer(r"--arm(?:=|\s+)(\S+)", text): + token = match.group(1) + if token in ids: + return token + return None + + +def _alive(pattern: str) -> bool: + return bool(_pgrep(pattern).strip()) + + +def _pgrep(pattern: str) -> str: + try: + proc = subprocess.run( + ["pgrep", "-af", pattern], + capture_output=True, text=True, timeout=2, + ) + except (OSError, subprocess.TimeoutExpired): + return "" + lines = [] + for line in proc.stdout.splitlines(): + if "pgrep" in line: + continue + lines.append(line) + return "\n".join(lines) + + +def _read_joints() -> list[float] | None: + try: + proc = subprocess.run( + ["timeout", "4", "ros2", "topic", "echo", "/joint_states", "--once"], + capture_output=True, text=True, timeout=6, + ) + except (OSError, subprocess.TimeoutExpired): + return None + return _parse_joint_echo(proc.stdout) + + +def _parse_joint_echo(text: str) -> list[float] | None: + for chunk in text.split("---"): + if "position:" not in chunk: + continue + try: + data = yaml.safe_load(chunk) + except yaml.YAMLError: + continue + if not isinstance(data, dict): + continue + names = list(data.get("name") or []) + pos = list(data.get("position") or []) + if names and len(names) == len(pos): + by = {str(name): float(value) for name, value in zip(names, pos)} + q = [] + for joint in _JOINTS: + key = next((name for name in by if name == joint or name.endswith(joint)), None) + if key is None: + q = [] + break + q.append(by[key]) + if len(q) == 7: + return q + if len(pos) >= 7: + try: + return [float(v) for v in pos[:7]] + except (TypeError, ValueError): + return None + return None + + +def _probe(sn: str, iface: str, pc_ip: str = "") -> tuple[dict | None, str]: + python = os.environ.get("RDK_PYTHON", "").strip() or str(Path.home() / "rdk_py310" / "bin" / "python") + script = ros2_root() / "scripts" / "lib" / "rdk_status.py" + if not Path(python).is_file() or not script.is_file(): + return None, "找不到 RDK 解释器或 rdk_status.py,无法读使能状态" + cmd = _probe_cmd(python, script, sn, iface, pc_ip) + try: + proc = subprocess.run(cmd, capture_output=True, text=True, timeout=8) + except subprocess.TimeoutExpired: + return None, "读 RDK 超时。若控制栈没开,检查网线,并确认示教器已开 Remote。" + except OSError as exc: + return None, f"读 RDK 失败:{exc}" + payload = _json_object(proc.stdout) or _json_object(proc.stderr) + if not payload: + tail = (proc.stdout + proc.stderr).strip()[-240:] + return None, tail or "RDK 没有返回状态" + return payload, "" + + +def _probe_cmd(python: str, script: Path, sn: str, iface: str, pc_ip: str) -> list[str]: + """RDK 白名单要本机 IPv4。只传网卡名 ``eth0`` 时,内核改名叫 enx… 就永远连不上。""" + cmd = [python, str(script), "--sn", sn, "--json"] + if iface: + cmd += ["--iface", iface] + if pc_ip: + cmd += ["--pc-ip", pc_ip] + return cmd + + +def _json_object(text: str) -> dict: + decoder = json.JSONDecoder() + for index, char in enumerate(text): + if char != "{": + continue + try: + data, _end = decoder.raw_decode(text[index:]) + except json.JSONDecodeError: + continue + if isinstance(data, dict) and "connected" in data: + return data + return {} + + +_KIN: dict[str, dict[str, float]] | None = None + + +def _kinematics() -> dict[str, dict[str, float]]: + global _KIN + if _KIN is not None: + return _KIN + root = ros2_root() + candidates = ( + root / "src/flexiv_description/config/Rizon4/default_kinematics.yaml", + root / "install/flexiv_description/share/flexiv_description/config/Rizon4/default_kinematics.yaml", + ) + path = next((item for item in candidates if item.is_file()), None) + if path is None: + raise FileNotFoundError("找不到 Rizon4 default_kinematics.yaml") + data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + kin = data.get("kinematics") or {} + _KIN = kin + return kin + + +def _rpy(roll: float, pitch: float, yaw: float) -> tuple: + return _mul(_mul(_rz(yaw), _ry(pitch)), _rx(roll)) + + +def _rx(angle: float) -> tuple: + c, s = math.cos(angle), math.sin(angle) + return ((1.0, 0.0, 0.0), (0.0, c, -s), (0.0, s, c)) + + +def _ry(angle: float) -> tuple: + c, s = math.cos(angle), math.sin(angle) + return ((c, 0.0, s), (0.0, 1.0, 0.0), (-s, 0.0, c)) + + +def _rz(angle: float) -> tuple: + c, s = math.cos(angle), math.sin(angle) + return ((c, -s, 0.0), (s, c, 0.0), (0.0, 0.0, 1.0)) + + +def _axis_rot(axis: tuple[float, float, float], angle: float) -> tuple: + x, y, z = axis + if abs(z) > 0.5: + return _rz(angle if z > 0 else -angle) + if abs(y) > 0.5: + return _ry(angle if y > 0 else -angle) + return _rx(angle if x > 0 else -angle) + + +def _mul(a: tuple, b: tuple) -> tuple: + return tuple( + tuple(sum(a[i][k] * b[k][j] for k in range(3)) for j in range(3)) + for i in range(3) + ) + + +def _apply(rot: tuple, vec: tuple[float, float, float]) -> tuple[float, float, float]: + return tuple(sum(rot[i][k] * vec[k] for k in range(3)) for i in range(3)) # type: ignore[return-value] + + +def _add(a: tuple[float, float, float], b: tuple[float, float, float]) -> tuple[float, float, float]: + return (a[0] + b[0], a[1] + b[1], a[2] + b[2]) + + +def _seven(raw: Any) -> list[float] | None: + if not isinstance(raw, list) or len(raw) < 7: + return None + try: + q = [float(v) for v in raw[:7]] + except (TypeError, ValueError): + return None + if any(v != v or abs(v) > 12.6 for v in q): + return None + return q diff --git a/install/arm_application/lib/python3.11/site-packages/arm_application/operator_camera_view.py b/install/arm_application/lib/python3.11/site-packages/arm_application/operator_camera_view.py index da4744e..217a4ed 100644 --- a/install/arm_application/lib/python3.11/site-packages/arm_application/operator_camera_view.py +++ b/install/arm_application/lib/python3.11/site-packages/arm_application/operator_camera_view.py @@ -1,6 +1,12 @@ """工位程序里的相机预览。 只订彩色和已对齐深度,不订左右红外。画面留在本进程,不另开查看器窗口。 + +回调里先丢掉当前页没在看的流。彩色按相机原分辨率收进来,不按时钟抽帧, +也不在收图的线程里做颜色转换。另一条线只画最新的一张,所以帧率跟相机走, +848 和 1280 都不会为了帧率被缩成 480。深度是伪彩色,仍然抽稀, +而且没打开深度时不订阅,避免大图挤占彩色。 +手眼页要 JPEG 时也按原分辨率编码,只编正在看的那一路。 """ from __future__ import annotations @@ -11,33 +17,121 @@ import time import numpy as np +# 彩色上限。D405 是 848×480,D435 是 1280×720,都不超过它,所以不会被缩小。 +_COLOR_WIDTH = 1280 +# 深度上色不是要看的画质。全幅每帧上色会把刷新占满,所以这一路仍抽稀。 +_DEPTH_WIDTH = 480 +# 手眼只在正在看的那一页编码,宽度跟彩色预览一样,不另缩一档。 +_JPEG_WIDTH = 1280 +# 只留给还在用 ``accept_frame`` 的调用。彩色实况不再用它抽帧: +# 相机大约 30 帧,再按这个间隔丢一次,画面会掉到十几帧。 +_INTERVAL = 1.0 / 30.0 +_DET_HOLD_S = 1.5 + _LOCK = threading.Lock() _STOP = threading.Event() _THREAD: threading.Thread | None = None _JPEG: dict[str, bytes] = {} +_PREVIEW: dict[str, tuple[bytes, int, int]] = {} +_PREVIEW_WANT: set[str] = set() +_JPEG_WANT: set[str] = set() +_NEXT: dict[str, float] = {} +_DET: dict[str, tuple[float, list[float]]] = {} +# 没画框的预览。检测一到就在这张上重画,不必等下一帧相机。 +_CLEAN: dict[str, dict] = {} +# 每路最近一秒的到达时刻,用来算帧率。值是 (宽, 高, 帧/秒),宽高是相机原图。 +_META: dict[str, tuple[int, int, float]] = {} +_STAMPS: dict[str, list[float]] = {} +# 每路最新原图。收图线程只往这里放,画图线程只取最新的一张。 +_RAW: dict[str, dict] = {} +_SLOTS: dict[str, list] = {} +_SLOT_AT: dict[str, int] = {} +_PAINTED: dict[str, int] = {} +_WAKE = threading.Event() +_PAINT_THREAD: threading.Thread | None = None _STATE = {"on": False, "error": "", "frames": {}, "stamp": 0.0} -def targets() -> list[dict]: - """真机腕部相机和手外相机。仿真相机不进预览。""" - from arm_application.operator_console import _arms, _site - from arm_application.site_io import overhead_camera +def preview_targets(site: dict) -> list[dict]: + """主界面只要左臂腕部相机和共用的手外相机。 + + 右臂腕部不进这一屏。仿真相机名字以 ``sim`` 开头,也不进。 + 推理框的角色仍是 ``wrist`` / ``overhead``,跟话题对得上。 + """ + from arm_application.site_io import listed_arms, overhead_camera found: list[dict] = [] seen: set[str] = set() - for arm in _arms(): - name = str(arm.get("camera") or "").strip().strip("/") - if not name or name.startswith("sim") or name in seen: - continue + left = next((row for row in listed_arms(site) if row.get("side") == "left"), None) + name = str((left or {}).get("camera") or "").strip().strip("/") + if name and not name.startswith("sim"): + cams = site.get("cameras") if isinstance(site.get("cameras"), dict) else {} + cfg = cams.get(name) if isinstance(cams, dict) and isinstance(cams.get(name), dict) else {} + tag = str((cfg or {}).get("model") or "D405").strip().upper() or "D405" seen.add(name) - found.append({"id": name, "label": f"{arm.get('label') or name} 腕部"}) - cam, _others = overhead_camera(_site(), None) - name = str((cam or {}).get("name") or "").strip().strip("/") - if name and not name.startswith("sim") and name not in seen: - found.append({"id": name, "label": str((cam or {}).get("label") or "手外相机")}) + found.append({"id": name, "label": f"左腕 {tag}", "role": "wrist"}) + cam, _others = overhead_camera(site or {}, None) + oname = str((cam or {}).get("name") or "").strip().strip("/") + if oname and not oname.startswith("sim") and oname not in seen: + tag = str((cam or {}).get("model") or "D435").strip().upper() or "D435" + found.append({"id": oname, "label": f"手外 {tag}", "role": "overhead"}) return found +def sim_preview_targets(site: dict) -> list[dict]: + """仿真腕部和仿真手外。画面进主界面,不另开 Gazebo 的相机窗。""" + from arm_application.site_io import overhead_camera + + found: list[dict] = [] + robots = ((site or {}).get("robot") or {}).get("robots") or [] + cams = site.get("cameras") if isinstance(site.get("cameras"), dict) else {} + for robot in robots: + if not isinstance(robot, dict): + continue + if str(robot.get("sn") or "").strip().lower() != "sim": + continue + cam = robot.get("camera") if isinstance(robot.get("camera"), dict) else {} + name = str((cam or {}).get("name") or "").strip().strip("/") + if not name: + continue + cfg = cams.get(name) if isinstance(cams, dict) and isinstance(cams.get(name), dict) else {} + tag = str((cfg or {}).get("model") or "D405").strip().upper() or "D405" + found.append({"id": name, "label": f"仿真左腕 {tag}", "role": "wrist", "source": "sim"}) + break + overhead, _others = overhead_camera(site or {}, {"arm": "sim"}) + oname = str((overhead or {}).get("name") or "").strip().strip("/") + if oname.startswith("sim"): + tag = str((overhead or {}).get("model") or "D435").strip().upper() or "D435" + found.append({"id": oname, "label": f"仿真手外 {tag}", "role": "overhead", "source": "sim"}) + return found + + +def stream_targets(site: dict | None = None) -> list[dict]: + """真机和仿真都订上。主界面切换看哪一组,没在看的那组回调里直接丢掉。""" + if site is None: + from arm_application.operator_console import _site + + site = _site() + real = preview_targets(site) + for item in real: + item["source"] = "real" + seen = {item["id"] for item in real} + return real + [item for item in sim_preview_targets(site) if item["id"] not in seen] + + +def targets() -> list[dict]: + """当前站点的主界面真机相机。规则在 ``preview_targets``。""" + from arm_application.operator_console import _site + + return preview_targets(_site()) + + +def sim_targets() -> list[dict]: + from arm_application.operator_console import _site + + return sim_preview_targets(_site()) + + def state() -> dict: with _LOCK: return { @@ -53,141 +147,492 @@ def frame(name: str, kind: str) -> bytes | None: return _JPEG.get(f"{name}_{kind}") +def previews() -> dict[str, tuple[bytes, int, int]]: + """当前预览。值是 ``(rgb888, 宽, 高)``,同一帧反复取到的是同一块字节。""" + with _LOCK: + return dict(_PREVIEW) + + +def preview_meta() -> dict[str, tuple[int, int, float]]: + """每路相机原图的宽、高,以及最近一秒的帧率。还没来帧就是空。""" + with _LOCK: + return dict(_META) + + +def _mark_rate(key: str, width: int, height: int) -> None: + """记下这一帧的原图尺寸,并用最近一秒的到达间隔算帧率。""" + now = time.monotonic() + with _LOCK: + stamps = _STAMPS.setdefault(key, []) + stamps.append(now) + cut = now - 1.0 + while len(stamps) > 1 and stamps[0] < cut: + stamps.pop(0) + if len(stamps) >= 2 and stamps[-1] > stamps[0]: + fps = (len(stamps) - 1) / (stamps[-1] - stamps[0]) + else: + fps = 0.0 + _META[key] = (int(width), int(height), float(fps)) + + +def watch(preview: set[str], jpeg: set[str] | None = None) -> None: + """当前页要哪几路。没点名的流在回调里直接丢掉,不拷图像。 + + ``preview`` 走原分辨率 RGB,``jpeg`` 走手眼用的 JPEG。两边都空着就只保持订阅。 + """ + global _PREVIEW_WANT, _JPEG_WANT + jpeg = set(jpeg or ()) + preview = set(preview) + with _LOCK: + if preview == _PREVIEW_WANT and jpeg == _JPEG_WANT: + return + _PREVIEW_WANT = preview + _JPEG_WANT = jpeg + for key in preview | jpeg: + _NEXT.pop(key, None) + + +def wants(key: str) -> tuple[bool, bool]: + """返回 ``(要预览, 要 JPEG)``。""" + with _LOCK: + return key in _PREVIEW_WANT, key in _JPEG_WANT + + +def offer_color(key: str, image: np.ndarray, *, bgr: bool, role: str) -> None: + """收一帧彩色原图。没人看就丢掉。有人看就整幅留下,不按时钟抽帧。""" + preview, jpeg = wants(key) + if not preview and not jpeg: + return + if image.ndim != 3 or image.shape[2] != 3 or image.shape[0] < 1 or image.shape[1] < 1: + return + _offer(key, image, bgr=bgr, role=role, kind="color") + + +def offer_depth(key: str, image: np.ndarray) -> None: + """收一帧深度原图。伪彩色仍在画图线程里做,不堵收图。""" + preview, _jpeg = wants(key) + if not preview: + return + if image.ndim != 2 or image.shape[0] < 1 or image.shape[1] < 1: + return + _offer(key, image, bgr=False, role="", kind="depth") + + +def _offer(key: str, image: np.ndarray, *, bgr: bool, role: str, kind: str) -> None: + height, width = int(image.shape[0]), int(image.shape[1]) + with _LOCK: + slots = _SLOTS.setdefault(key, [None, None]) + index = _SLOT_AT.get(key, 0) + slot = slots[index] + if slot is None or slot.shape != image.shape or slot.dtype != image.dtype: + slot = np.empty(image.shape, dtype=image.dtype) + slots[index] = slot + np.copyto(slot, image) + seq = int(_RAW.get(key, {}).get("seq", 0)) + 1 + _RAW[key] = { + "seq": seq, + "frame": slot, + "bgr": bool(bgr), + "role": role, + "kind": kind, + "w": width, + "h": height, + } + _SLOT_AT[key] = 1 - index + _mark_rate(key, width, height) + _WAKE.set() + + +def publish_latest() -> None: + """把各路还没画的最新原图做成预览。中间帧直接跳过,避免越画越落后。""" + with _LOCK: + jobs = [] + for key, item in _RAW.items(): + if _PAINTED.get(key) == item["seq"]: + continue + jobs.append(( + key, + int(item["seq"]), + np.array(item["frame"], copy=True), + bool(item["bgr"]), + str(item["role"]), + str(item["kind"]), + int(item["w"]), + int(item["h"]), + )) + for key, seq, frame, bgr, role, kind, width, height in jobs: + try: + preview, jpeg = wants(key) + if kind == "depth": + if preview: + colored = depth_preview(frame) + _store(key, colored, preview=colored, jpeg=False) + elif preview or jpeg: + limit = _JPEG_WIDTH if jpeg else _COLOR_WIDTH + rgb = rgb_preview(frame, bgr=bgr, limit=limit) + shown = rgb + if preview and int(rgb.shape[1]) > _COLOR_WIDTH: + shown = rgb_preview(rgb, bgr=False, limit=_COLOR_WIDTH) + if preview: + _remember_clean(key, shown, role, width, height) + shown = _overlay_detection(shown, role, width, height) + _store(key, rgb, preview=shown if preview else None, jpeg=jpeg) + except Exception as exc: # noqa: BLE001 —— 坏的一帧留在状态里,下一帧继续 + with _LOCK: + _STATE["error"] = str(exc) + finally: + with _LOCK: + if _PAINTED.get(key, 0) < seq: + _PAINTED[key] = seq + + +def _paint_loop() -> None: + while not _STOP.is_set(): + _WAKE.wait(0.005) + _WAKE.clear() + if _STOP.is_set(): + return + try: + publish_latest() + except Exception as exc: # noqa: BLE001 —— 画图线程不能因为一帧坏掉就退出 + with _LOCK: + _STATE["error"] = str(exc) + + +def accept_frame(key: str) -> bool: + """这一帧要不要做进预览。没人看,或者距上一帧不到间隔,都返回 False。 + + 彩色实况不再走这里。固定间隔会把相机自己的帧率再砍掉一截。 + """ + now = time.monotonic() + with _LOCK: + if key not in _PREVIEW_WANT and key not in _JPEG_WANT: + return False + if now < _NEXT.get(key, 0.0): + return False + _NEXT[key] = now + _INTERVAL + return True + + def _close_external_viewers() -> None: """关掉独立的四窗格查看器。它会把左右红外挂出来,本程序不再用它。""" subprocess.run(["pkill", "-f", "lib/arm_application/realsense_view"], check=False) subprocess.run(["pkill", "-f", "arm_application.realsense_view"], check=False) -def _jpeg(img: np.ndarray) -> bytes | None: +def paint_detection( + rgb: np.ndarray, + xyxy: list[float] | tuple, + src_width: int, + src_height: int, +) -> np.ndarray: + """把推理结果画到相机预览上:绿框、画面中心、跟踪红点、置信度和距离。 + + ``xyxy`` 前四项是原图像素的框。够长时第 5 项是置信度,第 7、8 项是跟踪点, + 第 9 项是距离(米)。空框原样返回。 + """ import cv2 - if img.size == 0: + if rgb.ndim != 3 or src_width <= 0 or src_height <= 0 or len(xyxy) < 4: + return rgb + scale_x = rgb.shape[1] / float(src_width) + scale_y = rgb.shape[0] / float(src_height) + x1 = int(round(float(xyxy[0]) * scale_x)) + y1 = int(round(float(xyxy[1]) * scale_y)) + x2 = int(round(float(xyxy[2]) * scale_x)) + y2 = int(round(float(xyxy[3]) * scale_y)) + height, width = rgb.shape[:2] + x1 = max(0, min(width - 1, x1)) + x2 = max(0, min(width - 1, x2)) + y1 = max(0, min(height - 1, y1)) + y2 = max(0, min(height - 1, y2)) + if x2 <= x1 or y2 <= y1: + return rgb + out = np.array(rgb, copy=True) + thick = max(2, width // 180) + cv2.rectangle(out, (x1, y1), (x2, y2), (80, 220, 120), thick) + cv2.drawMarker( + out, + (width // 2, height // 2), + (255, 220, 60), + cv2.MARKER_CROSS, + max(8, width // 24), + 1, + ) + if len(xyxy) >= 8: + px = int(round(float(xyxy[6]) * scale_x)) + py = int(round(float(xyxy[7]) * scale_y)) + px = max(0, min(width - 1, px)) + py = max(0, min(height - 1, py)) + cv2.circle(out, (px, py), max(3, width // 80), (255, 48, 48), -1) + if len(xyxy) >= 5: + conf = float(xyxy[4]) + dist = f" z={float(xyxy[8]):.3f}m" if len(xyxy) >= 9 else "" + cv2.putText( + out, + f"{conf:.2f}{dist}", + (x1, max(14, y1 - 6)), + cv2.FONT_HERSHEY_SIMPLEX, + 0.45, + (80, 220, 120), + 1, + cv2.LINE_AA, + ) + return out + + +def inference_status() -> str: + """腕部和手外最近一次推理框。超过保持时间就当没有。""" + now = time.monotonic() + with _LOCK: + items = dict(_DET) + parts = [] + for role, title in (("wrist", "腕部"), ("overhead", "手外")): + item = items.get(role) + if item is None or now - item[0] > _DET_HOLD_S or len(item[1]) < 5: + parts.append(f"{title} 没有框") + continue + parts.append(f"{title} 置信 {float(item[1][4]):.2f}") + return "推理 " + " ".join(parts) + + +def _remember_detection(role: str, data: list[float]) -> None: + with _LOCK: + if len(data) < 4: + _DET.pop(role, None) + else: + _DET[role] = (time.monotonic(), data) + _stamp_role(role) + + +def _remember_clean(key: str, rgb: np.ndarray, role: str, src_w: int, src_h: int) -> None: + with _LOCK: + _CLEAN[key] = { + "rgb": np.array(rgb, copy=True), + "role": role, + "src_w": int(src_w), + "src_h": int(src_h), + } + + +def _overlay_detection(rgb: np.ndarray, role: str, src_width: int, src_height: int) -> np.ndarray: + now = time.monotonic() + with _LOCK: + item = _DET.get(role) + if item is None or now - item[0] > _DET_HOLD_S or len(item[1]) < 4: + return rgb + return paint_detection(rgb, item[1], src_width, src_height) + + +def _stamp_role(role: str) -> None: + """检测更新后,把框画回这一路相机已经在显示的画面上。""" + now = time.monotonic() + with _LOCK: + frames = [(key, dict(meta)) for key, meta in _CLEAN.items() if meta.get("role") == role] + item = _DET.get(role) + fresh = item is not None and now - item[0] <= _DET_HOLD_S and len(item[1]) >= 4 + for key, meta in frames: + shown = meta["rgb"] + if fresh: + shown = paint_detection(shown, item[1], meta["src_w"], meta["src_h"]) + with _LOCK: + _PREVIEW[key] = (np.ascontiguousarray(shown).tobytes(), int(shown.shape[1]), int(shown.shape[0])) + _STATE["frames"][key] = True + _STATE["stamp"] = time.time() + + +def rgb_preview(image: np.ndarray, *, bgr: bool, limit: int = _COLOR_WIDTH) -> np.ndarray: + """缩成宽不超过 ``limit`` 的 RGB。返回的数组自己持有内存,不跟调用方共用缓冲。""" + import cv2 + + if image.ndim != 3 or image.shape[2] != 3 or image.shape[0] < 1 or image.shape[1] < 1: + raise ValueError("彩色预览要的是高×宽×3") + view = image + width = int(view.shape[1]) + if width > limit: + height = max(1, int(round(int(view.shape[0]) * limit / float(width)))) + view = cv2.resize(view, (limit, height), interpolation=cv2.INTER_AREA) + if bgr: + return cv2.cvtColor(view, cv2.COLOR_BGR2RGB) + if not view.flags["OWNDATA"] or not view.flags["C_CONTIGUOUS"]: + return np.ascontiguousarray(view) + return view + + +def depth_preview(depth: np.ndarray, limit: int = _DEPTH_WIDTH) -> np.ndarray: + """uint16 深度画成 RGB。百分位只在抽稀后的样本上算,无效像素保持黑色。""" + import cv2 + + if depth.ndim != 2 or depth.shape[0] < 1 or depth.shape[1] < 1: + raise ValueError("深度预览要的是高×宽") + step = max(1, int(depth.shape[1]) // limit) + small = depth[::step, ::step] + valid = small > 0 + gray = np.zeros(small.shape, np.uint8) + if bool(valid.any()): + sample = small[valid] + if sample.size > 8000: + sample = sample[:: max(1, sample.size // 8000)] + lo, hi = np.percentile(sample.astype(np.float32, copy=False), [5, 95]) + hi = max(float(hi), float(lo) + 1.0) + norm = np.clip((small.astype(np.float32) - float(lo)) / (hi - float(lo)), 0.0, 1.0) + gray = (norm * 255.0).astype(np.uint8) + gray[~valid] = 0 + colored = cv2.applyColorMap(gray, cv2.COLORMAP_JET) + colored[~valid] = 0 + return rgb_preview(colored, bgr=True, limit=limit) + + +def _jpeg_bgr(rgb: np.ndarray) -> bytes | None: + import cv2 + + if rgb.size == 0: return None - view = img - if view.shape[1] > 640: - scale = 640.0 / float(view.shape[1]) - view = cv2.resize(view, (640, max(1, int(view.shape[0] * scale)))) - ok, buf = cv2.imencode(".jpg", view, [int(cv2.IMWRITE_JPEG_QUALITY), 70]) + ok, buf = cv2.imencode( + ".jpg", + cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR), + [int(cv2.IMWRITE_JPEG_QUALITY), 70], + ) if not ok: return None return buf.tobytes() -def _depth_bgr(depth: np.ndarray) -> np.ndarray: - import cv2 - - vis = np.zeros((depth.shape[0], depth.shape[1], 3), np.uint8) - valid = depth > 0 - if not bool(valid.any()): - return vis - values = depth.astype(np.float32) - lo, hi = np.percentile(values[valid], [5, 95]) - hi = max(float(hi), float(lo) + 1.0) - gray = (np.clip((values - lo) / (hi - lo), 0.0, 1.0) * 255.0).astype(np.uint8) - gray[~valid] = 0 - colored = cv2.applyColorMap(gray, cv2.COLORMAP_JET) - colored[~valid] = 0 - return colored +def _as_array(data, dtype, count: int) -> np.ndarray: + """优先直接看消息缓冲。缓冲不支持时才拷一份。""" + try: + arr = np.frombuffer(data, dtype=dtype, count=count) + except (TypeError, BufferError, ValueError): + arr = np.frombuffer(bytes(data), dtype=dtype, count=count) + if arr.size < count: + raise ValueError("图像字节数不够") + return arr -def _color_array(msg) -> np.ndarray | None: +def _color_view(msg) -> np.ndarray | None: if msg.encoding not in ("bgr8", "rgb8") or msg.height <= 0 or msg.width <= 0: return None - arr = np.frombuffer(bytes(msg.data), dtype=np.uint8) need = int(msg.height) * int(msg.width) * 3 - if arr.size < need: - return None - arr = arr[:need].reshape(int(msg.height), int(msg.width), 3) - if msg.encoding == "rgb8": - arr = arr[:, :, ::-1] - return np.ascontiguousarray(arr) + arr = _as_array(msg.data, np.uint8, need) + return arr.reshape(int(msg.height), int(msg.width), 3) -def _depth_array(msg) -> np.ndarray | None: +def _depth_view(msg) -> np.ndarray | None: if msg.encoding not in ("16UC1", "mono16") or msg.height <= 0 or msg.width <= 0: return None - arr = np.frombuffer(bytes(msg.data), dtype=np.uint16) need = int(msg.height) * int(msg.width) - if arr.size < need: - return None - return np.ascontiguousarray(arr[:need].reshape(int(msg.height), int(msg.width))) + arr = _as_array(msg.data, np.uint16, need) + return arr.reshape(int(msg.height), int(msg.width)) + + +def _store(key: str, rgb: np.ndarray, *, preview: np.ndarray | None, jpeg: bool) -> None: + encoded = _jpeg_bgr(rgb) if jpeg else None + with _LOCK: + if preview is not None: + _PREVIEW[key] = (preview.tobytes(), int(preview.shape[1]), int(preview.shape[0])) + if encoded: + _JPEG[key] = encoded + if preview is not None or encoded: + _STATE["frames"][key] = True + _STATE["stamp"] = time.time() + _STATE["error"] = "" def _loop(cams: list[dict]) -> None: import rclpy from rclpy.node import Node - from rclpy.qos import qos_profile_sensor_data + from rclpy.qos import HistoryPolicy, QoSProfile, ReliabilityPolicy, qos_profile_sensor_data from sensor_msgs.msg import Image - - latest: dict[str, np.ndarray] = {} - lock = threading.Lock() + from std_msgs.msg import Float32MultiArray class _Node(Node): def __init__(self) -> None: super().__init__("operator_camera_view") + det_qos = QoSProfile( + history=HistoryPolicy.KEEP_LAST, + depth=5, + reliability=ReliabilityPolicy.RELIABLE, + ) + self.create_subscription( + Float32MultiArray, "workpiece/detection", self._det("wrist"), det_qos + ) + self.create_subscription( + Float32MultiArray, + "workpiece/detection_overhead", + self._det("overhead"), + det_qos, + ) + self._depth_subs: dict[str, object] = {} for cam in cams: name = cam["id"] + role = str(cam.get("role") or "wrist") self.create_subscription( Image, f"/{name}/color/image_raw", - self._color(name), - qos_profile_sensor_data, - ) - self.create_subscription( - Image, - f"/{name}/aligned_depth_to_color/image_raw", - self._depth(name), + self._color(name, role), qos_profile_sensor_data, ) - def _color(self, name: str): + def sync_depth(self) -> None: + """深度没打开就不订。订上之后每一帧都会整幅拷进进程,彩色帧率会被拖下去。""" + with _LOCK: + want = { + cam["id"] + for cam in cams + if f"{cam['id']}_depth" in _PREVIEW_WANT + } + for cam in cams: + name = cam["id"] + if name in want and name not in self._depth_subs: + self._depth_subs[name] = self.create_subscription( + Image, + f"/{name}/aligned_depth_to_color/image_raw", + self._depth(name), + qos_profile_sensor_data, + ) + elif name not in want and name in self._depth_subs: + self.destroy_subscription(self._depth_subs.pop(name)) + + def _det(self, role: str): + def _cb(msg: Float32MultiArray) -> None: + _remember_detection(role, [float(v) for v in msg.data]) + return _cb + + def _color(self, name: str, role: str): + key = f"{name}_color" + def _cb(msg: Image) -> None: - arr = _color_array(msg) - if arr is None: - return - with lock: - latest[f"{name}_color"] = arr + # 这里只留最新原图。颜色转换和画框放到画图线程,避免把后面的帧堵住。 + try: + view = _color_view(msg) + if view is None: + return + offer_color(key, view, bgr=(msg.encoding == "bgr8"), role=role) + except Exception as exc: # noqa: BLE001 —— 坏的一帧留在状态里,订阅不停 + with _LOCK: + _STATE["error"] = str(exc) return _cb def _depth(self, name: str): + key = f"{name}_depth" + def _cb(msg: Image) -> None: - arr = _depth_array(msg) - if arr is None: - return - with lock: - latest[f"{name}_depth"] = arr + try: + view = _depth_view(msg) + if view is None: + return + offer_depth(key, view) + except Exception as exc: # noqa: BLE001 —— 坏的一帧留在状态里,订阅不停 + with _LOCK: + _STATE["error"] = str(exc) return _cb rclpy.init() node = _Node() - next_encode = 0.0 try: while not _STOP.is_set(): - rclpy.spin_once(node, timeout_sec=0.05) - now = time.monotonic() - if now < next_encode: - continue - next_encode = now + 0.12 - with lock: - snap = dict(latest) - latest.clear() - frames = {} - blobs = {} - for key, arr in snap.items(): - name, _, kind = key.rpartition("_") - img = _depth_bgr(arr) if kind == "depth" else arr - blob = _jpeg(img) - if not blob: - continue - blobs[key] = blob - frames[key] = True - if blobs: - with _LOCK: - _JPEG.update(blobs) - _STATE["frames"].update(frames) - _STATE["stamp"] = time.time() - _STATE["error"] = "" + node.sync_depth() + rclpy.spin_once(node, timeout_sec=0.01) except Exception as exc: with _LOCK: _STATE["error"] = str(exc) @@ -200,8 +645,8 @@ def _loop(cams: list[dict]) -> None: def start() -> str: - global _THREAD - cams = targets() + global _THREAD, _PAINT_THREAD + cams = stream_targets() if not cams: with _LOCK: _STATE["error"] = "site 里没有可预览的真机相机" @@ -213,14 +658,18 @@ def start() -> str: _STATE["on"] = True _STATE["error"] = "" _STOP.clear() + _WAKE.clear() _THREAD = threading.Thread(target=_loop, args=(cams,), daemon=True, name="cam-view") _THREAD.start() + _PAINT_THREAD = threading.Thread(target=_paint_loop, daemon=True, name="cam-paint") + _PAINT_THREAD.start() names = "、".join(cam["label"] for cam in cams) - return f"预览订了 {names} 的彩色和深度,不含左右红外。" + return f"预览订了 {names} 的彩色原图,深度点开才订,不含左右红外。" def stop() -> None: _STOP.set() - thread = _THREAD - if thread is not None: - thread.join(timeout=1.5) + _WAKE.set() + for thread in (_THREAD, _PAINT_THREAD): + if thread is not None: + thread.join(timeout=1.5) diff --git a/install/arm_application/lib/python3.11/site-packages/arm_application/operator_console.py b/install/arm_application/lib/python3.11/site-packages/arm_application/operator_console.py index 7aed4c1..e83c1bc 100644 --- a/install/arm_application/lib/python3.11/site-packages/arm_application/operator_console.py +++ b/install/arm_application/lib/python3.11/site-packages/arm_application/operator_console.py @@ -12,6 +12,7 @@ from __future__ import annotations import json import os import re +import signal import subprocess import threading import time @@ -23,7 +24,7 @@ from urllib.parse import urlparse import yaml from arm_application import operator_tactile_view as tactile_view -from arm_application.site_io import eye_to_hand_result_name, overhead_camera +from arm_application.site_io import eye_to_hand_result_name, listed_arms, overhead_camera, usb_expectations HOST = "127.0.0.1" PORT = 8766 @@ -36,11 +37,60 @@ else: ROOT = Path.home() / "ros2" LOG_DIR = Path("/tmp/orisys_console") + +# 动臂和软拖拽都要这台臂,不能一边下发位置一边再开拖拽。 +# 相机、夹爪、触觉不碰这条,也不该排在它后面等。 +_ROBOT_LANES = frozenset({"arm", "teach"}) +_ARM_ACTIONS = frozenset({ + "pipeline", "stage_track", "stage_locate", "stage_screw", + "reset_home", "gauge_run", "real_up", "handeye_calibrate", +}) +_LANE_WAIT = { + "grip": "夹爪上一条还没到位", + "camera": "相机命令还没返回", + "tactile": "触觉命令还没返回", + "arm": "这台臂上一条运动还没结束", + "teach": "软拖拽还没返回", +} + + +def command_lane(action: str) -> str: + """每类命令一条通道。同一条还没返回才拒绝,不让相机去等夹爪。""" + if action == "stop_commands": + return "stop" + if action in ("camera_d405", "camera_d435"): + return "camera" + if action.startswith("tactile"): + return "tactile" + if action.startswith("gripper"): + return "grip" + if action == "handeye_teach": + return "teach" + if action in _ARM_ACTIONS: + return "arm" + return action + + +def command_blocked_message(running: set[str], lane: str) -> str: + """这条通道,或同一台臂上的另一条运动,还没结束时返回要显示的句子。""" + if lane == "stop": + return "" + if lane in running: + return _LANE_WAIT.get(lane, "这一条命令还没返回") + if lane in _ROBOT_LANES: + other = next((name for name in _ROBOT_LANES if name != lane and name in running), "") + if other == "arm": + return "这台臂还在动,软拖拽先不开" + if other == "teach": + return "软拖拽占着这台臂,运动先不开" + return "" ASSETS = Path(__file__).resolve().parent _LOCK = threading.Lock() _TACTILE = {"left": {}, "right": {}, "stamp": 0.0} _HIST = {"left": deque(maxlen=360), "right": deque(maxlen=360)} _PROCS: dict[str, int] = {} +_LIVE: dict[str, subprocess.Popen] = {} +_PROC_LOCK = threading.Lock() def _push_force(side: str, sample: dict) -> None: @@ -76,29 +126,26 @@ def _site() -> dict: def _arms() -> list[dict]: - robots = (_site().get("robot") or {}).get("robots") or [] - out = [] - for robot in robots: - if not isinstance(robot, dict): - continue - sn = str(robot.get("sn") or "") - arm_no = "89" if sn.endswith("063989") else "91" if sn.endswith("063991") else "" - if not arm_no: - continue - out.append({ - "id": arm_no, - "label": str(robot.get("label") or f"臂{arm_no}"), - "sn": sn, - "tactile": bool(robot.get("tactile")), - "camera": str((robot.get("camera") or {}).get("name") or ""), - "gripper": str((robot.get("gripper") or {}).get("model") or ""), - }) - return out or [ - {"id": "89", "label": "左臂89", "sn": "", "tactile": True, "camera": "d405_left", "gripper": ""}, - {"id": "91", "label": "右臂91", "sn": "", "tactile": False, "camera": "d405_right", "gripper": ""}, + """真机臂来自 site.yaml。没有清单就空着,不补本机的 89/91。""" + return [ + { + "id": row["id"], + "label": row["label"], + "sn": row["sn"], + "tactile": row["tactile"], + "camera": row["camera"], + "gripper": row["gripper"], + "rotary": row["rotary"], + } + for row in listed_arms(_site()) ] +def _default_arm() -> str: + rows = _arms() + return str(rows[0]["id"]) if rows else "" + + def _usb() -> list[dict]: found = {"d405": [], "d435": [], "orisys": []} want = { @@ -119,10 +166,11 @@ def _usb() -> list[dict]: continue speed = (dev / "speed").read_text().strip() if (dev / "speed").is_file() else "?" found[key].append({"port": dev.name, "speed": speed}) + expect = usb_expectations(_site()) return [ - {"id": "d405", "label": "腕部 D405", "expect": 2, "items": found["d405"]}, - {"id": "d435", "label": "手外 D435", "expect": 1, "items": found["d435"]}, - {"id": "orisys", "label": "视触觉", "expect": 2, "items": found["orisys"]}, + {"id": "d405", "label": "腕部 D405", "expect": expect["d405"], "items": found["d405"]}, + {"id": "d435", "label": "手外 D435", "expect": expect["d435"], "items": found["d435"]}, + {"id": "orisys", "label": "视触觉", "expect": expect["orisys"], "items": found["orisys"]}, ] @@ -214,13 +262,16 @@ def _poll_tactile() -> None: def _robot(arm: str) -> dict: + from arm_application.site_io import arm_short_id + robots = (_site().get("robot") or {}).get("robots") or [] for robot in robots: if not isinstance(robot, dict): continue sn = str(robot.get("sn") or "") - short = "89" if sn.endswith("063989") else "91" if sn.endswith("063991") else "" - if short == arm: + if sn.lower() == "sim": + continue + if arm_short_id(sn) == arm: return robot return {} @@ -305,12 +356,30 @@ def _handeye(arm: str) -> dict: or shared.get("handeye_waypoints_eye_to_hand") or (ROOT / "handeye" / "poses" / f"{arm}_eye_to_hand.yaml") )) + wrist = _result_card(wrist_file, wrist_pose, boards.get("eye_in_hand") or {}) + wrist_name = "" + if isinstance(robot.get("camera"), dict): + wrist_name = str(robot["camera"].get("name") or "").strip().strip("/") + wrist["preview_camera"] = wrist_name + wrist["preview_label"] = "腕部相机" + outside = _result_card(e2h_file, e2h_pose, boards.get("eye_to_hand") or {}) + outside["preview_camera"] = cam_name.strip().strip("/") + outside["preview_label"] = "手外相机" return { - "eye_in_hand": _result_card(wrist_file, wrist_pose, boards.get("eye_in_hand") or {}), - "eye_to_hand": _result_card(e2h_file, e2h_pose, boards.get("eye_to_hand") or {}), + "eye_in_hand": wrist, + "eye_to_hand": outside, } +def _by_arm(reader) -> dict: + return {row["id"]: reader(row["id"]) for row in _arms()} + + +def _gripper_live() -> dict: + from arm_application.operator_gripper_state import snapshot as grip_snapshot + return _by_arm(grip_snapshot) + + def status() -> dict: with _LOCK: tactile = { @@ -322,20 +391,23 @@ def status() -> dict: "left": list(_HIST["left"]), "right": list(_HIST["right"]), } + from arm_application.operator_arm_state import snapshot as arm_snapshot return { "arms": _arms(), + "arm_live": _by_arm(arm_snapshot), + "gripper_live": _gripper_live(), "usb": _usb(), "services": _services(), "grippers": _grippers(), "tactile": tactile, "history": history, "view": tactile_view.state(), - "handeye": {"89": _handeye("89"), "91": _handeye("91")}, + "handeye": _by_arm(_handeye), "now": time.time(), } -# 会动真机的动作必须 confirm。argv 里的 {arm} 只接受 89 或 91。 +# 会动真机的动作必须 confirm。臂号必须是 site.yaml 里某台真机的末两位。 _ACTIONS = { "real_up": { "title": "上电控制栈", @@ -377,21 +449,21 @@ _ACTIONS = { "script": "run_gripper.sh", "args": ["--arm", "{arm}", "--cli", "probe"], "moves": False, - "blurb": "读夹爪是否在线,不动手指。", + "blurb": "读夹爪是否在线,不动手指。驱动没在跑时先拉起,不回零。", }, "gripper_open": { "title": "夹爪张开", "script": "run_gripper.sh", "args": ["--arm", "{arm}", "--cli", "open"], "moves": True, - "blurb": "张开当前臂夹爪。", + "blurb": "张开当前臂夹爪。驱动没在跑时先拉起,不回零。", }, "gripper_close": { "title": "夹爪闭合", "script": "run_gripper.sh", "args": ["--arm", "{arm}", "--cli", "close"], "moves": True, - "blurb": "闭合当前臂夹爪。", + "blurb": "闭合当前臂夹爪。驱动没在跑时先拉起,不回零。", }, "gripper_rotate": { "title": "右爪转 90°", @@ -401,11 +473,11 @@ _ACTIONS = { "blurb": "右臂旋转爪转到 90°。左臂平行爪没有这一轴。", }, "pipeline": { - "title": "整体三段", + "title": "跟踪并对孔", "script": "run_all.sh", "args": ["--arm", "{arm}"], "moves": True, - "blurb": "跟踪、对孔、搜孔轻压一次跑完。会动臂。", + "blurb": "跟踪和对孔一次跑完。搜孔轻压已取消,这一步不含旋入。会动臂。", }, "stage_track": { "title": "只测跟踪", @@ -421,12 +493,12 @@ _ACTIONS = { "moves": True, "blurb": "只跑二阶段:朝下、悬停、拍照、对准孔。会动臂。", }, - "stage_search": { - "title": "只测搜孔", + "stage_screw": { + "title": "只测旋转装配", "script": "run_stage3_search.sh", - "args": ["--arm", "{arm}"], + "args": ["--arm", "{arm}", "--screw"], "moves": True, - "blurb": "只跑三阶段:触面、搜孔、轻压。会动臂。臂应已停在孔上方。", + "blurb": "按现场配置的拧入深度和螺距把螺纹规旋进去。会动臂。臂应已在孔口。", }, "reset_home": { "title": "复位到 home", @@ -462,9 +534,9 @@ _ACTIONS = { "handeye_teach": { "title": "示教采点", "script": "run_handeye.sh", - "args": ["--arm", "{arm}", "teach", "--mode", "{mode}", "--points", "12"], + "args": ["--arm", "{arm}", "teach", "--mode", "{mode}", "--points", "{points}"], "moves": True, - "blurb": "软拖拽示教,目标 12 个姿态。要先停控制栈,会动臂。", + "blurb": "软拖拽示教。点数和是否接着采由手眼页上的控件决定。要先停控制栈,会动臂。", }, "handeye_merge": { "title": "合并示教点", @@ -507,6 +579,63 @@ def catalog() -> dict: } +def _remember(action: str, proc: subprocess.Popen) -> None: + with _PROC_LOCK: + _LIVE[action] = proc + _PROCS[action] = proc.pid + + +def _forget(action: str) -> None: + with _PROC_LOCK: + _LIVE.pop(action, None) + + +def _pid_alive(pid: int) -> bool: + if pid <= 0: + return False + try: + os.kill(pid, 0) + except OSError: + return False + return True + + +def stop_started() -> dict: + """停掉本界面拉起的命令。只向登记过的进程组发信号,不扫全机。""" + with _PROC_LOCK: + items = list(_LIVE.items()) + if not items: + return {"ok": True, "message": "没有正在跑的命令"} + names: list[str] = [] + for action, proc in items: + pid = int(getattr(proc, "pid", 0) or 0) + if not _pid_alive(pid): + _forget(action) + continue + try: + os.killpg(os.getpgid(pid), signal.SIGTERM) + except ProcessLookupError: + _forget(action) + continue + except OSError as exc: + return {"ok": False, "message": f"停不了 {action}:{exc}"} + names.append(action) + time.sleep(0.3) + for action, proc in items: + pid = int(getattr(proc, "pid", 0) or 0) + if not _pid_alive(pid): + _forget(action) + continue + try: + os.killpg(os.getpgid(pid), signal.SIGKILL) + except OSError: + pass + _forget(action) + if not names: + return {"ok": True, "message": "没有正在跑的命令"} + return {"ok": True, "message": "已停止:" + "、".join(names)} + + def _stop_tactile() -> str: subprocess.run(["pkill", "-f", "lib/arm_application/tactile"], check=False) subprocess.run(["pkill", "-f", "arm_application/tactile_node.py"], check=False) @@ -514,7 +643,39 @@ def _stop_tactile() -> str: return "已发送停止视触觉节点" -def run_action(action: str, arm: str, confirm: bool, mode: str = "eye_in_hand") -> dict: +def render_action_args( + template: list[str], arm: str, mode: str, extras: dict | None = None, +) -> list[str]: + """把动作模板里的臂号、模式、点数换成这次界面上的值。""" + extra = extras or {} + needs_points = any("{points}" in str(token) for token in template) + points_text = "" + if needs_points: + try: + points = int(extra.get("points", 12)) + except (TypeError, ValueError): + raise ValueError("目标点数不是整数") from None + if points < 4 or points > 30: + raise ValueError("目标点数只接受 4–30") + points_text = str(points) + repl = {"{arm}": arm, "{mode}": mode, "{points}": points_text} + out: list[str] = [] + for token in template: + text = str(token) + for key, value in repl.items(): + text = text.replace(key, value) + out.append(text) + if extra.get("append"): + if "teach" not in out: + raise ValueError("只有示教采点能接着采") + out.append("--append") + return out + + +def run_action( + action: str, arm: str, confirm: bool, mode: str = "eye_in_hand", + extras: dict | None = None, +) -> dict: if action == "tactile_down": tactile_view.stop() return {"ok": True, "message": _stop_tactile()} @@ -528,8 +689,8 @@ def run_action(action: str, arm: str, confirm: bool, mode: str = "eye_in_hand") spec = _ACTIONS.get(action) if spec is None: return {"ok": False, "message": "没有这个动作"} - if arm not in ("89", "91"): - return {"ok": False, "message": "臂号只接受 89 或 91"} + if arm not in {row["id"] for row in _arms()}: + return {"ok": False, "message": f"臂号 {arm} 不在 site.yaml 里"} if mode not in ("eye_in_hand", "eye_to_hand"): return {"ok": False, "message": "手眼模式只接受 eye_in_hand 或 eye_to_hand"} if spec["moves"] and not confirm: @@ -537,40 +698,86 @@ def run_action(action: str, arm: str, confirm: bool, mode: str = "eye_in_hand") script = ROOT / "scripts" / spec["script"] if not script.is_file(): return {"ok": False, "message": f"找不到 {script}"} - args = [a.replace("{arm}", arm).replace("{mode}", mode) for a in spec["args"]] + from arm_application.operator_arm_state import ( + hold_until_idle, + motion_blocked, + release_for_control, + resume_after_control, + snapshot as arm_snapshot, + stack_running, + ) + blocked = motion_blocked( + action, + stack_up=stack_running(), + op_status=str((arm_snapshot(arm) or {}).get("op_status") or ""), + ) + if blocked: + return {"ok": False, "message": blocked} + uses_rdk = action in { + "real_up", "handeye_teach", "pipeline", "stage_track", "stage_locate", + "stage_screw", "reset_home", "gauge_run", "handeye_calibrate", + } + if uses_rdk: + release_for_control() + from arm_application.operator_gripper_state import hold_serial + grip_port = action in { + "gripper_probe", "gripper_open", "gripper_close", "gripper_rotate", "gripper_up", + } + try: + args = render_action_args(list(spec["args"]), arm, mode, extras) + except ValueError as exc: + return {"ok": False, "message": str(exc)} LOG_DIR.mkdir(parents=True, exist_ok=True) log_path = LOG_DIR / f"{action}_{arm}.log" shown = " ".join(["bash", f"scripts/{spec['script']}", *args]) - if spec.get("sync"): - try: - proc = subprocess.run( + try: + if spec.get("sync"): + proc = subprocess.Popen( ["bash", str(script), *args], cwd=str(ROOT), - capture_output=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, text=True, - timeout=float(spec.get("timeout") or 40), + start_new_session=True, ) - except subprocess.TimeoutExpired: - return {"ok": False, "message": "命令超时还没返回", "detail": shown} - detail = ((proc.stdout or "") + (proc.stderr or "")).strip() - log_path.write_text(detail) - ok = proc.returncode == 0 - return { - "ok": ok, - "message": shown if ok else f"退出码 {proc.returncode}", - "detail": detail[-4000:], - "log": str(log_path), - } - log_f = log_path.open("ab") - proc = subprocess.Popen( - ["bash", str(script), *args], - cwd=str(ROOT), - stdout=log_f, - stderr=subprocess.STDOUT, - start_new_session=True, - ) - _PROCS[action] = proc.pid - return {"ok": True, "message": shown, "pid": proc.pid, "log": str(log_path)} + _remember(action, proc) + try: + detail, _ = proc.communicate(timeout=float(spec.get("timeout") or 40)) + except subprocess.TimeoutExpired: + try: + os.killpg(os.getpgid(proc.pid), signal.SIGKILL) + except OSError: + pass + return {"ok": False, "message": "命令超时还没返回", "detail": shown} + finally: + _forget(action) + detail = (detail or "").strip() + log_path.write_text(detail) + ok = proc.returncode == 0 + return { + "ok": ok, + "message": shown if ok else f"退出码 {proc.returncode}", + "detail": detail[-4000:], + "log": str(log_path), + } + log_f = log_path.open("ab") + proc = subprocess.Popen( + ["bash", str(script), *args], + cwd=str(ROOT), + stdout=log_f, + stderr=subprocess.STDOUT, + start_new_session=True, + ) + _remember(action, proc) + if grip_port: + hold_serial(proc.pid) + if uses_rdk: + hold_until_idle(proc.pid, until_stack=(action == "real_up")) + uses_rdk = False + return {"ok": True, "message": shown, "pid": proc.pid, "log": str(log_path)} + finally: + if uses_rdk: + resume_after_control() def _bounded(name: str, raw, low: float, high: float) -> float: @@ -585,12 +792,12 @@ def _bounded(name: str, raw, low: float, high: float) -> float: def run_gripper_tune(arm: str, kind: str, numbers: dict) -> dict: """按界面上的位置、力度、速度下发夹爪。右臂另有角度、圈数。""" - if arm not in ("89", "91"): - return {"ok": False, "message": "臂号只接受 89 或 91"} + if arm not in {row["id"] for row in _arms()}: + return {"ok": False, "message": f"臂号 {arm} 不在 site.yaml 里"} if kind not in ("pos", "rotate", "rotate_by", "revolutions"): return {"ok": False, "message": "没有这种夹爪调节"} - if kind != "pos" and arm != "91": - return {"ok": False, "message": "左臂平行爪没有旋转轴"} + if kind != "pos" and not any(row["id"] == arm and row.get("rotary") for row in _arms()): + return {"ok": False, "message": "这台夹爪没有旋转轴"} try: if kind == "pos": pos = int(round(_bounded("位置", numbers.get("pos"), 0, 1000))) @@ -615,7 +822,17 @@ def run_gripper_tune(arm: str, kind: str, numbers: dict) -> dict: summary = f"转 {turns:.3f} 圈 力矩 {force}% 速度 {speed}%" except ValueError as exc: return {"ok": False, "message": str(exc)} + from arm_application.operator_gripper_state import pause_serial, resume_serial + script = ROOT / "scripts" / "run_gripper.sh" + pause_serial() + try: + return _run_gripper_argv(script, arm, force, speed, cli, summary) + finally: + resume_serial() + + +def _run_gripper_argv(script, arm: str, force: int, speed: int, cli: list[str], summary: str) -> dict: argv = [ "bash", str(script), "--arm", arm, @@ -624,19 +841,40 @@ def run_gripper_tune(arm: str, kind: str, numbers: dict) -> dict: "--cli", *cli, ] shown = " ".join(argv) + proc = subprocess.Popen( + argv, cwd=str(ROOT), stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + text=True, start_new_session=True, + ) + _remember(f"gripper_{arm}", proc) try: - proc = subprocess.run(argv, cwd=str(ROOT), capture_output=True, text=True, timeout=90) - except subprocess.TimeoutExpired: - return {"ok": False, "message": "夹爪 90 秒还没到位", "detail": shown} - detail = ((proc.stdout or "") + (proc.stderr or "")).strip() + try: + detail, _ = proc.communicate(timeout=90) + except subprocess.TimeoutExpired: + try: + os.killpg(os.getpgid(proc.pid), signal.SIGKILL) + except OSError: + pass + return {"ok": False, "message": "夹爪 90 秒还没到位", "detail": shown} + finally: + _forget(f"gripper_{arm}") + detail = (detail or "").strip() ok = proc.returncode == 0 return { "ok": ok, - "message": summary if ok else f"夹爪退出码 {proc.returncode}", + "message": summary if ok else _grip_failure_message(proc.returncode, detail), "detail": (summary + "\n" + shown + "\n" + detail)[-4000:], } +def _grip_failure_message(code: int, detail: str) -> str: + """退出码旁边带上脚本打出来的原因,别只剩一个数字。""" + for line in reversed(detail.splitlines()): + text = line.strip() + if text.startswith("ERROR:") or text.startswith("无法"): + return text + return f"夹爪退出码 {code}" + + class Handler(BaseHTTPRequestHandler): def log_message(self, fmt: str, *args) -> None: return @@ -700,12 +938,12 @@ class Handler(BaseHTTPRequestHandler): return if path == "/api/tactile/view": action = "tactile_view_on" if body.get("on") else "tactile_view_off" - result = run_action(action, "89", False) + result = run_action(action, _default_arm(), False) self._json(200 if result.get("ok") else 400, result) return result = run_action( str(body.get("action") or ""), - str(body.get("arm") or "89"), + str(body.get("arm") or _default_arm()), bool(body.get("confirm")), str(body.get("mode") or "eye_in_hand"), ) @@ -715,6 +953,10 @@ class Handler(BaseHTTPRequestHandler): def main() -> None: import atexit atexit.register(tactile_view.stop) + from arm_application.operator_arm_state import start as start_arm_state + from arm_application.operator_gripper_state import start as start_gripper_state + start_arm_state() + start_gripper_state() threading.Thread(target=_poll_tactile, daemon=True).start() server = ThreadingHTTPServer((HOST, PORT), Handler) print(f"Orisys 工位界面 http://{HOST}:{PORT}", flush=True) diff --git a/install/arm_application/lib/python3.11/site-packages/arm_application/operator_gripper_mesh.py b/install/arm_application/lib/python3.11/site-packages/arm_application/operator_gripper_mesh.py new file mode 100644 index 0000000..523abf5 --- /dev/null +++ b/install/arm_application/lib/python3.11/site-packages/arm_application/operator_gripper_mesh.py @@ -0,0 +1,138 @@ +"""大寰夹爪的一比一外形。 + +左臂按下载包 ``PGEA-3D-Model-and-2D-Drawing-4`` 里的 +``PGEA-100-26-W-F`` 尺寸图。图签就是这个型号,总长 138、宽 55、厚 29, +安装端抱闸约 20,主体 78,手指伸出约 40,闭合缝 1.2。 +现场装的是 ``PGEA-100-20-W``,这包里没有 100-20 的图,开口仍用 +``site.yaml`` 的 20 mm,不用图上的 26。 + +同包另一张完整的带抱闸图是 ``PGEA-100-40-W-F``(手指更长,张开约 40.8)。 +右臂是 ``RGI-100-30``,这包里没有旋转爪的图,仍按手册外形。 +""" + +from __future__ import annotations + +import math + +# 米。来源见模块说明。长指向指尖,宽是开合方向,厚是图上的 29。 +PGEA_SIZE = (0.138, 0.055, 0.029) +PGEA_STROKE_MM = 20.0 +PGEA_CLOSED_GAP_M = 0.0012 +PGEA_FINGER_M = 0.040 +RGI_SIZE = (0.158, 0.0755, 0.047) +RGI_STROKE_MM = 30.0 + +# 深色底上要能分开:银壳体、蓝抱闸、橙手指、绿导轨。 +# 手指以前是 0.10 的黑,乘上未连接时的 0.55 就沉进背景里。 +_BODY = (0.86, 0.88, 0.91) +_BRAKE = (0.16, 0.42, 0.90) +_FINGER = (0.95, 0.46, 0.10) +_RAIL = (0.18, 0.78, 0.28) + + +def triangles( + rotary: bool, + permille: float | None, + rotation_deg: float | None, + stroke_mm: float | None, +) -> tuple[list[tuple], str]: + """返回 ``(三角面, 说明)``。三角面是 ``(a, b, c, 明暗占位, rgb)``,明暗由界面再乘。""" + if rotary: + length, width, thick = RGI_SIZE + stroke = (float(stroke_mm) if stroke_mm else RGI_STROKE_MM) / 1000.0 + note = ( + f"RGI-100-30 外形 {length*1000:.0f}×{width*1000:.1f}×{thick*1000:.0f} mm" + f" 行程 {stroke*1000:.0f} mm。这包图纸里没有旋转爪,外形仍按手册。" + ) + body_end = 0.118 + brake = None + else: + length, width, thick = PGEA_SIZE + stroke = (float(stroke_mm) if stroke_mm else PGEA_STROKE_MM) / 1000.0 + note = ( + f"PGEA-100-26-W-F 尺寸图 外形 {length*1000:.0f}×{width*1000:.0f}×{thick*1000:.0f} mm" + f" 现场行程 {stroke*1000:.0f} mm。包里没有 100-20 的图,开口不用图上的 26。" + ) + body_end = length - PGEA_FINGER_M + brake = (0.0, 0.020) + gap = stroke * (0.5 if permille is None else max(0.0, min(1.0, float(permille) / 1000.0))) + if not rotary: + gap += PGEA_CLOSED_GAP_M + parts = _solid(length, width, thick, body_end, gap, brake, rotary) + if rotary and rotation_deg: + parts = _spin_fingers(parts, math.radians(float(rotation_deg)), body_end) + out = [] + for a, b, c, rgb in parts: + out.append((a, b, c, _face_shade(a, b, c), rgb)) + if permille is None: + note = "没有读数。下面是半开,不是当前开口。 " + note + return out, note + + +def _solid(length, width, thick, body_end, gap, brake, rotary: bool): + hx, hy = thick / 2.0, width / 2.0 + faces = [] + z0 = 0.0 + if brake is not None: + z0 = brake[1] + faces.extend(_box(0.0, brake[1], -hy, hy, -hx, hx, _BRAKE)) + faces.extend(_box(z0, body_end, -hy, hy, -hx, hx, _BODY)) + if rotary: + finger_t = min(0.010, hy * 0.4) + finger_h = thick * 0.72 + else: + # 尺寸图端视图:指厚 6.5,指高 16,都落在 55×29 的外形里面。 + finger_t = 0.0065 + finger_h = 0.016 + rail = 0.003 + faces.extend(_box(z0, body_end, -hy * 0.62, -hy * 0.62 + rail, hx - rail, hx, _RAIL)) + faces.extend(_box(z0, body_end, hy * 0.62 - rail, hy * 0.62, hx - rail, hx, _RAIL)) + half = gap / 2.0 + faces.extend(_box(body_end, length, -(half + finger_t), -half, -finger_h / 2, finger_h / 2, _FINGER)) + faces.extend(_box(body_end, length, half, half + finger_t, -finger_h / 2, finger_h / 2, _FINGER)) + return faces + + +def _spin_fingers(parts, angle: float, body_end: float): + c, s = math.cos(angle), math.sin(angle) + spun = [] + for a, b, c0, rgb in parts: + if a[0] >= body_end - 1e-6 and b[0] >= body_end - 1e-6 and c0[0] >= body_end - 1e-6: + a, b, c0 = (_rot_z(a, c, s), _rot_z(b, c, s), _rot_z(c0, c, s)) + spun.append((a, b, c0, rgb)) + return spun + + +def _face_shade(a, b, c) -> float: + """顶面亮、底面暗。零件颜色已经分开,明暗再把面分出来。""" + ux, uy, uz = b[0] - a[0], b[1] - a[1], b[2] - a[2] + vx, vy, vz = c[0] - a[0], c[1] - a[1], c[2] - a[2] + nx = uy * vz - uz * vy + ny = uz * vx - ux * vz + nz = ux * vy - uy * vx + length = math.sqrt(nx * nx + ny * ny + nz * nz) or 1.0 + dot = (nx * 0.15 + ny * -0.25 + nz * 0.95) / length + return 0.42 + 0.58 * max(0.0, min(1.0, dot)) + + +def _rot_z(point, c, s): + x, y, z = point + return (x, y * c - z * s, y * s + z * c) + + +def _box(x0, x1, y0, y1, z0, z1, rgb) -> list[tuple]: + """轴向盒子。x 指向指尖,y 是开合方向,z 是厚度。""" + p = ( + (x0, y0, z0), (x1, y0, z0), (x1, y1, z0), (x0, y1, z0), + (x0, y0, z1), (x1, y0, z1), (x1, y1, z1), (x0, y1, z1), + ) + quads = ( + (0, 1, 2, 3), (4, 7, 6, 5), + (0, 4, 5, 1), (3, 2, 6, 7), + (0, 3, 7, 4), (1, 5, 6, 2), + ) + out = [] + for a, b, c, d in quads: + out.append((p[a], p[b], p[c], rgb)) + out.append((p[a], p[c], p[d], rgb)) + return out diff --git a/install/arm_application/lib/python3.11/site-packages/arm_application/operator_gripper_state.py b/install/arm_application/lib/python3.11/site-packages/arm_application/operator_gripper_state.py new file mode 100644 index 0000000..b31d263 --- /dev/null +++ b/install/arm_application/lib/python3.11/site-packages/arm_application/operator_gripper_state.py @@ -0,0 +1,436 @@ +"""工位界面用的夹爪状态:只读开口和转角,并交给界面画出来。 + +夹爪节点已经占着串口时,只订它发布的话题,不再自己打开串口。 +没有节点时才短连一次。连上之后只读寄存器,不写初始化,避免手指回零把工件松开。 +""" + +from __future__ import annotations + +import os +import subprocess +import threading +import time +from typing import Any + +import yaml + +from arm_application.site_io import listed_arms, ros2_root + +_GRIP = {0: "运动中", 1: "已到位", 2: "已夹住", 3: "已掉落"} +_ROT = {0: "转动中", 1: "已到位", 2: "受阻", 3: "曾经受阻"} + +_LOCK = threading.Lock() +_CACHE: dict[str, dict] = {} +_STARTED = False +_PORT_HOLD = 0 +_PORT_BUSY = 0 +_PORT_LOCK = threading.Lock() + + +def snapshot(arm: str) -> dict: + with _LOCK: + row = _CACHE.get(arm) + return dict(row) if row else _empty(arm) + + +def _empty(arm: str) -> dict: + row = next((item for item in _arms() if item["id"] == arm), None) + return present( + arm, + rotary=bool(row and row["rotary"]), + stroke_mm=float(row["stroke_mm"]) if row else 20.0, + reading=None, + error="还没读到夹爪", + ) + + +def present( + arm: str, + *, + rotary: bool, + stroke_mm: float, + reading: dict | None, + error: str = "", +) -> dict: + """把一次读数收成界面能画的字典。不打开串口。""" + stroke = float(stroke_mm) if stroke_mm and stroke_mm > 0 else 20.0 + row = reading or {} + permille = _unit(row.get("permille"), 0, 1000) + connected = bool(row.get("connected")) and permille is not None + opening = None if permille is None else permille / 1000.0 * stroke + rotation = _finite(row.get("rotation_deg")) if rotary else 0.0 + grip_state = row.get("grip_state") + rot_state = row.get("rotation_state") + if not connected: + summary = error or str(row.get("error_msg") or "夹爪没连上") + else: + bits = [_GRIP.get(int(grip_state), "状态未知") if _finite(grip_state) is not None else "状态未知"] + bits.append(f"开口 {opening:.1f} mm" if opening is not None else "") + if rotary and rotation is not None: + bits.append(f"转角 {rotation:.1f}°") + if _finite(rot_state) is not None: + bits.append(_ROT.get(int(rot_state), "")) + summary = " ".join(bit for bit in bits if bit) + return { + "arm": arm, + "rotary": rotary, + "connected": connected, + "initialized": bool(row.get("initialized")) if connected else False, + "permille": permille, + "opening_mm": opening, + "stroke_mm": stroke, + "rotation_deg": rotation if rotary else None, + "force": _finite(row.get("force")), + "speed": _finite(row.get("speed")), + "grip_state": int(grip_state) if _finite(grip_state) is not None else None, + "grip_text": _GRIP.get(int(grip_state), "—") if _finite(grip_state) is not None else "—", + "rotation_text": _ROT.get(int(rot_state), "—") if rotary and _finite(rot_state) is not None else "—", + "moving": bool(row.get("moving")) if connected else False, + "gripped": bool(row.get("gripped")) or (_finite(grip_state) == 2) if connected else False, + "summary": summary, + "error": "" if connected else (error or str(row.get("error_msg") or "")), + "source": str(row.get("source") or ""), + "pose_live": connected, + "stamp": time.time(), + } + + +def retain_reading( + fresh: dict, + previous: dict | None, + *, + now: float | None = None, + hold_s: float = 15.0, +) -> dict: + """这一拍没读到开口时,短时间沿用上一拍。 + + 和机械臂同一件事:话题 echo 偶发超时会把刚读到的开口清成“没连上”, + 模型和顶栏就停在那里。超过 ``hold_s`` 仍没有新读数才放开。 + """ + if fresh.get("connected") or not previous or not previous.get("connected"): + return fresh + moment = time.time() if now is None else now + if moment - float(previous.get("stamp") or 0.0) > hold_s: + return fresh + kept = dict(fresh) + for key in ( + "connected", "initialized", "permille", "opening_mm", "stroke_mm", + "rotation_deg", "force", "speed", "grip_state", "grip_text", + "rotation_text", "moving", "gripped", "pose_live", "rotary", "source", + ): + if key in previous: + kept[key] = previous[key] + kept["summary"] = str(previous.get("summary") or fresh.get("summary") or "") + note = "沿用上次读数。" + if note not in kept["summary"]: + kept["summary"] = f"{kept['summary']} {note}".strip() + return kept + + +def pause_serial() -> None: + """控制命令要自己开串口,或把驱动拉起来。先拦住状态线程,等它把口放开。 + + 一次短读要过好几个寄存器,1.5 秒经常还没结束。提前返回会让新驱动 + 去抢还被占着的口,然后以「需要 24 V」失败。 + """ + global _PORT_HOLD + with _PORT_LOCK: + _PORT_HOLD += 1 + deadline = time.time() + 6.0 + while time.time() < deadline: + with _PORT_LOCK: + busy = _PORT_BUSY + if not busy: + return + time.sleep(0.05) + + +def hold_serial(pid: int) -> None: + """后台夹爪脚本还活着时,状态线程不要去开串口。""" + pause_serial() + + def _watch() -> None: + while pid > 0: + try: + os.kill(pid, 0) + except OSError: + break + time.sleep(0.2) + resume_serial() + + threading.Thread(target=_watch, daemon=True, name="gripper-port-hold").start() + + +def resume_serial() -> None: + global _PORT_HOLD + with _PORT_LOCK: + _PORT_HOLD = max(0, _PORT_HOLD - 1) + + +def serial_paused() -> bool: + with _PORT_LOCK: + return _PORT_HOLD > 0 + + +def start() -> None: + global _STARTED + with _LOCK: + if _STARTED: + return + _STARTED = True + threading.Thread(target=_loop, daemon=True, name="gripper-state").start() + + +def _loop() -> None: + while True: + try: + arms = _arms() + nodes = _nodes() + for arm in arms: + reading, error = _read_one(arm, nodes) + fresh = present( + arm["id"], + rotary=arm["rotary"], + stroke_mm=arm["stroke_mm"], + reading=reading, + error=error, + ) + _store(retain_reading(fresh, snapshot(arm["id"]))) + except Exception as exc: # noqa: BLE001 —— 读夹爪失败不能拖死界面 + for row in _arms(): + _store(present( + row["id"], rotary=bool(row.get("rotary")), stroke_mm=float(row.get("stroke_mm") or 20.0), + reading=None, error=str(exc), + )) + time.sleep(2.5) + + +def _store(row: dict) -> None: + with _LOCK: + _CACHE[str(row.get("arm") or "")] = row + + +def _arms() -> list[dict]: + path = ros2_root() / "config" / "site.yaml" + if not path.is_file(): + return [] + data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + shared = data.get("gripper") if isinstance(data.get("gripper"), dict) else {} + baud = int(shared.get("baud") or 115200) + slave = int(shared.get("slave_id") or 1) + rts = str(shared.get("rs485_rts") or "") + out = [] + for row in listed_arms(data): + item = dict(row) + item["baud"] = baud + item["slave"] = slave + item["rts"] = rts + out.append(item) + return out + + +def _nodes() -> dict[str, bool]: + text = _pgrep("dh_pgea_node|dual_gripper") + return { + "pgea": "dh_pgea_node" in text or "lib/arm_application/dh_pgea" in text, + "dual": "dual_gripper" in text, + } + + +def _read_one(arm: dict, nodes: dict[str, bool]) -> tuple[dict | None, str]: + if nodes.get("dual") and arm.get("side") in ("left", "right"): + topic = f"/dual_gripper_driver/{arm['side']}/status" + parsed = _parse_status_echo(_echo(topic)) + if parsed is not None: + parsed["source"] = f"话题 {topic}" + return parsed, "" + return None, "夹爪节点在跑,但这侧话题没有数据" + if not arm.get("rotary") and nodes.get("pgea"): + parsed = _parse_states_echo(_echo("/gripper/states")) + if parsed is not None: + parsed["source"] = "话题 /gripper/states" + return parsed, "" + return None, "平行爪节点在跑,但 /gripper/states 没有数据" + if not arm.get("port"): + return None, "site.yaml 里这台夹爪没有串口" + if serial_paused(): + return None, "控制命令正在用串口" + return _read_serial(arm) + + +def _read_serial(arm: dict) -> tuple[dict | None, str]: + """短连串口只读。auto_initialize 必须关掉,否则旋转爪一连上就会找零。""" + global _PORT_BUSY + if serial_paused(): + return None, "控制命令正在用串口" + with _PORT_LOCK: + _PORT_BUSY += 1 + try: + return _read_serial_open(arm) + finally: + with _PORT_LOCK: + _PORT_BUSY = max(0, _PORT_BUSY - 1) + + +def _read_serial_open(arm: dict) -> tuple[dict | None, str]: + try: + if arm["rotary"]: + from arm_application.dual_gripper import RotaryGripper, RotaryGripperConfig + grip = RotaryGripper(RotaryGripperConfig( + port=arm["port"], + slave_id=int(arm["slave"]), + baudrate=int(arm["baud"]), + timeout=0.25, + auto_initialize=False, + )) + grip.connect() + try: + status = grip.get_status() + finally: + grip.disconnect() + if not status.connected: + return None, status.error_msg or "旋转爪没有应答" + return { + "connected": True, + "initialized": bool(status.initialized), + "permille": float(status.finger_position_per_mille), + "rotation_deg": float(status.rotation_deg), + "force": float(status.force), + "speed": float(status.velocity), + "grip_state": int(status.gripper_state), + "rotation_state": int(status.rotation_state), + "moving": bool(status.is_moving), + "gripped": bool(status.is_gripped), + "error_msg": status.error_msg or "", + "source": f"串口 {arm['port']} 只读", + }, "" + from arm_application.dh_pgea import DhPgeaError, DhPgeaGripper + grip = DhPgeaGripper( + port=arm["port"], + baudrate=int(arm["baud"]), + slave_id=int(arm["slave"]), + stroke_mm=float(arm["stroke_mm"]), + rs485_rts=str(arm["rts"]), + ) + grip.open() + try: + state = grip.get_run_states(retries=1) + finally: + grip.close() + return { + "connected": True, + "initialized": bool(state.initialized), + "permille": float(state.position), + "rotation_deg": 0.0, + "force": float(state.target_force), + "speed": None, + "grip_state": int(state.grip_state), + "rotation_state": None, + "moving": int(state.grip_state) == 0, + "gripped": int(state.grip_state) == 2, + "error_msg": "", + "source": f"串口 {arm['port']} 只读", + }, "" + except Exception as exc: # noqa: BLE001 —— 串口被节点占着、没上电,都要变成一句人话 + text = str(exc).strip() or exc.__class__.__name__ + if "DhPgeaError" in exc.__class__.__name__ or "Permission" in text or "busy" in text.lower(): + return None, text + return None, text + + +def _echo(topic: str) -> str: + try: + proc = subprocess.run( + ["timeout", "3", "ros2", "topic", "echo", topic, "--once"], + capture_output=True, text=True, timeout=5, + ) + except (OSError, subprocess.TimeoutExpired): + return "" + return proc.stdout + + +def _parse_status_echo(text: str) -> dict | None: + for chunk in text.split("---"): + if "finger_position_per_mille:" not in chunk and "connected:" not in chunk: + continue + try: + data = yaml.safe_load(chunk) + except yaml.YAMLError: + continue + if not isinstance(data, dict) or "connected" not in data: + continue + return { + "connected": bool(data.get("connected")), + "initialized": bool(data.get("initialized")), + "permille": data.get("finger_position_per_mille"), + "rotation_deg": data.get("rotation_deg"), + "force": data.get("force"), + "speed": data.get("velocity"), + "grip_state": data.get("gripper_state"), + "rotation_state": data.get("rotation_state"), + "moving": bool(data.get("is_moving")), + "gripped": bool(data.get("is_gripped")), + "error_msg": str(data.get("error_msg") or ""), + } + return None + + +def _parse_states_echo(text: str) -> dict | None: + for chunk in text.split("---"): + if "data:" not in chunk: + continue + try: + data = yaml.safe_load(chunk) + except yaml.YAMLError: + continue + if not isinstance(data, dict): + continue + raw = data.get("data") + if not isinstance(raw, list) or len(raw) < 5: + continue + try: + init, grip, pos, _target, force = (float(v) for v in raw[:5]) + except (TypeError, ValueError): + continue + return { + "connected": True, + "initialized": init >= 0.5, + "permille": pos, + "rotation_deg": 0.0, + "force": force, + "speed": None, + "grip_state": int(grip), + "rotation_state": None, + "moving": int(grip) == 0, + "gripped": int(grip) == 2, + "error_msg": "", + } + return None + + +def _pgrep(pattern: str) -> str: + try: + proc = subprocess.run( + ["pgrep", "-af", pattern], + capture_output=True, text=True, timeout=2, + ) + except (OSError, subprocess.TimeoutExpired): + return "" + return "\n".join(line for line in proc.stdout.splitlines() if "pgrep" not in line) + + +def _finite(raw: Any) -> float | None: + try: + value = float(raw) + except (TypeError, ValueError): + return None + if value != value: + return None + return value + + +def _unit(raw: Any, low: float, high: float) -> float | None: + value = _finite(raw) + if value is None: + return None + return max(low, min(high, value)) diff --git a/install/arm_application/lib/python3.11/site-packages/arm_application/operator_handeye_board.py b/install/arm_application/lib/python3.11/site-packages/arm_application/operator_handeye_board.py new file mode 100644 index 0000000..d7cb7f5 --- /dev/null +++ b/install/arm_application/lib/python3.11/site-packages/arm_application/operator_handeye_board.py @@ -0,0 +1,91 @@ +"""手眼页的棋盘预览。只在已有彩色画面上画角点,不打开相机,也不动臂。""" + +from __future__ import annotations + +import threading +import time + +import numpy as np + +from arm_application.handeye_lib import BoardConfig, detect_chessboard + + +def coverage_fraction(corners: np.ndarray, width: int, height: int) -> float: + """角点外接框占画面的比例。宽或高为 0 时返回 0。""" + if width <= 0 or height <= 0: + return 0.0 + pts = np.asarray(corners, dtype=float).reshape(-1, 2) + if pts.size == 0: + return 0.0 + area = float(pts[:, 0].max() - pts[:, 0].min()) * float(pts[:, 1].max() - pts[:, 1].min()) + return area / float(width * height) + + +def coverage_words(fraction: float) -> str: + """30%–70% 是示教时棋盘该占的画面。再小对不准,再大姿态拉不开。""" + if fraction < 0.30: + return "偏小,再靠近一点" + if fraction > 0.70: + return "偏大,再离远一点" + return "大小合适" + + +def annotate_board(jpeg: bytes, cols: int, rows: int) -> tuple[bytes, str]: + """返回(可能画了角点的 JPEG,一行中文状态)。检测失败时 JPEG 原样返回。""" + import cv2 + + if cols <= 0 or rows <= 0: + return jpeg, "配置里没有这块板的内角点" + buf = np.frombuffer(jpeg, dtype=np.uint8) + image = cv2.imdecode(buf, cv2.IMREAD_COLOR) + if image is None: + return jpeg, "这一帧解不出来" + found = detect_chessboard(image, BoardConfig(int(cols), int(rows), 1.0)) + if found is None: + return jpeg, "画面里没有这块板" + corners, pattern = found + drawn = image.copy() + cv2.drawChessboardCorners(drawn, pattern, corners, True) + fraction = coverage_fraction(corners, drawn.shape[1], drawn.shape[0]) + ok, out = cv2.imencode(".jpg", drawn, [int(cv2.IMWRITE_JPEG_QUALITY), 70]) + blob = out.tobytes() if ok else jpeg + percent = int(round(fraction * 100)) + return blob, f"棋盘已找到,约占画面 {percent}%。{coverage_words(fraction)}" + + +class BoardWatch: + """后台画角点。界面线程只提交最新一帧,检测再慢也不卡住窗口。""" + + def __init__(self) -> None: + self._lock = threading.Lock() + self._pending: tuple | None = None + self._result = {"token": 0, "jpeg": b"", "text": ""} + self._stop = threading.Event() + self._thread = threading.Thread(target=self._loop, daemon=True, name="handeye-board") + self._thread.start() + + def submit(self, token: int, jpeg: bytes, cols: int, rows: int) -> None: + with self._lock: + if self._result["token"] == token and not self._pending: + return + self._pending = (token, jpeg, int(cols), int(rows)) + + def latest(self) -> tuple[bytes, str, int]: + with self._lock: + return self._result["jpeg"], self._result["text"], int(self._result["token"]) + + def _loop(self) -> None: + while not self._stop.is_set(): + with self._lock: + job = self._pending + self._pending = None + if job is None: + time.sleep(0.05) + continue + token, jpeg, cols, rows = job + try: + blob, text = annotate_board(jpeg, cols, rows) + except Exception as exc: # noqa: BLE001 —— 检测失败不能拖死预览 + blob, text = jpeg, f"棋盘检测失败:{exc}" + with self._lock: + self._result = {"token": token, "jpeg": blob, "text": text} diff --git a/install/arm_application/lib/python3.11/site-packages/arm_application/operator_teach.py b/install/arm_application/lib/python3.11/site-packages/arm_application/operator_teach.py index 6761ad8..98c2871 100644 --- a/install/arm_application/lib/python3.11/site-packages/arm_application/operator_teach.py +++ b/install/arm_application/lib/python3.11/site-packages/arm_application/operator_teach.py @@ -8,6 +8,8 @@ from __future__ import annotations +import re +import subprocess import threading from datetime import datetime, timezone from pathlib import Path @@ -17,13 +19,17 @@ import yaml _LOCK = threading.Lock() _BRIDGE = None _SN = "" +_YIELDED = False +_ARM_CONTROLLER = "rizon_arm_controller" def _file(arm: str) -> Path: from arm_application.operator_console import ROOT + from arm_application.site_io import listed_arms, load_site_dict - if arm not in ("89", "91"): - raise ValueError("臂号只接受 89 或 91") + known = {row["id"] for row in listed_arms(load_site_dict())} + if arm not in known: + raise ValueError(f"臂号 {arm} 不在 site.yaml 里") return ROOT / "handeye" / "tmp" / arm / "operator_teach.yaml" @@ -36,9 +42,14 @@ def _bridge_for(sn: str): return _BRIDGE close() from arm_application.handeye_teach import RdkBridge + from arm_application.operator_arm_state import release_for_control, resume_after_control - _BRIDGE = RdkBridge(sn) - _SN = sn + release_for_control() + try: + _BRIDGE = RdkBridge(sn) + _SN = sn + finally: + resume_after_control() return _BRIDGE @@ -55,19 +66,95 @@ def close() -> None: pass +def _switch_arm_controller(activate: bool) -> str: + """停或恢复关节轨迹控制器。硬件还在,只是不再下发关节位置。 + + 控制栈在 ``NRT_JOINT_POSITION`` 里持续 ``SendJointPosition``, + 模式一旦被改走,写循环两秒内又切回去。软拖拽要低阻抗,必须先停这条控制器。 + """ + if activate: + payload = ( + "{activate_controllers: ['%s'], deactivate_controllers: [], " + "strictness: 2, activate_asap: true, timeout: {sec: 3, nanosec: 0}}" + % _ARM_CONTROLLER + ) + else: + payload = ( + "{activate_controllers: [], deactivate_controllers: ['%s'], " + "strictness: 1, activate_asap: true, timeout: {sec: 3, nanosec: 0}}" + % _ARM_CONTROLLER + ) + try: + proc = subprocess.run( + [ + "ros2", "service", "call", + "/controller_manager/switch_controller", + "controller_manager_msgs/srv/SwitchController", + payload, + ], + capture_output=True, + text=True, + timeout=15, + ) + except FileNotFoundError: + return "找不到 ros2,停不了关节轨迹控制器" + except subprocess.TimeoutExpired: + return "关节轨迹控制器没回来" if activate else "停关节轨迹控制器超时" + text = ((proc.stdout or "") + (proc.stderr or "")).strip() + if proc.returncode == 0 and re.search(r"ok\s*[:=]\s*true", text, re.IGNORECASE): + return "" + tail = text.splitlines()[-1] if text else f"退出码 {proc.returncode}" + verb = "恢复" if activate else "停下" + return f"没能{verb}关节轨迹控制器:{tail}" + + +def _yield_position_stream() -> str: + """控制栈没起来就什么都不做。起来了就先把位置控制器让开。""" + global _YIELDED + from arm_application.operator_arm_state import stack_running + + if not stack_running(): + return "" + err = _switch_arm_controller(False) + if err: + return err + "。位置环还在推,软拖拽切不过去。" + _YIELDED = True + return "" + + +def _restore_position_stream() -> str: + global _YIELDED + if not _YIELDED: + return "" + err = _switch_arm_controller(True) + _YIELDED = False + return err + + def drag_on(sn: str) -> dict: with _LOCK: try: + yielded = _yield_position_stream() + if yielded: + return {"ok": False, "message": yielded} bridge = _bridge_for(sn) result = bridge.drag_on(mode="soft") except Exception as exc: - return {"ok": False, "message": str(exc)} - if not result.get("ok"): - return {"ok": False, "message": str(result.get("error") or result)} - return { - "ok": True, - "message": "软拖拽已开启。轻推机械臂,再记点。这一步要先停掉控制栈。", - } + restore = _restore_position_stream() + message = str(exc) + if restore: + message = f"{message};{restore}" + return {"ok": False, "message": message} + if not result.get("ok"): + restore = _restore_position_stream() + message = str(result.get("error") or result) + if restore: + message = f"{message};{restore}" + return {"ok": False, "message": message} + note = "软拖拽已开启。轻推机械臂,再记点。" + if _YIELDED: + note += "关节轨迹控制器已停下,关掉拖拽后会回来。" + return {"ok": True, "message": note} def drag_off(sn: str) -> dict: @@ -76,9 +163,16 @@ def drag_off(sn: str) -> dict: bridge = _bridge_for(sn) result = bridge.drag_off() except Exception as exc: - return {"ok": False, "message": str(exc)} + restore = _restore_position_stream() + message = str(exc) + if restore: + message = f"{message};{restore}" + return {"ok": False, "message": message} if not result.get("ok"): return {"ok": False, "message": str(result.get("error") or result)} + restore = _restore_position_stream() + if restore: + return {"ok": False, "message": f"软拖拽已关闭,但{restore}"} return {"ok": True, "message": "软拖拽已关闭,臂保持当前位置。"} diff --git a/install/arm_application/lib/python3.11/site-packages/arm_application/overhead_oneshot.py b/install/arm_application/lib/python3.11/site-packages/arm_application/overhead_oneshot.py index fa1d3b1..e8863dd 100644 --- a/install/arm_application/lib/python3.11/site-packages/arm_application/overhead_oneshot.py +++ b/install/arm_application/lib/python3.11/site-packages/arm_application/overhead_oneshot.py @@ -45,6 +45,11 @@ from arm_application.path_profile import ( # 伺服和测试从本模块再导 # 上一跳实际少走这么多,就认定冻结 J 在当前姿态走不动,不许按同一剩差再刷。 D435_MIN_PROGRESS_FRAC = 0.40 +# SwitchMode 返回不等于已经进 primitive。连试仍失败必须让一阶段立刻中止, +# 不许空等 150s 装成"还在粗定位"(2026-10-16 17:27)。 +D435_MOVEL_RETRY = 3 +D435_MOVEL_RETRY_S = 0.3 +D435_MOVEL_GAVE_UP = "厂家 MoveL 连试 3 次没走成" def best_det_conf(dets: Optional[Iterable]) -> Optional[float]: @@ -154,16 +159,27 @@ def d405_commands_arm( ) -> bool: """D405 这一帧的点能不能下发给左臂。 - 推理一直开着。正在用 D435 那一个点时,进程内 MoveL 结束并且停稳之前不下发。 - 停稳用 ``d435_handback_sec`` 和 ``lock_still_move_m``。赶路时 XY 已经进 15 mm - 也不算到位。不许在 MoveL 期间下发 D405(两套控制互抢会晃)。 - ``move_only``:只测 D435 位移,D405 点一律不下发(到位之后也不交)。 + 推理从启动就开着。没有高置信度目标、或 D435 厂家 MoveL 还没结束、 + 法兰还没连续停稳时,这帧**不下发**(输出截断,不许拿去控臂)。 + 厂家移动结束并且法兰停稳(``settled``,时长是 ``d435_handback_sec``) + 之后才放行,伺服用它追工件框的矩形中心。框心对准停稳后才进精拍下臂。 + ``move_only``:只测 D435 位移,D405 点一律不下发。 """ if move_only: return False - if not using_d435: - return True - return bool(arrived) and bool(settled) + if using_d435: + return bool(arrived) and bool(settled) + return True + + +def retract_goal_z(z_now: float, z_target: float) -> float: + """收臂目标 Z:已经高于目标就钉在当前高度,不许为了追 5 mm 往下补。 + + 2026-10-16:贴面 MoveL 之后 JTC 还握着悬停关节,收臂 hop1 计划 +5 mm + 实际抬了 18–25 mm;hop2 把「抬过头」当成误差 ``Δz=−13 mm`` 往回压, + 看起来就是进孔再弹出。抬过了 = 成功。 + """ + return max(float(z_now), float(z_target)) def oneshot_needs_verticalize( diff --git a/install/arm_application/lib/python3.11/site-packages/arm_application/perception_node.py b/install/arm_application/lib/python3.11/site-packages/arm_application/perception_node.py index cb94271..21510b1 100644 --- a/install/arm_application/lib/python3.11/site-packages/arm_application/perception_node.py +++ b/install/arm_application/lib/python3.11/site-packages/arm_application/perception_node.py @@ -105,7 +105,7 @@ class PerceptionNode(TolerantNode): self.declare_parameter("image_topic", "") self.declare_parameter("camera_info_topic", "") self.declare_parameter("depth_topic", "/d405/aligned_depth_to_color/image_raw") - self.declare_parameter("weights", "models/yolo/gongjian0929.onnx") + self.declare_parameter("weights", "/home/cc/ros2/models/yolo/gongjian0929.onnx") self.declare_parameter("conf", 0.10) self.declare_parameter("iou", 0.45) self.declare_parameter("imgsz", 640) @@ -336,9 +336,7 @@ class PerceptionNode(TolerantNode): r_pos=float(self.get_parameter("kf_r_pos").value), ) - from arm_application.site_io import resolve_repo_path - - weights_raw = resolve_repo_path(str(self.get_parameter("weights").value)) + weights_raw = str(self.get_parameter("weights").value) weights = str(resolve_weights(weights_raw, self.backend_name)) yolo_cfg = { "weights": weights, @@ -1141,8 +1139,8 @@ class PerceptionNode(TolerantNode): filt[2] = z_use self._last_filt = filt.copy() - if handoff: - # 画面里没有 conf≥门槛的目标:绿框可以留,三维点不许发 —— 伺服会拿去追。 + if handoff or self._d435_commanding: + # 没有高置信度,或 D435 厂家移动还没交接:推理留着,控制点不许发。 return self._publish_point(filt, stamp) self.get_logger().info( @@ -1427,15 +1425,11 @@ class PerceptionNode(TolerantNode): self._z_locked = bool(msg.data) def _on_d435_commanding(self, msg: Bool) -> None: - """伺服还在执行 D435 那一个点时,不再提交下一张。 + """伺服还在执行 D435 那一个点时,不再提交下一张,D405 控制点也不发。 - 这一段结束(到位并停稳)后清掉「已拍过」,漏检重新计 0.5 s。 + 这一轮已经拍过并走完。停稳后打开 D405 框心,不因交接瞬间漏检再 MoveL。 """ - commanding = bool(msg.data) - if self._d435_commanding and not commanding: - self._oneshot_shot = False - self._low_since = None - self._d435_commanding = commanding + self._d435_commanding = bool(msg.data) def _publish_overhead_k0(self, p_base: np.ndarray, is_k0: bool) -> None: pt = PointStamped() diff --git a/install/arm_application/lib/python3.11/site-packages/arm_application/pipeline_node.py b/install/arm_application/lib/python3.11/site-packages/arm_application/pipeline_node.py index fe4f9f8..a27c828 100644 --- a/install/arm_application/lib/python3.11/site-packages/arm_application/pipeline_node.py +++ b/install/arm_application/lib/python3.11/site-packages/arm_application/pipeline_node.py @@ -15,6 +15,7 @@ import os import signal import subprocess import time +from pathlib import Path from typing import Optional import numpy as np @@ -23,6 +24,7 @@ from geometry_msgs.msg import PointStamped from rclpy.qos import HistoryPolicy, QoSProfile, ReliabilityPolicy from std_msgs.msg import Bool, String +from arm_application.overhead_oneshot import D435_MOVEL_GAVE_UP from arm_application.stage3_search_node import Stage3Search @@ -70,6 +72,19 @@ def init_z_refusal( return "" +def hold_at_insert_z(final, *, skip3: bool) -> bool: + """贴面已经按 ``final_gap_mm`` 停下时,不再进三阶段。 + + 三阶段开局会抬到悬高上方测触觉,再落回 ``hole_hover_z_m``。 + 没有接触传感器。到位高度就是这次的贴面间隙,不另设一个 Z。 + """ + if skip3: + return True + if not isinstance(final, dict): + return False + return bool(final.get("insert_held")) + + def track_lock_z(hover_z: float, tool_len_m: float, surface_z_m: float) -> float: """一阶段锁高。 @@ -85,6 +100,20 @@ def track_lock_z(hover_z: float, tool_len_m: float, surface_z_m: float) -> float return hover + TRACK_CLEARANCE_M +def stage1_log_fatal(log_path: str) -> str: + """一阶段日志里已经写明失败时,不许再空等锁定。空串 = 还没到这一步。""" + try: + text = Path(log_path).read_text(encoding="utf-8", errors="replace") + except OSError: + return "" + if D435_MOVEL_GAVE_UP in text: + return ( + f"D435 {D435_MOVEL_GAVE_UP}(RDK 控制模式没切到 primitive)," + f"一阶段中止。不是视野/标定。看 {log_path}" + ) + return "" + + def stage1_timeout_reason(points_seen: int, timeout_s: float, log_path: str) -> str: """一阶段超时了,到底是"感知没给点"还是"给了点但没收敛"。 @@ -99,6 +128,9 @@ def stage1_timeout_reason(points_seen: int, timeout_s: float, log_path: str) -> 以前两种情况共用一句"没锁定(视野里没工件?)",于是"perception_node 因为缺依赖早退了"被读成"算法不收敛",白查半天。 """ + fatal = stage1_log_fatal(log_path) + if fatal: + return fatal if points_seen <= 0: return ( f"一阶段 {timeout_s:.0f}s 没锁定:**一个视觉目标点都没收到**" @@ -308,6 +340,11 @@ class AssemblyPipeline(Stage3Search): self._spin(0.1) if self._locked: break + fatal = stage1_log_fatal(log_path) + if fatal: + self._stop_stage1() + self.get_logger().error(fatal) + return 1 if self._s1.poll() is not None: self.get_logger().error(f"一阶段进程提前退出,看 {log_path}") return 1 @@ -363,24 +400,42 @@ class AssemblyPipeline(Stage3Search): if code != 0: self.get_logger().error(f"二阶段失败 exit={code},停在当前位姿") return code - self.get_logger().info( - f"二阶段 OK 残差 {final.get('residual_mm', -1)}mm" - + ( - " — 跳过搜孔,停在观测高度(看规尖是否对中;进程退出后臂保持)" - if skip3 - else " — 立刻下压搜孔并轻压(不拧入)" + stay = hold_at_insert_z(final, skip3=skip3) + if stay and final.get("insert_held"): + self.get_logger().info( + f"二阶段 OK 残差 {final.get('residual_mm', -1)}mm — " + f"插孔停在法兰 Z={final.get('flange_z_m')}m," + f"规尖离面 {final.get('tip_gap_mm')}mm" + f"(目标 {final.get('commanded_gap_mm')}mm,来自 final_gap_mm)。" + "没有接触传感器,不另设 Z,不抬回悬高。" + ) + else: + self.get_logger().info( + f"二阶段 OK 残差 {final.get('residual_mm', -1)}mm" + + ( + " — 跳过搜孔,停在观测高度(看规尖是否对中;进程退出后臂保持)" + if skip3 + else " — 立刻下压搜孔并轻压(不拧入)" + ) ) - ) else: self._state("skip_stage2") + stay = hold_at_insert_z(final, skip3=skip3) - if skip3: + if stay: self._state("skip_stage3") if self.d435_move_only: self.get_logger().info( "D435 位移测试结束 —— 臂停在当前高度,不交 D405、不对孔;" "15s 后本进程退出,臂保持不动" ) + elif isinstance(final, dict) and final.get("insert_held"): + if q is not None: + self._pin(q) + self.get_logger().info( + "插孔已停在贴面高度,不进搜孔、不抬回悬高。" + "15s 后本进程退出,臂保持这个 Z。" + ) else: self.get_logger().info( "跳过搜孔 —— 臂停在当前高度。请看规尖与孔是否对中;" diff --git a/install/arm_application/lib/python3.11/site-packages/arm_application/realsense_color_pub.py b/install/arm_application/lib/python3.11/site-packages/arm_application/realsense_color_pub.py index 1d8464a..c95b46a 100644 --- a/install/arm_application/lib/python3.11/site-packages/arm_application/realsense_color_pub.py +++ b/install/arm_application/lib/python3.11/site-packages/arm_application/realsense_color_pub.py @@ -176,6 +176,10 @@ class RealsenseColorPub(TolerantNode): self._overlay: Optional[list] = None self._overlay_stamp = 0.0 self._capture_started = False + self._cap_lock = threading.Lock() + self._cap_event = threading.Event() + self._pending = None + self._last_frame_mono = 0.0 self._usb_desc = '' self._last_ok = None self._site_yaml = _site_yaml_path() @@ -309,23 +313,26 @@ class RealsenseColorPub(TolerantNode): self._info_pub.publish(info) if depth_u16 is not None: - dh, dw = int(depth_u16.shape[0]), int(depth_u16.shape[1]) - dmsg = Image() - dmsg.header.stamp = stamp - dmsg.header.frame_id = self._frame_id - dmsg.height = dh - dmsg.width = dw - dmsg.encoding = '16UC1' - dmsg.is_bigendian = 0 - dmsg.step = dw * 2 - dmsg.data = array.array('B', np.ascontiguousarray(depth_u16).tobytes()) - self._depth_pub.publish(dmsg) - if not self._logged_depth: - self._logged_depth = True - self.get_logger().info( - f'首个对齐深度 {dw}x{dh} → ' - f'/{self._name}/aligned_depth_to_color/image_raw' - ) + self._publish_depth(depth_u16, stamp) + + def _publish_depth(self, depth_u16: np.ndarray, stamp=None) -> None: + dh, dw = int(depth_u16.shape[0]), int(depth_u16.shape[1]) + dmsg = Image() + dmsg.header.stamp = stamp or self.get_clock().now().to_msg() + dmsg.header.frame_id = self._frame_id + dmsg.height = dh + dmsg.width = dw + dmsg.encoding = '16UC1' + dmsg.is_bigendian = 0 + dmsg.step = dw * 2 + dmsg.data = array.array('B', np.ascontiguousarray(depth_u16).tobytes()) + self._depth_pub.publish(dmsg) + if not self._logged_depth: + self._logged_depth = True + self.get_logger().info( + f'首个对齐深度 {dw}x{dh} → ' + f'/{self._name}/aligned_depth_to_color/image_raw' + ) def _start_pipeline(self, reconnect: bool = False) -> None: try: @@ -457,7 +464,9 @@ class RealsenseColorPub(TolerantNode): trial.enable_stream( rs.stream.infrared, 2, w, h, rs.format.y8, fps ) - profile = pipe.start(trial) + # 回调里只留最新一帧。wait_for_frames 会等彩色、深度、红外齐套, + # 场景一动红外对不齐,彩色帧就在这里被丢掉。 + profile = pipe.start(trial, self._on_sdk_frame) have_depth = with_depth have_infra = infra_mode != 'none' self._infra_mode = infra_mode @@ -508,6 +517,13 @@ class RealsenseColorPub(TolerantNode): self._pipe = pipe self._profile = profile + try: + sensor = profile.get_device().first_color_sensor() + if sensor.supports(rs.option.frames_queue_size): + # 队列只留最新一张,避免越积越慢,动起来画面还停在旧帧。 + sensor.set_option(rs.option.frames_queue_size, 1) + except Exception: # noqa: BLE001 + pass self._have_depth = have_depth self._have_infra = have_infra self._logged_depth = False @@ -609,7 +625,7 @@ class RealsenseColorPub(TolerantNode): if not getattr(self, '_capture_started', False): self._capture_started = True - self._thread = threading.Thread(target=self._capture_loop, daemon=True) + self._thread = threading.Thread(target=self._publish_loop, daemon=True, name="rs-publish") self._thread.start() def _enable_color_ae_fast(self, profile, rs) -> None: @@ -702,18 +718,60 @@ class RealsenseColorPub(TolerantNode): ) return False - def _capture_loop(self) -> None: + def _on_sdk_frame(self, frame) -> None: + """SDK 回调。只拷走彩色并握住这一组帧,立刻返回。 + + 对齐、深度换算和 ROS 发布若做在这个回调里,下一帧来不及取, + 动起来时彩色帧号会跳。那些工作放到 ``_publish_loop``。 + """ + try: + frames = frame.as_frameset() + color = frames.get_color_frame() + if not color: + return + bgr = np.array(np.asanyarray(color.get_data()), copy=True) + frames.keep() + with self._cap_lock: + self._pending = (bgr, frames) + self._last_frame_mono = time.monotonic() + self._cap_event.set() + except Exception as exc: # noqa: BLE001 + try: + self.get_logger().warn(f'收帧:{exc}', throttle_duration_sec=2.0) + except Exception: # noqa: BLE001 + pass + + def _publish_loop(self) -> None: got = False - misses = 0 while self._running and rclpy.ok(): if self._pipe is None: if not self._reconnect(): time.sleep(2.0) continue + if not self._cap_event.wait(0.2): + silent = self._last_frame_mono and time.monotonic() - self._last_frame_mono > 2.0 + if silent: + usb = getattr(self, '_usb_desc', '?') + self.get_logger().error( + f'相机超过 2 秒没有新帧(USB={usb})。正在重连 —— ' + '请给 USB 线缆留点余量,别让机械臂把它拽掉。' + ) + self._last_frame_mono = 0.0 + got = False + self._reconnect() + continue + self._cap_event.clear() + with self._cap_lock: + item = self._pending + self._pending = None + if item is None: + continue + bgr, frames = item try: - t_wait0 = time.monotonic() - frames = self._pipe.wait_for_frames(timeout_ms=2000) - self._t_wait = time.monotonic() - t_wait0 + t_pub = time.monotonic() + # 彩色先发出去。深度对齐再慢,也不能把这一帧彩色拖掉。 + self._publish_frame(bgr, None) + self._t_pub = time.monotonic() - t_pub ir_l = ( frames.get_infrared_frame(1) if self._have_infra and self._infra_mode in ('both', 'left') @@ -724,44 +782,45 @@ class RealsenseColorPub(TolerantNode): if self._have_infra and self._infra_mode in ('both', 'right') else None ) + aligned = frames if self._align is not None: t_al = time.monotonic() - frames = self._align.process(frames) + aligned = self._align.process(frames) self._t_align = time.monotonic() - t_al + depth = aligned.get_depth_frame() color = frames.get_color_frame() except Exception as exc: # noqa: BLE001 - misses += 1 text = str(exc) dropped = ( 'disconnect' in text.lower() or 'ioctl' in text.lower() or 'errno=5' in text.lower() ) - if dropped or misses >= 4: + if dropped: usb = getattr(self, '_usb_desc', '?') self.get_logger().error( f'相机数据流中断(USB={usb}):{text[:120]}。' '正在重连 —— 请给 USB 线缆留点余量,别让机械臂把它拽掉。' ) got = False - misses = 0 + self._last_frame_mono = 0.0 self._reconnect() - continue - if misses == 1: - self.get_logger().warn(f'wait_for_frames:{exc}') + else: + self.get_logger().warn(f'整理帧:{exc}', throttle_duration_sec=2.0) continue - if not color: - misses += 1 - continue - misses = 0 - bgr = np.asanyarray(color.get_data()) - depth = frames.get_depth_frame() depth_u16 = np.asanyarray(depth.get_data()) if depth else None if depth_u16 is not None and self._depth_to_mm != 1.0: # 原始设备单位 → 真实 mm(D405 原始为 0.1 mm/单位) depth_u16 = np.clip( depth_u16.astype(np.float32) * self._depth_to_mm, 0, 65535 ).astype(np.uint16) + if depth_u16 is not None: + try: + self._publish_depth(depth_u16) + except Exception as exc: # noqa: BLE001 + if not self._running: + break + self.get_logger().warn(f'发布深度失败:{exc}', throttle_duration_sec=2.0) self._frame_i += 1 self._fps_meter(color, depth, depth_u16) # 预览相关的工作(CPU 上色、IR 拷贝、四窗格绘制)每 N 帧才做一次 @@ -794,7 +853,6 @@ class RealsenseColorPub(TolerantNode): ) try: t_pub = time.monotonic() - self._publish_frame(bgr, depth_u16) if self._ir1_pub is not None and ir_l is not None: self._publish_ir(ir_l, self._ir1_pub) if self._ir2_pub is not None and ir_r is not None: diff --git a/install/arm_application/lib/python3.11/site-packages/arm_application/servo_node.py b/install/arm_application/lib/python3.11/site-packages/arm_application/servo_node.py index 848dbcd..10eb349 100644 --- a/install/arm_application/lib/python3.11/site-packages/arm_application/servo_node.py +++ b/install/arm_application/lib/python3.11/site-packages/arm_application/servo_node.py @@ -50,6 +50,9 @@ from arm_application.geom_ik import ( ) from arm_application.handeye_lib import cam_point_to_base, cam_to_base_T from arm_application.overhead_oneshot import ( + D435_MOVEL_GAVE_UP, + D435_MOVEL_RETRY, + D435_MOVEL_RETRY_S, D435_NEAR_CRUISE_MPS, D435_SETTLE_S, D435_VENDOR_MOVEL_VEL_MPS, @@ -126,7 +129,7 @@ class ServoNode(TolerantNode): self.declare_parameter("point_topic", "visual_servo/filtered_point") # 默认值只作兜底:真机运行一定由 launch 传入 site.yaml 的 paths.handeye_file。 # 指向 /tmp 是历史遗留(标定临时产物),会让人误以为"标定结果在那里"。 - self.declare_parameter("handeye_file", "handeye/results/89_eye_in_hand.yaml") + self.declare_parameter("handeye_file", "/home/cc/ros2/handeye/results/89_eye_in_hand.yaml") # eye_in_hand = cam→法兰;eye_to_hand = cam→基座。写错会把固定相机当腕部用。 self.declare_parameter("handeye_mode", "eye_in_hand") self.declare_parameter("use_static_handeye_tf", False) @@ -224,8 +227,7 @@ class ServoNode(TolerantNode): self.declare_parameter("lock_hold_sec", 2.0) # D405 跟住后法兰静止多久进二阶段。哨兵 -1 = 没从 site 接入。 # lock_still_sec 仍须从 site 接上(缺了就报错)。 - # 交回 D405:进程内 MoveL 到位后,再叠 d435_handback_sec(法兰 XY 停稳)。 - # 旧杀栈冷启动曾把这 3s 垫在控制器重启后面;不杀栈后只留机械停稳。 + # 厂家 MoveL 结束后,法兰连续停稳 d435_handback_sec 才打开 D405 框心。 self.declare_parameter("lock_still_sec", -1.0) self.declare_parameter("d435_handback_sec", -1.0) self.declare_parameter("wrist_align_sec", -1.0) @@ -329,7 +331,7 @@ class ServoNode(TolerantNode): and self.wrist_handoff_conf >= 0.0 and self.overhead_hold_s >= 0.0 ) - # 框心对准进对孔用 wrist_align_sec。D435 交回只用 d435_handback_sec,两段不要合成一个数。 + # 框心对准进精拍用 wrist_align_sec。D435 交接只用 d435_handback_sec,两段不要合成一个数。 self._still = StillLock( still_sec=max(0.0, self.wrist_align_sec), still_move_m=max(0.0, self.lock_still_move_m), @@ -342,6 +344,8 @@ class ServoNode(TolerantNode): self._d435_vendor_done = False self._d435_vendor_busy = False self._d435_vendor_failed = False + self._d435_vendor_tries = 0 + self._d435_retry_after = 0.0 self._d435_settled = False self._d435_exec_until = 0.0 self._d435_pinned = False @@ -374,10 +378,9 @@ class ServoNode(TolerantNode): self.ws_min = np.array([float(v) for v in ws_min[:3]], dtype=float) self.ws_max = np.array([float(v) for v in ws_max[:3]], dtype=float) - from arm_application.site_io import resolve_repo_path - - handeye_path = resolve_repo_path(str(self.get_parameter("handeye_file").value)) - self.T_cam_to_gripper = load_handeye_T(handeye_path) + self.T_cam_to_gripper = load_handeye_T( + str(self.get_parameter("handeye_file").value) + ) self.handeye_mode = str( self.get_parameter("handeye_mode").value or "eye_in_hand" ).strip().lower() @@ -400,7 +403,7 @@ class ServoNode(TolerantNode): t_he = np.asarray(self.T_cam_to_gripper[:3, 3], dtype=float) axis_z = float(np.clip(R_he[2, 2], -1.0, 1.0)) self._cam_tilt_deg = float(np.degrees(np.arccos(axis_z))) - he_path = handeye_path + he_path = str(self.get_parameter("handeye_file").value) if self.handeye_mode == "eye_to_hand": self.get_logger().warn( f"HANDEYE yaml={he_path} mode=eye_to_hand " @@ -709,6 +712,8 @@ class ServoNode(TolerantNode): self._d435_vendor_done = False self._d435_vendor_busy = False self._d435_vendor_failed = False + self._d435_vendor_tries = 0 + self._d435_retry_after = 0.0 self._overhead_t = now_m self._last_seen_time = now_m self._stamp_sec = now_sec @@ -732,6 +737,8 @@ class ServoNode(TolerantNode): self._d435_vendor_done = False self._d435_vendor_busy = False self._d435_vendor_failed = False + self._d435_vendor_tries = 0 + self._d435_retry_after = 0.0 self._publish_d435_commanding(True) self.get_logger().info( f"D435 一次性设定 feat_base=({p_base[0]:.3f},{p_base[1]:.3f},{p_base[2]:.3f}) " @@ -759,9 +766,9 @@ class ServoNode(TolerantNode): settled=settled, move_only=self.d435_move_only, ): - # 推理服务不关。这一帧的点不下发给臂,目标仍是 D435 那一个点。 + # 推理不关。没到「法兰停稳」之前,这帧不能进运动控制。 self.get_logger().info( - "D435 位置还没到并且停稳,D405 这帧不下发", + "D405 输出已截断:D435 厂家移动还没结束或法兰还没停稳", throttle_duration_sec=1.0, ) return @@ -1407,9 +1414,12 @@ class ServoNode(TolerantNode): return geometric_jacobian(link_R, link_p, p_ee, RIZON_LINK_AXES) def _d435_oneshot_holds_the_arm(self) -> bool: - """D435 到点平移:action 短跳,等结果再重测。D405 主跟踪仍走原来的伺服。""" - if self.d435_move_only and self._hybrid and self._track_source == "overhead" and self._d435_settled: - # 位移测完了:守着,不许掉进 D405 的 _execute_twist。hold 只在到位那一拍发一次。 + """D435 粗定位只走厂家 MoveL。停稳后先钉住,等 D405 框心接上再放开。 + + 这段不许再用流式伺服去追 D435 那个点。D405 点被 ``_on_point`` 接上后 + 跟踪源变成腕部,这里返回 False,框心才开始带臂。 + """ + if self._hybrid and self._track_source == "overhead" and self._d435_settled: return True if not (self._hybrid and self._track_source == "overhead" and not self._d435_settled): return False @@ -1519,7 +1529,7 @@ class ServoNode(TolerantNode): """停或重新打开关节轨迹控制器。关节状态广播留着,硬件接口不下线。 用节点里的客户端,不走 ``ros2 service call``:那条命令自己就要一两秒, - 交回 D405 的预算会被它吃掉。 + 停稳锁定的预算会被它吃掉。 """ from controller_manager_msgs.srv import SwitchController @@ -1659,18 +1669,32 @@ class ServoNode(TolerantNode): if self._d435_vendor_failed or self._d435_vendor_busy: return True if not self._d435_vendor_done: - # busy 先于调用置位:另一条控制回调不能再开一次 MoveL,也不能提前交回 D405。 + if time.monotonic() < float(self._d435_retry_after): + return True + if int(self._d435_vendor_tries) >= int(D435_MOVEL_RETRY): + self._d435_vendor_failed = True + self.get_logger().error( + f"{D435_MOVEL_GAVE_UP} —— 一阶段中止,D405 也不带臂" + ) + return True + # busy 先于调用置位:另一条控制回调不能再开一次 MoveL,也不能提前锁定。 self._d435_vendor_busy = True ok = self._d435_vendor_movel() self._d435_vendor_busy = False if not ok: - self._d435_vendor_failed = True + self._d435_vendor_tries = int(self._d435_vendor_tries) + 1 + self._d435_retry_after = time.monotonic() + float(D435_MOVEL_RETRY_S) self.get_logger().error( - "厂家 MoveL 没走成 —— 停在原地,D405 的点继续不下发" + f"厂家 MoveL 第{self._d435_vendor_tries}次没走成," + f"{D435_MOVEL_RETRY_S:.1f}s 后再试" ) else: self._d435_vendor_done = True self._arrive.reset() + # MoveL 切回关节后 JTC 还握着出发前的悬停。不钉住的话 + # 硬件 3 s 窗口一过,臂会按悬停关节走回去(比交回 D405 的 10 cm 更远)。 + self._send_hold() + self._d435_pinned = True return True return True @@ -1729,7 +1753,7 @@ class ServoNode(TolerantNode): ): self._d435_pin_and_stop_hops( f"D435 上一跳几乎没走近,剩差还是 {dist*1000:.0f}mm " - f"—— 钉住不再补刀,等停稳后交回 D405" + f"—— 钉住不再补刀,等停稳后锁定进二阶段" ) return True if not at: @@ -1836,7 +1860,7 @@ class ServoNode(TolerantNode): return True def _tick_d435_arrive(self) -> None: - """走到 D435 那个点,并且法兰按一阶段的静止判据停稳,才允许下发 D405。""" + """厂家 MoveL 已结束:看法兰还在不在动。连续停稳后才打开 D405 框心。""" if not self._hybrid: return if ( @@ -1858,11 +1882,15 @@ class ServoNode(TolerantNode): return xy = np.asarray(T[:2, 3], dtype=float) dist = float(np.hypot(float(xy[0]) - goal[0], float(xy[1]) - goal[1])) - arrived = dist <= float(self.lock_still_move_m) or self._d435_give_up - settled = self._arrive.update( - xy, time.monotonic(), tracking_ok=arrived - ) + # 到位看法兰是否还在动,不看离视觉点还差几毫米。 + settled = self._arrive.update(xy, time.monotonic(), tracking_ok=True) if not settled: + self.get_logger().info( + f"D435 厂家 MoveL 已结束,法兰还在动或未满 " + f"{self.d435_handback_sec:.1f}s(离点 {dist*1000:.0f}mm)," + "D405 输出仍截断", + throttle_duration_sec=1.0, + ) return with self._lock: self._d435_settled = True @@ -1885,10 +1913,12 @@ class ServoNode(TolerantNode): self._lock_pub_t = now_m self._lock_pub.publish(Bool(data=True)) return + # 法兰已停稳:打开 D405。不在这里锁定;框心对准停稳后才精拍下臂。 self._publish_d435_commanding(False) self.get_logger().info( - f"已到 D435 位置(还差 {dist*1000:.0f}mm)并停稳 " - f"{self.d435_handback_sec:.1f}s —— 改回下发 D405 框心" + f"法兰已连续停稳 {self.d435_handback_sec:.1f}s" + f"(离 D435 点 {dist*1000:.0f}mm)" + " —— 打开 D405 输出,追工件框矩形中心;对准后才精拍下臂" ) def _tick_still_lock(self) -> None: diff --git a/install/arm_application/lib/python3.11/site-packages/arm_application/site_io.py b/install/arm_application/lib/python3.11/site-packages/arm_application/site_io.py index 8ddc507..9243123 100644 --- a/install/arm_application/lib/python3.11/site-packages/arm_application/site_io.py +++ b/install/arm_application/lib/python3.11/site-packages/arm_application/site_io.py @@ -10,83 +10,13 @@ import yaml def ros2_root() -> Path: - """仓库根目录。先看环境变量,再从本文件向上找 ``config/site.yaml``。 - - 不能默认 ``~/ros2``:别人把仓库克隆到别的目录时,写死的家目录路径是空的, - 启动会报文件不存在。 - """ for key in ("SITE_ROS2_ROOT", "ROS2_WS"): v = os.environ.get(key, "").strip() if v: return Path(v).expanduser() - here = Path(__file__).resolve() - for parent in here.parents: - if (parent / "config" / "site.yaml").is_file() and (parent / "scripts" / "env.sh").is_file(): - return parent return Path.home() / "ros2" -_PATH_KEYS = frozenset({ - "named_poses_file", - "handeye_file", - "handeye_waypoints", - "handeye_workdir", - "handeye_calibrate", - "yolo_weights", - "hole_weights", - "weights", - "workpiece_weights", - "config_file", - "append_poses_file", -}) - - -def resolve_repo_path(value: str, root: Path | None = None) -> str: - """仓库内的相对路径展开成绝对路径。已经是绝对路径的原样保留。""" - text = str(value or "").strip() - if not text: - return "" - path = Path(text).expanduser() - if path.is_absolute(): - return str(path) - base = root or ros2_root() - return str((base / path).resolve()) - - -def _is_path_key(key: str) -> bool: - return ( - key in _PATH_KEYS - or key.startswith("handeye_file") - or key.startswith("handeye_waypoints") - or key.endswith("_weights") - or key.endswith("_file") - or key.endswith("_workdir") - ) - - -def absolutize_site_paths(data: Any, root: Path | None = None) -> Any: - """把 site 里的仓库相对路径展开。只动路径字段,不动别的字符串。""" - base = root or ros2_root() - if isinstance(data, dict): - out: dict[Any, Any] = {} - for key, val in data.items(): - name = str(key) - if isinstance(val, str) and _is_path_key(name): - out[key] = resolve_repo_path(val, base) - elif ( - name == "config" - and isinstance(val, str) - and (val.endswith((".yaml", ".yml", ".json")) or "/" in val) - ): - out[key] = resolve_repo_path(val, base) - else: - out[key] = absolutize_site_paths(val, base) - return out - if isinstance(data, list): - return [absolutize_site_paths(item, base) for item in data] - return data - - def site_path() -> Path: env = os.environ.get("SITE_CONFIG", "").strip() if env: @@ -99,11 +29,7 @@ def load_site_dict(path: str | Path | None = None) -> dict[str, Any]: if not p.is_file(): return {} data = yaml.safe_load(p.read_text(encoding="utf-8")) or {} - if not isinstance(data, dict): - return {} - resolved = p.resolve() - root = resolved.parent.parent if resolved.parent.name == "config" else ros2_root() - return absolutize_site_paths(data, root) + return data if isinstance(data, dict) else {} def deep_merge(base: dict[str, Any], overlay: dict[str, Any]) -> dict[str, Any]: diff --git a/install/arm_application/lib/python3.11/site-packages/arm_application/stage2_locate_node.py b/install/arm_application/lib/python3.11/site-packages/arm_application/stage2_locate_node.py index 670bacb..a3575d2 100644 --- a/install/arm_application/lib/python3.11/site-packages/arm_application/stage2_locate_node.py +++ b/install/arm_application/lib/python3.11/site-packages/arm_application/stage2_locate_node.py @@ -69,6 +69,7 @@ from arm_application.overhead_oneshot import ( once_ff_tcp_xyz, oneshot_align_step_m, pixel_on_base_plane, + retract_goal_z, ) from arm_application.handeye_realsense import resolve_realsense_topics from arm_application.gauge_boot import perception_source_of @@ -249,7 +250,7 @@ class Stage2Locate(TolerantNode): # _coarse_target_reject / _bbox_on_border 的注释):粗居中的目的是把臂 # 挪到工件正上方,**挪错比不挪危险得多** —— 一挪错精定位就再也看不到 # k1/k2,而流程会把它当"孔检测失败",现场根本想不到是粗居中把臂带走的。 - self.coarse_max_offset_m = float(stage2.get("coarse_max_offset_m") or 0.12) + self.coarse_max_offset_m = float(stage2.get("coarse_max_offset_m") or 0.08) self.coarse_border_px = int(stage2.get("coarse_border_px") or 2) # 最后轻降到孔面上方 final_gap_mm(不接触)。启动参数 ≥0 覆盖 site。 self._final_gap_refused = "" @@ -901,10 +902,13 @@ class Stage2Locate(TolerantNode): min_dur: float = 1.6, hold_xy: bool = False, strict_xy: bool = False, + z_up_only: bool = False, ) -> Tuple[bool, np.ndarray, np.ndarray]: """闭环点到点:规划短 hop → 执行 → 重新看 TF → 重复,直到容差内。 hold_xy=True:只收 Z/姿态(悬停降锁高),不把下降中漂出的 XY 再拽回去。 + z_up_only=True:当前已经高于目标 Z 就停,不许为了追 5 mm 往下补 + (贴面 MoveL 后收臂 hop1 抬过头、hop2 回压进孔,2026-10-16)。 """ target = self._clamp_ws(target) last_dist: Optional[float] = None @@ -912,7 +916,14 @@ class Stage2Locate(TolerantNode): stall = 0 for hop in range(max_hops): p = Tee[:3, 3] - goal = np.array([p[0], p[1], target[2]]) if hold_xy else target + z_tgt = float(target[2]) + if z_up_only: + if float(p[2]) >= z_tgt - float(tol_m): + return True, q, Tee + z_tgt = retract_goal_z(float(p[2]), z_tgt) + goal = np.array([p[0], p[1], z_tgt]) if hold_xy else np.array( + [target[0], target[1], z_tgt], dtype=float + ) if hold_xy: goal = self._clamp_ws(goal) dp = goal - p @@ -1491,14 +1502,15 @@ class Stage2Locate(TolerantNode): 1. 目标点落在 ``workspace_xyz_*`` 的 XY 之外(含 5mm 余量)—— 一阶段刚锁 定时臂就在工件上方,工件**不该**在盒外; - 2. 目标点离当前夹爪轴线超过 ``coarse_max_offset_m``(缺省 0.12m)—— 粗居中 - 是"把臂挪到工件正上方",合理位移是厘米级;十几厘米只可能是坏读数。 + 2. 目标点离当前夹爪轴线超过 ``coarse_max_offset_m``(缺省 0.08m)—— 粗居中 + 是"把臂挪到工件正上方",合理位移是厘米级;十几厘米只可能是坏读数 + (2026-10-16 三次 110mm 回撤,0.12 放行了)。 注意:判误检后**原地不动**,由精定位(直接量 k1/k2)继续。不动的代价只是 "少挪一次",而挪错的代价是精定位彻底看不到孔 —— 所以这里必须选不动。 """ if max_offset_m is None: - max_offset_m = float(getattr(self, "coarse_max_offset_m", 0.12)) + max_offset_m = float(getattr(self, "coarse_max_offset_m", 0.08)) p = np.asarray(p_base, dtype=float).reshape(3) if not np.all(np.isfinite(p[:2])): return "目标点非有限值(深度/三角化失败)" @@ -2241,7 +2253,9 @@ class Stage2Locate(TolerantNode): ) fl0 = (float(Tee[0, 3]), float(Tee[1, 3]), float(Tee[2, 3])) ok = self._vendor_movel_tcp(movel_xyz, ONCE_FF_MOVEL_VEL_MPS) - q2, Tee2 = self._wait_state(2.0) + # MoveL 刚切回关节模式:JTC 还握着贴面前的悬停关节。等 2 s 再钉 + # 等于让 vmax=0.05 往悬停留 0.1 rad(2026-10-16 收臂实测多抬 13–20 mm)。 + q2, Tee2 = self._wait_state(0.3) if q2 is not None and Tee2 is not None: q, Tee = q2, Tee2 self._pin(q) @@ -3392,14 +3406,36 @@ class Stage2Locate(TolerantNode): ) # ---- 5) 锁定保持 ---- + # 贴面之后就停在这个法兰 Z。没有接触传感器,到位只认 final_gap_mm + # (规尖离面)。三阶段开局会抬回悬高,这里标明已经插到位,流水线不再上抬。 + if ( + self.descent_final_gap_m is not None + and self.final_descent_enable + and not self.hover_only + ): + final = dict(final) + final["insert_held"] = True + final["flange_z_m"] = round(float(Tee[2, 3]), 4) + final["tip_gap_mm"] = round(float(self.descent_final_gap_m) * 1000.0, 2) + final["commanded_gap_mm"] = round(float(self.final_gap_m) * 1000.0, 2) + final["insert_z_source"] = "final_gap_mm" self._state("hold") q_fin = self._q() self._pin(q_fin if q_fin is not None else q) - self.get_logger().info( - f"=== 二阶段定位完成: {self.target_hole} 残差 {final.get('residual_mm', -1)}mm," - f"规尖悬在孔面上方 ≈{gap_txt}mm(不接触)。" - f"结果: /stage2/result, /tmp/stage2_result.json ===" - ) + if final.get("insert_held"): + self.get_logger().info( + f"=== 二阶段定位完成: {self.target_hole} 残差 {final.get('residual_mm', -1)}mm," + f"法兰 Z={final.get('flange_z_m')}m,规尖离面 {gap_txt}mm" + f"(final_gap {self.final_gap_m * 1000:.1f}mm,无接触传感器)。" + "停在这一高度,不抬回悬高。" + f"结果: /stage2/result, /tmp/stage2_result.json ===" + ) + else: + self.get_logger().info( + f"=== 二阶段定位完成: {self.target_hole} 残差 {final.get('residual_mm', -1)}mm," + f"规尖悬在孔面上方 ≈{gap_txt}mm(不接触)。" + f"结果: /stage2/result, /tmp/stage2_result.json ===" + ) return 0, q_fin if q_fin is not None else q, Tee, final def _linger_hold(self, final: Dict[str, Any], seconds: Optional[float] = None) -> None: diff --git a/install/arm_application/lib/python3.11/site-packages/arm_application/stage3_search_node.py b/install/arm_application/lib/python3.11/site-packages/arm_application/stage3_search_node.py index e6e9bbf..acf1a50 100644 --- a/install/arm_application/lib/python3.11/site-packages/arm_application/stage3_search_node.py +++ b/install/arm_application/lib/python3.11/site-packages/arm_application/stage3_search_node.py @@ -526,7 +526,8 @@ class Stage3Search(Stage2Locate): tgt[2] += dz_m self._goto(q, Tee, tgt, verticalize=True, tol_m=0.001, tol_tilt=self.vert_tol_rad * 3.0, tag="retract", - v=0.03, max_hops=3, settle_s=0.2, min_dur=0.6) + v=0.03, max_hops=2, settle_s=0.2, min_dur=0.6, + hold_xy=True, z_up_only=True) except Exception: pass diff --git a/install/arm_application/share/arm_application/config/perception.yaml b/install/arm_application/share/arm_application/config/perception.yaml index c2411e9..5685bf9 100644 --- a/install/arm_application/share/arm_application/config/perception.yaml +++ b/install/arm_application/share/arm_application/config/perception.yaml @@ -12,7 +12,7 @@ perception_node: image_topic: "" camera_info_topic: "" depth_topic: "/d405/aligned_depth_to_color/image_raw" - weights: "models/yolo/gongjian0929.onnx" + weights: "/home/cc/ros2/models/yolo/gongjian0929.onnx" conf: 0.10 iou: 0.45 imgsz: 640 diff --git a/install/arm_application/share/arm_application/package.ps1 b/install/arm_application/share/arm_application/package.ps1 index c4a4018..44f3ad6 100644 --- a/install/arm_application/share/arm_application/package.ps1 +++ b/install/arm_application/share/arm_application/package.ps1 @@ -112,6 +112,5 @@ $env:COLCON_CURRENT_PREFIX=(Get-Item $PSCommandPath).Directory.Parent.Parent.Ful colcon_package_source_powershell_script "$env:COLCON_CURRENT_PREFIX/share/arm_application/hook/pythonpath.ps1" colcon_package_source_powershell_script "$env:COLCON_CURRENT_PREFIX/share/arm_application/hook/ament_prefix_path.ps1" -colcon_package_source_powershell_script "$env:COLCON_CURRENT_PREFIX/../../build/arm_application/share/arm_application/hook/pythonpath_develop.ps1" Remove-Item Env:\COLCON_CURRENT_PREFIX diff --git a/install/arm_application/share/colcon-core/packages/arm_application b/install/arm_application/share/colcon-core/packages/arm_application index a46ab6f..f7f9267 100644 --- a/install/arm_application/share/colcon-core/packages/arm_application +++ b/install/arm_application/share/colcon-core/packages/arm_application @@ -1 +1 @@ -arm_kinematics:arm_msgs:geometry_msgs:python3-serial:python3-yaml:rclpy:sensor_msgs:std_msgs:std_srvs:tf2_ros:trajectory_msgs \ No newline at end of file +arm_kinematics:arm_msgs:control_msgs:geometry_msgs:python3-serial:python3-yaml:rclpy:sensor_msgs:std_msgs:std_srvs:tf2_ros:trajectory_msgs \ No newline at end of file diff --git a/install/flexiv_hardware/include/flexiv_hardware/flexiv_dual_hardware_interface.hpp b/install/flexiv_hardware/include/flexiv_hardware/flexiv_dual_hardware_interface.hpp deleted file mode 100644 index 457aee2..0000000 --- a/install/flexiv_hardware/include/flexiv_hardware/flexiv_dual_hardware_interface.hpp +++ /dev/null @@ -1,139 +0,0 @@ -/** - * @file flexiv_dual_hardware_interface.hpp - * @brief Hardware interface to a pair of Flexiv robots for ROS 2 control. - * @copyright Copyright (C) 2016-2025 Flexiv Ltd. All Rights Reserved. - * @author Flexiv - */ - -#ifndef FLEXIV_HARDWARE__FLEXIV_DUAL_HARDWARE_INTERFACE_HPP_ -#define FLEXIV_HARDWARE__FLEXIV_DUAL_HARDWARE_INTERFACE_HPP_ - -#include -#include -#include -#include - -// ROS -#include -#include -#include -#include -#include -#include - -// ros2_control hardware_interface -#include -#include -#include - -#include "flexiv_hardware/visibility_control.h" - -// Flexiv -#include "flexiv/drdk/robot_pair.hpp" - -namespace flexiv_hardware { - -enum StoppingInterface -{ - NONE, - STOP_POSITION, - STOP_VELOCITY, - STOP_EFFORT -}; - -class FlexivDualHardwareInterface : public hardware_interface::SystemInterface -{ -public: - RCLCPP_SHARED_PTR_DEFINITIONS(FlexivDualHardwareInterface) - - FLEXIV_HARDWARE_PUBLIC - hardware_interface::CallbackReturn on_init( - const hardware_interface::HardwareInfo& info) override; - - FLEXIV_HARDWARE_PUBLIC - std::vector export_state_interfaces() override; - - FLEXIV_HARDWARE_PUBLIC - std::vector export_command_interfaces() override; - - FLEXIV_HARDWARE_PUBLIC - hardware_interface::return_type prepare_command_mode_switch( - const std::vector& start_interfaces, - const std::vector& stop_interfaces) override; - - FLEXIV_HARDWARE_PUBLIC - hardware_interface::return_type perform_command_mode_switch( - const std::vector& start_interfaces, - const std::vector& stop_interfaces) override; - - FLEXIV_HARDWARE_PUBLIC - hardware_interface::CallbackReturn on_activate( - const rclcpp_lifecycle::State& previous_state) override; - - FLEXIV_HARDWARE_PUBLIC - hardware_interface::CallbackReturn on_deactivate( - const rclcpp_lifecycle::State& previous_state) override; - - FLEXIV_HARDWARE_PUBLIC - hardware_interface::return_type read( - const rclcpp::Time& time, const rclcpp::Duration& period) override; - - FLEXIV_HARDWARE_PUBLIC - hardware_interface::return_type write( - const rclcpp::Time& time, const rclcpp::Duration& period) override; - -private: - // Flexiv DRDK - std::unique_ptr robot_pair_; - - // RDK control mode for joint position and velocity interfaces - flexiv::rdk::Mode rdk_control_mode_; - - // External axis type - std::string external_axis_type_ = ""; - - // Joint commands - std::vector hw_commands_joint_positions_; - std::vector hw_commands_joint_velocities_; - std::vector hw_commands_joint_efforts_; - - // Joint states - std::vector hw_states_joint_positions_; - std::vector hw_states_joint_velocities_; - std::vector hw_states_joint_efforts_; - - // Robot States - flexiv::rdk::RobotStates hw_flexiv_robot_states_left_; - flexiv::rdk::RobotStates hw_flexiv_robot_states_right_; - flexiv::rdk::RobotStates* hw_flexiv_robot_states_addr_left_ = &hw_flexiv_robot_states_left_; - flexiv::rdk::RobotStates* hw_flexiv_robot_states_addr_right_ = &hw_flexiv_robot_states_right_; - - // GPIO commands and states - std::vector hw_commands_gpio_out_; - std::vector hw_states_gpio_in_; - - // Current digital output map - std::map current_digital_outputs_left_; - std::map current_digital_outputs_right_; - - // Joint mapping - struct JointMap - { - int robot_index; // 0: Left, 1: Right - int dof_index; // Index in the robot's q vector - }; - std::vector joint_map_; - - static rclcpp::Logger getLogger(); - - // Control modes - bool controllers_initialized_; - std::vector stop_modes_; - std::vector start_modes_; - bool position_controller_running_; - bool velocity_controller_running_; - bool torque_controller_running_; -}; - -} /* namespace flexiv_hardware */ -#endif /* FLEXIV_HARDWARE__FLEXIV_DUAL_HARDWARE_INTERFACE_HPP_ */ diff --git a/install/flexiv_hardware/include/flexiv_hardware/flexiv_dual_hardware_interface.hpp b/install/flexiv_hardware/include/flexiv_hardware/flexiv_dual_hardware_interface.hpp new file mode 120000 index 0000000..c8349fb --- /dev/null +++ b/install/flexiv_hardware/include/flexiv_hardware/flexiv_dual_hardware_interface.hpp @@ -0,0 +1 @@ +/home/cc/ros2/src/flexiv_hardware/include/flexiv_hardware/flexiv_dual_hardware_interface.hpp \ No newline at end of file diff --git a/install/flexiv_hardware/include/flexiv_hardware/flexiv_hardware_interface.hpp b/install/flexiv_hardware/include/flexiv_hardware/flexiv_hardware_interface.hpp deleted file mode 100644 index adf1b7a..0000000 --- a/install/flexiv_hardware/include/flexiv_hardware/flexiv_hardware_interface.hpp +++ /dev/null @@ -1,156 +0,0 @@ -/** - * @file flexiv_hardware_interface.hpp - * @brief Hardware interface to Flexiv robots for ROS 2 control. Adapted from - * ros2_control_demos/example_3/hardware/include/ros2_control_demo_example_3/rrbot_system_multi_interface.hpp - * @copyright Copyright (C) 2016-2024 Flexiv Ltd. All Rights Reserved. - * @author Flexiv - */ - -#ifndef FLEXIV_HARDWARE__FLEXIV_HARDWARE_INTERFACE_HPP_ -#define FLEXIV_HARDWARE__FLEXIV_HARDWARE_INTERFACE_HPP_ - -#include -#include -#include -#include - -// ROS -#include -#include -#include -#include -#include -#include - -// ros2_control hardware_interface -#include -#include -#include - -#include "flexiv_hardware/visibility_control.h" - -// Flexiv -#include "flexiv/rdk/robot.hpp" - -namespace flexiv_hardware { - -enum StoppingInterface -{ - NONE, - STOP_POSITION, - STOP_VELOCITY, - STOP_EFFORT -}; - -class FlexivHardwareInterface : public hardware_interface::SystemInterface -{ -public: - RCLCPP_SHARED_PTR_DEFINITIONS(FlexivHardwareInterface) - - FLEXIV_HARDWARE_PUBLIC - hardware_interface::CallbackReturn on_init( - const hardware_interface::HardwareInfo& info) override; - - FLEXIV_HARDWARE_PUBLIC - std::vector export_state_interfaces() override; - - FLEXIV_HARDWARE_PUBLIC - std::vector export_command_interfaces() override; - - FLEXIV_HARDWARE_PUBLIC - hardware_interface::return_type prepare_command_mode_switch( - const std::vector& start_interfaces, - const std::vector& stop_interfaces) override; - - FLEXIV_HARDWARE_PUBLIC - hardware_interface::return_type perform_command_mode_switch( - const std::vector& start_interfaces, - const std::vector& stop_interfaces) override; - - FLEXIV_HARDWARE_PUBLIC - hardware_interface::CallbackReturn on_activate( - const rclcpp_lifecycle::State& previous_state) override; - - FLEXIV_HARDWARE_PUBLIC - hardware_interface::CallbackReturn on_deactivate( - const rclcpp_lifecycle::State& previous_state) override; - - FLEXIV_HARDWARE_PUBLIC - hardware_interface::return_type read( - const rclcpp::Time& time, const rclcpp::Duration& period) override; - - FLEXIV_HARDWARE_PUBLIC - hardware_interface::return_type write( - const rclcpp::Time& time, const rclcpp::Duration& period) override; - -private: - // Flexiv RDK - std::unique_ptr robot_; - - // RDK control mode for joint position and velocity interfaces - flexiv::rdk::Mode rdk_control_mode_; - - // Joint commands - std::vector hw_commands_joint_positions_; - std::vector hw_commands_joint_velocities_; - std::vector hw_commands_joint_efforts_; - - // Joint states - std::vector hw_states_joint_positions_; - std::vector hw_states_joint_velocities_; - std::vector hw_states_joint_efforts_; - - // Robot States - flexiv::rdk::RobotStates hw_flexiv_robot_states_; - flexiv::rdk::RobotStates* hw_flexiv_robot_states_addr_ = &hw_flexiv_robot_states_; - - // GPIO commands and states - std::vector hw_commands_gpio_out_; - std::vector hw_states_gpio_in_; - - // Map from RDK joint index to ROS joint index - // RDK expects: [ext_axis_1, ..., ext_axis_N, arm_joint_1, ..., arm_joint_7] - std::vector rdk_to_ros_map_; - - // Current digital output map - std::map current_digital_outputs_; - - static rclcpp::Logger getLogger(); - - // Control modes - bool controllers_initialized_; - std::vector stop_modes_; - std::vector start_modes_; - bool position_controller_running_; - bool velocity_controller_running_; - bool torque_controller_running_; - - // ===== 连接存活看门狗(2026-09-28 真机踩到)===== - // - // 现象:RDK 断连后 `operational()` 会**一直保持 true**,于是 read() 照跑却只 - // 返回最后一帧缓存(关节状态永久冻结),write() 把命令发进虚空也不抛异常, - // 而 rizon_arm_controller 是本地插值、不校验反馈 —— 最终表现为 - // **「控制器报 Goal reached/success、关节状态纹丝不动、臂一步没动」**。 - // - // 实测:14:53:44 RDK 打印 "Disconnected from the robot, waiting for reconnection", - // 此后 50 分钟里 /joint_states 的 robot_timestamp 推进量恒为 0,而 `op=1` 没变过。 - // 根因是两台臂出厂 MAC 相同(88:88:88:88:87:88)在同一 L2 段抢转发,链路丢包 40~53%。 - // - // 这里用 connected() + states().timestamp 双重判活:任一失效就返回 ERROR, - // 让 ros2_control 把栈停下来报错,而不是静默地"成功"。 - int64_t last_state_stamp_ns_ = 0; // 上一帧的机器人时间戳 - int64_t last_state_change_ns_ = 0; // 上一帧时间戳发生变化的本地时刻 - - // D435 粗定位的 MoveL 走已经连上的这份 RDK。write() 里一步一步推进, - // 不另开线程,也不在这段里往关节接口灌点。请求文件是 /tmp/flexiv_movel_req。 - enum class VendorMovelPhase { kIdle, kRunning }; - VendorMovelPhase vendor_movel_phase_ = VendorMovelPhase::kIdle; - long vendor_movel_seq_ = -1; - long vendor_movel_done_seq_ = -1; - std::chrono::steady_clock::time_point vendor_movel_deadline_ {}; - bool poll_vendor_movel(); - void finish_vendor_movel(long seq, bool ok, const std::string& detail); -}; - -} /* namespace flexiv_hardware */ -#endif /* FLEXIV_HARDWARE__FLEXIV_HARDWARE_INTERFACE_HPP_ */ diff --git a/install/flexiv_hardware/include/flexiv_hardware/flexiv_hardware_interface.hpp b/install/flexiv_hardware/include/flexiv_hardware/flexiv_hardware_interface.hpp new file mode 120000 index 0000000..2ac643f --- /dev/null +++ b/install/flexiv_hardware/include/flexiv_hardware/flexiv_hardware_interface.hpp @@ -0,0 +1 @@ +/home/cc/ros2/src/flexiv_hardware/include/flexiv_hardware/flexiv_hardware_interface.hpp \ No newline at end of file diff --git a/install/flexiv_hardware/include/flexiv_hardware/visibility_control.h b/install/flexiv_hardware/include/flexiv_hardware/visibility_control.h deleted file mode 100644 index 2c26a9e..0000000 --- a/install/flexiv_hardware/include/flexiv_hardware/visibility_control.h +++ /dev/null @@ -1,35 +0,0 @@ -#ifndef FLEXIV_HARDWARE__VISIBILITY_CONTROL_H_ -#define FLEXIV_HARDWARE__VISIBILITY_CONTROL_H_ - -// This logic was borrowed (then namespaced) from the examples on the gcc wiki: -// https://gcc.gnu.org/wiki/Visibility - -#if defined _WIN32 || defined __CYGWIN__ -#ifdef __GNUC__ -#define FLEXIV_HARDWARE_EXPORT __attribute__((dllexport)) -#define FLEXIV_HARDWARE_IMPORT __attribute__((dllimport)) -#else -#define FLEXIV_HARDWARE_EXPORT __declspec(dllexport) -#define FLEXIV_HARDWARE_IMPORT __declspec(dllimport) -#endif -#ifdef FLEXIV_HARDWARE_BUILDING_DLL -#define FLEXIV_HARDWARE_PUBLIC FLEXIV_HARDWARE_EXPORT -#else -#define FLEXIV_HARDWARE_PUBLIC FLEXIV_HARDWARE_IMPORT -#endif -#define FLEXIV_HARDWARE_PUBLIC_TYPE FLEXIV_HARDWARE_PUBLIC -#define FLEXIV_HARDWARE_LOCAL -#else -#define FLEXIV_HARDWARE_EXPORT __attribute__((visibility("default"))) -#define FLEXIV_HARDWARE_IMPORT -#if __GNUC__ >= 4 -#define FLEXIV_HARDWARE_PUBLIC __attribute__((visibility("default"))) -#define FLEXIV_HARDWARE_LOCAL __attribute__((visibility("hidden"))) -#else -#define FLEXIV_HARDWARE_PUBLIC -#define FLEXIV_HARDWARE_LOCAL -#endif -#define FLEXIV_HARDWARE_PUBLIC_TYPE -#endif - -#endif // FLEXIV_HARDWARE__VISIBILITY_CONTROL_H_ diff --git a/install/flexiv_hardware/include/flexiv_hardware/visibility_control.h b/install/flexiv_hardware/include/flexiv_hardware/visibility_control.h new file mode 120000 index 0000000..ee4aec6 --- /dev/null +++ b/install/flexiv_hardware/include/flexiv_hardware/visibility_control.h @@ -0,0 +1 @@ +/home/cc/ros2/src/flexiv_hardware/include/flexiv_hardware/visibility_control.h \ No newline at end of file diff --git a/install/flexiv_hardware/lib/libflexiv_hardware.so b/install/flexiv_hardware/lib/libflexiv_hardware.so deleted file mode 100755 index bac94b6..0000000 Binary files a/install/flexiv_hardware/lib/libflexiv_hardware.so and /dev/null differ diff --git a/install/flexiv_hardware/lib/libflexiv_hardware.so b/install/flexiv_hardware/lib/libflexiv_hardware.so new file mode 120000 index 0000000..ae36ea3 --- /dev/null +++ b/install/flexiv_hardware/lib/libflexiv_hardware.so @@ -0,0 +1 @@ +/home/cc/ros2/build/flexiv_hardware/libflexiv_hardware.so \ No newline at end of file diff --git a/install/flexiv_hardware/share/ament_index/resource_index/hardware_interface__pluginlib__plugin/flexiv_hardware b/install/flexiv_hardware/share/ament_index/resource_index/hardware_interface__pluginlib__plugin/flexiv_hardware deleted file mode 100644 index c130e64..0000000 --- a/install/flexiv_hardware/share/ament_index/resource_index/hardware_interface__pluginlib__plugin/flexiv_hardware +++ /dev/null @@ -1 +0,0 @@ -share/flexiv_hardware/flexiv_hardware_interface_plugin.xml diff --git a/install/flexiv_hardware/share/ament_index/resource_index/hardware_interface__pluginlib__plugin/flexiv_hardware b/install/flexiv_hardware/share/ament_index/resource_index/hardware_interface__pluginlib__plugin/flexiv_hardware new file mode 120000 index 0000000..aae224d --- /dev/null +++ b/install/flexiv_hardware/share/ament_index/resource_index/hardware_interface__pluginlib__plugin/flexiv_hardware @@ -0,0 +1 @@ +/home/cc/ros2/build/flexiv_hardware/ament_cmake_index/share/ament_index/resource_index/hardware_interface__pluginlib__plugin/flexiv_hardware \ No newline at end of file diff --git a/install/flexiv_hardware/share/ament_index/resource_index/package_run_dependencies/flexiv_hardware b/install/flexiv_hardware/share/ament_index/resource_index/package_run_dependencies/flexiv_hardware deleted file mode 100644 index edadeaa..0000000 --- a/install/flexiv_hardware/share/ament_index/resource_index/package_run_dependencies/flexiv_hardware +++ /dev/null @@ -1 +0,0 @@ -rclcpp;rclcpp_lifecycle;hardware_interface;pluginlib;ament_lint_auto;ament_lint_common;ament_cmake_gtest \ No newline at end of file diff --git a/install/flexiv_hardware/share/ament_index/resource_index/package_run_dependencies/flexiv_hardware b/install/flexiv_hardware/share/ament_index/resource_index/package_run_dependencies/flexiv_hardware new file mode 120000 index 0000000..90bdfb8 --- /dev/null +++ b/install/flexiv_hardware/share/ament_index/resource_index/package_run_dependencies/flexiv_hardware @@ -0,0 +1 @@ +/home/cc/ros2/build/flexiv_hardware/ament_cmake_index/share/ament_index/resource_index/package_run_dependencies/flexiv_hardware \ No newline at end of file diff --git a/install/flexiv_hardware/share/ament_index/resource_index/packages/flexiv_hardware b/install/flexiv_hardware/share/ament_index/resource_index/packages/flexiv_hardware deleted file mode 100644 index e69de29..0000000 diff --git a/install/flexiv_hardware/share/ament_index/resource_index/packages/flexiv_hardware b/install/flexiv_hardware/share/ament_index/resource_index/packages/flexiv_hardware new file mode 120000 index 0000000..b455cdb --- /dev/null +++ b/install/flexiv_hardware/share/ament_index/resource_index/packages/flexiv_hardware @@ -0,0 +1 @@ +/home/cc/ros2/build/flexiv_hardware/ament_cmake_index/share/ament_index/resource_index/packages/flexiv_hardware \ No newline at end of file diff --git a/install/flexiv_hardware/share/ament_index/resource_index/parent_prefix_path/flexiv_hardware b/install/flexiv_hardware/share/ament_index/resource_index/parent_prefix_path/flexiv_hardware deleted file mode 100644 index 7f1a9c5..0000000 --- a/install/flexiv_hardware/share/ament_index/resource_index/parent_prefix_path/flexiv_hardware +++ /dev/null @@ -1 +0,0 @@ -/home/cc/ros2/install/gpio_controller:/home/cc/ros2/install/flexiv_bringup:/home/cc/ros2/install/flexiv_test_nodes:/home/cc/ros2/install/flexiv_robot_states_broadcaster:/home/cc/ros2/install/flexiv_gripper:/home/cc/ros2/install/flexiv_msgs:/home/cc/ros2/install/flexiv_moveit_config:/home/cc/ros2/install/flexiv_hardware:/home/cc/ros2/install/arm_sim_gazebo:/home/cc/ros2/install/arm_bringup:/home/cc/ros2/install/arm_moveit_config:/home/cc/ros2/install/arm_description:/home/cc/ros2/install/flexiv_description:/home/cc/ros2/install/arm_driver_flexiv:/home/cc/ros2/install/arm_control:/home/cc/ros2/install/arm_application:/home/cc/ros2/install/arm_msgs:/home/cc/ros2/install/arm_kinematics:/home/cc/ros2/install/arm_interfaces:/home/cc/anaconda3/envs/ros_flexiv \ No newline at end of file diff --git a/install/flexiv_hardware/share/ament_index/resource_index/parent_prefix_path/flexiv_hardware b/install/flexiv_hardware/share/ament_index/resource_index/parent_prefix_path/flexiv_hardware new file mode 120000 index 0000000..aca5250 --- /dev/null +++ b/install/flexiv_hardware/share/ament_index/resource_index/parent_prefix_path/flexiv_hardware @@ -0,0 +1 @@ +/home/cc/ros2/build/flexiv_hardware/ament_cmake_index/share/ament_index/resource_index/parent_prefix_path/flexiv_hardware \ No newline at end of file diff --git a/install/flexiv_hardware/share/flexiv_hardware/cmake/ament_cmake_export_dependencies-extras.cmake b/install/flexiv_hardware/share/flexiv_hardware/cmake/ament_cmake_export_dependencies-extras.cmake deleted file mode 100644 index 590bb2d..0000000 --- a/install/flexiv_hardware/share/flexiv_hardware/cmake/ament_cmake_export_dependencies-extras.cmake +++ /dev/null @@ -1,94 +0,0 @@ -# generated from ament_cmake_export_dependencies/cmake/ament_cmake_export_dependencies-extras.cmake.in - -set(_exported_dependencies "hardware_interface;pluginlib;rclcpp;rclcpp_lifecycle") - -find_package(ament_cmake_libraries QUIET REQUIRED) - -# find_package() all dependencies -# and append their DEFINITIONS INCLUDE_DIRS, LIBRARIES, and LINK_FLAGS -# variables to flexiv_hardware_DEFINITIONS, flexiv_hardware_INCLUDE_DIRS, -# flexiv_hardware_LIBRARIES, and flexiv_hardware_LINK_FLAGS. -# Additionally collect the direct dependency names in -# flexiv_hardware_DEPENDENCIES as well as the recursive dependency names -# in flexiv_hardware_RECURSIVE_DEPENDENCIES. -if(NOT _exported_dependencies STREQUAL "") - find_package(ament_cmake_core QUIET REQUIRED) - set(flexiv_hardware_DEPENDENCIES ${_exported_dependencies}) - set(flexiv_hardware_RECURSIVE_DEPENDENCIES ${_exported_dependencies}) - set(_libraries) - foreach(_dep ${_exported_dependencies}) - if(NOT ${_dep}_FOUND) - find_package("${_dep}" QUIET REQUIRED) - endif() - # if a package provides modern CMake interface targets use them - # exclusively assuming the classic CMake variables only exist for - # backward compatibility - set(use_modern_cmake FALSE) - if(NOT "${${_dep}_TARGETS}" STREQUAL "") - foreach(_target ${${_dep}_TARGETS}) - # only use actual targets - # in case a package uses this variable for other content - if(TARGET "${_target}") - get_target_property(_include_dirs ${_target} INTERFACE_INCLUDE_DIRECTORIES) - if(_include_dirs) - list_append_unique(flexiv_hardware_INCLUDE_DIRS "${_include_dirs}") - endif() - - get_target_property(_imported_configurations ${_target} IMPORTED_CONFIGURATIONS) - if(_imported_configurations) - string(TOUPPER "${_imported_configurations}" _imported_configurations) - if(DEBUG_CONFIGURATIONS) - string(TOUPPER "${DEBUG_CONFIGURATIONS}" _debug_configurations_uppercase) - else() - set(_debug_configurations_uppercase "DEBUG") - endif() - foreach(_imported_config ${_imported_configurations}) - get_target_property(_imported_implib ${_target} IMPORTED_IMPLIB_${_imported_config}) - if(_imported_implib) - set(_imported_implib_config "optimized") - if(${_imported_config} IN_LIST _debug_configurations_uppercase) - set(_imported_implib_config "debug") - endif() - list(APPEND _libraries ${_imported_implib_config} ${_imported_implib}) - else() - get_target_property(_imported_location ${_target} IMPORTED_LOCATION_${_imported_config}) - if(_imported_location) - list(APPEND _libraries "${_imported_location}") - endif() - endif() - endforeach() - endif() - - get_target_property(_link_libraries ${_target} INTERFACE_LINK_LIBRARIES) - if(_link_libraries) - list(APPEND _libraries "${_link_libraries}") - endif() - set(use_modern_cmake TRUE) - endif() - endforeach() - endif() - if(NOT use_modern_cmake) - if(${_dep}_DEFINITIONS) - list_append_unique(flexiv_hardware_DEFINITIONS "${${_dep}_DEFINITIONS}") - endif() - if(${_dep}_INCLUDE_DIRS) - list_append_unique(flexiv_hardware_INCLUDE_DIRS "${${_dep}_INCLUDE_DIRS}") - endif() - if(${_dep}_LIBRARIES) - list(APPEND _libraries "${${_dep}_LIBRARIES}") - endif() - if(${_dep}_LINK_FLAGS) - list_append_unique(flexiv_hardware_LINK_FLAGS "${${_dep}_LINK_FLAGS}") - endif() - if(${_dep}_RECURSIVE_DEPENDENCIES) - list_append_unique(flexiv_hardware_RECURSIVE_DEPENDENCIES "${${_dep}_RECURSIVE_DEPENDENCIES}") - endif() - endif() - if(_libraries) - ament_libraries_deduplicate(_libraries "${_libraries}") - list(APPEND _libraries ${flexiv_hardware_LIBRARIES}) - list(REMOVE_DUPLICATES _libraries) - set(flexiv_hardware_LIBRARIES ${_libraries}) - endif() - endforeach() -endif() diff --git a/install/flexiv_hardware/share/flexiv_hardware/cmake/ament_cmake_export_dependencies-extras.cmake b/install/flexiv_hardware/share/flexiv_hardware/cmake/ament_cmake_export_dependencies-extras.cmake new file mode 120000 index 0000000..beb4258 --- /dev/null +++ b/install/flexiv_hardware/share/flexiv_hardware/cmake/ament_cmake_export_dependencies-extras.cmake @@ -0,0 +1 @@ +/home/cc/ros2/build/flexiv_hardware/ament_cmake_export_dependencies/ament_cmake_export_dependencies-extras.cmake \ No newline at end of file diff --git a/install/flexiv_hardware/share/flexiv_hardware/cmake/ament_cmake_export_include_directories-extras.cmake b/install/flexiv_hardware/share/flexiv_hardware/cmake/ament_cmake_export_include_directories-extras.cmake deleted file mode 100644 index 5805d6a..0000000 --- a/install/flexiv_hardware/share/flexiv_hardware/cmake/ament_cmake_export_include_directories-extras.cmake +++ /dev/null @@ -1,16 +0,0 @@ -# generated from ament_cmake_export_include_directories/cmake/ament_cmake_export_include_directories-extras.cmake.in - -set(_exported_include_dirs "${flexiv_hardware_DIR}/../../../include") - -# append include directories to flexiv_hardware_INCLUDE_DIRS -# warn about not existing paths -if(NOT _exported_include_dirs STREQUAL "") - find_package(ament_cmake_core QUIET REQUIRED) - foreach(_exported_include_dir ${_exported_include_dirs}) - if(NOT IS_DIRECTORY "${_exported_include_dir}") - message(WARNING "Package 'flexiv_hardware' exports the include directory '${_exported_include_dir}' which doesn't exist") - endif() - normalize_path(_exported_include_dir "${_exported_include_dir}") - list(APPEND flexiv_hardware_INCLUDE_DIRS "${_exported_include_dir}") - endforeach() -endif() diff --git a/install/flexiv_hardware/share/flexiv_hardware/cmake/ament_cmake_export_include_directories-extras.cmake b/install/flexiv_hardware/share/flexiv_hardware/cmake/ament_cmake_export_include_directories-extras.cmake new file mode 120000 index 0000000..34d19d0 --- /dev/null +++ b/install/flexiv_hardware/share/flexiv_hardware/cmake/ament_cmake_export_include_directories-extras.cmake @@ -0,0 +1 @@ +/home/cc/ros2/build/flexiv_hardware/ament_cmake_export_include_directories/ament_cmake_export_include_directories-extras.cmake \ No newline at end of file diff --git a/install/flexiv_hardware/share/flexiv_hardware/cmake/ament_cmake_export_libraries-extras.cmake b/install/flexiv_hardware/share/flexiv_hardware/cmake/ament_cmake_export_libraries-extras.cmake deleted file mode 100644 index adfc567..0000000 --- a/install/flexiv_hardware/share/flexiv_hardware/cmake/ament_cmake_export_libraries-extras.cmake +++ /dev/null @@ -1,141 +0,0 @@ -# generated from ament_cmake_export_libraries/cmake/template/ament_cmake_export_libraries.cmake.in - -set(_exported_libraries "flexiv_hardware") -set(_exported_library_names "") - -# populate flexiv_hardware_LIBRARIES -if(NOT _exported_libraries STREQUAL "") - # loop over libraries, either target names or absolute paths - list(LENGTH _exported_libraries _length) - set(_i 0) - while(_i LESS _length) - list(GET _exported_libraries ${_i} _arg) - - # pass linker flags along - if("${_arg}" MATCHES "^-" AND NOT "${_arg}" MATCHES "^-[l|framework]") - list(APPEND flexiv_hardware_LIBRARIES "${_arg}") - math(EXPR _i "${_i} + 1") - continue() - endif() - - if("${_arg}" MATCHES "^(debug|optimized|general)$") - # remember build configuration keyword - # and get following library - set(_cfg "${_arg}") - math(EXPR _i "${_i} + 1") - if(_i EQUAL _length) - message(FATAL_ERROR "Package 'flexiv_hardware' passes the build configuration keyword '${_cfg}' as the last exported library") - endif() - list(GET _exported_libraries ${_i} _library) - else() - # the value is a library without a build configuration keyword - set(_cfg "") - set(_library "${_arg}") - endif() - math(EXPR _i "${_i} + 1") - - if(NOT IS_ABSOLUTE "${_library}") - # search for library target relative to this CMake file - set(_lib "NOTFOUND") - find_library( - _lib NAMES "${_library}" - PATHS "${flexiv_hardware_DIR}/../../../lib" - NO_DEFAULT_PATH NO_CMAKE_FIND_ROOT_PATH - ) - - if(NOT _lib) - # warn about not existing library and ignore it - message(FATAL_ERROR "Package 'flexiv_hardware' exports the library '${_library}' which couldn't be found") - elseif(NOT IS_ABSOLUTE "${_lib}") - # the found library must be an absolute path - message(FATAL_ERROR "Package 'flexiv_hardware' found the library '${_library}' at '${_lib}' which is not an absolute path") - elseif(NOT EXISTS "${_lib}") - # the found library must exist - message(FATAL_ERROR "Package 'flexiv_hardware' found the library '${_lib}' which doesn't exist") - else() - list(APPEND flexiv_hardware_LIBRARIES ${_cfg} "${_lib}") - endif() - - else() - if(NOT EXISTS "${_library}") - # the found library must exist - message(WARNING "Package 'flexiv_hardware' exports the library '${_library}' which doesn't exist") - else() - list(APPEND flexiv_hardware_LIBRARIES ${_cfg} "${_library}") - endif() - endif() - endwhile() -endif() - -# find_library() library names with optional LIBRARY_DIRS -# and add the libraries to flexiv_hardware_LIBRARIES -if(NOT _exported_library_names STREQUAL "") - # loop over library names - # but remember related build configuration keyword if available - list(LENGTH _exported_library_names _length) - set(_i 0) - while(_i LESS _length) - list(GET _exported_library_names ${_i} _arg) - # pass linker flags along - if("${_arg}" MATCHES "^-" AND NOT "${_arg}" MATCHES "^-[l|framework]") - list(APPEND flexiv_hardware_LIBRARIES "${_arg}") - math(EXPR _i "${_i} + 1") - continue() - endif() - - if("${_arg}" MATCHES "^(debug|optimized|general)$") - # remember build configuration keyword - # and get following library name - set(_cfg "${_arg}") - math(EXPR _i "${_i} + 1") - if(_i EQUAL _length) - message(FATAL_ERROR "Package 'flexiv_hardware' passes the build configuration keyword '${_cfg}' as the last exported target") - endif() - list(GET _exported_library_names ${_i} _library) - else() - # the value is a library target without a build configuration keyword - set(_cfg "") - set(_library "${_arg}") - endif() - math(EXPR _i "${_i} + 1") - - # extract optional LIBRARY_DIRS from library name - string(REPLACE ":" ";" _library_dirs "${_library}") - list(GET _library_dirs 0 _library_name) - list(REMOVE_AT _library_dirs 0) - - set(_lib "NOTFOUND") - if(NOT _library_dirs) - # search for library in the common locations - find_library( - _lib - NAMES "${_library_name}" - ) - if(NOT _lib) - # warn about not existing library and later ignore it - message(WARNING "Package 'flexiv_hardware' exports library '${_library_name}' which couldn't be found") - endif() - else() - # search for library in the specified directories - find_library( - _lib - NAMES "${_library_name}" - PATHS ${_library_dirs} - NO_DEFAULT_PATH NO_CMAKE_FIND_ROOT_PATH - ) - if(NOT _lib) - # warn about not existing library and later ignore it - message(WARNING - "Package 'flexiv_hardware' exports library '${_library_name}' with LIBRARY_DIRS '${_library_dirs}' which couldn't be found") - endif() - endif() - if(_lib) - list(APPEND flexiv_hardware_LIBRARIES ${_cfg} "${_lib}") - endif() - endwhile() -endif() - -# TODO(dirk-thomas) deduplicate flexiv_hardware_LIBRARIES -# while maintaining library order -# as well as build configuration keywords -# as well as linker flags diff --git a/install/flexiv_hardware/share/flexiv_hardware/cmake/ament_cmake_export_libraries-extras.cmake b/install/flexiv_hardware/share/flexiv_hardware/cmake/ament_cmake_export_libraries-extras.cmake new file mode 120000 index 0000000..3bbd45d --- /dev/null +++ b/install/flexiv_hardware/share/flexiv_hardware/cmake/ament_cmake_export_libraries-extras.cmake @@ -0,0 +1 @@ +/home/cc/ros2/build/flexiv_hardware/ament_cmake_export_libraries/ament_cmake_export_libraries-extras.cmake \ No newline at end of file diff --git a/install/flexiv_hardware/share/flexiv_hardware/cmake/flexiv_hardwareConfig-version.cmake b/install/flexiv_hardware/share/flexiv_hardware/cmake/flexiv_hardwareConfig-version.cmake deleted file mode 100644 index 137063e..0000000 --- a/install/flexiv_hardware/share/flexiv_hardware/cmake/flexiv_hardwareConfig-version.cmake +++ /dev/null @@ -1,14 +0,0 @@ -# generated from ament/cmake/core/templates/nameConfig-version.cmake.in -set(PACKAGE_VERSION "1.9.1") - -set(PACKAGE_VERSION_EXACT False) -set(PACKAGE_VERSION_COMPATIBLE False) - -if("${PACKAGE_FIND_VERSION}" VERSION_EQUAL "${PACKAGE_VERSION}") - set(PACKAGE_VERSION_EXACT True) - set(PACKAGE_VERSION_COMPATIBLE True) -endif() - -if("${PACKAGE_FIND_VERSION}" VERSION_LESS "${PACKAGE_VERSION}") - set(PACKAGE_VERSION_COMPATIBLE True) -endif() diff --git a/install/flexiv_hardware/share/flexiv_hardware/cmake/flexiv_hardwareConfig-version.cmake b/install/flexiv_hardware/share/flexiv_hardware/cmake/flexiv_hardwareConfig-version.cmake new file mode 120000 index 0000000..0d1d7e2 --- /dev/null +++ b/install/flexiv_hardware/share/flexiv_hardware/cmake/flexiv_hardwareConfig-version.cmake @@ -0,0 +1 @@ +/home/cc/ros2/build/flexiv_hardware/ament_cmake_core/flexiv_hardwareConfig-version.cmake \ No newline at end of file diff --git a/install/flexiv_hardware/share/flexiv_hardware/cmake/flexiv_hardwareConfig.cmake b/install/flexiv_hardware/share/flexiv_hardware/cmake/flexiv_hardwareConfig.cmake deleted file mode 100644 index 46b0346..0000000 --- a/install/flexiv_hardware/share/flexiv_hardware/cmake/flexiv_hardwareConfig.cmake +++ /dev/null @@ -1,42 +0,0 @@ -# generated from ament/cmake/core/templates/nameConfig.cmake.in - -# prevent multiple inclusion -if(_flexiv_hardware_CONFIG_INCLUDED) - # ensure to keep the found flag the same - if(NOT DEFINED flexiv_hardware_FOUND) - # explicitly set it to FALSE, otherwise CMake will set it to TRUE - set(flexiv_hardware_FOUND FALSE) - elseif(NOT flexiv_hardware_FOUND) - # use separate condition to avoid uninitialized variable warning - set(flexiv_hardware_FOUND FALSE) - endif() - return() -endif() -set(_flexiv_hardware_CONFIG_INCLUDED TRUE) - -# output package information -if(NOT flexiv_hardware_FIND_QUIETLY) - message(STATUS "Found flexiv_hardware: 1.9.1 (${flexiv_hardware_DIR})") -endif() - -# warn when using a deprecated package -if(NOT "" STREQUAL "") - set(_msg "Package 'flexiv_hardware' is deprecated") - # append custom deprecation text if available - if(NOT "" STREQUAL "TRUE") - set(_msg "${_msg} ()") - endif() - # optionally quiet the deprecation message - if(NOT ${flexiv_hardware_DEPRECATED_QUIET}) - message(DEPRECATION "${_msg}") - endif() -endif() - -# flag package as ament-based to distinguish it after being find_package()-ed -set(flexiv_hardware_FOUND_AMENT_PACKAGE TRUE) - -# include all config extra files -set(_extras "ament_cmake_export_include_directories-extras.cmake;ament_cmake_export_libraries-extras.cmake;ament_cmake_export_dependencies-extras.cmake") -foreach(_extra ${_extras}) - include("${flexiv_hardware_DIR}/${_extra}") -endforeach() diff --git a/install/flexiv_hardware/share/flexiv_hardware/cmake/flexiv_hardwareConfig.cmake b/install/flexiv_hardware/share/flexiv_hardware/cmake/flexiv_hardwareConfig.cmake new file mode 120000 index 0000000..55d4646 --- /dev/null +++ b/install/flexiv_hardware/share/flexiv_hardware/cmake/flexiv_hardwareConfig.cmake @@ -0,0 +1 @@ +/home/cc/ros2/build/flexiv_hardware/ament_cmake_core/flexiv_hardwareConfig.cmake \ No newline at end of file diff --git a/install/flexiv_hardware/share/flexiv_hardware/environment/ament_prefix_path.dsv b/install/flexiv_hardware/share/flexiv_hardware/environment/ament_prefix_path.dsv deleted file mode 100644 index 79d4c95..0000000 --- a/install/flexiv_hardware/share/flexiv_hardware/environment/ament_prefix_path.dsv +++ /dev/null @@ -1 +0,0 @@ -prepend-non-duplicate;AMENT_PREFIX_PATH; diff --git a/install/flexiv_hardware/share/flexiv_hardware/environment/ament_prefix_path.dsv b/install/flexiv_hardware/share/flexiv_hardware/environment/ament_prefix_path.dsv new file mode 120000 index 0000000..55266c4 --- /dev/null +++ b/install/flexiv_hardware/share/flexiv_hardware/environment/ament_prefix_path.dsv @@ -0,0 +1 @@ +/home/cc/ros2/build/flexiv_hardware/ament_cmake_environment_hooks/ament_prefix_path.dsv \ No newline at end of file diff --git a/install/flexiv_hardware/share/flexiv_hardware/environment/ament_prefix_path.sh b/install/flexiv_hardware/share/flexiv_hardware/environment/ament_prefix_path.sh deleted file mode 100644 index 02e441b..0000000 --- a/install/flexiv_hardware/share/flexiv_hardware/environment/ament_prefix_path.sh +++ /dev/null @@ -1,4 +0,0 @@ -# copied from -# ament_cmake_core/cmake/environment_hooks/environment/ament_prefix_path.sh - -ament_prepend_unique_value AMENT_PREFIX_PATH "$AMENT_CURRENT_PREFIX" diff --git a/install/flexiv_hardware/share/flexiv_hardware/environment/ament_prefix_path.sh b/install/flexiv_hardware/share/flexiv_hardware/environment/ament_prefix_path.sh new file mode 120000 index 0000000..b13a2dc --- /dev/null +++ b/install/flexiv_hardware/share/flexiv_hardware/environment/ament_prefix_path.sh @@ -0,0 +1 @@ +/home/cc/anaconda3/envs/ros_flexiv/share/ament_cmake_core/cmake/environment_hooks/environment/ament_prefix_path.sh \ No newline at end of file diff --git a/install/flexiv_hardware/share/flexiv_hardware/environment/path.dsv b/install/flexiv_hardware/share/flexiv_hardware/environment/path.dsv deleted file mode 100644 index b94426a..0000000 --- a/install/flexiv_hardware/share/flexiv_hardware/environment/path.dsv +++ /dev/null @@ -1 +0,0 @@ -prepend-non-duplicate-if-exists;PATH;bin diff --git a/install/flexiv_hardware/share/flexiv_hardware/environment/path.dsv b/install/flexiv_hardware/share/flexiv_hardware/environment/path.dsv new file mode 120000 index 0000000..eb39dff --- /dev/null +++ b/install/flexiv_hardware/share/flexiv_hardware/environment/path.dsv @@ -0,0 +1 @@ +/home/cc/ros2/build/flexiv_hardware/ament_cmake_environment_hooks/path.dsv \ No newline at end of file diff --git a/install/flexiv_hardware/share/flexiv_hardware/environment/path.sh b/install/flexiv_hardware/share/flexiv_hardware/environment/path.sh deleted file mode 100644 index e59b749..0000000 --- a/install/flexiv_hardware/share/flexiv_hardware/environment/path.sh +++ /dev/null @@ -1,5 +0,0 @@ -# copied from ament_cmake_core/cmake/environment_hooks/environment/path.sh - -if [ -d "$AMENT_CURRENT_PREFIX/bin" ]; then - ament_prepend_unique_value PATH "$AMENT_CURRENT_PREFIX/bin" -fi diff --git a/install/flexiv_hardware/share/flexiv_hardware/environment/path.sh b/install/flexiv_hardware/share/flexiv_hardware/environment/path.sh new file mode 120000 index 0000000..5c7febb --- /dev/null +++ b/install/flexiv_hardware/share/flexiv_hardware/environment/path.sh @@ -0,0 +1 @@ +/home/cc/anaconda3/envs/ros_flexiv/share/ament_cmake_core/cmake/environment_hooks/environment/path.sh \ No newline at end of file diff --git a/install/flexiv_hardware/share/flexiv_hardware/flexiv_hardware_interface_plugin.xml b/install/flexiv_hardware/share/flexiv_hardware/flexiv_hardware_interface_plugin.xml deleted file mode 100644 index 9fec610..0000000 --- a/install/flexiv_hardware/share/flexiv_hardware/flexiv_hardware_interface_plugin.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - - ROS 2 Control Hardware interface for the Flexiv robots. This driver - uses Flexiv RDK. Position, Velocity and Effort interfaces are supported. - - - - - - ROS 2 Control Hardware interface for a pair of Flexiv robots. This driver - uses Flexiv DRDK. Position, Velocity and Effort interfaces are supported. - - - diff --git a/install/flexiv_hardware/share/flexiv_hardware/flexiv_hardware_interface_plugin.xml b/install/flexiv_hardware/share/flexiv_hardware/flexiv_hardware_interface_plugin.xml new file mode 120000 index 0000000..deb10b3 --- /dev/null +++ b/install/flexiv_hardware/share/flexiv_hardware/flexiv_hardware_interface_plugin.xml @@ -0,0 +1 @@ +/home/cc/ros2/src/flexiv_hardware/flexiv_hardware_interface_plugin.xml \ No newline at end of file diff --git a/install/flexiv_hardware/share/flexiv_hardware/local_setup.bash b/install/flexiv_hardware/share/flexiv_hardware/local_setup.bash deleted file mode 100644 index 49782f2..0000000 --- a/install/flexiv_hardware/share/flexiv_hardware/local_setup.bash +++ /dev/null @@ -1,46 +0,0 @@ -# generated from ament_package/template/package_level/local_setup.bash.in - -# source local_setup.sh from same directory as this file -_this_path=$(builtin cd "`dirname "${BASH_SOURCE[0]}"`" && pwd) -# provide AMENT_CURRENT_PREFIX to shell script -AMENT_CURRENT_PREFIX=$(builtin cd "`dirname "${BASH_SOURCE[0]}"`/../.." && pwd) -# store AMENT_CURRENT_PREFIX to restore it before each environment hook -_package_local_setup_AMENT_CURRENT_PREFIX=$AMENT_CURRENT_PREFIX - -# trace output -if [ -n "$AMENT_TRACE_SETUP_FILES" ]; then - echo "# . \"$_this_path/local_setup.sh\"" -fi -. "$_this_path/local_setup.sh" -unset _this_path - -# unset AMENT_ENVIRONMENT_HOOKS -# if not appending to them for return -if [ -z "$AMENT_RETURN_ENVIRONMENT_HOOKS" ]; then - unset AMENT_ENVIRONMENT_HOOKS -fi - -# restore AMENT_CURRENT_PREFIX before evaluating the environment hooks -AMENT_CURRENT_PREFIX=$_package_local_setup_AMENT_CURRENT_PREFIX -# list all environment hooks of this package - -# source all shell-specific environment hooks of this package -# if not returning them -if [ -z "$AMENT_RETURN_ENVIRONMENT_HOOKS" ]; then - _package_local_setup_IFS=$IFS - IFS=":" - for _hook in $AMENT_ENVIRONMENT_HOOKS; do - # restore AMENT_CURRENT_PREFIX for each environment hook - AMENT_CURRENT_PREFIX=$_package_local_setup_AMENT_CURRENT_PREFIX - # restore IFS before sourcing other files - IFS=$_package_local_setup_IFS - . "$_hook" - done - unset _hook - IFS=$_package_local_setup_IFS - unset _package_local_setup_IFS - unset AMENT_ENVIRONMENT_HOOKS -fi - -unset _package_local_setup_AMENT_CURRENT_PREFIX -unset AMENT_CURRENT_PREFIX diff --git a/install/flexiv_hardware/share/flexiv_hardware/local_setup.bash b/install/flexiv_hardware/share/flexiv_hardware/local_setup.bash new file mode 120000 index 0000000..673cd47 --- /dev/null +++ b/install/flexiv_hardware/share/flexiv_hardware/local_setup.bash @@ -0,0 +1 @@ +/home/cc/ros2/build/flexiv_hardware/ament_cmake_environment_hooks/local_setup.bash \ No newline at end of file diff --git a/install/flexiv_hardware/share/flexiv_hardware/local_setup.dsv b/install/flexiv_hardware/share/flexiv_hardware/local_setup.dsv deleted file mode 100644 index d4a37ec..0000000 --- a/install/flexiv_hardware/share/flexiv_hardware/local_setup.dsv +++ /dev/null @@ -1,2 +0,0 @@ -source;share/flexiv_hardware/environment/ament_prefix_path.sh -source;share/flexiv_hardware/environment/path.sh diff --git a/install/flexiv_hardware/share/flexiv_hardware/local_setup.dsv b/install/flexiv_hardware/share/flexiv_hardware/local_setup.dsv new file mode 120000 index 0000000..49afd59 --- /dev/null +++ b/install/flexiv_hardware/share/flexiv_hardware/local_setup.dsv @@ -0,0 +1 @@ +/home/cc/ros2/build/flexiv_hardware/ament_cmake_environment_hooks/local_setup.dsv \ No newline at end of file diff --git a/install/flexiv_hardware/share/flexiv_hardware/local_setup.sh b/install/flexiv_hardware/share/flexiv_hardware/local_setup.sh deleted file mode 100644 index 260497e..0000000 --- a/install/flexiv_hardware/share/flexiv_hardware/local_setup.sh +++ /dev/null @@ -1,184 +0,0 @@ -# generated from ament_package/template/package_level/local_setup.sh.in - -# since this file is sourced use either the provided AMENT_CURRENT_PREFIX -# or fall back to the destination set at configure time -: ${AMENT_CURRENT_PREFIX:="/home/cc/ros2/install/flexiv_hardware"} -if [ ! -d "$AMENT_CURRENT_PREFIX" ]; then - if [ -z "$COLCON_CURRENT_PREFIX" ]; then - echo "The compile time prefix path '$AMENT_CURRENT_PREFIX' doesn't " \ - "exist. Consider sourcing a different extension than '.sh'." 1>&2 - else - AMENT_CURRENT_PREFIX="$COLCON_CURRENT_PREFIX" - fi -fi - -# function to append values to environment variables -# using colons as separators and avoiding leading separators -ament_append_value() { - # arguments - _listname="$1" - _value="$2" - #echo "listname $_listname" - #eval echo "list value \$$_listname" - #echo "value $_value" - - # avoid leading separator - eval _values=\"\$$_listname\" - if [ -z "$_values" ]; then - eval export $_listname=\"$_value\" - #eval echo "set list \$$_listname" - else - # field separator must not be a colon - _ament_append_value_IFS=$IFS - unset IFS - eval export $_listname=\"\$$_listname:$_value\" - #eval echo "append list \$$_listname" - IFS=$_ament_append_value_IFS - unset _ament_append_value_IFS - fi - unset _values - - unset _value - unset _listname -} - -# function to append non-duplicate values to environment variables -# using colons as separators and avoiding leading separators -ament_append_unique_value() { - # arguments - _listname=$1 - _value=$2 - #echo "listname $_listname" - #eval echo "list value \$$_listname" - #echo "value $_value" - - # check if the list contains the value - eval _values=\$$_listname - _duplicate= - _ament_append_unique_value_IFS=$IFS - IFS=":" - if [ "$AMENT_SHELL" = "zsh" ]; then - ament_zsh_to_array _values - fi - for _item in $_values; do - # ignore empty strings - if [ -z "$_item" ]; then - continue - fi - if [ $_item = $_value ]; then - _duplicate=1 - fi - done - unset _item - - # append only non-duplicates - if [ -z "$_duplicate" ]; then - # avoid leading separator - if [ -z "$_values" ]; then - eval $_listname=\"$_value\" - #eval echo "set list \$$_listname" - else - # field separator must not be a colon - unset IFS - eval $_listname=\"\$$_listname:$_value\" - #eval echo "append list \$$_listname" - fi - fi - IFS=$_ament_append_unique_value_IFS - unset _ament_append_unique_value_IFS - unset _duplicate - unset _values - - unset _value - unset _listname -} - -# function to prepend non-duplicate values to environment variables -# using colons as separators and avoiding trailing separators -ament_prepend_unique_value() { - # arguments - _listname="$1" - _value="$2" - #echo "listname $_listname" - #eval echo "list value \$$_listname" - #echo "value $_value" - - # check if the list contains the value - eval _values=\"\$$_listname\" - _duplicate= - _ament_prepend_unique_value_IFS=$IFS - IFS=":" - if [ "$AMENT_SHELL" = "zsh" ]; then - ament_zsh_to_array _values - fi - for _item in $_values; do - # ignore empty strings - if [ -z "$_item" ]; then - continue - fi - if [ "$_item" = "$_value" ]; then - _duplicate=1 - fi - done - unset _item - - # prepend only non-duplicates - if [ -z "$_duplicate" ]; then - # avoid trailing separator - if [ -z "$_values" ]; then - eval export $_listname=\"$_value\" - #eval echo "set list \$$_listname" - else - # field separator must not be a colon - unset IFS - eval export $_listname=\"$_value:\$$_listname\" - #eval echo "prepend list \$$_listname" - fi - fi - IFS=$_ament_prepend_unique_value_IFS - unset _ament_prepend_unique_value_IFS - unset _duplicate - unset _values - - unset _value - unset _listname -} - -# unset AMENT_ENVIRONMENT_HOOKS -# if not appending to them for return -if [ -z "$AMENT_RETURN_ENVIRONMENT_HOOKS" ]; then - unset AMENT_ENVIRONMENT_HOOKS -fi - -# list all environment hooks of this package -ament_append_value AMENT_ENVIRONMENT_HOOKS "$AMENT_CURRENT_PREFIX/share/flexiv_hardware/environment/ament_prefix_path.sh" -ament_append_value AMENT_ENVIRONMENT_HOOKS "$AMENT_CURRENT_PREFIX/share/flexiv_hardware/environment/path.sh" - -# source all shell-specific environment hooks of this package -# if not returning them -if [ -z "$AMENT_RETURN_ENVIRONMENT_HOOKS" ]; then - _package_local_setup_IFS=$IFS - IFS=":" - if [ "$AMENT_SHELL" = "zsh" ]; then - ament_zsh_to_array AMENT_ENVIRONMENT_HOOKS - fi - for _hook in $AMENT_ENVIRONMENT_HOOKS; do - if [ -f "$_hook" ]; then - # restore IFS before sourcing other files - IFS=$_package_local_setup_IFS - # trace output - if [ -n "$AMENT_TRACE_SETUP_FILES" ]; then - echo "# . \"$_hook\"" - fi - . "$_hook" - fi - done - unset _hook - IFS=$_package_local_setup_IFS - unset _package_local_setup_IFS - unset AMENT_ENVIRONMENT_HOOKS -fi - -# reset AMENT_CURRENT_PREFIX after each package -# allowing to source multiple package-level setup files -unset AMENT_CURRENT_PREFIX diff --git a/install/flexiv_hardware/share/flexiv_hardware/local_setup.sh b/install/flexiv_hardware/share/flexiv_hardware/local_setup.sh new file mode 120000 index 0000000..23657ac --- /dev/null +++ b/install/flexiv_hardware/share/flexiv_hardware/local_setup.sh @@ -0,0 +1 @@ +/home/cc/ros2/build/flexiv_hardware/ament_cmake_environment_hooks/local_setup.sh \ No newline at end of file diff --git a/install/flexiv_hardware/share/flexiv_hardware/local_setup.zsh b/install/flexiv_hardware/share/flexiv_hardware/local_setup.zsh deleted file mode 100644 index fe161be..0000000 --- a/install/flexiv_hardware/share/flexiv_hardware/local_setup.zsh +++ /dev/null @@ -1,59 +0,0 @@ -# generated from ament_package/template/package_level/local_setup.zsh.in - -AMENT_SHELL=zsh - -# source local_setup.sh from same directory as this file -_this_path=$(builtin cd -q "`dirname "${(%):-%N}"`" > /dev/null && pwd) -# provide AMENT_CURRENT_PREFIX to shell script -AMENT_CURRENT_PREFIX=$(builtin cd -q "`dirname "${(%):-%N}"`/../.." > /dev/null && pwd) -# store AMENT_CURRENT_PREFIX to restore it before each environment hook -_package_local_setup_AMENT_CURRENT_PREFIX=$AMENT_CURRENT_PREFIX - -# function to convert array-like strings into arrays -# to wordaround SH_WORD_SPLIT not being set -ament_zsh_to_array() { - local _listname=$1 - local _dollar="$" - local _split="{=" - local _to_array="(\"$_dollar$_split$_listname}\")" - eval $_listname=$_to_array -} - -# trace output -if [ -n "$AMENT_TRACE_SETUP_FILES" ]; then - echo "# . \"$_this_path/local_setup.sh\"" -fi -# the package-level local_setup file unsets AMENT_CURRENT_PREFIX -. "$_this_path/local_setup.sh" -unset _this_path - -# unset AMENT_ENVIRONMENT_HOOKS -# if not appending to them for return -if [ -z "$AMENT_RETURN_ENVIRONMENT_HOOKS" ]; then - unset AMENT_ENVIRONMENT_HOOKS -fi - -# restore AMENT_CURRENT_PREFIX before evaluating the environment hooks -AMENT_CURRENT_PREFIX=$_package_local_setup_AMENT_CURRENT_PREFIX -# list all environment hooks of this package - -# source all shell-specific environment hooks of this package -# if not returning them -if [ -z "$AMENT_RETURN_ENVIRONMENT_HOOKS" ]; then - _package_local_setup_IFS=$IFS - IFS=":" - for _hook in $AMENT_ENVIRONMENT_HOOKS; do - # restore AMENT_CURRENT_PREFIX for each environment hook - AMENT_CURRENT_PREFIX=$_package_local_setup_AMENT_CURRENT_PREFIX - # restore IFS before sourcing other files - IFS=$_package_local_setup_IFS - . "$_hook" - done - unset _hook - IFS=$_package_local_setup_IFS - unset _package_local_setup_IFS - unset AMENT_ENVIRONMENT_HOOKS -fi - -unset _package_local_setup_AMENT_CURRENT_PREFIX -unset AMENT_CURRENT_PREFIX diff --git a/install/flexiv_hardware/share/flexiv_hardware/local_setup.zsh b/install/flexiv_hardware/share/flexiv_hardware/local_setup.zsh new file mode 120000 index 0000000..8c19084 --- /dev/null +++ b/install/flexiv_hardware/share/flexiv_hardware/local_setup.zsh @@ -0,0 +1 @@ +/home/cc/ros2/build/flexiv_hardware/ament_cmake_environment_hooks/local_setup.zsh \ No newline at end of file diff --git a/install/flexiv_hardware/share/flexiv_hardware/package.xml b/install/flexiv_hardware/share/flexiv_hardware/package.xml deleted file mode 100644 index ebabe8d..0000000 --- a/install/flexiv_hardware/share/flexiv_hardware/package.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - flexiv_hardware - 1.9.1 - Hardware interfaces to Flexiv robots for ROS 2 control - Mun Seng Phoon - Apache 2.0 - Mun Seng Phoon - - ament_cmake - - rclcpp - rclcpp_lifecycle - hardware_interface - pluginlib - - ament_lint_auto - ament_lint_common - ament_cmake_gtest - - - ament_cmake - - diff --git a/install/flexiv_hardware/share/flexiv_hardware/package.xml b/install/flexiv_hardware/share/flexiv_hardware/package.xml new file mode 120000 index 0000000..adf94cf --- /dev/null +++ b/install/flexiv_hardware/share/flexiv_hardware/package.xml @@ -0,0 +1 @@ +/home/cc/ros2/src/flexiv_hardware/package.xml \ No newline at end of file diff --git a/install/setup.bash b/install/setup.bash index d3b480f..b2857a6 100644 --- a/install/setup.bash +++ b/install/setup.bash @@ -19,19 +19,8 @@ _colcon_prefix_chain_bash_source_script() { # source chained prefixes # setting COLCON_CURRENT_PREFIX avoids determining the prefix in the sourced script -# 不写死某一台机器的 conda。已激活的环境优先,否则找当前用户的 ros_flexiv。 -if [ -n "${CONDA_PREFIX:-}" ] && [ -f "${CONDA_PREFIX}/local_setup.bash" ]; then - COLCON_CURRENT_PREFIX="${CONDA_PREFIX}" -elif [ -f "${HOME}/anaconda3/envs/ros_flexiv/local_setup.bash" ]; then - COLCON_CURRENT_PREFIX="${HOME}/anaconda3/envs/ros_flexiv" -elif [ -f "${HOME}/miniconda3/envs/ros_flexiv/local_setup.bash" ]; then - COLCON_CURRENT_PREFIX="${HOME}/miniconda3/envs/ros_flexiv" -else - COLCON_CURRENT_PREFIX="" -fi -if [ -n "${COLCON_CURRENT_PREFIX}" ]; then - _colcon_prefix_chain_bash_source_script "$COLCON_CURRENT_PREFIX/local_setup.bash" -fi +COLCON_CURRENT_PREFIX="/home/cc/anaconda3/envs/ros_flexiv" +_colcon_prefix_chain_bash_source_script "$COLCON_CURRENT_PREFIX/local_setup.bash" # source this prefix # setting COLCON_CURRENT_PREFIX avoids determining the prefix in the sourced script diff --git a/install/setup.zsh b/install/setup.zsh index 9490adc..39dd846 100644 --- a/install/setup.zsh +++ b/install/setup.zsh @@ -19,19 +19,8 @@ _colcon_prefix_chain_zsh_source_script() { # source chained prefixes # setting COLCON_CURRENT_PREFIX avoids determining the prefix in the sourced script -# 不写死某一台机器的 conda。已激活的环境优先,否则找当前用户的 ros_flexiv。 -if [ -n "${CONDA_PREFIX:-}" ] && [ -f "${CONDA_PREFIX}/local_setup.zsh" ]; then - COLCON_CURRENT_PREFIX="${CONDA_PREFIX}" -elif [ -f "${HOME}/anaconda3/envs/ros_flexiv/local_setup.zsh" ]; then - COLCON_CURRENT_PREFIX="${HOME}/anaconda3/envs/ros_flexiv" -elif [ -f "${HOME}/miniconda3/envs/ros_flexiv/local_setup.zsh" ]; then - COLCON_CURRENT_PREFIX="${HOME}/miniconda3/envs/ros_flexiv" -else - COLCON_CURRENT_PREFIX="" -fi -if [ -n "${COLCON_CURRENT_PREFIX}" ]; then - _colcon_prefix_chain_zsh_source_script "$COLCON_CURRENT_PREFIX/local_setup.zsh" -fi +COLCON_CURRENT_PREFIX="/home/cc/anaconda3/envs/ros_flexiv" +_colcon_prefix_chain_zsh_source_script "$COLCON_CURRENT_PREFIX/local_setup.zsh" # source this prefix # setting COLCON_CURRENT_PREFIX avoids determining the prefix in the sourced script diff --git a/scripts/gen_site_example.py b/scripts/gen_site_example.py index 980682a..6852ebc 100644 --- a/scripts/gen_site_example.py +++ b/scripts/gen_site_example.py @@ -51,8 +51,8 @@ HEADER = """\ # 用法:复制本文件为 config/site.yaml,然后把所有 <占位符> 换成现场实际值。 # 现场值包括:机械臂 SN / IP、相机序列号、夹爪串口、传感器 USB 路径。 # -# 说明:仓库里的文件用相对仓库根的路径(例如 models/yolo/...)。 -# 不要改回某个用户的绝对家目录,换台电脑就会找不到文件。 +# 说明:site.yaml 里的路径出现 /home/USER 是因为生成时匿名化了用户名; +# 部署时请改成实际用户的 home 目录。 # ============================================================================= """ diff --git a/scripts/lib/export_site_env.py b/scripts/lib/export_site_env.py index e0485a1..c679ed3 100644 --- a/scripts/lib/export_site_env.py +++ b/scripts/lib/export_site_env.py @@ -42,13 +42,7 @@ def load_site(path: Path | None = None) -> dict: data = yaml.safe_load(text) or {} if not isinstance(data, dict): raise ValueError(f"无效的 site.yaml: {path}") - # 相对路径按仓库根展开。site.yaml 里不再写 /home/某用户/ros2。 - src = str(_root() / "src" / "arm_application") - if src not in sys.path: - sys.path.insert(0, src) - from arm_application.site_io import absolutize_site_paths - - return absolutize_site_paths(data, _root()) + return data def list_robots(data: dict) -> list[dict]: @@ -947,7 +941,7 @@ def servo_overrides(data: dict) -> dict: if "d435_handback_sec" not in servo: raise RuntimeError( "servo.d435_handback_sec 必须显式写" - "(厂家 MoveL 到位后,再确认多久才交回 D405)。不许用代码默认秒数。" + "(厂家 MoveL 结束后,法兰停稳多久才打开 D405 框心)。不许用代码默认秒数。" ) if "wrist_align_sec" not in servo: raise RuntimeError( diff --git a/scripts/rdk_drag_bridge.py b/scripts/rdk_drag_bridge.py index af19238..bde0e4e 100755 --- a/scripts/rdk_drag_bridge.py +++ b/scripts/rdk_drag_bridge.py @@ -33,7 +33,6 @@ RDK 的 FloatingJoint/FloatingCartesian 通常需要额外授权。 {"cmd":"drag_on","mode":"soft"} # soft | floating {"cmd":"drag_off"} {"cmd":"joints"} - {"cmd":"move_joints","q":[0,0,0,0,0,0,0],"speed":0.15} {"cmd":"quit"} 每个命令的应答都是单行 JSON,含 ``ok`` 字段;失败时另有 ``error``。 @@ -170,69 +169,6 @@ def _ensure_operational(robot) -> Optional[str]: return ensure_operational(robot) -def _restore_tracking_impedance(robot, flexivrdk) -> None: - """把关节阻抗从软拖拽拉回名义值,否则回放点位时臂跟不住指令。""" - try: - info = robot.info() - groups = dict(getattr(info, 'single_arm_groups', None) or {}) - except Exception as exc: - log(f'读名义阻抗失败: {exc}') - groups = {} - if groups: - for group in groups: - try: - nominal = [float(v) for v in list(info.K_q_nom[group])] - robot.SetJointImpedance(group, nominal) - log(f'恢复阻抗 {group} K={nominal}') - except Exception as exc: - log(f'恢复阻抗 {group}: {exc}') - return - try: - robot.SetJointImpedance([350.0] * 7, [7.0] * 7) - except Exception as exc: - log(f'恢复阻抗: {exc}') - - -def _send_joint_target(robot, flexivrdk, target: List[float], grouped: Optional[bool]) -> bool: - """下发 7 关节目标。返回是否走了分组接口。""" - if grouped is True: - _send_grouped_joint_target(robot, flexivrdk, target) - return True - try: - n = len(target) - robot.SendJointPosition(target, [0.0] * n, [1.5] * n, [2.0] * n) - return False - except Exception as exc: - if grouped is False: - raise - log(f'扁平关节指令失败,改分组: {exc}') - _send_grouped_joint_target(robot, flexivrdk, target) - return True - - -def _send_grouped_joint_target(robot, flexivrdk, target: List[float]) -> None: - info = robot.info() - groups = dict(getattr(info, 'single_arm_groups', None) or {}) - if not groups: - raise RuntimeError('没有分组,扁平关节指令也失败了') - states = robot.states() - cmds = {} - placed = False - for group in groups: - try: - current = [float(v) for v in list(states[group].q)] - except Exception: - current = list(target) - use = target if (not placed and len(current) == len(target)) else current - if use is target: - placed = True - n = len(use) - cmds[group] = flexivrdk.NrtJointPositionCmd(use, [0.0] * n, [1.5] * n, [2.0] * n) - if not placed: - raise RuntimeError('没有一组关节数和示教点一致,拒绝下发') - robot.SendJointPosition(cmds) - - def _idle_async(robot, lock: threading.Lock, flexivrdk, timeout: float = 4.0) -> bool: """切到 IDLE,同时避免把 stdin 循环永久阻塞住。""" done = threading.Event() @@ -487,65 +423,6 @@ def main() -> int: info['tool'] = '' reply(info) - elif cmd == 'move_joints': - if robot is None: - reply({'ok': False, 'error': '尚未连接'}) - continue - raw = msg.get('q') - if not isinstance(raw, list) or len(raw) < 7: - reply({'ok': False, 'error': '需要 7 个关节角(弧度)'}) - continue - try: - target = [float(v) for v in raw[:7]] - except (TypeError, ValueError): - reply({'ok': False, 'error': '关节角不是数字'}) - continue - if any(v != v or abs(v) > 12.6 for v in target): - reply({'ok': False, 'error': '关节角不是有限值,或超出约两圈'}) - continue - try: - speed = float(msg.get('speed', 0.15)) - except (TypeError, ValueError): - speed = 0.15 - speed = max(0.05, min(0.35, speed)) - if soft is not None: - soft.stop() - soft = None - drag_on = False - err = _ensure_operational(robot) - if err: - reply({'ok': False, 'error': err}) - continue - try: - with rlock: - robot.SwitchMode(flexivrdk.Mode.NRT_JOINT_IMPEDANCE) - current = [float(v) for v in list(robot.states().q)[:7]] - _restore_tracking_impedance(robot, flexivrdk) - max_delta = max(abs(a - b) for a, b in zip(current, target)) - step_s = 0.05 - steps = max(1, int(max_delta / (speed * step_s))) - if steps * step_s > 150.0: - reply({ - 'ok': False, - 'error': f'路程太长({max_delta:.2f} rad),请先拖近一点再回放', - }) - continue - grouped: Optional[bool] = None - for index in range(1, steps + 1): - alpha = index / steps - sample = [a + (b - a) * alpha for a, b in zip(current, target)] - with rlock: - grouped = _send_joint_target(robot, flexivrdk, sample, grouped) - time.sleep(step_s) - for _hold in range(8): - with rlock: - grouped = _send_joint_target(robot, flexivrdk, target, grouped) - time.sleep(step_s) - cached_q = list(target) - reply({'ok': True, 'q': target, 'from': current, 'steps': steps, 'speed': speed}) - except Exception as exc: - reply({'ok': False, 'error': f'回点失败: {exc}'}) - elif cmd == 'quit': if soft is not None: soft.stop() diff --git a/scripts/rdk_release_movel.py b/scripts/rdk_release_movel.py index 5e5e46b..fb8ea56 100644 --- a/scripts/rdk_release_movel.py +++ b/scripts/rdk_release_movel.py @@ -23,9 +23,7 @@ sys.path.insert(0, str(ROOT / "scripts" / "lib")) from rdk_release import is_control_stack_cmd # noqa: E402 -RDK_PYTHON = os.environ.get("RDK_PYTHON", "").strip() or str( - Path.home() / "rdk_py310" / "bin" / "python" -) +RDK_PYTHON = os.environ.get("RDK_PYTHON", "/home/cc/rdk_py310/bin/python") def _procs() -> list[tuple[int, str]]: diff --git a/scripts/run_all.sh b/scripts/run_all.sh index ef771a3..647484f 100755 --- a/scripts/run_all.sh +++ b/scripts/run_all.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# 跟踪 → 对孔。搜孔轻压已取消,本脚本不再进入那一步,也不能用参数打开。 +# 三阶段装配流程的完整入口:一阶段跟踪 → 二阶段对孔 → 三阶段搜孔轻压。 # # 为什么要单独有这个脚本: # pipeline.launch.py 只管两件事 —— 发布手眼静态 TF、起 assembly_pipeline 串联节点。 @@ -13,10 +13,10 @@ # 4) exec pipeline.launch.py,把下面的 CLI 参数映射成它的 launch 参数 # # 用法: -# bash ~/ros2/scripts/run_all.sh --arm 89 # 跟踪 + 对孔 +# 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 # 臂已在工件上方,跳过跟踪 -# 旋入不在这里。用 run_stage3_search.sh --arm 89 --screw +# bash ~/ros2/scripts/run_all.sh --arm 89 --depth 6 --pitch 1.0 # bash ~/ros2/scripts/run_all.sh --arm 89 --camera-preview # 相机带 Qt 预览窗 # bash ~/ros2/scripts/run_all.sh --arm 89 --no-camera # 相机已在别处拉起了 # bash ~/ros2/scripts/run_all.sh --arm 89 --no-tactile # 不起触觉服务 @@ -37,7 +37,7 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" # ---- 默认值:全部沿用 site.yaml / launch 默认,CLI 只做覆盖 ---- SKIP_STAGE1=false SKIP_STAGE2=false -SKIP_STAGE3=true # 搜孔已取消。--skip-stage3 仍然接受,但没有开关能把它重新打开。 +SKIP_STAGE3=false HOLE="" DEPTH="" PITCH="" @@ -83,12 +83,6 @@ while [[ $# -gt 0 ]]; do esac done -if [[ -n "${DEPTH}" || -n "${PITCH}" ]]; then - echo "ERROR: 搜孔轻压已取消,整体流程不再搜孔,也不在这里旋入。" >&2 - echo " 旋入请用: bash ~/ros2/scripts/run_stage3_search.sh --arm ${SITE_ROBOT_ARM:-89} --screw" >&2 - exit 1 -fi - if [[ -n "${FINAL_GAP}" && "${NO_DESCENT}" == true ]]; then echo "ERROR: 同时写了 --final-gap 和 --no-descent,不知道听谁的。" >&2 exit 1 @@ -145,9 +139,7 @@ if [[ "${D435_ONLY}" == true ]]; then fi echo "=== MoveL:XY 对准 K0,工具尖高度保持当前(K0 的 z 是工件表面)===" # --go:不按直线距离拦截。800 mm 是脚本自己的保护,不是机械臂限位。 - # shellcheck disable=SC1091 - source "${ROOT}/scripts/lib/dev_tools.sh" - exec "$(dev_rdk_python)" "${ROOT}/scripts/rdk_goto_xyz.py" \ + exec /home/cc/rdk_py310/bin/python "${ROOT}/scripts/rdk_goto_xyz.py" \ --xyz "${kx}" "${ky}" "${kz}" --keep-z --go --vel 0.05 fi @@ -247,7 +239,7 @@ fi # 每次 echo 吃满超时 × 60 次 ≈ 3 分钟,终端上没有任何新行(2026-10-09 真机)。 _SKIP_TACTILE_REASON="" if [[ "${SKIP_STAGE3}" == true && "${WITH_TACTILE}" == true ]]; then - echo "=== 搜孔已取消:跟踪和对孔不用触觉,跳过拉起 ===" + echo "=== --skip-stage3:一/二阶段不用触觉,跳过拉起 ===" WITH_TACTILE=false _SKIP_TACTILE_REASON="stage3" fi diff --git a/scripts/run_camera_d405.sh b/scripts/run_camera_d405.sh index 829337c..4821e9a 100755 --- a/scripts/run_camera_d405.sh +++ b/scripts/run_camera_d405.sh @@ -31,8 +31,6 @@ done # shellcheck disable=SC1091 source "${ROOT}/scripts/env.sh" -# 用源码里的采集逻辑,不走 install 里那份旧的。install 的入口仍由 ros2 run 找到。 -export PYTHONPATH="${ROOT}/src/arm_application${PYTHONPATH:+:${PYTHONPATH}}" # shellcheck disable=SC1091 source "${ROOT}/scripts/lib/load_site_config.sh" @@ -129,14 +127,12 @@ DO_PREVIEW="${SITE_CAM_PREVIEW:-1}" if [[ "${PREVIEW}" -ge 0 ]]; then DO_PREVIEW="${PREVIEW}" fi -# 程序界面只看彩色和深度。红外是旧的四窗格查看器才要的。 -# 彩色要等红外齐帧时,场景一动,整组帧对不齐,彩色就被丢掉。 -if [[ "${DO_PREVIEW}" == "1" && "${SITE_CAM_ENABLE_INFRA:-1}" == "1" ]]; then +if [[ "${SITE_CAM_ENABLE_INFRA:-1}" == "1" ]]; then CAM_ARGS+=(-p enable_infra:=true) else CAM_ARGS+=(-p enable_infra:=false) fi -if [[ "${DO_PREVIEW}" == "1" && "${SITE_CAM_PREVIEW_STEREO:-1}" == "1" ]]; then +if [[ "${SITE_CAM_PREVIEW_STEREO:-1}" == "1" ]]; then CAM_ARGS+=(-p preview_stereo:=true) else CAM_ARGS+=(-p preview_stereo:=false) diff --git a/scripts/run_camera_d435.sh b/scripts/run_camera_d435.sh index 937a425..af6a5ca 100755 --- a/scripts/run_camera_d435.sh +++ b/scripts/run_camera_d435.sh @@ -17,8 +17,6 @@ while [[ $# -gt 0 ]]; do done # shellcheck disable=SC1091 source "${ROOT}/scripts/env.sh" -# 用源码里的采集逻辑,不走 install 里那份旧的。install 的入口仍由 ros2 run 找到。 -export PYTHONPATH="${ROOT}/src/arm_application${PYTHONPATH:+:${PYTHONPATH}}" # shellcheck disable=SC1091 source "${ROOT}/scripts/lib/load_site_config.sh" SN="${SITE_D435_SERIAL:-}" diff --git a/scripts/run_real.sh b/scripts/run_real.sh index fc45245..1517e97 100755 --- a/scripts/run_real.sh +++ b/scripts/run_real.sh @@ -19,22 +19,15 @@ while [[ $# -gt 0 ]]; do --list|-l) LIST_ONLY=true; shift ;; -h|--help) sed -n '2,8p' "$0" - echo " 机械臂模型在工位程序里看,这里默认不开 RViz。" - echo " 其余参数传给 ros2 launch。要临时打开 RViz:use_rviz:=true" + echo " Extra args are passed to ros2 launch (e.g. use_rviz:=false)." exit 0 ;; *) break ;; esac done -# shellcheck disable=SC1091 -source "${ROOT}/scripts/lib/dev_tools.sh" -dev_source_conda +source /home/cc/anaconda3/etc/profile.d/conda.sh conda activate ros_flexiv -if [[ ! -f "${ROOT}/install/setup.bash" ]]; then - echo "ERROR: ${ROOT}/install/setup.bash 不存在。先在仓库根目录 colcon build,不要在中文路径下编。" >&2 - exit 1 -fi # shellcheck disable=SC1091 source "${ROOT}/install/setup.bash" # shellcheck disable=SC1091 @@ -51,7 +44,7 @@ export LD_LIBRARY_PATH="${FLEXIV_PREFIX}/lib:${LD_LIBRARY_PATH:-}" ROBOT_SN="${SITE_ROBOT_SN:?run_real.sh 需要已配置的机械臂 SN —— 见 scripts/lib/load_site_config.sh 的报错}" ROBOT_IP="${ROBOT_IP:-${SITE_ROBOT_IP:-192.168.100.1}}" NET_IFACE="${SITE_NET_IFACE:-}" -RDK_PYTHON="$(dev_rdk_python)" +RDK_PYTHON="${RDK_PYTHON:-/home/cc/rdk_py310/bin/python}" if [[ "${LIST_ONLY}" == true ]]; then python3 "${ROOT}/scripts/lib/export_site_env.py" --print @@ -128,4 +121,4 @@ if ! "${RDK_PYTHON}" "${ROOT}/scripts/rdk_switch_tool.py" "${RDK_TOOL_ARGS[@]}"; exit 1 fi -exec ros2 launch arm_bringup stack.launch.py backend:=real robot_sn:="${ROBOT_SN}" use_rviz:=false "$@" +exec ros2 launch arm_bringup stack.launch.py backend:=real robot_sn:="${ROBOT_SN}" "$@" diff --git a/src/arm_application/arm_application/handeye_calibrate.py b/src/arm_application/arm_application/handeye_calibrate.py index a04c653..e7e827d 100644 --- a/src/arm_application/arm_application/handeye_calibrate.py +++ b/src/arm_application/arm_application/handeye_calibrate.py @@ -137,9 +137,7 @@ class HandeyeCalibrate(TolerantNode): self.declare_parameter('pin_hold_sec', 0.45) # 每拍多长 self.declare_parameter('skip_unstable_poses', True) - from arm_application.site_io import ros2_root - - self.declare_parameter('output_dir', str(ros2_root() / 'handeye' / 'tmp')) + self.declare_parameter('output_dir', '/home/cc/ros2/handeye/tmp') self.declare_parameter('result_file', '') self.declare_parameter('cleanup_images', True) # 眼在手上:AX=XB 的 gripper 用 Studio 工具 TCP,再折成 cam→法兰写 T_4x4。 diff --git a/src/arm_application/arm_application/handeye_teach.py b/src/arm_application/arm_application/handeye_teach.py index c657a18..a78b0fc 100644 --- a/src/arm_application/arm_application/handeye_teach.py +++ b/src/arm_application/arm_application/handeye_teach.py @@ -108,12 +108,10 @@ class RdkBridge: self.robot_sn = robot_sn self._last_q: Optional[List[float]] = None self._io = threading.Lock() - from arm_application.site_io import ros2_root - - py = os.environ.get('RDK_PYTHON', '').strip() or str( - Path.home() / 'rdk_py310' / 'bin' / 'python' - ) - bridge = ros2_root() / 'scripts' / 'rdk_drag_bridge.py' + py = os.environ.get('RDK_PYTHON', '/home/cc/rdk_py310/bin/python') + bridge = Path.home() / 'ros2' / 'scripts' / 'rdk_drag_bridge.py' + if not bridge.is_file(): + bridge = Path('/home/cc/ros2/scripts/rdk_drag_bridge.py') if not bridge.is_file(): raise FileNotFoundError(f'找不到 rdk_drag_bridge.py:{bridge}') @@ -222,13 +220,6 @@ class RdkBridge: return self._last_q return self._last_q - def move_joints(self, joints: List[float], speed: float = 0.15) -> dict: - """按关节阻抗把臂缓慢送到已记录的 7 个关节角。speed 单位 rad/s。""" - return self._ask( - {'cmd': 'move_joints', 'q': [float(v) for v in joints[:7]], 'speed': float(speed)}, - timeout=180.0, - ) - def close(self) -> None: try: self._ask({'cmd': 'quit'}, timeout=3.0) @@ -248,9 +239,7 @@ class RdkBridge: class HandeyeTeach(TolerantNode): def __init__(self) -> None: super().__init__('handeye_teach') - from arm_application.site_io import ros2_root - - self.declare_parameter('output_dir', str(ros2_root() / 'handeye' / 'tmp')) + self.declare_parameter('output_dir', '/home/cc/ros2/handeye/tmp') self.declare_parameter('width', 1280) self.declare_parameter('height', 720) self.declare_parameter('fps', 15) diff --git a/src/arm_application/arm_application/overhead_oneshot.py b/src/arm_application/arm_application/overhead_oneshot.py index fa1d3b1..e8863dd 100644 --- a/src/arm_application/arm_application/overhead_oneshot.py +++ b/src/arm_application/arm_application/overhead_oneshot.py @@ -45,6 +45,11 @@ from arm_application.path_profile import ( # 伺服和测试从本模块再导 # 上一跳实际少走这么多,就认定冻结 J 在当前姿态走不动,不许按同一剩差再刷。 D435_MIN_PROGRESS_FRAC = 0.40 +# SwitchMode 返回不等于已经进 primitive。连试仍失败必须让一阶段立刻中止, +# 不许空等 150s 装成"还在粗定位"(2026-10-16 17:27)。 +D435_MOVEL_RETRY = 3 +D435_MOVEL_RETRY_S = 0.3 +D435_MOVEL_GAVE_UP = "厂家 MoveL 连试 3 次没走成" def best_det_conf(dets: Optional[Iterable]) -> Optional[float]: @@ -154,16 +159,27 @@ def d405_commands_arm( ) -> bool: """D405 这一帧的点能不能下发给左臂。 - 推理一直开着。正在用 D435 那一个点时,进程内 MoveL 结束并且停稳之前不下发。 - 停稳用 ``d435_handback_sec`` 和 ``lock_still_move_m``。赶路时 XY 已经进 15 mm - 也不算到位。不许在 MoveL 期间下发 D405(两套控制互抢会晃)。 - ``move_only``:只测 D435 位移,D405 点一律不下发(到位之后也不交)。 + 推理从启动就开着。没有高置信度目标、或 D435 厂家 MoveL 还没结束、 + 法兰还没连续停稳时,这帧**不下发**(输出截断,不许拿去控臂)。 + 厂家移动结束并且法兰停稳(``settled``,时长是 ``d435_handback_sec``) + 之后才放行,伺服用它追工件框的矩形中心。框心对准停稳后才进精拍下臂。 + ``move_only``:只测 D435 位移,D405 点一律不下发。 """ if move_only: return False - if not using_d435: - return True - return bool(arrived) and bool(settled) + if using_d435: + return bool(arrived) and bool(settled) + return True + + +def retract_goal_z(z_now: float, z_target: float) -> float: + """收臂目标 Z:已经高于目标就钉在当前高度,不许为了追 5 mm 往下补。 + + 2026-10-16:贴面 MoveL 之后 JTC 还握着悬停关节,收臂 hop1 计划 +5 mm + 实际抬了 18–25 mm;hop2 把「抬过头」当成误差 ``Δz=−13 mm`` 往回压, + 看起来就是进孔再弹出。抬过了 = 成功。 + """ + return max(float(z_now), float(z_target)) def oneshot_needs_verticalize( diff --git a/src/arm_application/arm_application/perception_node.py b/src/arm_application/arm_application/perception_node.py index cb94271..21510b1 100644 --- a/src/arm_application/arm_application/perception_node.py +++ b/src/arm_application/arm_application/perception_node.py @@ -105,7 +105,7 @@ class PerceptionNode(TolerantNode): self.declare_parameter("image_topic", "") self.declare_parameter("camera_info_topic", "") self.declare_parameter("depth_topic", "/d405/aligned_depth_to_color/image_raw") - self.declare_parameter("weights", "models/yolo/gongjian0929.onnx") + self.declare_parameter("weights", "/home/cc/ros2/models/yolo/gongjian0929.onnx") self.declare_parameter("conf", 0.10) self.declare_parameter("iou", 0.45) self.declare_parameter("imgsz", 640) @@ -336,9 +336,7 @@ class PerceptionNode(TolerantNode): r_pos=float(self.get_parameter("kf_r_pos").value), ) - from arm_application.site_io import resolve_repo_path - - weights_raw = resolve_repo_path(str(self.get_parameter("weights").value)) + weights_raw = str(self.get_parameter("weights").value) weights = str(resolve_weights(weights_raw, self.backend_name)) yolo_cfg = { "weights": weights, @@ -1141,8 +1139,8 @@ class PerceptionNode(TolerantNode): filt[2] = z_use self._last_filt = filt.copy() - if handoff: - # 画面里没有 conf≥门槛的目标:绿框可以留,三维点不许发 —— 伺服会拿去追。 + if handoff or self._d435_commanding: + # 没有高置信度,或 D435 厂家移动还没交接:推理留着,控制点不许发。 return self._publish_point(filt, stamp) self.get_logger().info( @@ -1427,15 +1425,11 @@ class PerceptionNode(TolerantNode): self._z_locked = bool(msg.data) def _on_d435_commanding(self, msg: Bool) -> None: - """伺服还在执行 D435 那一个点时,不再提交下一张。 + """伺服还在执行 D435 那一个点时,不再提交下一张,D405 控制点也不发。 - 这一段结束(到位并停稳)后清掉「已拍过」,漏检重新计 0.5 s。 + 这一轮已经拍过并走完。停稳后打开 D405 框心,不因交接瞬间漏检再 MoveL。 """ - commanding = bool(msg.data) - if self._d435_commanding and not commanding: - self._oneshot_shot = False - self._low_since = None - self._d435_commanding = commanding + self._d435_commanding = bool(msg.data) def _publish_overhead_k0(self, p_base: np.ndarray, is_k0: bool) -> None: pt = PointStamped() diff --git a/src/arm_application/arm_application/pipeline_node.py b/src/arm_application/arm_application/pipeline_node.py index fab29f0..a27c828 100644 --- a/src/arm_application/arm_application/pipeline_node.py +++ b/src/arm_application/arm_application/pipeline_node.py @@ -1,11 +1,11 @@ #!/usr/bin/env python3 -"""跟踪锁定 → 孔定位。搜孔轻压已取消,本节点不再下压搜孔。 +"""三阶段串联:视觉跟踪锁定 → 孔定位 → 搜孔并轻轻按压(默认不拧入)。 流畅性(相对 bash 杀进程再起节点): - 相机 / 触觉 / 手眼 TF 全程只起一次 - 一阶段锁定后 SIGTERM 跟踪节点,立刻 pin 当前关节,不回零、不抬臂 - - 二阶段已垂直 / 已在悬停高则跳过对应动作;对准完就停,不再下压搜孔 - - 旋入不在这个节点里 + - 二阶段已垂直 / 已在悬停高则跳过对应动作;对准完立刻下压搜孔 + - 找到孔位后力闭环轻压保持;拧入是后续阶段(--depth 才插入) """ from __future__ import annotations @@ -15,6 +15,7 @@ import os import signal import subprocess import time +from pathlib import Path from typing import Optional import numpy as np @@ -23,6 +24,7 @@ from geometry_msgs.msg import PointStamped from rclpy.qos import HistoryPolicy, QoSProfile, ReliabilityPolicy from std_msgs.msg import Bool, String +from arm_application.overhead_oneshot import D435_MOVEL_GAVE_UP from arm_application.stage3_search_node import Stage3Search @@ -70,6 +72,19 @@ def init_z_refusal( return "" +def hold_at_insert_z(final, *, skip3: bool) -> bool: + """贴面已经按 ``final_gap_mm`` 停下时,不再进三阶段。 + + 三阶段开局会抬到悬高上方测触觉,再落回 ``hole_hover_z_m``。 + 没有接触传感器。到位高度就是这次的贴面间隙,不另设一个 Z。 + """ + if skip3: + return True + if not isinstance(final, dict): + return False + return bool(final.get("insert_held")) + + def track_lock_z(hover_z: float, tool_len_m: float, surface_z_m: float) -> float: """一阶段锁高。 @@ -85,6 +100,20 @@ def track_lock_z(hover_z: float, tool_len_m: float, surface_z_m: float) -> float return hover + TRACK_CLEARANCE_M +def stage1_log_fatal(log_path: str) -> str: + """一阶段日志里已经写明失败时,不许再空等锁定。空串 = 还没到这一步。""" + try: + text = Path(log_path).read_text(encoding="utf-8", errors="replace") + except OSError: + return "" + if D435_MOVEL_GAVE_UP in text: + return ( + f"D435 {D435_MOVEL_GAVE_UP}(RDK 控制模式没切到 primitive)," + f"一阶段中止。不是视野/标定。看 {log_path}" + ) + return "" + + def stage1_timeout_reason(points_seen: int, timeout_s: float, log_path: str) -> str: """一阶段超时了,到底是"感知没给点"还是"给了点但没收敛"。 @@ -99,6 +128,9 @@ def stage1_timeout_reason(points_seen: int, timeout_s: float, log_path: str) -> 以前两种情况共用一句"没锁定(视野里没工件?)",于是"perception_node 因为缺依赖早退了"被读成"算法不收敛",白查半天。 """ + fatal = stage1_log_fatal(log_path) + if fatal: + return fatal if points_seen <= 0: return ( f"一阶段 {timeout_s:.0f}s 没锁定:**一个视觉目标点都没收到**" @@ -125,7 +157,7 @@ class AssemblyPipeline(Stage3Search): super().__init__(node_name=node_name) self.declare_parameter("skip_stage1", False) self.declare_parameter("skip_stage2", False) - self.declare_parameter("skip_stage3", True) # 搜孔轻压已取消,写成 false 也不再下压 + self.declare_parameter("skip_stage3", False) # 默认搜孔+轻压;拧入靠 --depth self.declare_parameter("d435_move_only", False) self.declare_parameter("init_z_m", -1.0) # >0:启动后先到这个法兰高度 self.declare_parameter("stage1_timeout_sec", 150.0) @@ -308,6 +340,11 @@ class AssemblyPipeline(Stage3Search): self._spin(0.1) if self._locked: break + fatal = stage1_log_fatal(log_path) + if fatal: + self._stop_stage1() + self.get_logger().error(fatal) + return 1 if self._s1.poll() is not None: self.get_logger().error(f"一阶段进程提前退出,看 {log_path}") return 1 @@ -333,9 +370,7 @@ class AssemblyPipeline(Stage3Search): def run(self) -> int: skip1 = bool(self.get_parameter("skip_stage1").value) skip2 = bool(self.get_parameter("skip_stage2").value) - if not bool(self.get_parameter("skip_stage3").value): - self.get_logger().info("搜孔轻压已取消,忽略要求重新打开它的参数") - skip3 = True + skip3 = bool(self.get_parameter("skip_stage3").value) if self.d435_move_only: skip2 = True skip3 = True @@ -365,23 +400,45 @@ class AssemblyPipeline(Stage3Search): if code != 0: self.get_logger().error(f"二阶段失败 exit={code},停在当前位姿") return code - self.get_logger().info( - f"二阶段 OK 残差 {final.get('residual_mm', -1)}mm" - " — 搜孔已取消,停在这里。旋入请另跑。" - ) + stay = hold_at_insert_z(final, skip3=skip3) + if stay and final.get("insert_held"): + self.get_logger().info( + f"二阶段 OK 残差 {final.get('residual_mm', -1)}mm — " + f"插孔停在法兰 Z={final.get('flange_z_m')}m," + f"规尖离面 {final.get('tip_gap_mm')}mm" + f"(目标 {final.get('commanded_gap_mm')}mm,来自 final_gap_mm)。" + "没有接触传感器,不另设 Z,不抬回悬高。" + ) + else: + self.get_logger().info( + f"二阶段 OK 残差 {final.get('residual_mm', -1)}mm" + + ( + " — 跳过搜孔,停在观测高度(看规尖是否对中;进程退出后臂保持)" + if skip3 + else " — 立刻下压搜孔并轻压(不拧入)" + ) + ) else: self._state("skip_stage2") + stay = hold_at_insert_z(final, skip3=skip3) - if skip3: + if stay: self._state("skip_stage3") if self.d435_move_only: self.get_logger().info( "D435 位移测试结束 —— 臂停在当前高度,不交 D405、不对孔;" "15s 后本进程退出,臂保持不动" ) + elif isinstance(final, dict) and final.get("insert_held"): + if q is not None: + self._pin(q) + self.get_logger().info( + "插孔已停在贴面高度,不进搜孔、不抬回悬高。" + "15s 后本进程退出,臂保持这个 Z。" + ) else: self.get_logger().info( - "搜孔已取消 —— 臂停在当前高度。" + "跳过搜孔 —— 臂停在当前高度。请看规尖与孔是否对中;" "15s 后本进程退出,臂保持不动" ) if final: diff --git a/src/arm_application/arm_application/servo_node.py b/src/arm_application/arm_application/servo_node.py index 848dbcd..10eb349 100644 --- a/src/arm_application/arm_application/servo_node.py +++ b/src/arm_application/arm_application/servo_node.py @@ -50,6 +50,9 @@ from arm_application.geom_ik import ( ) from arm_application.handeye_lib import cam_point_to_base, cam_to_base_T from arm_application.overhead_oneshot import ( + D435_MOVEL_GAVE_UP, + D435_MOVEL_RETRY, + D435_MOVEL_RETRY_S, D435_NEAR_CRUISE_MPS, D435_SETTLE_S, D435_VENDOR_MOVEL_VEL_MPS, @@ -126,7 +129,7 @@ class ServoNode(TolerantNode): self.declare_parameter("point_topic", "visual_servo/filtered_point") # 默认值只作兜底:真机运行一定由 launch 传入 site.yaml 的 paths.handeye_file。 # 指向 /tmp 是历史遗留(标定临时产物),会让人误以为"标定结果在那里"。 - self.declare_parameter("handeye_file", "handeye/results/89_eye_in_hand.yaml") + self.declare_parameter("handeye_file", "/home/cc/ros2/handeye/results/89_eye_in_hand.yaml") # eye_in_hand = cam→法兰;eye_to_hand = cam→基座。写错会把固定相机当腕部用。 self.declare_parameter("handeye_mode", "eye_in_hand") self.declare_parameter("use_static_handeye_tf", False) @@ -224,8 +227,7 @@ class ServoNode(TolerantNode): self.declare_parameter("lock_hold_sec", 2.0) # D405 跟住后法兰静止多久进二阶段。哨兵 -1 = 没从 site 接入。 # lock_still_sec 仍须从 site 接上(缺了就报错)。 - # 交回 D405:进程内 MoveL 到位后,再叠 d435_handback_sec(法兰 XY 停稳)。 - # 旧杀栈冷启动曾把这 3s 垫在控制器重启后面;不杀栈后只留机械停稳。 + # 厂家 MoveL 结束后,法兰连续停稳 d435_handback_sec 才打开 D405 框心。 self.declare_parameter("lock_still_sec", -1.0) self.declare_parameter("d435_handback_sec", -1.0) self.declare_parameter("wrist_align_sec", -1.0) @@ -329,7 +331,7 @@ class ServoNode(TolerantNode): and self.wrist_handoff_conf >= 0.0 and self.overhead_hold_s >= 0.0 ) - # 框心对准进对孔用 wrist_align_sec。D435 交回只用 d435_handback_sec,两段不要合成一个数。 + # 框心对准进精拍用 wrist_align_sec。D435 交接只用 d435_handback_sec,两段不要合成一个数。 self._still = StillLock( still_sec=max(0.0, self.wrist_align_sec), still_move_m=max(0.0, self.lock_still_move_m), @@ -342,6 +344,8 @@ class ServoNode(TolerantNode): self._d435_vendor_done = False self._d435_vendor_busy = False self._d435_vendor_failed = False + self._d435_vendor_tries = 0 + self._d435_retry_after = 0.0 self._d435_settled = False self._d435_exec_until = 0.0 self._d435_pinned = False @@ -374,10 +378,9 @@ class ServoNode(TolerantNode): self.ws_min = np.array([float(v) for v in ws_min[:3]], dtype=float) self.ws_max = np.array([float(v) for v in ws_max[:3]], dtype=float) - from arm_application.site_io import resolve_repo_path - - handeye_path = resolve_repo_path(str(self.get_parameter("handeye_file").value)) - self.T_cam_to_gripper = load_handeye_T(handeye_path) + self.T_cam_to_gripper = load_handeye_T( + str(self.get_parameter("handeye_file").value) + ) self.handeye_mode = str( self.get_parameter("handeye_mode").value or "eye_in_hand" ).strip().lower() @@ -400,7 +403,7 @@ class ServoNode(TolerantNode): t_he = np.asarray(self.T_cam_to_gripper[:3, 3], dtype=float) axis_z = float(np.clip(R_he[2, 2], -1.0, 1.0)) self._cam_tilt_deg = float(np.degrees(np.arccos(axis_z))) - he_path = handeye_path + he_path = str(self.get_parameter("handeye_file").value) if self.handeye_mode == "eye_to_hand": self.get_logger().warn( f"HANDEYE yaml={he_path} mode=eye_to_hand " @@ -709,6 +712,8 @@ class ServoNode(TolerantNode): self._d435_vendor_done = False self._d435_vendor_busy = False self._d435_vendor_failed = False + self._d435_vendor_tries = 0 + self._d435_retry_after = 0.0 self._overhead_t = now_m self._last_seen_time = now_m self._stamp_sec = now_sec @@ -732,6 +737,8 @@ class ServoNode(TolerantNode): self._d435_vendor_done = False self._d435_vendor_busy = False self._d435_vendor_failed = False + self._d435_vendor_tries = 0 + self._d435_retry_after = 0.0 self._publish_d435_commanding(True) self.get_logger().info( f"D435 一次性设定 feat_base=({p_base[0]:.3f},{p_base[1]:.3f},{p_base[2]:.3f}) " @@ -759,9 +766,9 @@ class ServoNode(TolerantNode): settled=settled, move_only=self.d435_move_only, ): - # 推理服务不关。这一帧的点不下发给臂,目标仍是 D435 那一个点。 + # 推理不关。没到「法兰停稳」之前,这帧不能进运动控制。 self.get_logger().info( - "D435 位置还没到并且停稳,D405 这帧不下发", + "D405 输出已截断:D435 厂家移动还没结束或法兰还没停稳", throttle_duration_sec=1.0, ) return @@ -1407,9 +1414,12 @@ class ServoNode(TolerantNode): return geometric_jacobian(link_R, link_p, p_ee, RIZON_LINK_AXES) def _d435_oneshot_holds_the_arm(self) -> bool: - """D435 到点平移:action 短跳,等结果再重测。D405 主跟踪仍走原来的伺服。""" - if self.d435_move_only and self._hybrid and self._track_source == "overhead" and self._d435_settled: - # 位移测完了:守着,不许掉进 D405 的 _execute_twist。hold 只在到位那一拍发一次。 + """D435 粗定位只走厂家 MoveL。停稳后先钉住,等 D405 框心接上再放开。 + + 这段不许再用流式伺服去追 D435 那个点。D405 点被 ``_on_point`` 接上后 + 跟踪源变成腕部,这里返回 False,框心才开始带臂。 + """ + if self._hybrid and self._track_source == "overhead" and self._d435_settled: return True if not (self._hybrid and self._track_source == "overhead" and not self._d435_settled): return False @@ -1519,7 +1529,7 @@ class ServoNode(TolerantNode): """停或重新打开关节轨迹控制器。关节状态广播留着,硬件接口不下线。 用节点里的客户端,不走 ``ros2 service call``:那条命令自己就要一两秒, - 交回 D405 的预算会被它吃掉。 + 停稳锁定的预算会被它吃掉。 """ from controller_manager_msgs.srv import SwitchController @@ -1659,18 +1669,32 @@ class ServoNode(TolerantNode): if self._d435_vendor_failed or self._d435_vendor_busy: return True if not self._d435_vendor_done: - # busy 先于调用置位:另一条控制回调不能再开一次 MoveL,也不能提前交回 D405。 + if time.monotonic() < float(self._d435_retry_after): + return True + if int(self._d435_vendor_tries) >= int(D435_MOVEL_RETRY): + self._d435_vendor_failed = True + self.get_logger().error( + f"{D435_MOVEL_GAVE_UP} —— 一阶段中止,D405 也不带臂" + ) + return True + # busy 先于调用置位:另一条控制回调不能再开一次 MoveL,也不能提前锁定。 self._d435_vendor_busy = True ok = self._d435_vendor_movel() self._d435_vendor_busy = False if not ok: - self._d435_vendor_failed = True + self._d435_vendor_tries = int(self._d435_vendor_tries) + 1 + self._d435_retry_after = time.monotonic() + float(D435_MOVEL_RETRY_S) self.get_logger().error( - "厂家 MoveL 没走成 —— 停在原地,D405 的点继续不下发" + f"厂家 MoveL 第{self._d435_vendor_tries}次没走成," + f"{D435_MOVEL_RETRY_S:.1f}s 后再试" ) else: self._d435_vendor_done = True self._arrive.reset() + # MoveL 切回关节后 JTC 还握着出发前的悬停。不钉住的话 + # 硬件 3 s 窗口一过,臂会按悬停关节走回去(比交回 D405 的 10 cm 更远)。 + self._send_hold() + self._d435_pinned = True return True return True @@ -1729,7 +1753,7 @@ class ServoNode(TolerantNode): ): self._d435_pin_and_stop_hops( f"D435 上一跳几乎没走近,剩差还是 {dist*1000:.0f}mm " - f"—— 钉住不再补刀,等停稳后交回 D405" + f"—— 钉住不再补刀,等停稳后锁定进二阶段" ) return True if not at: @@ -1836,7 +1860,7 @@ class ServoNode(TolerantNode): return True def _tick_d435_arrive(self) -> None: - """走到 D435 那个点,并且法兰按一阶段的静止判据停稳,才允许下发 D405。""" + """厂家 MoveL 已结束:看法兰还在不在动。连续停稳后才打开 D405 框心。""" if not self._hybrid: return if ( @@ -1858,11 +1882,15 @@ class ServoNode(TolerantNode): return xy = np.asarray(T[:2, 3], dtype=float) dist = float(np.hypot(float(xy[0]) - goal[0], float(xy[1]) - goal[1])) - arrived = dist <= float(self.lock_still_move_m) or self._d435_give_up - settled = self._arrive.update( - xy, time.monotonic(), tracking_ok=arrived - ) + # 到位看法兰是否还在动,不看离视觉点还差几毫米。 + settled = self._arrive.update(xy, time.monotonic(), tracking_ok=True) if not settled: + self.get_logger().info( + f"D435 厂家 MoveL 已结束,法兰还在动或未满 " + f"{self.d435_handback_sec:.1f}s(离点 {dist*1000:.0f}mm)," + "D405 输出仍截断", + throttle_duration_sec=1.0, + ) return with self._lock: self._d435_settled = True @@ -1885,10 +1913,12 @@ class ServoNode(TolerantNode): self._lock_pub_t = now_m self._lock_pub.publish(Bool(data=True)) return + # 法兰已停稳:打开 D405。不在这里锁定;框心对准停稳后才精拍下臂。 self._publish_d435_commanding(False) self.get_logger().info( - f"已到 D435 位置(还差 {dist*1000:.0f}mm)并停稳 " - f"{self.d435_handback_sec:.1f}s —— 改回下发 D405 框心" + f"法兰已连续停稳 {self.d435_handback_sec:.1f}s" + f"(离 D435 点 {dist*1000:.0f}mm)" + " —— 打开 D405 输出,追工件框矩形中心;对准后才精拍下臂" ) def _tick_still_lock(self) -> None: diff --git a/src/arm_application/arm_application/site_io.py b/src/arm_application/arm_application/site_io.py index c16cb57..9243123 100644 --- a/src/arm_application/arm_application/site_io.py +++ b/src/arm_application/arm_application/site_io.py @@ -10,83 +10,13 @@ import yaml def ros2_root() -> Path: - """仓库根目录。先看环境变量,再从本文件向上找 ``config/site.yaml``。 - - 不能默认 ``~/ros2``:别人把仓库克隆到别的目录时,写死的家目录路径是空的, - 启动会报文件不存在。 - """ for key in ("SITE_ROS2_ROOT", "ROS2_WS"): v = os.environ.get(key, "").strip() if v: return Path(v).expanduser() - here = Path(__file__).resolve() - for parent in here.parents: - if (parent / "config" / "site.yaml").is_file() and (parent / "scripts" / "env.sh").is_file(): - return parent return Path.home() / "ros2" -_PATH_KEYS = frozenset({ - "named_poses_file", - "handeye_file", - "handeye_waypoints", - "handeye_workdir", - "handeye_calibrate", - "yolo_weights", - "hole_weights", - "weights", - "workpiece_weights", - "config_file", - "append_poses_file", -}) - - -def resolve_repo_path(value: str, root: Path | None = None) -> str: - """仓库内的相对路径展开成绝对路径。已经是绝对路径的原样保留。""" - text = str(value or "").strip() - if not text: - return "" - path = Path(text).expanduser() - if path.is_absolute(): - return str(path) - base = root or ros2_root() - return str((base / path).resolve()) - - -def _is_path_key(key: str) -> bool: - return ( - key in _PATH_KEYS - or key.startswith("handeye_file") - or key.startswith("handeye_waypoints") - or key.endswith("_weights") - or key.endswith("_file") - or key.endswith("_workdir") - ) - - -def absolutize_site_paths(data: Any, root: Path | None = None) -> Any: - """把 site 里的仓库相对路径展开。只动路径字段,不动别的字符串。""" - base = root or ros2_root() - if isinstance(data, dict): - out: dict[Any, Any] = {} - for key, val in data.items(): - name = str(key) - if isinstance(val, str) and _is_path_key(name): - out[key] = resolve_repo_path(val, base) - elif ( - name == "config" - and isinstance(val, str) - and (val.endswith((".yaml", ".yml", ".json")) or "/" in val) - ): - out[key] = resolve_repo_path(val, base) - else: - out[key] = absolutize_site_paths(val, base) - return out - if isinstance(data, list): - return [absolutize_site_paths(item, base) for item in data] - return data - - def site_path() -> Path: env = os.environ.get("SITE_CONFIG", "").strip() if env: @@ -94,90 +24,12 @@ def site_path() -> Path: return ros2_root() / "config" / "site.yaml" -def arm_short_id(sn: str) -> str: - """序列号末两位就是臂号(``Rizon4-063989`` → ``89``)。 - - 和 ``scripts/lib/export_site_env.arm_short_id`` 同一条规则。换一台臂 - 只改 ``site.yaml`` 的 SN,不要在界面里再写一张本机对照表。 - """ - tail = str(sn or "").strip().rsplit("-", 1)[-1] - return tail[-2:] if tail.isdigit() and len(tail) >= 2 else "" - - -def listed_arms(data: dict[str, Any] | None) -> list[dict[str, Any]]: - """``robots[]`` 里的真机臂。``sn: sim`` 不进控制。 - - 旋转爪只看夹爪型号里有没有 ``RGI``。右臂也可以是平行爪,不能靠臂号猜。 - """ - robots = ((data or {}).get("robot") or {}).get("robots") or [] - out: list[dict[str, Any]] = [] - seen: set[str] = set() - for robot in robots: - if not isinstance(robot, dict): - continue - sn = str(robot.get("sn") or "").strip() - if not sn or sn.lower() == "sim": - continue - arm_no = arm_short_id(sn) - if not arm_no: - continue - if arm_no in seen: - raise ValueError(f"两台臂的序列号末两位都是 {arm_no},--arm 分不清") - seen.add(arm_no) - side = str(robot.get("arm") or "").strip().lower() - grip = robot.get("gripper") if isinstance(robot.get("gripper"), dict) else {} - model = str(grip.get("model") or "") - shared = (data or {}).get("gripper") if isinstance((data or {}).get("gripper"), dict) else {} - stroke = grip.get("stroke_mm") - if stroke is None: - stroke = shared.get("stroke_mm") or 20.0 - out.append({ - "id": arm_no, - "label": str(robot.get("label") or "").strip() or f"臂{arm_no}", - "sn": sn, - "side": side if side in ("left", "right") else "", - "tactile": bool(robot.get("tactile")), - "camera": str((robot.get("camera") or {}).get("name") or "") if isinstance(robot.get("camera"), dict) else "", - "gripper": model, - "rotary": "RGI" in model.upper(), - "iface": str(robot.get("iface") or ""), - "pc_ip": str(robot.get("pc_ip") or ""), - "port": str(grip.get("port") or ""), - "stroke_mm": float(stroke), - }) - return out - - -def usb_expectations(data: dict[str, Any] | None) -> dict[str, int]: - """设备页上「应有几台」跟 ``site.yaml`` 走,不写死本机的 2+1+2。""" - cameras = (data or {}).get("cameras") or {} - d405 = 0 - d435 = 0 - if isinstance(cameras, dict): - for key, val in cameras.items(): - model = str((val or {}).get("model") or "") if isinstance(val, dict) else "" - token = f"{key} {model}".lower() - if "d405" in token: - d405 += 1 - elif "d435" in token: - d435 += 1 - tactile = (data or {}).get("tactile") or {} - orisys = 0 - if isinstance(tactile, dict): - orisys = sum(1 for key in ("left_video", "right_video") if tactile.get(key) is not None) - return {"d405": d405, "d435": d435, "orisys": orisys} - - def load_site_dict(path: str | Path | None = None) -> dict[str, Any]: p = Path(path).expanduser() if path else site_path() if not p.is_file(): return {} data = yaml.safe_load(p.read_text(encoding="utf-8")) or {} - if not isinstance(data, dict): - return {} - resolved = p.resolve() - root = resolved.parent.parent if resolved.parent.name == "config" else ros2_root() - return absolutize_site_paths(data, root) + return data if isinstance(data, dict) else {} def deep_merge(base: dict[str, Any], overlay: dict[str, Any]) -> dict[str, Any]: diff --git a/src/arm_application/arm_application/stage2_locate_node.py b/src/arm_application/arm_application/stage2_locate_node.py index 670bacb..a3575d2 100644 --- a/src/arm_application/arm_application/stage2_locate_node.py +++ b/src/arm_application/arm_application/stage2_locate_node.py @@ -69,6 +69,7 @@ from arm_application.overhead_oneshot import ( once_ff_tcp_xyz, oneshot_align_step_m, pixel_on_base_plane, + retract_goal_z, ) from arm_application.handeye_realsense import resolve_realsense_topics from arm_application.gauge_boot import perception_source_of @@ -249,7 +250,7 @@ class Stage2Locate(TolerantNode): # _coarse_target_reject / _bbox_on_border 的注释):粗居中的目的是把臂 # 挪到工件正上方,**挪错比不挪危险得多** —— 一挪错精定位就再也看不到 # k1/k2,而流程会把它当"孔检测失败",现场根本想不到是粗居中把臂带走的。 - self.coarse_max_offset_m = float(stage2.get("coarse_max_offset_m") or 0.12) + self.coarse_max_offset_m = float(stage2.get("coarse_max_offset_m") or 0.08) self.coarse_border_px = int(stage2.get("coarse_border_px") or 2) # 最后轻降到孔面上方 final_gap_mm(不接触)。启动参数 ≥0 覆盖 site。 self._final_gap_refused = "" @@ -901,10 +902,13 @@ class Stage2Locate(TolerantNode): min_dur: float = 1.6, hold_xy: bool = False, strict_xy: bool = False, + z_up_only: bool = False, ) -> Tuple[bool, np.ndarray, np.ndarray]: """闭环点到点:规划短 hop → 执行 → 重新看 TF → 重复,直到容差内。 hold_xy=True:只收 Z/姿态(悬停降锁高),不把下降中漂出的 XY 再拽回去。 + z_up_only=True:当前已经高于目标 Z 就停,不许为了追 5 mm 往下补 + (贴面 MoveL 后收臂 hop1 抬过头、hop2 回压进孔,2026-10-16)。 """ target = self._clamp_ws(target) last_dist: Optional[float] = None @@ -912,7 +916,14 @@ class Stage2Locate(TolerantNode): stall = 0 for hop in range(max_hops): p = Tee[:3, 3] - goal = np.array([p[0], p[1], target[2]]) if hold_xy else target + z_tgt = float(target[2]) + if z_up_only: + if float(p[2]) >= z_tgt - float(tol_m): + return True, q, Tee + z_tgt = retract_goal_z(float(p[2]), z_tgt) + goal = np.array([p[0], p[1], z_tgt]) if hold_xy else np.array( + [target[0], target[1], z_tgt], dtype=float + ) if hold_xy: goal = self._clamp_ws(goal) dp = goal - p @@ -1491,14 +1502,15 @@ class Stage2Locate(TolerantNode): 1. 目标点落在 ``workspace_xyz_*`` 的 XY 之外(含 5mm 余量)—— 一阶段刚锁 定时臂就在工件上方,工件**不该**在盒外; - 2. 目标点离当前夹爪轴线超过 ``coarse_max_offset_m``(缺省 0.12m)—— 粗居中 - 是"把臂挪到工件正上方",合理位移是厘米级;十几厘米只可能是坏读数。 + 2. 目标点离当前夹爪轴线超过 ``coarse_max_offset_m``(缺省 0.08m)—— 粗居中 + 是"把臂挪到工件正上方",合理位移是厘米级;十几厘米只可能是坏读数 + (2026-10-16 三次 110mm 回撤,0.12 放行了)。 注意:判误检后**原地不动**,由精定位(直接量 k1/k2)继续。不动的代价只是 "少挪一次",而挪错的代价是精定位彻底看不到孔 —— 所以这里必须选不动。 """ if max_offset_m is None: - max_offset_m = float(getattr(self, "coarse_max_offset_m", 0.12)) + max_offset_m = float(getattr(self, "coarse_max_offset_m", 0.08)) p = np.asarray(p_base, dtype=float).reshape(3) if not np.all(np.isfinite(p[:2])): return "目标点非有限值(深度/三角化失败)" @@ -2241,7 +2253,9 @@ class Stage2Locate(TolerantNode): ) fl0 = (float(Tee[0, 3]), float(Tee[1, 3]), float(Tee[2, 3])) ok = self._vendor_movel_tcp(movel_xyz, ONCE_FF_MOVEL_VEL_MPS) - q2, Tee2 = self._wait_state(2.0) + # MoveL 刚切回关节模式:JTC 还握着贴面前的悬停关节。等 2 s 再钉 + # 等于让 vmax=0.05 往悬停留 0.1 rad(2026-10-16 收臂实测多抬 13–20 mm)。 + q2, Tee2 = self._wait_state(0.3) if q2 is not None and Tee2 is not None: q, Tee = q2, Tee2 self._pin(q) @@ -3392,14 +3406,36 @@ class Stage2Locate(TolerantNode): ) # ---- 5) 锁定保持 ---- + # 贴面之后就停在这个法兰 Z。没有接触传感器,到位只认 final_gap_mm + # (规尖离面)。三阶段开局会抬回悬高,这里标明已经插到位,流水线不再上抬。 + if ( + self.descent_final_gap_m is not None + and self.final_descent_enable + and not self.hover_only + ): + final = dict(final) + final["insert_held"] = True + final["flange_z_m"] = round(float(Tee[2, 3]), 4) + final["tip_gap_mm"] = round(float(self.descent_final_gap_m) * 1000.0, 2) + final["commanded_gap_mm"] = round(float(self.final_gap_m) * 1000.0, 2) + final["insert_z_source"] = "final_gap_mm" self._state("hold") q_fin = self._q() self._pin(q_fin if q_fin is not None else q) - self.get_logger().info( - f"=== 二阶段定位完成: {self.target_hole} 残差 {final.get('residual_mm', -1)}mm," - f"规尖悬在孔面上方 ≈{gap_txt}mm(不接触)。" - f"结果: /stage2/result, /tmp/stage2_result.json ===" - ) + if final.get("insert_held"): + self.get_logger().info( + f"=== 二阶段定位完成: {self.target_hole} 残差 {final.get('residual_mm', -1)}mm," + f"法兰 Z={final.get('flange_z_m')}m,规尖离面 {gap_txt}mm" + f"(final_gap {self.final_gap_m * 1000:.1f}mm,无接触传感器)。" + "停在这一高度,不抬回悬高。" + f"结果: /stage2/result, /tmp/stage2_result.json ===" + ) + else: + self.get_logger().info( + f"=== 二阶段定位完成: {self.target_hole} 残差 {final.get('residual_mm', -1)}mm," + f"规尖悬在孔面上方 ≈{gap_txt}mm(不接触)。" + f"结果: /stage2/result, /tmp/stage2_result.json ===" + ) return 0, q_fin if q_fin is not None else q, Tee, final def _linger_hold(self, final: Dict[str, Any], seconds: Optional[float] = None) -> None: diff --git a/src/arm_application/arm_application/stage3_search_node.py b/src/arm_application/arm_application/stage3_search_node.py index e6e9bbf..acf1a50 100644 --- a/src/arm_application/arm_application/stage3_search_node.py +++ b/src/arm_application/arm_application/stage3_search_node.py @@ -526,7 +526,8 @@ class Stage3Search(Stage2Locate): tgt[2] += dz_m self._goto(q, Tee, tgt, verticalize=True, tol_m=0.001, tol_tilt=self.vert_tol_rad * 3.0, tag="retract", - v=0.03, max_hops=3, settle_s=0.2, min_dur=0.6) + v=0.03, max_hops=2, settle_s=0.2, min_dur=0.6, + hold_xy=True, z_up_only=True) except Exception: pass diff --git a/src/arm_application/config/perception.yaml b/src/arm_application/config/perception.yaml index c2411e9..5685bf9 100644 --- a/src/arm_application/config/perception.yaml +++ b/src/arm_application/config/perception.yaml @@ -12,7 +12,7 @@ perception_node: image_topic: "" camera_info_topic: "" depth_topic: "/d405/aligned_depth_to_color/image_raw" - weights: "models/yolo/gongjian0929.onnx" + weights: "/home/cc/ros2/models/yolo/gongjian0929.onnx" conf: 0.10 iou: 0.45 imgsz: 640 diff --git a/src/arm_application/launch/pipeline.launch.py b/src/arm_application/launch/pipeline.launch.py index fee00c6..ea18aff 100644 --- a/src/arm_application/launch/pipeline.launch.py +++ b/src/arm_application/launch/pipeline.launch.py @@ -159,7 +159,7 @@ def generate_launch_description() -> LaunchDescription: [ DeclareLaunchArgument("skip_stage1", default_value="false"), DeclareLaunchArgument("skip_stage2", default_value="false"), - DeclareLaunchArgument("skip_stage3", default_value="true"), + DeclareLaunchArgument("skip_stage3", default_value="false"), DeclareLaunchArgument("target_hole", default_value=""), DeclareLaunchArgument("insert_depth_mm", default_value=""), DeclareLaunchArgument("screw_pitch_mm", default_value=""), diff --git a/src/arm_application/test/test_overhead_oneshot.py b/src/arm_application/test/test_overhead_oneshot.py index 5d18dc6..443ba2b 100644 --- a/src/arm_application/test/test_overhead_oneshot.py +++ b/src/arm_application/test/test_overhead_oneshot.py @@ -40,6 +40,7 @@ from arm_application.overhead_oneshot import ( FROZEN_J_HOP_MAX_M, build_arc_xy_move, d405_commands_arm, + retract_goal_z, flange_down_w, hold_translation_vz, hybrid_cruise_vxy, @@ -149,7 +150,7 @@ class TestHandoff: assert "伺服没接住" in perc def test_d405_points_stay_off_the_arm_until_arrived_and_settled(self): - """用着 D435 那个点时,到位并且停稳之前,D405 的点不下发。推理不在这里关。""" + """D435 厂家移动没结束、法兰没停稳:D405 不下发。两件都满足才追框心。""" assert d405_commands_arm(using_d435=False, arrived=False, settled=False) is True assert d405_commands_arm(using_d435=True, arrived=False, settled=False) is False assert d405_commands_arm(using_d435=True, arrived=True, settled=False) is False @@ -169,7 +170,7 @@ class TestHandoff: assert "d435_commanding" in perc def test_after_d435_wrist_tracks_box_center_then_hole_without_depth(self): - """交回 D405 追框心;本分支单次精拍 k1/k2,孔坐标只交到工件面。""" + """交回 D405 追框矩形中心;框心对准后才精拍,孔坐标只交到工件面。""" perc = ( __import__("pathlib").Path(__file__).resolve().parents[1] / "arm_application" / "perception_node.py" @@ -344,7 +345,7 @@ class TestHandoff: assert is_control_stack_cmd("perception_node") is False def test_d435_move_only_never_hands_back_to_d405(self): - """只测位移:D405 点一律不下发,到位停稳也不交。默认路径仍会交回。""" + """只测位移:D405 点一律不下发。混合路径法兰停稳后才打开框心。""" assert d405_commands_arm( using_d435=False, arrived=True, settled=True, move_only=True ) is False @@ -370,7 +371,8 @@ class TestHandoff: run_all = (ws / "scripts" / "run_all.sh").read_text(encoding="utf-8") assert "move_only=self.d435_move_only" in servo assert "不交 D405、不进二阶段" in servo - assert "改回下发 D405" in servo + assert "打开 D405 输出,追工件框矩形中心" in servo + assert "锁定进二阶段,D405 流式不再带臂" not in servo assert "self.d435_move_only" in perc assert "or wrist_needs_handoff(" in perc assert "d435_move_only:={str(self.d435_move_only).lower()}" in pipe @@ -464,6 +466,21 @@ class TestHandoff: hold = src[src.index("def _d435_oneshot_holds_the_arm") : src.index("def _tick_d435_arrive")] assert 'self._track_source == "overhead"' in hold assert "not self._d435_settled" in hold + fn = src[ + src.index("def _d435_oneshot_holds_the_arm") : src.index( + "def _d435_pin_and_stop_hops" + ) + ] + assert "and self._d435_settled" in fn + assert "等 D405 框心接上" in fn + locked = src[ + src.index("def _d435_oneshot_holds_locked") : src.index( + "def _d435_oneshot_holds_locked_joint" + ) + ] + assert "self._d435_vendor_done = True" in locked + pin_at = locked.find("self._d435_vendor_done = True") + assert "_send_hold()" in locked[pin_at : pin_at + 500] assert "_publish_joint_schedule(" not in hold assert "oneshot_cartesian_dp(" not in hold assert "trap_joint_schedule(" not in hold @@ -872,7 +889,7 @@ class TestHoleBiasAxes: assert st2.get("hole_once_lock_xy_descend") is True assert st2.get("once_ff_look_per_m") == pytest.approx(ONCE_FF_LOOK_PER_M) servo = (site.get("servo") or {}) - assert servo.get("d435_handback_sec") == pytest.approx(0.5) + assert servo.get("d435_handback_sec") == pytest.approx(2.0) def test_oneshot_step_covers_half_hole_pair(self): """框心到 k1 约 29mm:必须整步走完,不许再截成 12mm。""" @@ -976,3 +993,85 @@ class TestHoleBiasAxes: assert rel is not None np.testing.assert_allclose(rel, [0.580, -0.460, 0.0245], atol=1e-9) assert once_ff_movel_from_tcp([0.580, -0.460, 0.096], [0.00129, 0.0], 0.0) is None + + +class TestRetractHold: + def test_retract_goal_z_never_goes_down(self): + """抬过头 = 成功。真机 hop1 +18 mm 后 hop2 不许用 −13 mm 往回压。""" + assert retract_goal_z(0.258, 0.258 + 0.005) == pytest.approx(0.263) + assert retract_goal_z(0.276, 0.258 + 0.005) == pytest.approx(0.276) + assert retract_goal_z(0.276, 0.263) >= 0.276 - 1e-12 + + def test_stage3_retract_is_z_up_only(self): + root = __import__("pathlib").Path(__file__).resolve().parents[1] + st3 = (root / "arm_application" / "stage3_search_node.py").read_text( + encoding="utf-8" + ) + blk = st3[st3.find("def _retract") : st3.find("def _resnap_align")] + assert "z_up_only=True" in blk + assert "hold_xy=True" in blk + st2 = (root / "arm_application" / "stage2_locate_node.py").read_text( + encoding="utf-8" + ) + assert "z_up_only: bool = False" in st2 + assert "retract_goal_z(" in st2 + + def test_vendor_movel_hold_seeds_joint_commands(self): + """MoveL 切回关节模式后必须用当前角覆盖 JTC 悬停指令,否则收臂会先弹。""" + root = __import__("pathlib").Path(__file__).resolve().parents[3] + cpp = ( + root / "src" / "flexiv_hardware" / "src" / "flexiv_hardware_interface.cpp" + ).read_text(encoding="utf-8") + hpp = ( + root + / "src" + / "flexiv_hardware" + / "include" + / "flexiv_hardware" + / "flexiv_hardware_interface.hpp" + ).read_text(encoding="utf-8") + assert "hold_current_joint_commands" in cpp + assert "hold_current_joint_commands" in hpp + assert "vendor_movel_hold_commands_" in cpp + assert "kArming" in hpp + assert "NRT_PRIMITIVE_EXECUTION" in cpp + assert "切到 NRT_PRIMITIVE_EXECUTION 超时" in cpp + assert "robot_->states().q" in cpp + assert "milliseconds(500)" in cpp + assert "nrt_gate_settle" in cpp + assert "vendor_movel_hold_q_" in cpp + assert "kHoldReleaseRad = 0.020" in cpp + assert "vendor_movel_hold_until_" not in cpp + settle = cpp[cpp.find("void nrt_gate_settle") : cpp.find("void request_nrt_settle")] + assert "gate.v_filt.assign" in settle + assert "gate.parked = true" in settle + assert "gate.last_cmd = q" in settle + servo = ( + __import__("pathlib").Path(__file__).resolve().parents[1] + / "arm_application" + / "servo_node.py" + ).read_text(encoding="utf-8") + assert "D435_MOVEL_GAVE_UP" in servo + assert "D435_MOVEL_RETRY" in servo + pipe = ( + __import__("pathlib").Path(__file__).resolve().parents[1] + / "arm_application" + / "pipeline_node.py" + ).read_text(encoding="utf-8") + assert "stage1_log_fatal(" in pipe + assert "finish_vendor_movel" in cpp + fin = cpp[cpp.find("void FlexivHardwareInterface::finish_vendor_movel") :] + fin = fin[: fin.find("bool FlexivHardwareInterface::poll_vendor_movel")] + assert "hold_current_joint_commands()" in fin + wr = cpp[cpp.find("FlexivHardwareInterface::write") :] + wr = wr[: wr.find("if (position_controller_running_")] + assert "vendor_movel_hold_commands_" in wr + assert "vendor_movel_hold_q_" in wr + assert "hw_states_joint_positions_" not in wr + send = cpp[cpp.find("static NrtSendGate nrt_gate;") :] + send = send[: send.find("if (nrt_should_send")] + assert "nrt_gate_settle(nrt_gate, target_pos)" in send + i_pin = cpp.find("const bool pin_arrival") + look = cpp[i_pin : cpp.find("send_pos[i] = target_pos[i]", i_pin)] + assert "pin_arrival" in look + assert "near_rest = pin_arrival" in look diff --git a/src/arm_application/test/test_overhead_track.py b/src/arm_application/test/test_overhead_track.py index be72bba..091b3f0 100644 --- a/src/arm_application/test/test_overhead_track.py +++ b/src/arm_application/test/test_overhead_track.py @@ -238,7 +238,7 @@ class TestRealSiteBranchDefault: assert s["handeye_mode"] == "eye_in_hand" assert s["handeye_file"].endswith("89_eye_in_hand.yaml") assert s["lock_still_sec"] == pytest.approx(3.0) - assert s["d435_handback_sec"] == pytest.approx(0.5) + assert s["d435_handback_sec"] == pytest.approx(2.0) assert s["wrist_align_sec"] == pytest.approx(0.5) assert s["lock_still_move_m"] == pytest.approx(0.015) # 负向:跟踪外参不能被换成顶视文件 diff --git a/src/arm_application/test/test_pipeline_stage1_diag.py b/src/arm_application/test/test_pipeline_stage1_diag.py index 720474c..624df10 100644 --- a/src/arm_application/test/test_pipeline_stage1_diag.py +++ b/src/arm_application/test/test_pipeline_stage1_diag.py @@ -11,13 +11,42 @@ ultralytics 早退了"被读成"算法不收敛",白查半天。这里把这 from __future__ import annotations +from pathlib import Path + import pytest -from arm_application.pipeline_node import init_z_refusal, stage1_timeout_reason, track_lock_z +from arm_application.overhead_oneshot import D435_MOVEL_GAVE_UP +from arm_application.pipeline_node import ( + hold_at_insert_z, + init_z_refusal, + stage1_log_fatal, + stage1_timeout_reason, + track_lock_z, +) -def test_zero_points_blames_perception_not_calibration(): - msg = stage1_timeout_reason(0, 150.0, "/tmp/pipeline_stage1.log") +def test_insert_hold_does_not_return_to_hover(): + """贴面后停住。三阶段开局会抬回悬高;没有传感器,不另设一个 Z。""" + assert hold_at_insert_z({"insert_held": True}, skip3=False) is True + assert hold_at_insert_z({}, skip3=False) is False + assert hold_at_insert_z(None, skip3=False) is False + assert hold_at_insert_z({}, skip3=True) is True + src = ( + Path(__file__).resolve().parents[1] / "arm_application" / "stage2_locate_node.py" + ).read_text(encoding="utf-8") + hold = src[src.find("# ---- 5) 锁定保持 ----") : src.find("def _linger_hold")] + assert 'final["insert_held"] = True' in hold + assert 'final["insert_z_source"] = "final_gap_mm"' in hold + pipe = ( + Path(__file__).resolve().parents[1] / "arm_application" / "pipeline_node.py" + ).read_text(encoding="utf-8") + run = pipe[pipe.find("def run") :] + assert "hold_at_insert_z(" in run + assert "不抬回悬高" in run + + +def test_zero_points_blames_perception_not_calibration(tmp_path: Path): + msg = stage1_timeout_reason(0, 150.0, str(tmp_path / "missing.log")) assert "一个视觉目标点都没收到" in msg # 必须逐后端给出对号入座的查法,否则人还是不知道下一步做什么 assert "yolo" in msg and "truth" in msg and "external" in msg @@ -27,8 +56,8 @@ def test_zero_points_blames_perception_not_calibration(): assert "不是标定" in msg -def test_points_seen_but_no_convergence_blames_geometry(): - msg = stage1_timeout_reason(123, 150.0, "/tmp/pipeline_stage1.log") +def test_points_seen_but_no_convergence_blames_geometry(tmp_path: Path): + msg = stage1_timeout_reason(123, 150.0, str(tmp_path / "missing.log")) assert "123" in msg assert "没收敛" in msg assert "手眼" in msg @@ -36,10 +65,11 @@ def test_points_seen_but_no_convergence_blames_geometry(): assert "ultralytics" not in msg -def test_timeout_seconds_and_log_path_are_in_the_message(): - msg = stage1_timeout_reason(0, 42.0, "/tmp/x.log") +def test_timeout_seconds_and_log_path_are_in_the_message(tmp_path: Path): + log = str(tmp_path / "x.log") + msg = stage1_timeout_reason(0, 42.0, log) assert "42s" in msg - assert "/tmp/x.log" in msg + assert log in msg def test_real_arm_locks_track_height_at_hover(): @@ -78,6 +108,20 @@ def test_movel_ipc_roundtrip(): assert parse_done("nope\n") is None +def test_movel_gave_up_in_log_is_fatal_not_a_vision_timeout(tmp_path: Path): + """2026-10-16 17:27:MoveL 控制模式失败后空等 150s 被读成粗定位卡住。""" + p = tmp_path / "stage1.log" + p.write_text(f"foo\n{D435_MOVEL_GAVE_UP} —— 一阶段中止\n", encoding="utf-8") + why = stage1_log_fatal(str(p)) + assert why + assert "控制模式" in why + assert "不是视野" in why + msg = stage1_timeout_reason(80, 150.0, str(p)) + assert "控制模式" in msg + assert "没收敛" not in msg + assert stage1_log_fatal(str(tmp_path / "nope.log")) == "" + + def test_missing_geometry_does_not_pretend_the_hover_is_clear(): """工具长或工件面缺失时必须抬升。缺数还锁在悬停高,等于没检查。""" assert track_lock_z(0.320, 0.0, 0.022) == pytest.approx(0.370) diff --git a/src/arm_application/test/test_stage2_coarse_guard.py b/src/arm_application/test/test_stage2_coarse_guard.py index d8f24c5..8f41e6f 100644 --- a/src/arm_application/test/test_stage2_coarse_guard.py +++ b/src/arm_application/test/test_stage2_coarse_guard.py @@ -229,9 +229,27 @@ def test_site_yaml_has_the_two_guard_keys(): assert "coarse_max_offset_m" in stage2, "site.yaml 没接 coarse_max_offset_m" assert "coarse_border_px" in stage2, "site.yaml 没接 coarse_border_px" off = float(stage2["coarse_max_offset_m"]) - # 必须小于真机那次出事的 211.5mm、又大于合理粗居中的厘米级位移 - assert 0.03 < off < BAD_ERR_M, ( - f"coarse_max_offset_m={off} 必须夹在'合理粗居中'与'那次 211.5mm'之间" + # 必须小于真机那次出事的 211.5mm、又大于合理粗居中的厘米级位移。 + # 还要挡住 2026-10-16 的 110mm 回撤(0.12 放行了)。 + assert 0.03 < off < 0.10, ( + f"coarse_max_offset_m={off} 必须夹在'合理粗居中'与'110mm 回撤'之间" + ) + + +def test_d435_handoff_110mm_reverse_is_rejected(): + """2026-10-16:D405 把法兰拉到 (0.569,-0.375),工件仍在 (0.512,-0.469)。 + + 110mm 必须挡住。旧上限 0.12 会放行 —— 这条是「改了阈值真能红」的负向。 + """ + gauge = np.array([0.569, -0.375]) + workpiece = np.array([0.512, -0.469, 0.320]) + err = float(np.hypot(workpiece[0] - gauge[0], workpiece[1] - gauge[1])) + assert 0.10 < err < 0.12, f"夹具前提变了:偏差是 {err*1000:.1f}mm 不是 ~110mm" + why = _reject(_Stub(max_offset_m=0.08), workpiece, gauge) + assert why is not None, "110mm 回撤漏过去了" + assert "上限" in why + assert _reject(_Stub(max_offset_m=0.12), workpiece, gauge) is None, ( + "旧上限 0.12 必须放行这条,否则负向基线失真" ) diff --git a/src/flexiv_ros2/flexiv_hardware/include/flexiv_hardware/flexiv_hardware_interface.hpp b/src/flexiv_ros2/flexiv_hardware/include/flexiv_hardware/flexiv_hardware_interface.hpp index adf1b7a..e8af21b 100644 --- a/src/flexiv_ros2/flexiv_hardware/include/flexiv_hardware/flexiv_hardware_interface.hpp +++ b/src/flexiv_ros2/flexiv_hardware/include/flexiv_hardware/flexiv_hardware_interface.hpp @@ -143,13 +143,26 @@ private: // D435 粗定位的 MoveL 走已经连上的这份 RDK。write() 里一步一步推进, // 不另开线程,也不在这段里往关节接口灌点。请求文件是 /tmp/flexiv_movel_req。 - enum class VendorMovelPhase { kIdle, kRunning }; + enum class VendorMovelPhase { kIdle, kArming, kRunning }; VendorMovelPhase vendor_movel_phase_ = VendorMovelPhase::kIdle; long vendor_movel_seq_ = -1; long vendor_movel_done_seq_ = -1; + double vendor_movel_x_ = 0.0; + double vendor_movel_y_ = 0.0; + double vendor_movel_z_ = 0.0; + double vendor_movel_vel_ = 0.0; + int vendor_movel_keep_z_ = 0; std::chrono::steady_clock::time_point vendor_movel_deadline_ {}; + std::chrono::steady_clock::time_point vendor_movel_arm_until_ {}; + // MoveL 切回关节模式后 JTC 还握着贴面前的悬停关节。钉住的是结束 + // 瞬间的关节角,直到 JTC 指令贴近它才放下。不许因“指令已被写成 + // 状态”或超时放行,也不许把 MoveL 的关节差当成下一拍的速度。 + bool vendor_movel_hold_commands_ = false; + std::chrono::steady_clock::time_point vendor_movel_hold_since_ {}; + std::vector vendor_movel_hold_q_; bool poll_vendor_movel(); void finish_vendor_movel(long seq, bool ok, const std::string& detail); + void hold_current_joint_commands(); }; } /* namespace flexiv_hardware */ diff --git a/src/flexiv_ros2/flexiv_hardware/src/flexiv_hardware_interface.cpp b/src/flexiv_ros2/flexiv_hardware/src/flexiv_hardware_interface.cpp index 7a648c7..d1e9545 100644 --- a/src/flexiv_ros2/flexiv_hardware/src/flexiv_hardware_interface.cpp +++ b/src/flexiv_ros2/flexiv_hardware/src/flexiv_hardware_interface.cpp @@ -191,6 +191,35 @@ bool nrt_should_send(NrtSendGate& gate, const std::vector& target, doubl return true; } +// MoveL 结束时 last_cmd 还是出发前的关节。下一拍若用 +// (到位角 - last_cmd) / dt 当速度,整段位移会变成一步速度, +// 前瞻再沿同一方向推(2026-10-19 09:25:vcmd=1.686 rad/s, +// 光心从 5mm 被推到 276mm 后急停)。锚到到位角并驻留,速度清零。 +struct NrtSettleRequest { + bool pending = false; + std::vector q_rdk; +}; + +NrtSettleRequest nrt_settle_request; + +void nrt_gate_settle(NrtSendGate& gate, const std::vector& q) +{ + gate.last_cmd = q; + gate.last_sent = q; + gate.prev_target = q; + gate.park_target = q; + gate.v_filt.assign(q.size(), 0.0); + gate.have_sent = true; + gate.parked = true; + gate.still_ticks = 0; +} + +void request_nrt_settle(const std::vector& q_rdk) +{ + nrt_settle_request.q_rdk = q_rdk; + nrt_settle_request.pending = true; +} + void fill_nrt_limits(std::vector& max_vel, std::vector& max_acc, double d_from_state, double cmd_speed) { @@ -741,6 +770,29 @@ hardware_interface::return_type FlexivHardwareInterface::read( return hardware_interface::return_type::OK; } +void FlexivHardwareInterface::hold_current_joint_commands() +{ + // 冻结 MoveL 结束瞬间的关节角。之后不许改去跟实时状态, + // 否则前瞻把臂推走时,钉住目标会跟着跑。 + const auto q = robot_->states().q; + const size_t ncmd = hw_commands_joint_positions_.size(); + vendor_movel_hold_q_.assign(ncmd, 0.0); + std::vector q_rdk(robot_->info().DoF, 0.0); + for (size_t rdk_idx = 0; rdk_idx < robot_->info().DoF && rdk_idx < q.size(); + ++rdk_idx) { + const size_t ros_idx = rdk_to_ros_map_[rdk_idx]; + q_rdk[rdk_idx] = q[rdk_idx]; + if (ros_idx < hw_states_joint_positions_.size() && ros_idx < ncmd) { + hw_states_joint_positions_[ros_idx] = q[rdk_idx]; + hw_commands_joint_positions_[ros_idx] = q[rdk_idx]; + vendor_movel_hold_q_[ros_idx] = q[rdk_idx]; + } + } + request_nrt_settle(q_rdk); + vendor_movel_hold_commands_ = true; + vendor_movel_hold_since_ = std::chrono::steady_clock::now(); +} + void FlexivHardwareInterface::finish_vendor_movel( long seq, bool ok, const std::string& detail) { @@ -756,12 +808,62 @@ void FlexivHardwareInterface::finish_vendor_movel( } vendor_movel_done_seq_ = seq; vendor_movel_phase_ = VendorMovelPhase::kIdle; + hold_current_joint_commands(); } bool FlexivHardwareInterface::poll_vendor_movel() { // 这一拍正在走 MoveL:只查 reachedTarget,不往关节接口灌点。 // read() 仍每拍更新状态,时间戳看门狗不会把这段误判成断连。 + if (vendor_movel_phase_ == VendorMovelPhase::kArming) { + // SwitchMode 返回了不等于已经进 primitive。2026-10-16 17:27 + // 同一拍 ExecutePrimitive 报 "not in an applicable control mode"。 + const auto now = std::chrono::steady_clock::now(); + const auto mode = robot_->mode(); + if (mode == flexiv::rdk::Mode::NRT_PRIMITIVE_EXECUTION) { + try { + const auto pose = robot_->states().tcp_pose; + double z = vendor_movel_z_; + if (vendor_movel_keep_z_ != 0) { + z = pose[2]; + } + const auto euler = movel_euler_deg(pose[3], pose[4], pose[5], pose[6]); + const flexiv::rdk::Coord target( + std::array {vendor_movel_x_, vendor_movel_y_, z}, + euler, + std::array {"WORLD", "WORLD_ORIGIN"}); + robot_->ExecutePrimitive( + "MoveL", + {{"target", target}, {"vel", vendor_movel_vel_}}, + true); + vendor_movel_phase_ = VendorMovelPhase::kRunning; + vendor_movel_deadline_ = now + std::chrono::seconds(90); + RCLCPP_INFO(getLogger(), + "厂家 MoveL 已下发 xyz=(%.3f, %.3f, %.3f) keep_z=%d vel=%.3f", + vendor_movel_x_, vendor_movel_y_, z, + vendor_movel_keep_z_, vendor_movel_vel_); + } catch (const std::exception& e) { + try { + robot_->SwitchMode(rdk_control_mode_); + } catch (const std::exception&) { + } + finish_vendor_movel(vendor_movel_seq_, false, e.what()); + } + return true; + } + if (now > vendor_movel_arm_until_) { + try { + robot_->SwitchMode(rdk_control_mode_); + } catch (const std::exception&) { + } + finish_vendor_movel( + vendor_movel_seq_, false, + "切到 NRT_PRIMITIVE_EXECUTION 超时,当前 mode=" + + std::to_string(static_cast(mode))); + return true; + } + return true; + } if (vendor_movel_phase_ == VendorMovelPhase::kRunning) { try { if (std::chrono::steady_clock::now() > vendor_movel_deadline_) { @@ -812,23 +914,22 @@ bool FlexivHardwareInterface::poll_vendor_movel() if (keep_z != 0) { z = pose[2]; } - const auto euler = movel_euler_deg(pose[3], pose[4], pose[5], pose[6]); if (robot_->fault()) { robot_->ClearFault(3); } - robot_->SwitchMode(flexiv::rdk::Mode::NRT_PRIMITIVE_EXECUTION); - const flexiv::rdk::Coord target( - std::array {x, y, z}, - euler, - std::array {"WORLD", "WORLD_ORIGIN"}); - robot_->ExecutePrimitive( - "MoveL", {{"target", target}, {"vel", vel}}, true); vendor_movel_seq_ = seq; - vendor_movel_phase_ = VendorMovelPhase::kRunning; - vendor_movel_deadline_ = std::chrono::steady_clock::now() + std::chrono::seconds(90); + vendor_movel_x_ = x; + vendor_movel_y_ = y; + vendor_movel_z_ = z; + vendor_movel_vel_ = vel; + vendor_movel_keep_z_ = keep_z; + robot_->SwitchMode(flexiv::rdk::Mode::NRT_PRIMITIVE_EXECUTION); + vendor_movel_phase_ = VendorMovelPhase::kArming; + vendor_movel_arm_until_ = std::chrono::steady_clock::now() + + std::chrono::seconds(2); RCLCPP_INFO(getLogger(), - "厂家 MoveL 已下发 xyz=(%.3f, %.3f, %.3f) keep_z=%d vel=%.3f", - x, y, z, keep_z, vel); + "厂家 MoveL 切 primitive 中 xyz=(%.3f, %.3f, %.3f) keep_z=%d 当前 mode=%d", + x, y, z, keep_z, static_cast(robot_->mode())); } catch (const std::exception& e) { try { robot_->SwitchMode(rdk_control_mode_); @@ -853,6 +954,41 @@ hardware_interface::return_type FlexivHardwareInterface::write( std::vector max_vel(robot_->info().DoF, kServoJointVelocity); std::vector max_acc(robot_->info().DoF, kServoJointAcceleration); + static rclcpp::Clock steady_clock(RCL_STEADY_TIME); + + if (vendor_movel_hold_commands_) { + const auto now_hold = std::chrono::steady_clock::now(); + double d_ext = 0.0; + bool cmd_nan = vendor_movel_hold_q_.size() + != hw_commands_joint_positions_.size(); + const size_t n = std::min( + hw_commands_joint_positions_.size(), vendor_movel_hold_q_.size()); + for (size_t i = 0; i < n; ++i) { + const double c = hw_commands_joint_positions_[i]; + if (c != c) { + cmd_nan = true; + continue; + } + // 比的是这一拍 JTC 写进来的指令和冻结角,不是我们自己覆盖后的值。 + d_ext = std::max(d_ext, std::fabs(c - vendor_movel_hold_q_[i])); + } + const bool min_hold = now_hold + >= vendor_movel_hold_since_ + std::chrono::milliseconds(500); + // 出发前的悬停和到位角差远大于 0.02 rad。超时放行会把那段再发出去。 + constexpr double kHoldReleaseRad = 0.020; + if (!cmd_nan && min_hold && d_ext < kHoldReleaseRad) { + vendor_movel_hold_commands_ = false; + } else { + for (size_t i = 0; i < n; ++i) { + hw_commands_joint_positions_[i] = vendor_movel_hold_q_[i]; + } + if (now_hold > vendor_movel_hold_since_ + std::chrono::seconds(3)) { + RCLCPP_WARN_THROTTLE(getLogger(), steady_clock, 2000, + "MoveL 后关节指令离到位角还差 %.3f rad,继续钉住到位角", + d_ext); + } + } + } bool is_pos_nan = false; bool is_vel_nan = false; @@ -869,8 +1005,6 @@ hardware_interface::return_type FlexivHardwareInterface::write( } } - static rclcpp::Clock steady_clock(RCL_STEADY_TIME); - if (position_controller_running_ && !is_pos_nan) { if (robot_->mode() != rdk_control_mode_) { RCLCPP_WARN_THROTTLE(getLogger(), steady_clock, 2000, @@ -924,6 +1058,12 @@ hardware_interface::return_type FlexivHardwareInterface::write( std::fabs(target_pos[rdk_idx] - hw_states_joint_positions_[ros_idx])); } static NrtSendGate nrt_gate; + // 钉住期间每拍把速度门闩锚回当前下发角。last_cmd 若还是 + // MoveL 之前的关节,这一拍的速度就是整段位移。 + if (vendor_movel_hold_commands_ || nrt_settle_request.pending) { + nrt_gate_settle(nrt_gate, target_pos); + nrt_settle_request.pending = false; + } RCLCPP_INFO_THROTTLE(getLogger(), steady_clock, 2000, "HW write running=%d nan=%d mode=%d want=%d op=%d busy=%d d_state=%.4f", static_cast(position_controller_running_), static_cast(is_pos_nan), @@ -954,8 +1094,10 @@ hardware_interface::return_type FlexivHardwareInterface::write( } constexpr double kLookahead = 0.14; std::vector send_pos = target_pos; - // Near rest: do NOT add lookahead (it sustains micro-oscillation) - const bool near_rest = (cmd_speed < 0.025 && d_from_state < 0.010); + // 钉住到位角时不许前瞻。近静止也不加,否则会自己维持微振。 + const bool pin_arrival = vendor_movel_hold_commands_; + const bool near_rest = pin_arrival + || (cmd_speed < 0.025 && d_from_state < 0.010); for (size_t i = 0; i < target_pos.size(); ++i) { if (near_rest) { send_pos[i] = target_pos[i];