Update workstation UI, ROS task control and device integration

This commit is contained in:
cheng
2026-10-20 15:46:13 +08:00
parent 2b74c50ba8
commit e5b06df6da
141 changed files with 91520 additions and 748 deletions
+207 -4
View File
@@ -19,7 +19,9 @@
## 首页动作接口
- 检测 → `stage_track`,运行现有第一阶段跟踪:D405 优先、丢失目标后 D435 补位;会移动机械臂,不进入对孔或旋入。
- 对孔 → `stage_locate`,只运行第二阶段,明确关闭最终下压和后续旋入。
- 对孔 → `stage_locate`,运行新版第二阶段:精对孔并下降至配置的进孔高度;不自动转 A7。当前 hole_entry_mm=1.0。
- 旋转 → 独立 `a7_rotation` ROS 节点,复用上游首轮 A7 正反转和四轮松爪、微抬、空回、降回、合爪旋转;不重新跟踪/对孔。夹爪通过本站 GripperExecute 服务控制,不调用 shell。起点高度/姿态、夹爪或限位不满足时中止。
- 开始全部流程 → 跟踪、对孔、A7 旋转依次执行,失败/取消后不进入下一段。
- 回零 → 所选臂命名点文件中的 `servo.home_pose_name`(默认 `home`),经原生轨迹 Action 返回初始姿态,不是关节全零。
- 停止 → 取消本站任务、通过控制器管理服务暂停对应臂的运动控制器(硬件接口调用 `Robot::Stop`),停止本站流程,再等待新鲜关节反馈持续稳定。失败时明确报告停止未确认,并阻止继续运动。仅下一次显式动作恢复本站暂停的控制器。
@@ -54,11 +56,11 @@ PGEA 的常驻驱动提供:
## 图像链路
RealSense 采集节点只在显式请求时启动。已有发布者会被复用,工位不会按进程名终止它。
RealSense 采集节点只在显式请求时启动。外部发布者不会被接管,工位也不会按进程名终止它。只有本站进程记录与当前唯一发布者身份同时匹配,才允许预览和启动后续算法。
采集配置继续来自 `site.yaml`,算法使用的彩色/深度独立于界面预览。
`cam_bridge.py` 负责 ROS 图像接收与共享内存输出。当前 Qt 界面只请求真机彩色流;
没有查看需求的图像与检测订阅会撤销。预览处理最高 15 fps、彩色宽度上限 848,
没有查看需求的图像与检测订阅会撤销。彩色预览不再额外限到 15 fps,保留最高 1920×1080 的显示图像,
算法输入不受影响,采集端现有原图话题的发布速率不会因此降低。
图像或桥进程超过 2 秒未更新时清除旧显示。共享内存由生产者释放,读取者只解除映射。
深度、仿真及标定流的后端能力保留给独立工具,不再提供首页切换入口。
@@ -74,7 +76,7 @@ RealSense 采集节点只在显式请求时启动。已有发布者会被复用
默认不自动拉额外查看器、红外或触觉流。上电的网络/工具准备与手眼标定保留原维护脚本,
由后端作为明确的系统/标定工具运行。`operator_console.py` 的旧 HTTP/脚本控制路径已经退役。
`processes.py` 只登记并停止自己启动的进程组,不扫描和终止外部进程。Linux 子进程设置
`processes.py` 只登记并停止自己启动的进程树,不扫描和终止外部进程。Linux 子进程设置
父进程退出信号,后端也监测界面退出,避免留下预览或控制孤儿进程。
“停止动作”取消工位任务,保持采集和驱动;窗口关闭时取消任务并退出自己持有的进程。
软件流程停止不能证明控制器已停稳;轨迹、夹爪停止未确认时会明确报告结果未知。
@@ -95,3 +97,204 @@ RealSense 采集节点只在显式请求时启动。已有发布者会被复用
每次后端启动会生成一份私有站点配置快照,所有子进程使用同一份设备身份与参数。
现场 `site.yaml` 发生变化后拒绝新操作并提示重启,已执行任务继续使用原快照,避免运行中换臂或换相机。
## 第一批节点治理
后端按设备驱动、运动控制、算法任务、界面辅助分类展示 ROS 图,并保留完整命名空间及同名实例数。
分类仅表示职责:发现一个节点不等于确认它健康,更不代表工位拥有它。
监控页另列出本站实际启动的进程 PID、启动时间和退出状态,以及当前任务占用的资源、臂号和取消请求。
后端断联时这些缓存统一降为未知。
Linux 启动器作为 subreaper 持续监督每条启动命令,主命令结束后先终止并回收其遗留子进程,
包括自行创建 session 的节点,再允许任务返回。控制栈、相机、算法继续分进程运行;不按节点名字杀外部进程。
日志保留历史,但任务结果仅截取本次启动写入的部分,避免成功任务显示上次错误。
使能显示同时要求本臂关节反馈新鲜、原生驱动 RobotStates 中的机器人时间戳持续推进;
仅 ROS header 更新或重复缓存帧不能确认使能。依据是本项目原生驱动只在 connected/operational 时更新该结构。
这属于应用层运行状态判断,不替代机器人自身安全状态或安全控制器。
本批尚未迁移全局运动接口到双臂命名空间,也未替换旧命令行维护/标定脚本。
这些入口仍需后续逐条迁移;目前继续使用全局运动资源互斥,禁止双臂同时下发。
相机未连接时不更改采集分辨率、USB 模式或宣称实测帧率。预览改动使用模拟图像验证。
项目发现配置统一在 `config/ros_runtime.env`:默认 domain 42、仅本机发现,不再继承 shell 里
不明来源的 domain 0 或仿真域。显式部署覆盖使用 `REAL_ROS_DOMAIN_ID` 和
`REAL_ROS_LOCALHOST_ONLY`,所有子启动脚本继承同一值。CLI 观察节点前需先 source 本项目的
`scripts/env.sh`。ROS 域用于防止误混入,不属于访问控制或机器人安全功能。
运行中的旧工位仍保持原域,代码修改不会热切换它。迁移需要在任务结束后完整关闭旧工位,
再重新启动;旧域外部控制栈必须由原入口退出,不能同时对同一硬件启动第二份驱动。
## 操作并发与收尾
资源策略集中在 `command_policy.py`:Qt 只合并同一臂的同一按钮重复请求,其他请求交由后端
按实际写入设备判断。机械臂使能/跟踪/对孔/回零、相机刷新、夹爪控制和传感器刷新/底图重置
可跨模块并行;读取图像和力反馈不等于独占采集模块。A7 与全流程实际控制夹爪,所以执行期间
保留该夹爪的互锁。仍共用控制器的机械臂运动保持互斥。同一相机初始化由相机适配器内部串行,
只有断流或配置不匹配才重启;机械臂使能不阻止相机重连。触觉预览不占用触觉采集资源。
机械臂反馈中断仅记录在 backend.device_errors.arms,不提升为全后端错误,不禁用其他模块。
冲突提示显示动作名称、臂号和执行/收尾/停止阶段。子进程启动器通过私有阶段文件报告收尾,
直到整个所属进程树退出才释放资源。工位 pipeline 的结果展示停留改为 0.3 秒;手工 CLI 默认仍为
15 秒。此停留仅发布结果/预览,不更改轨迹、定位精度或控制器保持行为。
停止 RPC 不经过任务队列;明确停止机械臂时只取消运动控制任务,保留独立的相机连接和只读任务。
停止未确认仍锁住后续机械臂控制入口,不能靠并发改动绕过停止确认。
## 高清相机配置
当前请求 D405 彩色/原生深度 1280×720@15,D435 彩色 1920×1080@15、原生深度
1280×720@15,由 SDK 对齐到彩色像素坐标。采集器只尝试本台设备实际枚举支持的配置,
不再把 1080p 一律过滤,也不强迫 D435 彩色和原生深度使用同一分辨率。
Qt 预览通过本站采集节点的 `color/preview/compressed` 接收 JPEG 95 图像,只有订阅者存在
时才编码;算法继续订阅完整 `color/image_raw`,无算法订阅者时不序列化这份大图。
预览桥支持到 1920×1080,原生分辨率在该范围内保持不缩小。压缩仅用于显示,不用于定位计算。
两台相机共用 5Gbps Hub 的现场并发测试出现断流,单台可接近 30fps;部署应使用独立 USB3
连接并重新核对两路同时采集的实测值,不能仅凭配置数字认为帧率已达标。
“刷新相机”核对本站采集所有权及新鲜 CameraInfo:正常且分辨率匹配的流保持运行,
断流或配置不匹配才重启本站对应进程,并等待 DDS 旧发现记录退出、收到实际新帧后报告成功。
相机刷新与机械臂模块独立,不接管外部节点。SDK 留出四个帧缓冲供对齐持有,
应用层仍只保留最新一帧;每轮处理后释放 SDK 引用,避免等待新帧时占住旧缓冲。
### 使能与轨迹恢复
使能的连接检查不恢复本站暂停的轨迹控制器;只有明确的新动作才恢复控制器。
关节控制循环不再自动 ClearFault/Enable。JTC 使用实际关节反馈作为新轨迹起点
(open_loop_control=false)。硬件激活、控制器恢复和厂家 MoveL 结束后,
旧目标被拦截,直到指令流从实测关节附近重新开始;同时清空 NRT 前馈缓存。
更换驱动库使用原子替换,运行中的驱动保持原版本,重启工位后加载更新。
离线回归包括暂停后使能不恢复控制器,以及旧目标持续重放不能通过硬件保护。
### 实体急停后的任务取消
原生驱动在 connected/operational 丢失、故障或硬件时间戳停滞时锁存中断,
read/write 返回 ERROR,要求控制器停止;write 在处理厂家 MoveL 请求前检查锁存。
仅恢复 Enable 或清故障不会解除锁存,需要显式重新激活硬件生命周期
(当前工位可通过重启控制栈重新连接)。重新激活会丢弃旧 MoveL 请求序号。
后端在运动期间监控原生反馈,故障或连续 0.75 秒没有推进时取消机器人控制任务,
走既有停止与子进程清理流程;取消后的迟到成功也按 cancelled 记录。
显式重新连接时,驱动先调用 Stop 清除机器人端残留动作,再请求 Enable;Stop 失败则不使能。
此软件逻辑不替代实体急停。Qt 顶部急停图标仍未接硬件接口,保持禁用。
验证使用隔离 ROS 域和纯 C++ 状态锁存测试,没有真实机械臂动作测试。
### 底部操作日志
底部日志记录按钮点击、重复点击拦截,以及后端收到请求、拒绝、任务开始、阶段变化、
取消和最终结果。时间精确到毫秒,显示臂号和任务编号;短任务的状态变化也保留为事件,
不依赖界面轮询碰巧看到 running。任务返回的 detail 和完整进程日志路径也会显示。
界面保留最近 3000 行,翻看历史时不强制滚到底部;后端缓存最近 512 条事件,
完整操作事件追加保存到 log/station/operations.jsonl,进程输出保留原独立日志。
### 重写的触觉与平行夹爪模块
参考 `ctag2f-90d-sdk/gripper/panel.py` 的数据处理方式,自行实现本站节点与界面,
不导入、不运行它的 panel、GripperClient、GripperWorker 或自动抓取流程。
低层触觉算法使用 `/home/cheng/orisys-sdk/src` 中的 Orisys SDK。
- `arm_application.tactile_flow_node`:left/right 各自独立 ROS2 节点和进程,
每路独占一个触觉相机;CPU 目标 30 Hz。读取 SDK 的 VRAW 矢量场绘制箭头,
不重复解算光流;没有画面订阅者时不编码 JPEG。不强制 USB reset、不停止别人的进程。
- `/tactile/{left,right}/status`:TactileStatus,含在线、帧率、接触、三向原始力和错误。
这里保留 RAW 通道量纲以兼容现有装配阈值,不把未经校准的值标为牛顿。
- `/tactile/{left,right}/flow/compressed`:CompressedImage/JPEG。预览与状态使用独立通道,
不被 Qt 的低频状态刷新限制。队列只留最新样本,断流显示离线并尝试本侧重连。
- `arm_application.gripper_node`:一个串口一个节点,使用现有经过验证的 DH Modbus 协议层,
仅暴露 `/station/grippers/arm_<id>/execute` 类型化服务与 `status` 状态话题。
无旧 Float32 数组控制入口;连接/探测绝不初始化或移动。当前支持 PGE/PGEA 平行夹爪,
右臂 RGI 旋转夹爪仍须独立匹配驱动,不能用 PGE 寄存器替代。
- 界面:光流区标题右侧提供“刷新传感器”,显示 left/right 帧率和三向力;
夹爪区标题右侧提供“连接 / 探测”,开合使用任务操作按钮和后端默认力/速度。
连接与刷新不下发运动,闭合、张开才写位置,并等待实际到位反馈。
主入口仍是 `bash station/run_station.sh`。重启后在首页分别点“刷新传感器”和夹爪“连接 / 探测”。
本站会自管相应节点,不要同时从其他入口重复启动。ROS 独立启动入口为
`tactile.launch.py`(左右触觉)与 `station_gripper.launch.py arm:=89`(平行爪)。
旧 `dh_pgea.launch.py` / `tactile` executable 保留为兼容入口,主界面不再启动它们。
站点的 `tactile.arm_id` 明确绑定机械臂(当前 89),`sdk_python_path` 为低层 SDK 的 src 路径;
配置 JSON 已复制进本仓库 `src/arm_application/config/tactile/ctag2f90d.json`。
`left_video/right_video: auto` 只选择 USB vendor 1bcf 的触觉 UVC 采集接口,排除 Intel RealSense、
笔记本摄像头与重复接口。首次插入后需确认物理左右;建议改成各自稳定的 `/dev/v4l/by-path/...`,
避免 USB 插口调整导致左右顺序变化。两侧不能指向同一设备,跨进程锁会拒绝重复占用。
额外 CPU 依赖见 `station/requirements-tactile.txt`,已装入现有 ros_flexiv;未另建 conda 环境。
验证为假串口、合成光流、隔离 ROS 域与 Qt 离线回归;未做真实开合或机械臂动作测试。
### RGI STEP 三维模型
主界面机械臂与夹爪均使用 Qt QOpenGLWidget 内嵌渲染,不是 RViz;ROS 提供设备状态。
`run_real.sh` 默认 `use_rviz:=false`,RViz 可作为独立调试工具按需打开。
右臂 RGI 模型来自用户提供的 `RGI-100-30-O-S_简易模型_V3.0_20241010.STEP`(SolidWorks AP203,毫米)。
原件保存在 `station/src/station/assets/models/rgi-100-30/source.step`,离线通过
`scripts/convert_step_mesh.py` 生成 `mesh.npz` 与含原件 SHA256、单位和边界的 `mesh.json`。
当前缓存 35148 个三角面,加载后按米渲染,运行时不依赖 CAD 内核,不在 UI 中转换 STEP。
显示颜色做了提亮处理以便观察边缘,几何保持原始装配位姿。
选择右臂显示真实 RGI CAD 外形,可拖拽旋转观察、双击复位;目前是静态装配模型,
未猜测各零件的运动轴,也未把整个夹爪当作手指错误旋转。待建立零件/关节映射并有
旋转爪实时反馈后再加入开合与旋转动画。左臂仍显示标注为近似的 PGEA 尺寸模型,
不会把 RGI 外形套到左臂。本次没有更改机械臂控制或发出设备命令。
首页夹爪区已精简:标题右侧只留“连接 / 探测”,移除力、速度、目标位置控件;
开合继续使用任务操作按钮及后端默认参数。传感器标题右侧只留“刷新传感器”,
负责重启本站拥有的左右触觉采集节点,重新检测设备并恢复预览,不操作机械臂或夹爪。
两个入口尺寸均为 92×28,与“刷新相机”一致。
### 标定力显示与光流布局
界面 Fx、Fy 置于每张光流的左、右两侧,保留方向正负号;下方显示法向 Fn,均标注 N。
列宽和下方行高固定,数值更新不会推动布局。
`tactile_flow_node` 在保留 `/tactile/<side>/status` 原始值的同时,新增标准
`/tactile/<side>/wrench`(geometry_msgs/WrenchStamped):force.x=Fx、y=Fy、z=Fn,单位牛顿。
装配算法仍接收原始 status,判断阈值不变。没有新鲜标定力话题时界面显示横线,
不会把原始数值直接标成 N。
换算曲线来自用户给定的 CTAG2F-90D 示例项目 `config/gripper.json`,已作为纯数据复制到
`src/arm_application/config/tactile/force_calibration.json`。使用分段线性插值、配置的外推和
零下限,切向分量按绝对值换算后恢复正负号。这是参考项目的现有曲线,并非本次
对左右两只设备逐只做出的实测标定。可在 tactile 配置中设置
`left_force_calibration_file` / `right_force_calibration_file` 覆盖各自数据,
或用 `force_calibration_file` 指定共用曲线;不导入或运行示例面板代码。
### 光流旁的切向力曲线与底图重置
每张光流左右是 X/Y 切向力折线图,显示最近 10 秒(N);四张小图使用相同时间窗
与对称量程,零力在中线。所有图表与画面顶底对齐,下方仍显示 Fn 标定法向力。
采样来自标定 wrench 话题的有界历史,不使用原始力替代。
“重置底图”在“刷新传感器”前。它通过 `/tactile/<side>/reset_reference`(std_srvs/Trigger)
请求本站节点在采集线程里、获取新画面后执行 SDK Sensor.reset(),不重启采集、不操作夹爪。
成功后清空该侧的光流缓存和力历史;离线侧返回未重置,连接恢复后不自动补执行旧请求。
超时明确报告结果未知,不自动重试。建议传感器无受力时操作。
跟踪/对孔/旋入流程与底图重置互斥,避免执行中更换测量参考。
触觉区最新布局:每侧 X 力图、光流、Y 力图为三个等大的正方形画布,随可用宽高适配。
切向力图纵轴固定 −10~10 N,显示数值刻度,超出范围的曲线裁剪;不再自动缩放。
横轴固定滚动 60 秒(−60、−30、0s)。后端标定力历史容量扩至 2100 点,
按实际时间截取最近一分钟,满足 30 Hz 采样;重置底图仍清空对应历史。
触觉标题采用 `left(30fps) 当前法向力 5N` 形式,fps 小字、不使用圆点分隔符,
下方不再重复显示法向力。曲线末端的圆点标记也已去除。
曲线横轴改为最近 1800 个采样点的序号(0/900/1800),纵轴仍固定 ±10 N;
不再由墙钟时间推动横轴或剔除数据。没有新样本时曲线保持不动。
新增轻量 `tactile_preview` 增量通道,携带 JPEG 与自上次序号之后的标定力样本,
最高 30 Hz;Qt 绘图计时器直接消费该缓存,避免低频状态刷新导致曲线跳动。
每次重置/刷新会变更参考版本,使客户端清空旧曲线;重复返回的采样序号不会重复追加。
### 远端相机预览
`config/site.yaml` 的 `remote_camera_preview` 显式选择远端相机。当前读取
192.168.2.10(域 0)的 d405_left / d435 原始图像,通过本机有线地址
192.168.2.50 发现,避免组播走 VPN 虚拟网卡。远端未发布 JPEG 预览时使用 raw。
只有 `station.cam_bridge` 子进程改用远端 ROS 域和网卡;机械臂后端、夹爪与
传感器的环境不变。“刷新相机”重建只读预览订阅并等待画面,不启动/停止
本机或远端物理相机驱动。首页远端订阅不再要求该相机由本机后端持有。
远端模式仅用于显示,不把网络图像自动接入本机机械臂跟踪或标定运动。
关闭 `remote_camera_preview.enabled` 并重启工位即可恢复本站采集模式。
+3
View File
@@ -0,0 +1,3 @@
# Additional CPU optical-tactile dependencies for ros_flexiv (numpy remains 1.26.4).
scipy==1.14.1
numba==0.60.0
@@ -0,0 +1,28 @@
{
"source": "source.step",
"source_sha256": "1958dfeb262958ef6f58a262f1ff1b4e84992c244409b76e5b273c35bff59a24",
"units": "m",
"source_units": "mm",
"triangles": 35148,
"surfaces": 468,
"solids": 15,
"bounds_m": [
[
-0.04100000113248825,
-0.0035000001080334187,
-0.01899999938905239
],
[
0.04100000113248825,
0.028999999165534973,
0.01899999938905239
]
],
"dimensions_mm": [
82.0,
32.5,
38.0
],
"articulated": false,
"note": "Original assembled STEP pose; no inferred joints or live finger animation."
}
File diff suppressed because one or more lines are too long
+50 -9
View File
@@ -1,19 +1,60 @@
"""Linux child launcher: stop a managed process if its station backend disappears."""
"""Supervise one owned command and reap its orphaned descendants on Linux.
The launcher remains alive until its command AND descendants have exited. It never
matches process names and never signals processes outside its own subtree.
"""
import ctypes
import os
import signal
import subprocess
import sys
import threading
from pathlib import Path
def report_phase(phase):
path = os.environ.get('ORISYS_PROCESS_PHASE_FILE')
if path:
try:
target = Path(path)
temporary = target.with_suffix('.tmp')
temporary.write_text(phase, encoding='utf-8')
temporary.replace(target)
except OSError:
pass # Diagnostic failure must not change command execution.
def main():
parent=int(sys.argv[1]);argv=sys.argv[2:]
if not argv: raise SystemExit('missing executable')
parent = int(sys.argv[1])
argv = sys.argv[2:]
if not argv:
raise SystemExit('missing executable')
stopping = threading.Event()
signal.signal(signal.SIGINT, lambda *_: stopping.set())
signal.signal(signal.SIGTERM, lambda *_: stopping.set())
if sys.platform.startswith('linux'):
libc=ctypes.CDLL(None,use_errno=True)
if libc.prctl(1,signal.SIGINT,0,0,0)!=0:
raise OSError(ctypes.get_errno(),'PR_SET_PDEATHSIG failed')
if os.getppid()!=parent: raise SystemExit('station backend exited before child startup')
os.execvpe(argv[0],argv,os.environ)
libc = ctypes.CDLL(None, use_errno=True)
# Orphaned grandchildren are reparented here rather than escaping to init.
for option, value in ((36, 1), (1, signal.SIGINT)):
if libc.prctl(option, value, 0, 0, 0) != 0:
raise OSError(ctypes.get_errno(), 'process supervision prctl failed')
if os.getppid() != parent or stopping.is_set():
raise SystemExit('station backend exited before child startup')
proc = subprocess.Popen(argv, stdin=subprocess.DEVNULL)
report_phase('执行中')
try:
while proc.poll() is None and not stopping.wait(.1):
pass
finally:
report_phase('停止中' if stopping.is_set() else '收尾')
if sys.platform.startswith('linux'):
from arm_application.process_lifecycle import stop_owned_children
stop_owned_children(os.getpid())
elif proc.poll() is None:
proc.terminate()
code = proc.wait()
raise SystemExit(130 if stopping.is_set() else code if code >= 0 else 128 - code)
if __name__=='__main__': main()
if __name__ == '__main__':
main()
+47
View File
@@ -0,0 +1,47 @@
"""One command/resource policy. The backend, not GUI lanes, owns arbitration."""
TITLES = {'stage_track':'检测跟踪', 'stage_locate':'对孔', 'stage_screw':'旋转',
'pipeline':'完整流程', 'reset_home':'回零', 'real_up':'使能',
'camera_refresh':'刷新相机', 'camera_d405':'连接 D405', 'camera_d435':'连接 D435',
'gripper_open':'夹爪全开', 'gripper_close':'夹爪全闭', 'gripper_init':'夹爪到位', 'gripper_rotate':'夹爪旋转',
'handeye_show':'查看标定', 'handeye_merge':'整理标定点',
'handeye_teach':'标定采点', 'handeye_calibrate':'自动标定',
'teach_goto':'示教回点', 'teach_record':'记录示教点', 'teach_delete':'删除示教点',
'tactile_reset':'重置底图','tactile_refresh':'刷新传感器','tactile_up':'连接触觉传感器','tactile_down':'断开触觉传感器','gripper_probe':'连接 / 探测夹爪',
'gripper_tune':'夹爪位置控制','stop':'停止机械臂'}
def title(action):
return TITLES.get(action, action)
def resources_for(action, arm):
if action == 'handeye_show': return set()
if action in ('handeye_merge', 'teach_delete'): return {f'points:{arm}', f'calibration:{arm}'}
if action.startswith('gripper'): return {f'gripper:{arm}'}
# Camera setup is serialized inside the device adapter; it never restarts an
# active acquisition stream. Refresh may run while a robot consumes that stream.
if action.startswith('camera'): return {f'camera-setup:{arm}' if action != 'camera_d435' else 'camera-setup:overhead'}
if action in ('tactile_view_on', 'tactile_view_off'): return {'tactile-view'}
if action=='tactile_reset': return {'tactile','tactile-reference'}
if action.startswith('tactile'): return {'tactile'}
# Only actual command writers own devices. Reading camera/force feedback is
# not an exclusive lease on the independent acquisition modules.
resources = {'robot-control', f'arm:{arm}'}
# A7 rounds open/close the gripper; pure robot tracking/home never do.
if action in ('pipeline','stage_screw'):
resources.add(f'gripper:{arm}')
if action == 'teach_record': resources.add(f'points:{arm}')
if action in ('handeye_teach','handeye_calibrate'):
resources.update({f'points:{arm}', f'calibration:{arm}'})
return resources
def conflict_message(tasks, resources):
rows=[]
for task in tasks:
aid=(task.get('context') or {}).get('arm')
arm=f'机械臂 {aid} · ' if aid else ''
phase='停止中' if task.get('cancel_requested') else task.get('phase') or '执行中'
rows.append(f"{arm}{title(task['name'])}({phase})")
return '暂不能执行:'+';'.join(rows)+';占用资源:'+', '.join(sorted(resources))
+370 -123
View File
@@ -21,6 +21,8 @@ from station.launch_plans import environment, launch, camera_plan, gripper_plan
from station.processes import ProcessSupervisor
from station.rpc import Server, Handler
from station.tasks import TaskManager, Cancelled
from station.node_inventory import inventory
from station.command_policy import resources_for
class StationService:
@@ -35,19 +37,25 @@ class StationService:
self._frozen_config.write_text(yaml.safe_dump(self.site,allow_unicode=True),encoding="utf-8")
self._frozen_config.chmod(0o600)
self.arms = {a['id']:a for a in listed_arms(self.site)}
self.tasks = TaskManager()
from arm_application.site_io import overhead_camera
overhead, _ = overhead_camera(self.site, None)
self._camera_names = sorted({a['camera'] for a in self.arms.values() if a.get('camera')} |
({overhead['name']} if overhead and overhead.get('name') else set()))
self.tasks = TaskManager(log_path=self.root/'log/station/operations.jsonl')
self.processes = ProcessSupervisor(self.root/'log/station')
self._lock = threading.RLock()
self._snapshot = {}
self._stop = threading.Event()
self.gateway = gateway
self.error = ''
self._arm_errors = {}
self._gripper_unknown = set()
self._stopping = threading.Event()
self._stop_error = ""
self._stop_lock = threading.Lock()
self._submit_lock = threading.RLock()
self._motion_arm = None
self._camera_locks = {name:threading.RLock() for name in self._camera_names}
if self.gateway is None and not offline:
try:
from station.ros_gateway import RosGateway
@@ -75,9 +83,9 @@ class StationService:
def catalog(self):
value = metadata.catalog()
available = {'real_up','camera_d405','camera_d435','tactile_up','tactile_down',
available = {'real_up','camera_d405','camera_d435','tactile_up','tactile_down','tactile_refresh',
'tactile_view_on','tactile_view_off','gripper_up','gripper_probe','gripper_open',
'gripper_close','gripper_rotate','pipeline','stage_track','stage_locate','stage_screw',
'gripper_close','gripper_init','gripper_rotate','pipeline','stage_track','stage_locate','stage_screw',
'reset_home','handeye_show','handeye_teach','handeye_merge','handeye_calibrate'}
for spec in value['actions']:
spec['available'] = spec['id'] in available
@@ -91,9 +99,14 @@ class StationService:
try:
try: self._check_configuration()
except RuntimeError as exc: self.error=str(exc)
self._check_motion_interruption()
arm_live, grip_live = self.gateway.states() if self.gateway else ({},{})
graph = self.gateway.graph() if self.gateway else {'nodes':[],'topics':[],'services':[]}
nodes = graph['nodes']
processes = self.processes.snapshot()
tasks = self.tasks.snapshot()
monitoring = inventory(graph, processes, tasks, arm_live, grip_live)
cameras = [self._camera_ownership(name, processes) for name in self._camera_names]
with metadata._LOCK:
tactile_status = {'left':dict(metadata._TACTILE['left']), 'right':dict(metadata._TACTILE['right']),
'stamp': metadata._TACTILE['stamp']}
@@ -103,9 +116,9 @@ class StationService:
if time.time()-tactile_status[side].get('stamp',0)>2:
tactile_status[side]['connected']=False
snap = {'arms':list(self.arms.values()),'arm_live':arm_live,'gripper_live':grip_live,
'usb':metadata._usb(), 'services':[
{'id':'real','label':'控制栈','up':'controller_manager' in nodes},
{'id':'camera','label':'相机','up':any(n.startswith('realsense') for n in nodes)},
'camera_ownership':cameras, 'usb':metadata._usb(), 'services':[
{'id':'real','label':'控制反馈','up':any(r.get('pose_live') for r in arm_live.values())},
{'id':'camera','label':'本站相机采集','up':any(c['owned'] for c in cameras)},
{'id':'tactile','label':'视触觉','up':any(value.get('connected') for value in tactile_status.values() if isinstance(value,dict))},
{'id':'gripper','label':'夹爪','up':any(r.get('connected') for r in grip_live.values())},
{'id':'pipeline','label':'跟踪对孔','up':any(r['running'] for k,r in self.processes.snapshot().items() if k.startswith('motion:'))}],
@@ -113,18 +126,36 @@ class StationService:
'teach_points':{aid:points.list_points(aid) for aid in self.arms},
'grippers':metadata._grippers(), 'tactile':tactile_status, 'history':history,
'view':getattr(self.gateway,'tactile_state',lambda:{})(), 'gauge_pass':gauge, 'now':time.time(),
'backend':{'error':self.error,'pid':os.getpid(),'protocol':1,'graph':graph},
'tasks':self.tasks.snapshot()}
'backend':{'error':self.error,'device_errors':{'arms':dict(self._arm_errors)},'pid':os.getpid(),'protocol':1,'graph':graph,
'monitoring':monitoring, 'ros_domain':os.environ.get('ROS_DOMAIN_ID','0'),
'localhost_only':os.environ.get('ROS_LOCALHOST_ONLY','0') == '1'},
'tasks':tasks}
with self._lock: self._snapshot = snap
except Exception as exc:
with self._lock:
self._snapshot.setdefault('backend',{})['error'] = f'状态读取失败:{exc}'
self._stop.wait(max(.05,.5-(time.monotonic()-started)))
def _check_motion_interruption(self):
check = getattr(self.gateway, 'motion_interruption', None)
if check is None: return
active = {row.get('context', {}).get('arm') for row in self.tasks.snapshot()
if row['state'] in ('queued', 'running') and row['name'] != 'real_up'
and 'robot-control' in row.get('resources', [])}
for arm in active:
if arm is None: continue
reason = check(arm)
if reason:
self._arm_errors[arm] = reason
# Set cancellation first, before waiting for ROS or process cleanup.
self.tasks.cancel_resources({'robot-control'})
self.stop_commands(arm)
def snapshot(self):
with self._lock:
result = copy.deepcopy(self._snapshot)
result['tasks'] = self.tasks.snapshot()
result['operation_log'] = self.tasks.events.snapshot()
return result
def _arm(self, aid):
@@ -133,38 +164,32 @@ class StationService:
def submit(self, action, arm, mode='eye_in_hand', extras=None):
with self._submit_lock:
return self._submit(action, arm, mode, extras)
self.tasks.events.add('received', '收到操作请求', action=action, arm=arm)
try:
return self._submit(action, arm, mode, extras)
except Exception as exc:
self.tasks.events.add('rejected', '拒绝执行:' + str(exc), action=action, arm=arm)
raise
def _submit(self, action, arm, mode='eye_in_hand', extras=None):
self._check_configuration()
self._arm(arm)
if self._stopping.is_set(): raise RuntimeError('正在停止机械臂,请等待停止结果')
if self._stop_error and action in ('pipeline','stage_track','stage_locate','stage_screw','reset_home','teach_goto'):
resources = resources_for(action, arm)
if self._stopping.is_set() and 'robot-control' in resources:
raise RuntimeError('正在停止机械臂,请等待停止结果')
if self._stop_error and 'robot-control' in resources:
raise RuntimeError('上次停止未确认,请先再次停止并确认状态:' + self._stop_error)
extras = extras or {}
spec = next((s for s in self.catalog()['actions'] if s['id']==action),None)
special = {'camera_refresh','gripper_tune','teach_record','teach_delete','teach_goto','teach_drag_on','teach_drag_off'}
special = {'camera_refresh','tactile_refresh','tactile_reset','gripper_tune','teach_record','teach_delete','teach_goto','teach_drag_on','teach_drag_off'}
if not spec and action not in special: raise ValueError('未注册的操作')
if spec and not spec['available']: raise ValueError(spec['unavailable_reason'])
if mode not in ('eye_in_hand','eye_to_hand'): raise ValueError('手眼模式不合法')
if action.startswith('gripper'): resources = {f'gripper:{arm}'}
elif action == 'camera_refresh': resources = {f'camera:{arm}', 'camera:overhead'}
elif action.startswith('camera_'):
resources = {'camera:overhead' if action=='camera_d435' else f'camera:{arm}'}
elif action.startswith('tactile'): resources = {'tactile'}
elif action in ('handeye_show','handeye_merge','teach_delete'): resources = {f'points:{arm}'}
else:
# Existing ROS stack has global controller/action names; never allow two arm commands concurrently.
resources = {'robot-control',f'arm:{arm}'}
if action=='teach_record': resources.add(f'points:{arm}')
if action in ('pipeline','stage_track','stage_locate','stage_screw','reset_home'): resources.add(f'gripper:{arm}')
if action in ('pipeline','stage_track','stage_locate','stage_screw'):
resources.update({f'camera:{arm}', 'camera:overhead'})
if action.startswith('handeye_') and action!='handeye_show': resources.add(f'calibration:{arm}')
return self.tasks.submit(action, resources, lambda event:self.execute(action,arm,mode,extras,event))
return self.tasks.submit(action, resources, lambda event:self.execute(action,arm,mode,extras,event),
context={"arm":arm,"mode":mode})
def execute(self, action, aid, mode, extras, event):
motion = action in ('pipeline','stage_track','stage_locate','stage_screw','reset_home','teach_goto')
motion = action in ('pipeline','stage_track','stage_locate','stage_screw','reset_home','teach_goto','handeye_teach','handeye_calibrate')
if motion:
with self._lock: self._motion_arm = aid
try:
@@ -177,24 +202,59 @@ class StationService:
arm = self._arm(aid)
if event.is_set(): raise Cancelled('操作已取消')
if action=='teach_delete': return points.delete_point(aid, str(extras.get('name') or ''))
if action in ('tactile_view_on','tactile_up'):
if action=='tactile_reset':
if self.gateway is None:raise RuntimeError(self.error)
if aid!=str((self.site.get('tactile') or {}).get('arm_id','89')):
raise RuntimeError('当前臂未配置这组触觉传感器')
from std_srvs.srv import Trigger
results=[]
owned=self.processes.snapshot()
for side in ('left','right'):
if event.is_set():raise Cancelled('底图重置已取消')
if not owned.get(f'tactile:{side}',{}).get('running'):
results.append((side,False,'本站节点未启动,请先刷新传感器'));continue
try:
reply=self.gateway.call(Trigger,f'/tactile/{side}/reset_reference',Trigger.Request(),event,4.)
if reply.success:self.gateway.clear_tactile_reference(side)
results.append((side,reply.success,reply.message))
except Cancelled:raise
except Exception as exc:results.append((side,False,str(exc)))
return {'ok':any(row[1] for row in results),
'message':';'.join(f'{side}:{message}' for side,ok,message in results)}
if action in ('tactile_view_on','tactile_up','tactile_refresh'):
if self.gateway is None: raise RuntimeError(self.error)
if not arm['tactile']: raise RuntimeError('当前臂未配置触觉设备')
if not self.gateway.publisher_count('/tactile/left/status'):
existing=self.processes.snapshot().get('tactile')
if not existing or not existing['running']:
self.processes.start('tactile',[sys.executable,'-m','arm_application.tactile_node',
'--ros-args','-p','image_hz:=5.0'],env=self._environment(aid),cwd=str(self.root))
deadline=time.monotonic()+12
while not self.gateway.publisher_count('/tactile/left/status'):
if event.wait(.1): raise Cancelled('触觉启动等待已取消')
if time.monotonic()>deadline: raise RuntimeError('触觉驱动未就绪,请查看 log/station/tactile.log')
self.gateway.watch_tactile(True)
return {'ok':True,'message':'已连接常驻触觉驱动;仅订阅其力反馈与预览,不重复打开传感器'}
if not arm['tactile'] or aid != str((self.site.get('tactile') or {}).get('arm_id','89')):
raise RuntimeError('当前臂未配置这组触觉传感器')
if (self.site.get('tactile') or {}).get('backend') != 'station_flow':
raise RuntimeError('请将 tactile.backend 配置为 station_flow')
created=[]
try:
for side in ('left','right'):
key=f'tactile:{side}'
node=f'orisys_tactile_{os.getpid()}_{side}'
running=self.processes.snapshot().get(key,{}).get('running')
publishers=self.gateway.publisher_nodes(f'/tactile/{side}/status')
if any(p!=node for p in publishers) or (publishers and not running):
raise RuntimeError(f'{side} 传感器由外部节点占用,本站不共享、不接管')
if action=='tactile_refresh' and running:
self.processes.stop(key)
getattr(self.gateway,'clear_tactile_reference',lambda side:None)(side)
running=False
if not running:
self.processes.start(key,[sys.executable,'-m','arm_application.tactile_flow_node',
'--ros-args','-p',f'side:={side}','-r',f'__node:={node}'],
env=self._environment(aid),cwd=str(self.root))
created.append(key)
self.gateway.watch_tactile(True)
return {'ok':True,'message':'左右触觉节点已刷新,等待设备在线;仅采集光流与力反馈'}
except Exception:
for key in created: self.processes.stop(key)
raise
if action in ('tactile_view_off','tactile_down'):
if self.gateway: self.gateway.watch_tactile(False)
if action=='tactile_down': self.processes.stop('tactile')
return {'ok':True,'message':'触觉预览已关闭;外部驱动不受影响'}
if action=='tactile_down':
for side in ('left','right'): self.processes.stop(f'tactile:{side}')
return {'ok':True,'message':'触觉预览已关闭' if action=='tactile_view_off' else '本站触觉节点已停止'}
if action=='handeye_show':
return {'ok':True,'message':'手眼配置已读取','detail':str(metadata._handeye(aid).get(mode) or {})}
if action=='handeye_merge':
@@ -202,16 +262,19 @@ class StationService:
if self.gateway is None: raise RuntimeError(self.error or 'ROS 后端不可用')
if action in ('pipeline','stage_track','stage_locate','stage_screw','reset_home','teach_goto'):
nodes = self.gateway.graph()['nodes']
if any(name in nodes for name in ('assembly_pipeline','servo_node','stage2_locate','stage3_search')):
raise RuntimeError('已有外部跟踪/对孔节点在运行,请先在其原入口停止,工位不会抢占')
if any(name in nodes for name in ('assembly_pipeline','servo_node','stage2_locate','stage3_search','orisys_a7_rotation')):
raise RuntimeError('已有外部跟踪/对孔/旋转节点在运行,请先在其原入口停止,工位不会抢占')
if action.startswith('gripper'):
return self._gripper(action, arm, extras, event)
from station.remote_camera import settings as remote_preview_settings
if action.startswith('camera_') and remote_preview_settings(self.site):
return {'ok':True,'message':'已请求重新订阅远端相机;由 Qt 独立预览进程确认画面,不启动本地采集'}
if action == 'camera_refresh':
results = []
for overhead, name in ((False, 'D405'), (True, 'D435')):
if event.is_set(): raise Cancelled('相机刷新已取消')
try:
result = self._camera(arm, overhead, event)
result = self._camera(arm, overhead, event, refresh=True)
results.append({'camera': name, **result})
except Cancelled: raise
except Exception as exc: results.append({'camera':name,'ok':False,'message':str(exc)})
@@ -222,8 +285,9 @@ class StationService:
if action=='real_up':
# Network/tool preparation remains a bringup concern, never used for daily motion commands.
if 'controller_manager' in self.gateway.graph()['nodes']:
self.gateway.require_arm(aid,event)
return {'ok':True,'message':'本臂控制器已就绪;已检查连接并恢复本站暂停的控制器,不重复启动驱动'}
self.gateway.require_arm(aid,event,resume=False)
self._arm_errors.pop(aid,None)
return {'ok':True,'message':'本臂连接已就绪;保持当前位置,不恢复暂停的任务'}
env = self._environment(aid)
self.gateway.active_arm = aid
proc, log = self.processes.start(f'stack:{aid}', ['bash',str(self.root/'scripts/run_real.sh'),'--arm',aid],env,str(self.root))
@@ -233,7 +297,8 @@ class StationService:
self.processes.stop(f'stack:{aid}');raise Cancelled('控制栈启动已取消')
if proc.poll() is not None: raise RuntimeError(f'控制栈启动失败,见 {log}')
try:
self.gateway.require_arm(aid,event)
self.gateway.require_arm(aid,event,resume=False)
self._arm_errors.pop(aid,None)
return {'ok':True,'message':'控制栈已就绪','log':str(log)}
except RuntimeError: pass
self.processes.stop(f'stack:{aid}')
@@ -289,34 +354,103 @@ class StationService:
from arm_application.site_io import track_camera_role
if track_camera_role(self.site) != 'wrist':
raise RuntimeError('检测要求 D405 优先,请将 perception.track_camera 配置为 wrist')
if action=='stage_screw' and not arm['tactile']:
raise RuntimeError('当前臂没有配置触觉,拒绝旋入')
self._camera(arm,False,event);self._camera(arm,True,event)
if action in ('stage_screw','pipeline'):
self._rotation_ready(arm)
env=self._environment(aid)
if action in ('pipeline','stage_track','stage_locate'):
if action=='stage_screw':
argv=[sys.executable,'-m','arm_application.a7_rotation_node']
else:
self._camera(arm,False,event);self._camera(arm,True,event)
argv=launch(self.root,'pipeline.launch.py',skip_stage1=action=='stage_locate',
skip_stage2=action=='stage_track',skip_stage3=True,preview=False,
resnap=False,no_descent=action=='stage_locate',d435_move_only=False)
else:
with metadata._LOCK:
force_ready=all(metadata._TACTILE[side].get('connected') and time.time()-metadata._TACTILE[side].get('stamp',0)<2 for side in ('left','right'))
if not force_ready: raise RuntimeError('两指触觉反馈未就绪,拒绝旋入')
argv=launch(self.root,'stage3_search.launch.py',preview=False)
return self.processes.run(f'motion:{aid}',argv,event,env,str(self.root))
run_a7_after_insert=False,resnap=False,no_descent=False,
d435_move_only=False,completion_linger_sec=0.3)
result=self.processes.run(f'motion:{aid}',argv,event,env,str(self.root),
on_phase=lambda phase:self.tasks.set_phase(event,phase))
if action=='pipeline' and result.get('ok'):
if event.is_set():raise Cancelled('流程已取消,不启动旋转')
self._rotation_ready(arm)
self.tasks.set_phase(event,'第三阶段:A7 旋转')
result=self.processes.run(f'motion:{aid}',
[sys.executable,'-m','arm_application.a7_rotation_node'],event,env,str(self.root),
on_phase=lambda phase:self.tasks.set_phase(event,phase))
return result
raise ValueError('操作未实现')
def _rotation_ready(self,arm):
aid=arm['id']
if arm['rotary']:raise RuntimeError('A7 工序使用平行夹爪,当前旋转夹爪不适用')
if aid in self._gripper_unknown:raise RuntimeError('夹爪上一条结果未知,请先连接 / 探测')
if '/gripper/open' not in self.gateway.graph()['services']:
raise RuntimeError('请先连接本站平行夹爪,再开始旋转或全流程')
grip=self.gateway.states()[1].get(aid,{})
if not grip.get('connected') or grip.get('moving'):
raise RuntimeError('夹爪离线或仍在运动,请先探测确认')
ga=self.site.get('gauge_assembly') or {}
for key in ('extreme_margin_deg','extreme_direction','extreme_speed_deg_s',
'unwind_open_permille','unwind_lift_mm'):
if key not in ga:raise RuntimeError('缺少 A7 工序参数:'+key)
@staticmethod
def _number(values,key,low,high):
value=float(values.get(key))
if not math.isfinite(value) or not low<=value<=high: raise ValueError(f'{key} 只接受 {low}–{high}')
return value
def _camera(self,arm,overhead,event):
def _camera_node_name(self, name):
return f"orisys_camera_{os.getpid()}_" + ''.join(c if c.isalnum() else '_' for c in name)
def _camera_ownership(self, name, processes=None):
record = (processes if processes is not None else self.processes.snapshot()).get(f'camera:{name}', {})
publishers = self.gateway.publisher_nodes(f'/{name}/color/image_raw') if self.gateway and hasattr(self.gateway, 'publisher_nodes') else []
owned = bool(record.get('running') and publishers == [self._camera_node_name(name)])
return {'id':name, 'owned':owned, 'publishers':publishers,
'conflict':bool(publishers and not owned)}
def _camera(self,arm,overhead,event,refresh=False):
env=self._environment(arm['id'])
name,argv=camera_plan(env,overhead)
lock = self._camera_locks.setdefault(name, threading.RLock())
while not lock.acquire(timeout=.1):
if event.is_set(): raise Cancelled('相机连接已取消')
try:
if event.is_set(): raise Cancelled('相机连接已取消')
return self._ensure_camera(name, argv, env, event, refresh=refresh)
finally:
lock.release()
def _ensure_camera(self,name,argv,env,event,refresh=False):
topic=f'/{name}/color/image_raw'
key=f'camera:{name}'
if self.gateway.publisher_count(topic): return {'ok':True,'message':f'{name} 已有采集节点,复用'}
owner = self._camera_ownership(name)
if refresh:
if owner['owned'] and hasattr(self.gateway,'camera_health'):
health=self.gateway.camera_health(name)
depth_on=self.gateway.publisher_count(f'/{name}/aligned_depth_to_color/image_raw')>0
# USB2 fallback may publish 640x480 while the plan still asks for 1280x720.
# Fresh owned frames are enough unless depth was dropped — tracking needs it.
if health['fresh'] and health['width']>0 and health['height']>0 and depth_on:
return {'ok':True,'message':f'{name} 实时画面正常,保持当前采集'}
if health['fresh'] and not depth_on:
self.tasks.events.add('running', f'{name} 只有彩色、没有深度,重启采集', action='camera_refresh')
if owner['conflict']:
raise RuntimeError(f'{name} 有外部或重复发布者,拒绝重启他人设备')
record=self.processes.snapshot().get(key,{})
if record.get('running'):
self.processes.stop(key)
deadline=time.monotonic()+12.
while self.gateway.publisher_count(topic) and time.monotonic()<deadline:
if event.wait(.1):raise Cancelled('相机刷新已取消')
if hasattr(self.gateway,'clear_camera_health'):self.gateway.clear_camera_health(name)
owner=self._camera_ownership(name)
def fresh():
return self.gateway.camera_health(name)['fresh'] if hasattr(self.gateway,'camera_health') else True
if owner['owned']:
if not fresh():raise RuntimeError(f'{name} 进程在线但没有新帧,请刷新相机')
return {'ok':True,'message':f'{name} 已收到实时画面'}
if self.gateway.publisher_count(topic):
raise RuntimeError(f'{name} 话题由外部或重复采集节点发布,本站不读取、不接管;请在原入口停止')
argv += ['-r', f'__node:={self._camera_node_name(name)}']
existing=self.processes.snapshot().get(key)
created=not existing or not existing['running']
if created:
@@ -326,7 +460,11 @@ class StationService:
if event.wait(.1):
if created: self.processes.stop(key)
raise Cancelled('相机启动等待已取消')
if self.gateway.publisher_count(topic): return {'ok':True,'message':f'{name} 已启动'}
owner = self._camera_ownership(name)
if owner['owned'] and fresh(): return {'ok':True,'message':f'{name} 本站采集已启动并收到新帧'}
if owner['conflict']:
if created: self.processes.stop(key)
raise RuntimeError(f'{name} 出现外部或重复发布者,拒绝连接')
if not self.processes.snapshot()[key]['running']: raise RuntimeError(f'{name} 采集进程退出,请看 log/station')
self.processes.stop(key)
raise RuntimeError(f'{name} 相机未就绪')
@@ -339,88 +477,192 @@ class StationService:
if not 0<=count<=40: raise ValueError('采点数超出范围')
argv+=['--points',str(count)]
if extras.get('append'): argv+=['--append']
return self.processes.run(f'tool:{aid}',argv,event,self._environment(aid),str(self.root))
return self.processes.run(f'tool:{aid}',argv,event,self._environment(aid),str(self.root),
on_phase=lambda phase:self.tasks.set_phase(event,phase))
def _gripper(self,action,arm,values,event):
from arm_msgs.srv import GripperExecute
aid=arm['id'];endpoint=f'/station/grippers/arm_{aid}/execute'
if endpoint not in self.gateway.graph()['services']:
if action not in ('gripper_up','gripper_probe'):
raise RuntimeError('请先启动本臂常驻夹爪驱动')
if self.gateway.publisher_count('/gripper/states') and aid==self.gateway.active_arm:
def _wait_jaw(self, aid, target, event, timeout):
from arm_application.dh_pgea import grip_motion_settled
started = time.monotonic()
seen = False
last = {}
idle = 0
while time.monotonic() - started < timeout:
if event.is_set():
raise Cancelled('已停止等待夹爪')
live = self.gateway.states()[1].get(aid, {})
permille = live.get('permille')
moving = bool(live.get('moving'))
if moving or (last.get('permille') is not None and permille != last.get('permille')):
seen = True
idle = 0
state = live.get('grip_state')
if live.get('connected') and permille is not None and state is not None:
if grip_motion_settled(int(state), int(round(float(permille))), int(target), seen):
return live
if last and not moving:
idle += 1
if idle >= 3:
return live
last = live
time.sleep(0.08)
return last
def _ensure_dh_pgea(self, arm, event):
aid = arm['id']
services = self.gateway.graph()['services']
if '/gripper/open' in services and '/gripper/close' in services:
return
owned = self.processes.snapshot().get(f'gripper:{aid}') or {}
running_ours = bool(owned.get('running'))
argv = owned.get('command') or []
ours_is_dh = running_ours and any('dh_pgea' in str(part) for part in argv)
if running_ours and not ours_is_dh:
self.processes.stop(f'gripper:{aid}')
time.sleep(0.2)
running_ours = False
if not running_ours:
if self.gateway.publisher_count('/gripper/states') and aid == self.gateway.active_arm:
raise RuntimeError('旧夹爪驱动正在持有设备;仅显示其状态,请先手动停旧驱动再启用新驱动')
argv=gripper_plan(arm,self.site.get('gripper') or {})
self.processes.start(f'gripper:{aid}',argv,env=self._environment(aid),cwd=str(self.root))
request=GripperExecute.Request()
request.command.command='connect' if action in ('gripper_up','gripper_probe') else 'move'
if action=='gripper_open': values={**values,'pos':1000,'force':33,'speed':25}
if action=='gripper_close': values={**values,'pos':0,'force':33,'speed':25}
kind=values.get('kind','pos')
if action=='gripper_rotate': values={**values,'angle':90,'torque':33,'rot_speed':25};kind='rotate'
if request.command.command!='connect':
if aid in self._gripper_unknown: raise RuntimeError('上次夹爪执行结果未知;请先探测设备状态再发新命令')
if kind=='pos':
request.command.position=self._number(values,'pos',0,1000)/1000*arm['stroke_mm']
elif arm['rotary']:
names={'rotate':'rotate_to','rotate_by':'rotate_by','revolutions':'rotate_revolutions','rotate_home':'home_rotation'}
if kind not in names: raise ValueError('未知旋转操作')
request.command.command=names[kind]
request.command.position=0. if kind=='rotate_home' else self._number(values,{'rotate':'angle','rotate_by':'delta','revolutions':'revolutions'}[kind],-50 if kind=='revolutions' else -32767,50 if kind=='revolutions' else 32767)
else: raise ValueError('平行夹爪没有旋转轴')
request.command.force=self._number(values,'force' if kind=='pos' else 'torque',20,100)
request.command.speed=self._number(values,'speed' if kind=='pos' else 'rot_speed',1,100)
request.command.wait_complete=False;request.command.timeout=6.
before=self.gateway.states()[1].get(aid,{})
if request.command.command!='connect' and not before.get('connected'):
if event.is_set():
raise Cancelled('已取消')
self.processes.start(f'gripper:{aid}', gripper_plan(arm, self.site.get('gripper') or {}),
env=self._environment(aid), cwd=str(self.root))
deadline = time.monotonic() + 12
while '/gripper/open' not in self.gateway.graph()['services']:
if event.is_set():
raise Cancelled('已取消')
if time.monotonic() >= deadline:
raise RuntimeError('dh_pgea 未就绪:/gripper/open 服务没有出现')
time.sleep(0.1)
def _gripper(self, action, arm, values, event):
aid = arm['id']
self._ensure_dh_pgea(arm, event)
if action in ('gripper_up', 'gripper_probe'):
deadline = time.monotonic() + 5
live = {}
while time.monotonic() < deadline:
if event.is_set():
raise Cancelled('已取消')
live = self.gateway.states()[1].get(aid, {})
if live.get('connected'):
self._gripper_unknown.discard(aid)
return {'ok': True, 'message': live.get('summary') or '夹爪已连接,未执行初始化或运动'}
time.sleep(0.1)
raise RuntimeError(live.get('error') or live.get('summary') or '夹爪状态超时或驱动未启动')
if action in ('gripper_open', 'gripper_close', 'gripper_init'):
from std_srvs.srv import Trigger
from arm_application.dh_pgea import grip_motion_settled
names = {'gripper_open': '/gripper/open', 'gripper_close': '/gripper/close',
'gripper_init': '/gripper/initialize'}
target = {'gripper_open': 1000, 'gripper_close': 0, 'gripper_init': 1000}[action]
timeout = 25 if action == 'gripper_init' else 20
response = self.gateway.call(Trigger, names[action], Trigger.Request(), event, timeout)
if not response.success:
return {'ok': False, 'message': response.message}
live = self._wait_jaw(aid, target, event, 8.0)
permille = live.get('permille')
state = live.get('grip_state')
settled = permille is not None and state is not None and grip_motion_settled(
int(state), int(round(float(permille))), int(target), True)
self._gripper_unknown.discard(aid)
if action == 'gripper_init':
return {'ok': True, 'message': response.message or '夹爪已到位(初始化完成)'}
if not settled:
opening = live.get('opening_mm')
text = f'当前开口 {opening:.1f} mm' if isinstance(opening, (int, float)) else f'当前 {permille}‰'
return {'ok': False, 'message': ('未闭合到位,' if action == 'gripper_close' else '未全开,') + text}
if action == 'gripper_close' and live.get('gripped'):
return {'ok': True, 'message': response.message or '夹爪已夹住'}
return {'ok': True, 'message': response.message or ('已全闭' if action == 'gripper_close' else '已全开')}
from arm_msgs.srv import GripperExecute
request = GripperExecute.Request()
request.command.command = 'move'
kind = values.get('kind', 'pos')
if action == 'gripper_rotate':
values = {**values, 'angle': 90, 'torque': 33, 'rot_speed': 25}
kind = 'rotate'
if aid in self._gripper_unknown:
raise RuntimeError('上次夹爪执行结果未知;请先探测设备状态再发新命令')
if kind == 'pos':
request.command.position = self._number(values, 'pos', 0, 1000) / 1000 * arm['stroke_mm']
elif arm['rotary']:
names = {'rotate': 'rotate_to', 'rotate_by': 'rotate_by', 'revolutions': 'rotate_revolutions',
'rotate_home': 'home_rotation'}
if kind not in names:
raise ValueError('未知旋转操作')
request.command.command = names[kind]
request.command.position = 0. if kind == 'rotate_home' else self._number(
values, {'rotate': 'angle', 'rotate_by': 'delta', 'revolutions': 'revolutions'}[kind],
-50 if kind == 'revolutions' else -32767, 50 if kind == 'revolutions' else 32767)
else:
raise ValueError('平行夹爪没有旋转轴')
request.command.force = self._number(values, 'force' if kind == 'pos' else 'torque', 20, 100)
request.command.speed = self._number(values, 'speed' if kind == 'pos' else 'rot_speed', 1, 100)
request.command.wait_complete = False
request.command.timeout = 6.
before = self.gateway.states()[1].get(aid, {})
if not before.get('connected'):
raise RuntimeError('夹爪没有新鲜的在线状态,拒绝运动')
if request.command.command!='connect' and before.get('moving'):
raise RuntimeError('夹爪仍在执行运动,拒绝覆盖上一条命令')
endpoint = '/dh_pgea/execute'
if endpoint not in self.gateway.graph()['services']:
raise RuntimeError('请先启动本臂常驻夹爪驱动')
target_angle = None
if kind!='pos' and request.command.command!='connect':
current=before.get('rotation_deg')
if kind in ('rotate_by','revolutions') and current is None:
if kind != 'pos':
current = before.get('rotation_deg')
if kind in ('rotate_by', 'revolutions') and current is None:
raise RuntimeError('相对旋转需要新鲜的当前转角')
target_angle = request.command.position if kind=='rotate' else 0. if kind=='rotate_home' else float(current)+request.command.position*(360. if kind=='revolutions' else 1.)
sent_at=time.monotonic()
target_angle = request.command.position if kind == 'rotate' else 0. if kind == 'rotate_home' else float(current) + request.command.position * (360. if kind == 'revolutions' else 1.)
sent_at = time.monotonic()
try:
response=self.gateway.call(GripperExecute,endpoint,request,event,15)
if not response.success: return {'ok':False,'message':response.message}
if request.command.command=='connect':
if getattr(getattr(response,'status',None),'is_moving',False):
return {'ok':False,'message':'夹爪仍在运动,尚不能确认上次任务结束'}
self._gripper_unknown.discard(aid)
return {'ok':True,'message':response.message}
# The driver's response acknowledges register writes, not physical completion.
started=time.monotonic(); observed=False
while time.monotonic()-started<90:
live=self.gateway.states()[1].get(aid,{})
response = self.gateway.call(GripperExecute, endpoint, request, event, 15)
if not response.success:
return {'ok': False, 'message': response.message}
started = time.monotonic()
observed = False
while time.monotonic() - started < 90:
live = self.gateway.states()[1].get(aid, {})
if event.is_set():
self._gripper_unknown.add(aid)
raise Cancelled('已停止等待;夹爪尚未确认停机,请检查设备并重新探测')
if live.get('connected'):
observed = observed or bool(live.get('moving'))
reached = live.get('grip_state') in (1,2) if kind=='pos' else live.get('rotation_text')=='已到位'
target=values.get('pos')
near=kind=='pos' and target is not None and abs(float(live.get('permille',-10000))-float(target))<10
if kind!='pos' and target_angle is not None and live.get('rotation_deg') is not None:
near=abs(float(live['rotation_deg'])-target_angle)<1.
caught=kind=='pos' and live.get('grip_state')==2 and live.get('received_monotonic',0)>sent_at
if reached and (observed or near or caught) and time.monotonic()-started>.1:
return {'ok':True,'message':'夹爪已到位' if not live.get('gripped') else '夹爪已夹住'}
reached = live.get('grip_state') in (1, 2) if kind == 'pos' else live.get('rotation_text') == '已到位'
target = values.get('pos')
near = kind == 'pos' and target is not None and abs(float(live.get('permille', -10000)) - float(target)) < 10
if kind != 'pos' and target_angle is not None and live.get('rotation_deg') is not None:
near = abs(float(live['rotation_deg']) - target_angle) < 1.
caught = kind == 'pos' and live.get('grip_state') == 2 and live.get('received_monotonic', 0) > sent_at
if reached and (observed or near or caught) and time.monotonic() - started > .1:
return {'ok': True, 'message': '夹爪已到位' if not live.get('gripped') else '夹爪已夹住'}
time.sleep(.1)
self._gripper_unknown.add(aid)
raise RuntimeError('夹爪到位等待超时,执行结果未知;请检查设备')
except Exception:
if request.command.command!='connect': self._gripper_unknown.add(aid)
self._gripper_unknown.add(aid)
raise
def stop_commands(self, arm=None):
self.tasks.events.add('received', '收到停止请求', action='stop', arm=arm or '')
try:
result = self._stop_commands(arm)
except Exception as exc:
self.tasks.events.add('failed', str(exc), action='stop', arm=arm or '')
raise
self.tasks.events.add('succeeded' if result.get('ok') else 'failed',
result.get('message', '停止处理结束'), action='stop', arm=arm or '')
return result
def _stop_commands(self, arm=None):
if arm is not None: self._arm(arm)
with self._stop_lock:
with self._submit_lock:
self._stopping.set()
with self._lock: arm = self._motion_arm or arm
self.tasks.cancel()
if arm is None: self.tasks.cancel()
else: self.tasks.cancel_resources({'robot-control'})
tool_arms = {(t.get('context') or {}).get('arm') for t in self.tasks.snapshot()
if t['state'] in ('queued','running') and t['name'] in ('handeye_teach','handeye_calibrate')}
try:
failure = None
if arm is not None:
@@ -430,7 +672,8 @@ class StationService:
except Exception as exc:
failure = exc
for key in self.processes.snapshot():
if key.startswith(('motion:', 'tool:')): self.processes.stop(key)
if key.startswith('motion:') or (key.startswith('tool:') and (arm is None or key.split(':',1)[1] in tool_arms)):
self.processes.stop(key)
if arm is None:
return {'ok': True, 'message': '已取消本站任务;未请求停止外部机械臂控制器'}
if failure:
@@ -454,6 +697,10 @@ class StationService:
if method=='stop': return self.stop_commands(params.get('arm'))
if method=='gauge_finished': return metadata.notify_gauge_finished()
if method=='gauge_reset': return metadata.reset_gauge_pass()
if method=='tactile_preview':
frames=self.gateway.tactile_frames() if self.gateway else {}
packet=self.gateway.tactile_sample_packet(params.get('after') or {}) if self.gateway else {'samples':{},'epochs':{}}
return {**packet,'frames':{key:base64.b64encode(blob).decode() for key,blob in frames.items()}}
if method=='tactile_frames':
frames=self.gateway.tactile_frames() if self.gateway else {}
return {key:base64.b64encode(blob).decode() for key,blob in frames.items()}
+17 -5
View File
@@ -58,9 +58,21 @@ def camera_plan(env, overhead=False):
def gripper_plan(arm, shared):
if arm['rotary']:
raise RuntimeError('本项目缺少旋转夹爪驱动实现;请提供匹配的 GripperExecute 服务,不能猜测寄存器')
params = {'port':arm['port'], 'baud':shared.get('baud',115200), 'slave_id':shared.get('slave_id',1),
'stroke_mm':arm['stroke_mm'], 'auto_init':'false', 'rs485_rts':shared.get('rs485_rts','')}
argv=[sys.executable,'-m','arm_application.dh_pgea_node','--ros-args','-r',f'__ns:=/station/grippers/arm_{arm["id"]}']
for key,value in params.items():
if value != '': argv.extend(['-p',f'{key}:={value}'])
params = {
'port': arm['port'],
'baud': shared.get('baud', 115200),
'slave_id': shared.get('slave_id', 1),
'stroke_mm': arm['stroke_mm'],
'force_pct': shared.get('force_pct', 50),
'speed_pct': shared.get('speed_pct', 50),
'open_position': shared.get('open_position', 1000),
'close_position': shared.get('close_position', 0),
'auto_init': 'false',
'rs485_rts': shared.get('rs485_rts', ''),
'model': arm.get('gripper') or shared.get('model') or 'PGEA-100-20-W',
}
argv = [sys.executable, '-m', 'arm_application.dh_pgea_node', '--ros-args']
for key, value in params.items():
if value != '':
argv.extend(['-p', f'{key}:={value}'])
return argv
+41
View File
@@ -0,0 +1,41 @@
"""Pure monitoring projection. Discovery is not health or proof of ownership."""
from collections import Counter
GROUPS = ('设备驱动', '运动控制', '算法任务', '界面辅助', '其他/待核实')
def node_group(name):
base = name.rsplit('/', 1)[-1]
if base.startswith(('realsense', 'orisys_camera_', 'dh_pgea', 'dual_gripper', 'tactile', 'orisys_tactile_', 'orisys_gripper')):
return GROUPS[0]
if base in ('controller_manager', 'robot_state_publisher', 'joint_state_publisher',
'real_trajectory_adapter', 'named_pose_server', 'arm_status_node', 'gpio_controller') or base.endswith(('_controller', '_broadcaster')):
return GROUPS[1]
if base.startswith(('assembly_pipeline', 'servo_node', 'perception_node', 'stage2_locate', 'stage3_search', 'orisys_a7_rotation', 'handeye_')):
return GROUPS[2]
if base.startswith(('orisys_station_', 'operator_camera_', '_ros2cli')):
return GROUPS[3]
return GROUPS[4]
def inventory(graph, processes, tasks, arms, grippers):
# Keep full namespaces and duplicate counts. Names alone never grant process ownership.
names = graph.get('node_paths', graph.get('nodes', []))
nodes = [{'name': name, 'group': node_group(name), 'instances': count,
'owner': 'unverified', 'health': 'discovered'}
for name, count in sorted(Counter(names).items())]
managed = []
for key, record in sorted(processes.items()):
prefix = key.split(':', 1)[0]
group = GROUPS[0] if prefix in ('camera', 'gripper', 'tactile') else GROUPS[1] if prefix == 'stack' else GROUPS[2]
managed.append({'key': key, 'group': group, **record})
devices = []
for aid, state in sorted(arms.items()):
devices.append({'id': 'arm:'+aid, 'health': 'fault' if state.get('fault') else 'live' if state.get('pose_live') else 'offline',
'detail': state.get('summary', ''), 'enabled': state.get('enabled')})
for aid, state in sorted(grippers.items()):
devices.append({'id': 'gripper:'+aid, 'health': 'live' if state.get('connected') else 'offline',
'detail': state.get('summary', '')})
active = [dict(row) for row in tasks if row['state'] in ('queued', 'running')]
return {'nodes': nodes, 'processes': managed, 'devices': devices, 'active_tasks': active,
'duplicates': [row['name'] for row in nodes if row['instances'] > 1]}
+36
View File
@@ -0,0 +1,36 @@
"""Bounded operation events with an append-only per-session disk record."""
import json
import threading
import time
import uuid
from collections import deque
from pathlib import Path
class OperationLog:
def __init__(self, path=None, limit=512):
self.session = uuid.uuid4().hex
self._lock = threading.RLock()
self._rows = deque(maxlen=limit)
self._sequence = 0
self.path = Path(path) if path else None
if self.path: self.path.parent.mkdir(parents=True, exist_ok=True)
self.error = ''
def add(self, kind, message, *, action='', arm='', task_id=''):
with self._lock:
self._sequence += 1
row = dict(seq=self._sequence, time=time.time(), kind=kind, message=str(message),
action=action, arm=arm, task_id=task_id, session=self.session)
self._rows.append(row)
if self.path:
try:
with self.path.open('a', encoding='utf-8') as output:
output.write(json.dumps(row, ensure_ascii=False)+'\n')
self.error = ''
except OSError as exc: self.error = str(exc)
return row
def snapshot(self):
with self._lock:
return dict(session=self.session, events=list(self._rows), path=str(self.path or ''), error=self.error)
+442 -69
View File
@@ -9,6 +9,8 @@ from __future__ import annotations
import sys
import re
import math
import time
from functools import lru_cache
import threading
from pathlib import Path
@@ -74,8 +76,6 @@ from PySide6.QtWidgets import (
from arm_application.site_io import arm_short_id, listed_arms, load_site_dict
from station.runtime_client import (
command_blocked_message,
command_lane,
run_action,
stop_started,
status,
@@ -84,6 +84,7 @@ from station import operator_arm_mesh as arm_mesh
from station import operator_gripper_mesh as gripper_mesh
from station import operator_arm_state as arm_state
from station import runtime_client as runtime
from station.command_policy import title as command_title
from station import operator_camera_view as camera_view
def _theme_color(color: str) -> str:
@@ -123,16 +124,17 @@ _SERIES = (
class _Job(QThread):
finished_ok = Signal(dict)
def __init__(self, fn) -> None:
def __init__(self, fn, label="") -> None:
super().__init__()
self._fn = fn
self._label = label
def run(self) -> None:
try:
result = self._fn()
except Exception as exc:
result = {"ok": False, "message": str(exc)}
self.finished_ok.emit(result)
self.finished_ok.emit({**result, "operation": self._label})
class ValueTune(QWidget):
@@ -281,6 +283,9 @@ class FrameLabel(QLabel):
return QSize(96, 64) if self._compact else QSize(320, 200)
def setText(self, text: str) -> None: # noqa: N802
source = getattr(self, "_source", None)
if source is not None and source.isNull() and self.text() == text:
return
self._source = QPixmap()
self._scaled = QPixmap()
self._token = 0
@@ -493,6 +498,85 @@ class ForceChart(QWidget):
painter.end()
class SquareTriplet(QWidget):
"""Three equal square canvases; extra layout height never stretches a tile."""
def __init__(self, items):
super().__init__();self.items=items;self.gap=6
for item in items:
item.setParent(self);item.setMinimumSize(0,0)
policy=QSizePolicy(QSizePolicy.Policy.Expanding,QSizePolicy.Policy.Preferred)
policy.setHeightForWidth(True);self.setSizePolicy(policy)
def heightForWidth(self,width):return max(1,(width-2*self.gap)//3)
def sizeHint(self):return QSize(360,116)
def minimumSizeHint(self):return QSize(180,56)
def resizeEvent(self,event):
size=max(1,min(self.heightForWidth(self.width()),self.height()))
x=max(0,(self.width()-3*size-2*self.gap)//2)
y=max(0,(self.height()-size)//2)
for item in self.items:
item.setGeometry(x,y,size,size);x+=size+self.gap
super().resizeEvent(event)
def _display_force(value):
"""Display deadband only; calibrated feedback and control retain full precision."""
return 0.0 if abs(value) < 0.3 else value
class TangentialChart(QWidget):
"""Signed force history: fixed -10..10 N; horizontal position is sample index."""
MAX_SAMPLES=150
def __init__(self, axis):
super().__init__();self.axis=axis;self._samples=[];self._scale=10.;self._now=time.time()
def set_samples(self, samples, now=None):
rows=list(samples)[-self.MAX_SAMPLES:]
key=(len(rows),rows[-1].get('seq',rows[-1].get('t')) if rows else None)
if key==getattr(self,'_sample_key',None):return
self._sample_key=key;self._samples=rows;self.update()
def paintEvent(self, event):
painter=QPainter(self);painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.fillRect(self.rect(),QColor(_theme_color('#f6faff')))
font=QFont(self.font());font.setPixelSize(8 if self.width()<90 else 9 if self.width()<110 else 10);painter.setFont(font)
color=QColor('#1683ff' if self.axis=='x' else '#d89127')
ink=QColor(_theme_color('#7386a3'))
painter.setPen(ink)
painter.drawText(QRect(0,1,self.width(),15),int(Qt.AlignmentFlag.AlignCenter),(self.axis.upper()+' (N)' if self.width()<90 else self.axis.upper()+' 切向力 (N)'))
field='fshearx' if self.axis=='x' else 'fsheary'
rows=[r for r in self._samples if math.isfinite(r.get(field,float('nan')))]
latest=f'{_display_force(rows[-1][field]):+.2f}' if rows else '—'
painter.setPen(color)
painter.drawText(QRect(0,16,self.width(),15),int(Qt.AlignmentFlag.AlignCenter),latest)
top=30 if self.height()<110 else 36
left,right,bottom=25,max(26,self.width()-15),max(top+1,self.height()-21)
zero=(top+bottom)/2.
ticks=(-10,0,10) if self.height()<128 else (-10,-5,0,5,10)
for tick in ticks:
y=zero-tick/10.*(bottom-top)/2.
painter.setPen(QPen(QColor(_theme_color('#cad9ed' if tick==0 else '#dfe9f7')),1))
painter.drawLine(left,round(y),right,round(y))
painter.setPen(ink)
painter.drawText(QRect(0,round(y)-6,left-4,12),int(Qt.AlignmentFlag.AlignRight|Qt.AlignmentFlag.AlignVCenter),str(tick))
painter.setPen(QPen(ink,1))
painter.drawLine(left,top,left,bottom);painter.drawLine(left,bottom,right,bottom)
for fraction,text in ((0.,'0'),(.5,str(self.MAX_SAMPLES//2)),(1.,str(self.MAX_SAMPLES))):
x=left+fraction*(right-left)
painter.drawLine(round(x),bottom,round(x),bottom+3)
painter.drawText(QRect(round(x)-15,bottom+4,30,14),int(Qt.AlignmentFlag.AlignCenter),text)
painter.setClipRect(QRect(left,top,right-left+1,bottom-top+1))
path=QPainterPath()
for i,row in enumerate(rows):
x=left+i/max(self.MAX_SAMPLES-1,1)*(right-left)
y=zero-_display_force(row[field])/10.*(bottom-top)/2.
if i:path.lineTo(x,y)
else:path.moveTo(x,y)
painter.setPen(QPen(color,1.6));painter.drawPath(path)
painter.end()
def _card() -> QFrame:
frame = QFrame()
frame.setObjectName("card")
@@ -597,7 +681,7 @@ def _action_icon(name: str) -> QPixmap:
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"):
elif name in ("gripper_up", "gripper_open", "gripper_close", "gripper_init", "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
@@ -1135,7 +1219,7 @@ class MeshView(QOpenGLWidget):
class ArmSketch(MeshView):
"""用非夕 Rizon4 的外壳网格画当前姿态。没有关节角时摆在零位,并标明。
"""用非夕 Rizon4 外壳网格画姿态;没有实时反馈时显示本臂配置的回零位。
双击把观察角放回打开界面时的角度。关节仍是实时的,真机不动。
"""
@@ -1163,8 +1247,10 @@ class ArmSketch(MeshView):
joints = [round(float(v), 3) for v in q[:7]]
draw_live = bool(live)
else:
joints = [0.0] * 7
draw_live = False
self._pose_key=None
self._frames=[]
self.set_mesh([], '未配置有效的回零位,等待实时关节反馈', False)
return
try:
pose_key = (tuple(joints), draw_live, _workpiece_key(workpiece))
if pose_key == self._pose_key:
@@ -1179,7 +1265,7 @@ class ArmSketch(MeshView):
return
names = "、".join(item["name"] for item in self._frames)
if not draw_live:
note = f"非夕 Rizon4 外壳,零位。还没有关节角,这不是当前姿态。坐标系:{names}。"
note = f"非夕 Rizon4 外壳,配置的回零位示意。没有实时关节反馈,这不是实际姿态。坐标系:{names}。"
else:
note = f"非夕 Rizon4 外壳,按实时关节角摆放。白壳和灰蓝关节是厂家材质。坐标系:{names}。"
self.set_gl_mesh(blob, count, center, radius, note, draw_live)
@@ -1219,7 +1305,18 @@ class GripperSketch(MeshView):
self._thin_caption = True
def _caption(self) -> str:
return "拖拽改视角"
return ""
def _caption_band(self) -> int:
return 0
def _paint_caption(self, painter: QPainter) -> None:
pass
def mouseDoubleClickEvent(self,event):
if event.button()==Qt.MouseButton.LeftButton:
self._yaw,self._pitch=_VIEW_YAW,_VIEW_PITCH
self._press=None;self.update();event.accept()
def set_pose(
self,
@@ -1237,12 +1334,26 @@ class GripperSketch(MeshView):
if pose_key == getattr(self, "_pose_key", None):
return
self._pose_key = pose_key
self._cad_static=bool(rotary)
if rotary:
note='RGI-100-30-O-S:原始 STEP 装配外形。尚未定义运动轴与零件绑定,开合/旋转不会改变模型。'
try:
blob,count,center,radius=gripper_mesh.rgi_cad_gl()
if getattr(self,'_loaded_cad',False) is False:
self.set_gl_mesh(blob,count,center,radius,note,False)
self._loaded_cad=True
self.setToolTip(note+' 左键拖拽旋转视角,双击复位。')
except Exception as exc:
self.set_mesh([],f'夹爪 STEP 网格读取失败:{exc}',False)
return
self._loaded_cad=False
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)
self.setToolTip(note)
@lru_cache(maxsize=24)
@@ -1268,6 +1379,10 @@ class StationApp(QMainWindow):
self.resize(1600, 940)
self._site = load_site_dict()
self._catalog = listed_arms(self._site)
self._home_poses={}
for arm in self._catalog:
try: self._home_poses[arm['id']]=arm_state.configured_home(self._site,arm['id'])
except (OSError,ValueError,KeyError,TypeError): self._home_poses[arm['id']]=None
self._workpieces = _workpieces_by_arm(self._site)
self._arm = self._catalog[0]["id"] if self._catalog else ""
self._snap = {}
@@ -1279,6 +1394,8 @@ class StationApp(QMainWindow):
self._camera_fps = {}
self._camera_cards = []
self._flow_slots = []
self._flow_axis_charts = {}
self._flow_triplets = {}
self._camera_windows = {}
self._depth_on = False
self._closing = False
@@ -1292,12 +1409,18 @@ class StationApp(QMainWindow):
self._timer.start(500)
self._images = QTimer(self)
self._images.timeout.connect(self._paint_images)
self._images.start(67)
self._images.start(16)
self._refresh()
def _build(self) -> None:
root = QWidget()
self.setCentralWidget(root)
# Allocate the status strip before the first command, so showMessage
# cannot insert a new row and move the dashboard after startup.
status_bar=self.statusBar()
status_bar.setSizeGripEnabled(False)
status_bar.setFixedHeight(22)
status_bar.show()
outer = QVBoxLayout(root)
outer.setContentsMargins(0, 0, 0, 0)
outer.setSpacing(0)
@@ -1321,7 +1444,9 @@ class StationApp(QMainWindow):
self._detail = QPlainTextEdit()
self._detail.setObjectName("log")
self._detail.setReadOnly(True)
self._detail.setMaximumBlockCount(300)
self._detail.setMaximumBlockCount(3000)
self._log_session = None
self._log_sequence = 0
self._detail.hide()
outer.addWidget(self._detail)
self.setMinimumSize(1100, 700)
@@ -1472,7 +1597,7 @@ class StationApp(QMainWindow):
a.addWidget(self._robot_panel())
preview, box = self._panel("机器人实时预览")
tools = QHBoxLayout()
self._pose_note = QLabel("等待实时关节 · 零位示意")
self._pose_note = QLabel("等待实时关节 · 回零位示意")
self._pose_note.setObjectName("muted")
tools.addWidget(self._pose_note, 1)
self._view_buttons = []
@@ -1489,12 +1614,28 @@ class StationApp(QMainWindow):
self._arm_sketch.setMinimumSize(240, 180)
box.addWidget(self._arm_sketch, 1)
a.addWidget(preview, 1)
gripper, grip_box = self._panel("夹爪实时 3D")
placeholder = QLabel("夹爪实时 3D 预览\n预留位置")
placeholder.setObjectName("gripperPlaceholder")
placeholder.setMinimumHeight(180)
placeholder.setAlignment(Qt.AlignmentFlag.AlignCenter)
grip_box.addWidget(placeholder, 1)
gripper, grip_box = self._panel("夹爪 3D")
self._gripper_sketch=GripperSketch(160,140)
grip_box.addWidget(self._gripper_sketch,1)
self._grip_status = QLabel('夹爪未连接')
self._grip_status.setObjectName('muted');self._grip_status.setWordWrap(True)
grip_box.addWidget(self._grip_status)
self._gripper_probe_button=_btn('连接 / 探测')
self._gripper_probe_button.setFixedSize(92,28)
self._gripper_probe_button.setToolTip('拉起 dh_pgea 并读取当前夹爪状态,不初始化、不移动')
self._gripper_probe_button.clicked.connect(lambda:self._ask('gripper_probe'))
self._action_buttons.append((self._gripper_probe_button,'gripper_probe'))
grip_box.itemAt(0).widget().layout().addWidget(self._gripper_probe_button)
grip_row = QHBoxLayout()
grip_row.setSpacing(8)
for text, action in (("全闭", "gripper_close"), ("全开", "gripper_open"), ("到位", "gripper_init")):
button = _btn(text)
button.setFixedHeight(28)
button.clicked.connect(lambda _checked=False, a=action: self._ask(a))
self._action_buttons.append((button, action))
grip_row.addWidget(button, 1)
grip_box.addLayout(grip_row)
a.addWidget(gripper, 1)
b.addWidget(self._camera_panel("main"), 3)
b.addWidget(self._task_panel(), 2)
@@ -1545,7 +1686,20 @@ class StationApp(QMainWindow):
self._camera_cards.append((card, cam))
self._camera_labels.append((page_key, cam, shot))
box.addLayout(row, 3)
box.addWidget(_section("传感器光流"))
flow_heading=_section('传感器光流')
self._sensor_reset_button=_btn('重置底图')
self._sensor_reset_button.setFixedSize(92,28)
self._sensor_reset_button.setToolTip('传感器无受力时点击,以当前画面更新光流底图并清空曲线')
self._sensor_reset_button.clicked.connect(lambda:self._ask('tactile_reset'))
self._action_buttons.append((self._sensor_reset_button,'tactile_reset'))
flow_heading.layout().addWidget(self._sensor_reset_button)
self._sensor_refresh_button=_btn('刷新传感器')
self._sensor_refresh_button.setFixedSize(92,28)
self._sensor_refresh_button.setToolTip('重新检测并启动本站左右触觉节点,不操作夹爪或机械臂')
self._sensor_refresh_button.clicked.connect(lambda:self._ask('tactile_refresh'))
self._action_buttons.append((self._sensor_refresh_button,'tactile_refresh'))
flow_heading.layout().addWidget(self._sensor_refresh_button)
box.addWidget(flow_heading)
flow_row = QHBoxLayout()
flow_row.setSpacing(10)
for side in ("left", "right"):
@@ -1555,10 +1709,15 @@ class StationApp(QMainWindow):
cell_box.setSpacing(4)
title = QLabel(side)
title.setObjectName("muted")
cell_box.addWidget(title)
title.setTextFormat(Qt.TextFormat.PlainText)
title.setFixedHeight(20)
flow = FrameLabel("光流显示预留", compact=True)
flow.setMinimumHeight(100)
cell_box.addWidget(flow, 1)
charts={axis:TangentialChart(axis) for axis in ('x','y')}
self._flow_axis_charts[side]=charts
triplet=SquareTriplet([charts['x'],flow,charts['y']])
self._flow_triplets[side]=triplet
cell_box.addWidget(triplet,1)
cell_box.addWidget(title)
self._flow_slots.append((title, flow, side))
flow_row.addWidget(cell, 1)
box.addLayout(flow_row, 2)
@@ -1597,7 +1756,7 @@ class StationApp(QMainWindow):
table.setColumnStretch(i, 1)
values.append(value)
lay.addLayout(table)
if values is self._tcp_values: card.setToolTip("等待控制器提供 TCP 位姿反馈")
if values is self._tcp_values: card.setToolTip("厂家 WORLD 坐标系 TCP 反馈;位置 mm,姿态 Rx/Ry/Rz 为 XYZ 欧拉角(°)")
row.addWidget(card, 2)
box.addLayout(row)
return panel
@@ -1621,7 +1780,7 @@ class StationApp(QMainWindow):
panel, box = self._panel("任务操作")
for actions in (
(("检测", "stage_track"), ("对孔", "stage_locate"), ("旋转", "stage_screw")),
(("夹爪闭合", "gripper_close"), ("张开", "gripper_open")),
(("全闭", "gripper_close"), ("全开", "gripper_open"), ("到位", "gripper_init")),
(("回零", "reset_home"), ("停止", "stop")),
):
row = QHBoxLayout()
@@ -1635,38 +1794,96 @@ class StationApp(QMainWindow):
return panel
def _page_calibration(self) -> QWidget:
page, lay = self._page_box("标定")
page, lay = self._page_box("手眼标定")
self._calibration_arm = QLabel()
self._calibration_arm.setObjectName("muted")
lay.addWidget(self._calibration_arm)
body = QHBoxLayout()
body.setSpacing(14)
preview, box = self._panel("标定预览")
placeholder = QLabel("标定相机画面\n等待接入 handeye 标定系统")
placeholder.setObjectName("gripperPlaceholder")
placeholder.setMinimumHeight(320)
placeholder.setAlignment(Qt.AlignmentFlag.AlignCenter)
box.addWidget(placeholder, 1)
preview, box = self._panel("相机实时预览")
self._calibration_fps = QLabel("— FPS")
self._calibration_fps.setObjectName("muted")
box.itemAt(0).widget().layout().addWidget(self._calibration_fps)
self._calibration_preview = FrameLabel("等待相机画面", compact=True)
self._calibration_preview.setProperty("plainCamera", True)
self._calibration_preview.setMinimumHeight(360)
self._calibration_preview.set_badge("", "")
self._calibration_preview.setCursor(Qt.CursorShape.PointingHandCursor)
self._calibration_preview.setToolTip("点击全屏预览")
self._calibration_preview.clicked.connect(self._open_calibration_camera)
box.addWidget(self._calibration_preview, 1)
self._calibration_hint = QLabel()
self._calibration_hint.setObjectName("muted")
self._calibration_hint.setWordWrap(True)
box.addWidget(self._calibration_hint)
body.addWidget(preview, 3)
right = QVBoxLayout()
controls, box = self._panel("标定流程")
note = QLabel("页面预留,尚未接入标定功能。")
self._calibration_mode = QComboBox()
self._calibration_mode.addItem("眼在手上 · D405", "eye_in_hand")
self._calibration_mode.addItem("眼在手外 · D435", "eye_to_hand")
self._calibration_mode.currentIndexChanged.connect(self._calibration_changed)
box.addWidget(self._calibration_mode)
self._calibration_board = QLabel()
self._calibration_board.setWordWrap(True)
box.addWidget(self._calibration_board)
self._calibration_count = QLabel()
box.addWidget(self._calibration_count)
for text in ("1 示教采点", "2 整理点位", "3 自动走点并求解"):
button = _btn(text)
button.setFixedHeight(40)
button.setEnabled(False)
button.setToolTip("本版先预览界面和已有数据,尚未开放采点与运动操作")
box.addWidget(button)
note = QLabel("当前为预览版:显示实时相机和已有标定数据。\n自动标定会移动机械臂,后续单独接入。")
note.setObjectName("muted")
note.setWordWrap(True)
box.addWidget(note)
for text in ("选择标定模式", "采集标定点", "计算标定结果", "保存标定结果"):
button = _btn(text)
button.setFixedHeight(44)
button.setEnabled(False)
button.setToolTip("后续接入 handeye 标定系统")
box.addWidget(button)
box.addStretch(1)
result = QLabel("标定结果\n—")
result.setObjectName("muted")
box.addWidget(result)
body.addWidget(controls, 2)
right.addWidget(controls)
result, box = self._panel("已有标定结果")
self._calibration_result = QLabel("等待标定数据")
self._calibration_result.setWordWrap(True)
self._calibration_result.setTextFormat(Qt.TextFormat.PlainText)
self._calibration_result.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
box.addWidget(self._calibration_result)
right.addWidget(result, 1)
body.addLayout(right, 2)
lay.addLayout(body, 1)
return self._scroll(page)
def _calibration_camera(self):
mode = self._calibration_mode.currentData()
return next((cam for cam in camera_view.targets()
if (cam.get("arm") == self._arm if mode == "eye_in_hand" else not cam.get("arm"))), None)
def _calibration_changed(self, *_args) -> None:
self._calibration_preview.setText("等待相机画面")
self._sync_camera_watch()
self._update_calibration()
self._paint_images()
def _open_calibration_camera(self) -> None:
camera = self._calibration_camera()
if camera: self._open_camera(camera["id"], True)
def _update_calibration(self) -> None:
mode = self._calibration_mode.currentData()
data = ((self._snap.get("handeye") or {}).get(self._arm) or {}).get(mode) or {}
board = data.get("board") or {}
square = board.get("square_m")
size = f"{float(square) * 1000:g} mm" if square is not None else "—"
self._calibration_board.setText(f"棋盘格内角点:{board.get('cols') or '—'} × {board.get('rows') or '—'}\n方格边长:{size}")
self._calibration_count.setText(f"已有示教点:{data.get('poses') if data.get('poses') is not None else '—'} 个")
self._calibration_hint.setText("标定板固定在台面,相机随机械臂移动。" if mode == "eye_in_hand" else "相机固定在工位,标定板安装在机械臂末端。")
if data.get("exists"):
translation = data.get("translation_m")
xyz = " / ".join(f"{float(v)*1000:.2f}" for v in translation) if translation else "—"
meaning = "相机 → 法兰" if mode == "eye_in_hand" else "相机 → 基座"
text = f"变换方向:{meaning}\n有效视角:{data.get('views') or '—'}\n平移 XYZ(mm):{xyz}\n生成时间(UTC):{data.get('utc') or '—'}\n\n{data.get('file') or ''}"
else:
text = "当前机械臂和模式尚无标定结果。"
self._calibration_result.setText(text)
def _page_ros_monitor(self) -> QWidget:
page, lay = self._page_box("ROS 2 节点监控")
self._ros_monitor_state = QLabel("等待状态")
@@ -1677,6 +1894,13 @@ class StationApp(QMainWindow):
self._ros_filter.setClearButtonEnabled(True)
self._ros_filter.textChanged.connect(lambda _text: self._update_ros_monitor())
lay.addWidget(self._ros_filter)
panel, box = self._panel("设备反馈 / 工位进程 / 当前任务")
self._ros_runtime = QPlainTextEdit()
self._ros_runtime.setReadOnly(True)
self._ros_runtime.setMinimumHeight(150)
self._ros_runtime.setMaximumHeight(210)
box.addWidget(self._ros_runtime)
lay.addWidget(panel)
row = QHBoxLayout()
row.setSpacing(14)
self._ros_lists = {}
@@ -1711,15 +1935,42 @@ class StationApp(QMainWindow):
graph = backend.get("graph") or {}
stamp = self._snap.get("now")
updated = datetime.fromtimestamp(stamp).strftime("%H:%M:%S") if stamp else "—"
message = "离线模式 · 未连接 ROS 2" if offline else error or f"自动刷新 · 最近更新 {updated}"
scope = "本机" if backend.get("localhost_only") else "网络"
message = "离线模式 · 未连接 ROS 2" if offline else error or f"Domain {backend.get('ros_domain', '—')} · {scope}发现 · 最近更新 {updated}"
self._ros_monitor_state.setText(message)
self._ros_monitor_state.setToolTip(error or message)
query = self._ros_filter.text().strip().casefold()
monitoring = backend.get("monitoring") or {}
health_words = {"live":"反馈在线", "offline":"无新鲜反馈", "fault":"故障", "unknown":"状态未知"}
runtime_rows = []
if not offline and not error:
for device in monitoring.get("devices", []):
runtime_rows.append(f"设备 {device['id']} · {health_words.get(device['health'], device['health'])} · {device.get('detail') or ''}")
for process in monitoring.get("processes", []):
state = "运行" if process.get("running") else f"已退出 / {process.get('exit_code')}"
runtime_rows.append(f"工位管理 [{process['group']}] {process['key']} · PID {process['pid']} · {state}")
for task in monitoring.get("active_tasks", []):
phase = "停止请求中" if task.get("cancel_requested") else task.get("phase") or task["state"]
runtime_rows.append(f"任务 {task['name']} · 臂 {(task.get('context') or {}).get('arm', '—')} · {phase} · {', '.join(task.get('resources', []))}")
if not monitoring.get("active_tasks"): runtime_rows.append("工位当前没有运行中的任务;外部运动状态需看设备反馈。")
runtime_text = "\n".join(line for line in runtime_rows if query in line.casefold())
if self._ros_runtime.toPlainText() != runtime_text: self._ros_runtime.setPlainText(runtime_text)
self._ros_runtime.setPlaceholderText("离线模式" if offline else error or "等待后端归属记录")
for key, view in self._ros_lists.items():
values = sorted(str(item) for item in graph.get(key, [])) if not offline and not error else []
rows = [item for item in values if query in item.casefold()]
self._ros_counts[key].setText("—" if offline or error else str(len(values)))
text = "\n".join(rows)
if key == "nodes" and monitoring.get("nodes"):
from station.node_inventory import GROUPS
groups = []
for group in GROUPS:
found = [r for r in monitoring["nodes"] if r["group"] == group and query in r["name"].casefold()]
if found:
groups.append(f"[{group}]")
groups.extend(f"{r['name']}" + (f" ⚠ 同名 {r['instances']} 个" if r['instances'] > 1 else "") for r in found)
text = "\n".join(groups)
else:
text = "\n".join(rows)
if view.toPlainText() != text:
view.setPlainText(text)
view.setPlaceholderText("离线模式,暂无数据" if offline else "状态不可用" if error else "没有匹配项" if query else "暂未发现")
@@ -1814,10 +2065,45 @@ class StationApp(QMainWindow):
want = {f"{cid}_color" for cid in self._camera_windows}
want |= {f"{cam['id']}_color" for page, cam, _shot in self._camera_labels
if page == key and cam.get("arm") in (None, self._arm)}
if key == "calibration":
camera = self._calibration_camera()
if camera: want.add(f"{camera['id']}_color")
# Preview only station-owned capture nodes, never arbitrary same-name topics.
owned = {row['id'] for row in self._snap.get('camera_ownership', []) if row.get('owned')}
from station.remote_camera import settings
remote=settings(self._site)
allowed=set(remote['cameras']) if remote else owned
want &= {f"{cid}_color" for cid in allowed}
camera_view.watch(want, set())
def _paint_tactile(self):
sensor_arm=str((self._site.get('tactile') or {}).get('arm_id','89'))
for title,flow,side in self._flow_slots:
reading=(self._snap.get('tactile') or {}).get(side,{}) if self._arm==sensor_arm else {}
online=bool(reading.get('connected'))
samples=runtime.tactile_view.samples(side) or (self._snap.get('history_n') or {}).get(side,[])
force=samples[-1] if online and samples and time.time()-samples[-1].get('t',0)<.75 else reading.get('force_n') if online else None
fps=f"{reading.get('fps',0):.0f}fps" if online else '离线'
normal=(f"{_display_force(force['fnormal']):.2f}".rstrip('0').rstrip('.')+'N') if force else '— N'
title.setText(f"{side}({fps}) 当前法向力 {normal}")
blob=runtime.tactile_view.frame(side,'shear') if online else None
if blob:flow.show_jpeg(blob)
else:flow.setText('等待光流画面' if online else reading.get('error') or '未连接传感器')
for chart in self._flow_axis_charts[side].values():
chart.set_samples(samples if online and force else [])
def _paint_images(self) -> None:
key = next((key for key, page in self._pages.items() if page is self._stack.currentWidget()), "")
if key == 'main':self._paint_tactile()
if key == "calibration":
camera = self._calibration_camera()
frame_key = f"{camera['id']}_color" if camera else ""
frame = camera_view.previews().get(frame_key)
info = camera_view.preview_meta().get(frame_key)
self._calibration_fps.setText(f"{info[2]:.0f} FPS" if frame and info else "— FPS")
if frame: self._calibration_preview.show_rgb(*frame)
else: self._calibration_preview.setText("等待相机画面" if camera else "未配置此模式的相机")
return
if key not in ("main", "camera"): return
frames, meta = camera_view.previews(), camera_view.preview_meta()
for page, cam, label in self._camera_labels:
@@ -1842,32 +2128,95 @@ class StationApp(QMainWindow):
if "--offline" not in sys.argv:
camera_view.stop()
camera_view.start()
from station.remote_camera import settings
remote=settings(self._site)
if remote and "--offline" not in sys.argv:
wanted={f"{cam['id']}_color" for page,cam,_ in self._camera_labels
if page=='main' and cam.get('arm') in (None,aid) and cam['id'] in remote['cameras']}
deadline=time.monotonic()+15.
while time.monotonic()<deadline:
ready=wanted & set(camera_view.previews())
if ready==wanted:break
time.sleep(.1)
missing=sorted(wanted-set(camera_view.previews()))
return {'ok':not missing,'message':('远端相机已收到实时画面' if not missing else '远端相机仍未收到画面:'+', '.join(missing))}
return result
self._start(refresh, "camera")
self._start(refresh, f"camera_refresh:{aid}", f"刷新相机 · 臂 {aid}")
def _stop_arm(self) -> None:
aid = self._arm
self._start(lambda: stop_started(aid), "stop")
self._start(lambda: stop_started(aid), "stop", "停止机械臂")
def _ask(self, action: str) -> None:
aid = self._arm
self._start(lambda: run_action(action, aid, True), command_lane(action))
self._start(lambda: run_action(action, aid, True), f"{action}:{aid}", f"{command_title(action)} · 臂 {aid}")
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, 4000)
def _start(self, fn, key: str, label: str = "") -> None:
# Only coalesce duplicate requests here. Device arbitration is authoritative
# in the backend, so independent commands never share a coarse GUI lane.
self._append_operation_log(f"[点击] {label or key}")
existing = self._jobs.get(key)
if existing and existing.isRunning():
message = "已请求停止,正在等待控制器确认" if key == "stop" else f"{label or key}请求尚未结束,请查看当前任务状态"
self._append_operation_log("[未重复发送] " + message)
self.statusBar().showMessage(message, 5000)
return
job = _Job(fn)
self._jobs[lane] = job
self._prune_jobs()
job = _Job(fn, label)
self._jobs[key] = job
job.finished_ok.connect(self._ran)
job.finished.connect(self._prune_jobs)
job.start()
def _prune_jobs(self) -> None:
for key, job in list(self._jobs.items()):
if not job.isRunning():
self._jobs.pop(key, None)
job.deleteLater()
def _append_operation_log(self, message, stamp=None):
from datetime import datetime
when = datetime.fromtimestamp(stamp) if stamp is not None else datetime.now()
scroll = self._detail.verticalScrollBar()
at_bottom = scroll.value() >= scroll.maximum()-3
previous = scroll.value()
self._detail.appendPlainText(f"{when:%H:%M:%S.%f}"[:-3] + " " + message)
scroll.setValue(scroll.maximum() if at_bottom else previous)
def _sync_operation_log(self):
journal = self._snap.get('operation_log') or {}
session = journal.get('session')
if not session: return
if self._log_session != session:
self._log_session, self._log_sequence = session, 0
events = journal.get('events', [])
if events and events[0]['seq'] > self._log_sequence+1:
self._append_operation_log('部分较早记录已移出缓存,完整记录:' + journal.get('path', ''))
if journal.get('error'):
failure = str(journal['error'])
if getattr(self, '_log_disk_error', '') != failure:
self._append_operation_log('[日志保存失败] ' + failure)
self._log_disk_error = failure
else: self._log_disk_error = ''
self._detail.setToolTip('完整操作记录:' + journal.get('path', ''))
for event in events:
if event['seq'] <= self._log_sequence: continue
operation = command_title(event.get('action', ''))
arm = event.get('arm')
token = event.get('task_id', '')
context = operation + (f" · 臂 {arm}" if arm else '') + (f" · #{token[:8]}" if token else '')
self._append_operation_log(context + ' · ' + event['message'], event['time'])
self._log_sequence = event['seq']
def _ran(self, result: dict) -> None:
from datetime import datetime
message = str(result.get("message") or result.get("output") or "操作已结束")
self._detail.appendPlainText(f"{datetime.now():%H:%M:%S} {message}")
operation = str(result.get("operation") or "")
if operation: message = operation + " · " + message
self._append_operation_log(("[反馈] " if result.get("ok") else "[失败/拒绝] ") + message)
detail = str(result.get('detail') or '').strip()
if detail: self._append_operation_log((operation or '操作') + ' · 执行输出:\n' + detail)
if result.get('log'): self._append_operation_log('完整执行日志:' + str(result['log']))
self.statusBar().showMessage(message.splitlines()[0], 7000)
self._refresh()
@@ -1914,7 +2263,7 @@ class StationApp(QMainWindow):
if item: frame.show_rgb(*item)
else: frame.setText("画面已断开")
timer.timeout.connect(update)
timer.start(67)
timer.start(16)
update()
self._camera_windows[camera_id] = dialog
def finished(_result):
@@ -1940,6 +2289,7 @@ class StationApp(QMainWindow):
def _refresh(self) -> None:
from datetime import datetime
self._snap = status()
self._sync_operation_log()
self._clock.setText(datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
error = str((self._snap.get("backend") or {}).get("error") or "")
live = (self._snap.get("arm_live") or {}).get(self._arm) or {}
@@ -1950,7 +2300,9 @@ class StationApp(QMainWindow):
robot_tone = "idle" if enabled is None else "ok" if enabled is True else "warn"
if not error and (live.get("fault") is True or live.get("estop_released") is False):
word, robot_tone = "故障" if live.get("fault") is True else "急停", "warn"
detail = "离线模式,未读取机械臂使能状态" if "--offline" in sys.argv else "未收到可靠的使能反馈" if enabled is None else "机器人" + word
detail = "离线模式,未读取机械臂使能状态" if "--offline" in sys.argv else "未收到可靠的使能反馈" if enabled is None else str(live.get("summary") or "机器人" + word)
arm_error=(((self._snap.get('backend') or {}).get('device_errors') or {}).get('arms') or {}).get(self._arm)
if arm_error:detail=str(arm_error)
self._indicator("robot", "机器人" + word, robot_tone, detail)
frames = camera_view.previews()
online = sum(bool(frames.get(f"{cam['id']}_color")) for _page, cam, _label in self._camera_labels if _page == "main")
@@ -1964,34 +2316,55 @@ class StationApp(QMainWindow):
self._enable_label.setProperty("iconColor", color)
self._enable_label.setToolTip("当前模式:" + str(live.get("mode") or "未知"))
q = live.get("q") if live.get("pose_live") else None
self._arm_sketch.set_pose(q, bool(q), self._workpieces.get(self._arm))
self._pose_note.setText("实时关节姿态" if q else "等待实时关节 · 零位示意")
home=self._home_poses.get(self._arm)
self._arm_sketch.set_pose(q if q else home, bool(q), self._workpieces.get(self._arm))
self._pose_note.setText('实时关节姿态' if q else '等待实时关节 · 回零位示意' if home else '未配置回零位 · 等待实时关节')
for i, label in enumerate(self._joint_values):
label.setText(f"{q[i] * 180 / 3.141592653589793:.1f}" if q and len(q) > i else "—")
tcp = live.get('tcp_pose') if not error else None
for i, label in enumerate(self._tcp_values):
label.setText(f"{tcp[i]:.1f}" if tcp and len(tcp)==6 else "—")
services = ((self._snap.get("backend") or {}).get("graph") or {}).get("services") or []
for button, action in self._action_buttons:
available = not error
reason = error
if action in ("gripper_open", "gripper_close"):
available = available and bool(grip.get("connected")) and not grip.get("moving") and f"/station/grippers/arm_{self._arm}/execute" in services
reason = reason or "需要本臂夹爪在线并提供控制接口"
if action in ("gripper_open", "gripper_close", "gripper_init", "gripper_probe"):
# 夹爪走 USB-RS485,不依赖机械臂使能。
needed = "/gripper/initialize" if action == "gripper_init" else "/gripper/open"
if action == "gripper_probe":
available = not error
reason = error or "连接并读取夹爪,不使能机械臂、不回零"
else:
available = needed in services
reason = "请先连接 / 探测,拉起 dh_pgea" if not available else ""
button.setEnabled(available)
button.setToolTip({
"gripper_probe": "拉起 dh_pgea 并读取当前夹爪状态,不初始化、不移动",
"gripper_open": "全开:不需要使能机械臂,也不需要先到位",
"gripper_close": "全闭:不需要使能机械臂,也不需要先到位",
"gripper_init": "到位=回零标定,不是使能。会先张开满行程;夹着工件时不要点",
"tactile_refresh": "重新检测并启动本站左右触觉节点,不操作夹爪或机械臂",
"tactile_reset": "传感器无受力时点击,以当前画面更新光流底图并清空曲线",
"real_up": "连接并请求使能当前臂;示教器需处于 Auto/Remote,实体急停需释放;不会运行检测或回零",
"stage_track": "第一阶段跟踪:D405 优先,丢失目标时 D435 补位;会移动机械臂",
"stage_locate": "运行孔位对准,不下压或旋入",
"stage_locate": "新版二阶段:孔位对准后按配置下降到进孔高度;不自动旋转",
"stage_screw": "新版 A7 工序:首轮正反转,再执行四轮松爪、微抬、空回、降回、合爪和旋转",
"pipeline": "依次执行检测、对孔和 A7 旋转;任一步失败或停止即中止后续",
"reset_home": "回到当前臂配置的初始位置(home)",
}.get(action, "") if available else reason)
arm = next((a for a in self._catalog if a["id"] == self._arm), {})
for aid, button in self._arm_switches.items(): _mark(button, active=aid == self._arm)
for title, flow, side in self._flow_slots:
title.setText(side)
flow.setText("光流显示预留 · 等待接入")
self._grip_status.setText(str(grip.get('summary') or '夹爪未连接'))
self._gripper_sketch.set_pose(grip.get('permille'),grip.get('rotation_deg'),
bool(arm.get('rotary')),bool(grip.get('connected')),arm.get('stroke_mm'))
sensor_arm=str((self._site.get('tactile') or {}).get('arm_id','89'))
self._paint_tactile()
self._settings_info.setText(f"机械臂:{arm.get('label', '—')}\n设备序列号:{arm.get('sn', '—')}\n界面:简体中文 · {'深色' if self._theme == 'dark' else '浅色'}")
self._arm_selector.blockSignals(True)
self._arm_selector.setCurrentIndex(self._arm_selector.findData(self._arm))
self._arm_selector.blockSignals(False)
self._calibration_arm.setText(f"当前机械臂:{arm.get('label', '—')} · {arm.get('sn', '—')}")
self._update_calibration()
self._sync_camera_watch()
self._update_ros_monitor()
@@ -2099,7 +2472,7 @@ QScrollArea > QWidget > QWidget { background: #f0f5fc; }
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; }
QScrollBar:vertical { background: #f0f5fc; width: 8px; }
QScrollBar::handle:vertical { background: #cad9ed; border-radius: 4px; min-height: 24px; }
QStatusBar { background: #f8fbff; color: #7387a4; }
QStatusBar { background: #f8fbff; color: #7387a4; font-size: 11px; padding: 0; }
"""
+41
View File
@@ -233,3 +233,44 @@ def _seven(raw: Any) -> list[float] | None:
if any(v != v or abs(v) > 12.6 for v in q):
return None
return q
def configured_home(site: dict, arm: str, root=None) -> list[float]:
"""Read the same named-pose/servo override used by the explicit home action.
Presentation only: no ROS clients, command submission or device access.
"""
from pathlib import Path
from arm_application.site_io import arm_short_id
robot=next((r for r in (site.get('robot') or {}).get('robots',[])
if arm_short_id(str(r.get('sn','')))==arm),None)
if robot is None: raise ValueError('未找到本臂回零配置')
path=Path(robot.get('named_poses_file') or '')
if not path.is_absolute(): path=Path(root or ros2_root())/path
servo={**(site.get('servo') or {}),**(robot.get('servo') or {})}
name=str(servo.get('home_pose_name') or 'home')
try:
poses=yaml.safe_load(path.read_text())['named_pose_server']['ros__parameters']
except yaml.YAMLError as exc:
raise ValueError('回零配置 YAML 无效') from exc
raw=poses.get('named_poses.'+name)
if not isinstance(raw,(list,tuple)) or len(raw)!=7: raise ValueError('回零配置需要七个关节角')
q=_seven(raw)
if q is None: raise ValueError('回零配置必须包含七个有限关节角')
return q
def tcp_display_pose(position, quaternion):
"""Native WORLD TCP pose: metres/xyzw to millimetres/XYZ Euler degrees."""
values = [float(v) for v in (*position, *quaternion)]
if len(position) != 3 or len(quaternion) != 4 or not all(math.isfinite(v) for v in values):
return None
x, y, z, w = values[3:]
norm = math.sqrt(x*x+y*y+z*z+w*w)
if norm < 1e-9:
return None
x, y, z, w = (v/norm for v in (x,y,z,w))
roll = math.atan2(2*(w*x+y*z), 1-2*(x*x+y*y))
pitch = math.asin(max(-1., min(1., 2*(w*y-z*x))))
yaw = math.atan2(2*(w*z+x*y), 1-2*(y*y+z*z))
return [v*1000 for v in values[:3]] + [math.degrees(v) for v in (roll,pitch,yaw)]
+66 -28
View File
@@ -23,12 +23,13 @@ from pathlib import Path
import numpy as np
# 界面预览与算法原图分开:显示限制到 848 像素宽,算法仍使用原始分辨率。
_COLOR_WIDTH = 848
# 彩色预览保留到 1080p;算法仍使用原始分辨率。
_COLOR_WIDTH = 1920
_COLOR_HEIGHT = 1080
# 深度上色不是要看的画质。全幅每帧上色会把刷新占满,所以这一路仍抽稀。
_DEPTH_WIDTH = 480
# 手眼只在正在看的那一页编码,宽度跟彩色预览一样,不另缩一档。
_JPEG_WIDTH = 1280
_JPEG_WIDTH = 1920
# 只留给还在用 ``accept_frame`` 的调用。彩色实况不再用它抽帧:
# 相机大约 30 帧,再按这个间隔丢一次,画面会掉到十几帧。
_INTERVAL = 1.0 / 30.0
@@ -58,7 +59,7 @@ _WAKE = threading.Event()
_PAINT_THREAD: threading.Thread | None = None
_STATE = {"on": False, "error": "", "frames": {}, "stamp": 0.0}
# 和 cam_bridge 约定的共享内存头:序号、宽、高、字节数。序号为奇数表示正在写。
_SHM_MAX = 1280 * 720 * 3
_SHM_MAX = 1920 * 1080 * 3
_SHM_HEAD = struct.Struct("<QIII")
_BRIDGE: subprocess.Popen | None = None
_BRIDGE_DIR: Path | None = None
@@ -125,9 +126,15 @@ def stream_targets(site: dict | None = None) -> list[dict]:
from arm_application.site_io import load_site_dict as _site
site = _site()
from station.remote_camera import settings
remote=settings(site)
real = preview_targets(site)
if remote:
return [{**cam,'source':'remote','preview_transport':remote['transport']}
for cam in real if cam['id'] in remote['cameras']]
for item in real:
item["source"] = "real"
item["preview_transport"] = "compressed"
seen = {item["id"] for item in real}
return real + [item for item in sim_preview_targets(site) if item["id"] not in seen]
@@ -458,9 +465,10 @@ def rgb_preview(image: np.ndarray, *, bgr: bool, limit: int = _COLOR_WIDTH) -> n
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)
scale = min(1.0, float(limit) / width, float(_COLOR_HEIGHT) / int(view.shape[0]))
if scale < 1.0:
size = (max(1, int(round(width * scale))), max(1, int(round(int(view.shape[0]) * scale))))
view = cv2.resize(view, size, interpolation=cv2.INTER_AREA)
if bgr:
return cv2.cvtColor(view, cv2.COLOR_BGR2RGB)
if not view.flags["OWNDATA"] or not view.flags["C_CONTIGUOUS"]:
@@ -508,12 +516,12 @@ def _jpeg_bgr(rgb: np.ndarray) -> bytes | None:
def _as_array(data, dtype, count: int) -> np.ndarray:
"""显式复制消息缓冲,让异步绘图不依赖 ROS 回调的生命周期。"""
"""bytes 持有独立消息快照;不再为 NumPy 视图重复复制一次。"""
raw = bytes(data)
arr = np.frombuffer(raw, dtype=dtype, count=count)
if arr.size < count:
raise ValueError("图像字节数不够")
return arr.copy()
return arr
def _color_view(msg) -> np.ndarray | None:
@@ -565,12 +573,13 @@ def _open_node(cams: list[dict]):
import rclpy
from rclpy.node import Node
from rclpy.qos import HistoryPolicy, QoSProfile, ReliabilityPolicy, qos_profile_sensor_data
from sensor_msgs.msg import Image
from sensor_msgs.msg import Image, CompressedImage
from std_msgs.msg import Float32MultiArray
from arm_application.site_io import arm_short_id, load_site_dict
configured_arm=arm_short_id(os.environ.get("SITE_ROBOT_SN") or (load_site_dict().get('robot') or {}).get('sn'))
def camera_role(cam):
if cam.get('source')=='remote':return ''
if cam.get('arm') and cam['arm'] != configured_arm:
return '' # Legacy global detections have no arm identity; never overlay another arm's result.
return str(cam.get('role') or 'wrist')
@@ -598,6 +607,10 @@ def _open_node(cams: list[dict]):
def create(key):
name, kind = key.rsplit('_', 1)
cam = by_id[name]
if kind == 'color' and cam.get('preview_transport') == 'compressed':
return self.create_subscription(CompressedImage, f"/{name}/color/preview/compressed",
self._compressed(name, camera_role(cam)),
QoSProfile(depth=1,reliability=ReliabilityPolicy.BEST_EFFORT))
topic = f"/{name}/color/image_raw" if kind == 'color' else f"/{name}/aligned_depth_to_color/image_raw"
callback = self._color(name, camera_role(cam)) if kind == 'color' else self._depth(name)
return self.create_subscription(Image, topic, callback, qos_profile_sensor_data)
@@ -625,7 +638,9 @@ def _open_node(cams: list[dict]):
if not preview and not jpeg:
return False
now = time.monotonic()
# UI preview is independent of perception rate. Avoid copying full-resolution frames we won't render.
# Color keeps camera cadence. The latest-frame mailbox bounds memory and latency.
if key.endswith("_color") and not jpeg:
return True
fps = 10.0 if jpeg else 15.0
if now - self._last_frame.get(key, 0) < 1.0/fps:
return False
@@ -637,6 +652,18 @@ def _open_node(cams: list[dict]):
_remember_detection(role, [float(v) for v in msg.data])
return _cb
def _compressed(self, name: str, role: str):
key = f"{name}_color"
def callback(msg):
if not self._accept(key): return
try:
import cv2
image = cv2.imdecode(np.frombuffer(msg.data,dtype=np.uint8),cv2.IMREAD_COLOR)
if image is not None: offer_color(key,image,bgr=True,role=role)
except Exception as exc:
with _LOCK: _STATE['error'] = str(exc)
return callback
def _color(self, name: str, role: str):
key = f"{name}_color"
@@ -782,6 +809,10 @@ def _ingest() -> None:
shm = _attach(name) if name else None
if shm is None:
continue
# Inspect the sequence before copying a multi-megabyte frame that has not changed.
sequence = _SHM_HEAD.unpack_from(shm.buf, 0)[0]
if sequence % 2 or _SHM_SEQ.get(name) == sequence:
continue
got = _shm_read(shm)
if got is None or _SHM_SEQ.get(name) == got[0]:
continue
@@ -879,6 +910,7 @@ def _stop_bridge() -> None:
pass
with _LOCK:
_STATE["on"] = False
_PREVIEW.clear();_JPEG.clear();_META.clear()
def start() -> str:
@@ -900,26 +932,32 @@ def start() -> str:
_STATE["error"] = ""
_publish_want(payload)
_STOP.clear()
log_f = open(directory / "bridge.log", "ab", buffering=0)
try:
from station.remote_camera import bridge_environment
from arm_application.site_io import load_site_dict
bridge_env=bridge_environment(load_site_dict(),os.environ,directory)
ready=threading.Event()
errors=[]
def launch_and_poll():
global _BRIDGE
try:
_BRIDGE = subprocess.Popen(
[sys.executable, "-m", "station.child_process", str(os.getpid()),
sys.executable, "-m", "station.cam_bridge", str(directory)],
start_new_session=True,
stdin=subprocess.DEVNULL,
stdout=log_f,
stderr=subprocess.STDOUT,
)
with open(directory / "bridge.log", "ab", buffering=0) as log_f:
_BRIDGE = subprocess.Popen(
[sys.executable, "-m", "station.child_process", str(os.getpid()),
sys.executable, "-m", "station.cam_bridge", str(directory)],
env=bridge_env,start_new_session=True,stdin=subprocess.DEVNULL,
stdout=log_f,stderr=subprocess.STDOUT)
except OSError as exc:
with _LOCK:
_STATE["on"] = False
_STATE["error"] = str(exc)
return str(exc)
finally:
log_f.close()
_POLL_THREAD = threading.Thread(target=_poll_loop, daemon=True, name="cam-poll")
errors.append(str(exc))
with _LOCK:_STATE.update(on=False,error=str(exc))
finally:
ready.set()
if not errors:_poll_loop()
# Linux parent-death signals are tied to the spawning thread. Refresh jobs
# are short-lived Qt threads, so this persistent thread must own Popen.
_POLL_THREAD = threading.Thread(target=launch_and_poll, daemon=True, name="cam-poll")
_POLL_THREAD.start()
if not ready.wait(3.):return '相机预览进程启动超时'
if errors:return errors[0]
names = "、".join(cam["label"] for cam in cams)
return "预览已启动;仅订当前页所需彩色/深度,不含红外。"
@@ -136,3 +136,33 @@ def _box(x0, x1, y0, y1, z0, z1, rgb) -> list[tuple]:
out.append((p[a], p[b], p[c], rgb))
out.append((p[a], p[c], p[d], rgb))
return out
# STEP tessellation is produced offline. No CAD kernel is loaded by the Qt app.
from functools import lru_cache
from pathlib import Path
@lru_cache(maxsize=1)
def rgi_cad_gl():
"""Return the original assembled RGI STEP mesh in metres for the GPU."""
import numpy as np
path=Path(__file__).resolve().parent/'assets/models/rgi-100-30/mesh.npz'
with np.load(path,allow_pickle=False) as data:
triangles=data['triangles'];rgb=data['colors']
if triangles.ndim!=3 or triangles.shape[1:]!=(3,3) or rgb.shape!=(len(triangles),3):
raise ValueError('RGI 模型网格格式无效')
if not np.isfinite(triangles).all() or not np.isfinite(rgb).all():
raise ValueError('RGI 模型存在非有限坐标')
# The STEP uses a nearly pure blue display colour; lift it for readable edges.
rgb=.35*rgb+.65*np.array([.78,.81,.86],dtype=np.float32)
normal=np.cross(triangles[:,1]-triangles[:,0],triangles[:,2]-triangles[:,0])
length=np.maximum(np.linalg.norm(normal,axis=1),1e-12)
direction=np.array([.3,-.5,.8]);direction/=np.linalg.norm(direction)
shade=.42+.58*np.abs(normal@direction)/length
positions=triangles.reshape(-1,3)
colors=np.repeat(rgb*shade[:,None],3,axis=0)
center=(positions.min(axis=0)+positions.max(axis=0))*.5
radius=max(float(np.linalg.norm(positions-center,axis=1).max()),1e-4)
packed=np.concatenate([positions,colors],axis=1).astype(np.float32)
return packed.tobytes(),len(positions),tuple(float(x) for x in center),radius
@@ -27,8 +27,12 @@ def present(
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 "")
bits = []
if not row.get("initialized"):
bits.append("零点未标定")
bits.append(_GRIP.get(int(grip_state), "状态未知") if _finite(grip_state) is not None else "状态未知")
if opening is not None:
bits.append(f"开口 {opening:.1f} mm")
if rotary and rotation is not None:
bits.append(f"转角 {rotation:.1f}°")
if _finite(rot_state) is not None:
+30 -4
View File
@@ -6,6 +6,8 @@ import os
import sys
import threading
import time
import tempfile
import uuid
from pathlib import Path
from station.tasks import Cancelled
@@ -17,6 +19,8 @@ class ProcessSupervisor:
self.log_dir.mkdir(parents=True, exist_ok=True)
self._lock = threading.RLock()
self._items = {}
self._records = {}
self._phase_dir = tempfile.TemporaryDirectory(prefix='orisys_process_phase_')
def start(self, key, argv, env=None, cwd=None):
with self._lock:
@@ -24,36 +28,57 @@ class ProcessSupervisor:
if old and old.poll() is None:
raise RuntimeError(f"{key} 已在运行")
log = self.log_dir / (key.replace('/', '_').replace(':', '_') + '.log')
offset = log.stat().st_size if log.exists() else 0
phase_file = str(Path(self._phase_dir.name)/uuid.uuid4().hex)
child_env = dict(os.environ if env is None else env)
child_env['ORISYS_PROCESS_PHASE_FILE'] = phase_file
with log.open('ab') as output:
managed = [sys.executable, "-m", "station.child_process", str(os.getpid()), *argv]
proc = subprocess.Popen(managed, env=env, cwd=cwd, stdout=output, stderr=subprocess.STDOUT,
proc = subprocess.Popen(managed, env=child_env, cwd=cwd, stdout=output, stderr=subprocess.STDOUT,
stdin=subprocess.DEVNULL, start_new_session=True)
self._items[key] = proc
self._records[key] = {"owner": "station", "started": time.time(), "command": list(argv),
"log": str(log), "log_offset": offset, "phase_file":phase_file}
return proc, log
def run(self, key, argv, event, env=None, cwd=None, timeout=None):
def run(self, key, argv, event, env=None, cwd=None, timeout=None, on_phase=None):
if event.is_set():
raise Cancelled("任务已取消")
proc, log = self.start(key, argv, env, cwd)
started = time.monotonic()
last_phase = None
try:
while proc.poll() is None:
phase = self.phase(key)
if on_phase and phase != last_phase: on_phase(phase)
last_phase = phase
if event.wait(.1):
raise Cancelled("已取消工位流程;控制器停止状态仍需确认")
if timeout and time.monotonic() - started > timeout:
raise RuntimeError(f"{key} 执行超时")
code = proc.returncode
with log.open('rb') as stream:
stream.seek(max(0, log.stat().st_size - 6000))
stream.seek(max(self._records[key]["log_offset"], log.stat().st_size - 6000))
detail = stream.read().decode('utf-8', errors='replace')
return {"ok": code == 0, "message": f"{key} 已结束(退出码 {code})", "detail": detail, "log": str(log)}
finally:
if proc.poll() is None:
if on_phase: on_phase('停止中' if event.is_set() else '收尾')
self.stop(key)
def phase(self, key):
try:
phase = Path(self._records[key]['phase_file']).read_text(encoding='utf-8').strip()
return phase if phase in ('执行中','收尾','停止中') else '准备中'
except (KeyError, OSError):
return '准备中'
def snapshot(self):
with self._lock:
return {key: {"pid": proc.pid, "running": proc.poll() is None, "exit_code": proc.poll()}
return {key: {**{k:v for k,v in self._records[key].items() if k not in ("log_offset", "phase_file")},
"pid": proc.pid, "running": proc.poll() is None, "exit_code": proc.poll(),
"state": "running" if proc.poll() is None else "exited",
"phase":self.phase(key) if proc.poll() is None else "已退出"}
for key, proc in self._items.items()}
def stop(self, key):
@@ -67,3 +92,4 @@ class ProcessSupervisor:
def close(self):
for key in self.snapshot():
self.stop(key)
self._phase_dir.cleanup()
+41
View File
@@ -0,0 +1,41 @@
"""Explicit read-only camera preview source; robot/service environment is unchanged."""
from __future__ import annotations
import ipaddress
from pathlib import Path
def settings(site):
cfg=(site or {}).get('remote_camera_preview') or {}
if not cfg.get('enabled'):return None
domain=int(cfg.get('domain',0))
if not 0<=domain<=232:raise ValueError('远端相机 ROS 域必须为 0–232')
peer=str(ipaddress.IPv4Address(cfg['host']))
interface=str(ipaddress.IPv4Address(cfg['interface_address']))
cameras=list(cfg.get('cameras') or [])
if not cameras or any(not isinstance(c,str) or not c.replace('_','').isalnum() for c in cameras):
raise ValueError('远端相机名称无效')
transport=cfg.get('transport','raw')
if transport not in ('raw','compressed'):raise ValueError('远端相机传输方式无效')
return {'domain':domain,'host':peer,'interface_address':interface,'cameras':cameras,'transport':transport}
def bridge_environment(site,parent,directory):
"""Bind preview DDS to the physical NIC, avoiding VPN multicast routing."""
env=dict(parent);cfg=settings(site)
if not cfg:return env
path=Path(directory)/'remote-camera-dds.xml'
path.write_text('''<?xml version="1.0" encoding="UTF-8"?>
<profiles xmlns="http://www.eprosima.com/XMLSchemas/fastRTPS_Profiles">
<transport_descriptors><transport_descriptor><transport_id>remote_camera_udp</transport_id><type>UDPv4</type>
<interfaceWhiteList><address>'''+cfg['interface_address']+'''</address></interfaceWhiteList>
</transport_descriptor></transport_descriptors>
<participant profile_name="remote_camera_readonly" is_default_profile="true"><rtps>
<userTransports><transport_id>remote_camera_udp</transport_id></userTransports><useBuiltinTransports>false</useBuiltinTransports>
<builtin><initialPeersList><locator><udpv4><address>'''+cfg['host']+'''</address></udpv4></locator></initialPeersList></builtin>
</rtps></participant></profiles>
''')
env.update(ROS_DOMAIN_ID=str(cfg['domain']),ROS_LOCALHOST_ONLY='0',
RMW_IMPLEMENTATION='rmw_fastrtps_cpp',FASTRTPS_DEFAULT_PROFILES_FILE=str(path),
FASTDDS_DEFAULT_PROFILES_FILE=str(path))
env.pop('ROS_DISCOVERY_SERVER',None)
return env
+153 -11
View File
@@ -17,7 +17,8 @@ class RosGateway:
import rclpy
from rclpy.executors import MultiThreadedExecutor
from rclpy.node import Node
from sensor_msgs.msg import JointState
from sensor_msgs.msg import JointState, CameraInfo
from flexiv_msgs.msg import RobotStates
from arm_msgs.msg import GripperStatus, ArmStatus
from std_msgs.msg import Float32MultiArray
from arm_application.site_io import listed_arms, arm_short_id
@@ -27,6 +28,7 @@ class RosGateway:
self.executor = MultiThreadedExecutor(num_threads=3)
self.executor.add_node(self.node)
self._lock = threading.RLock()
self._camera_info = {}
self._joints = {}
self._joint_history = {}
self._controller_lock = threading.RLock()
@@ -34,6 +36,10 @@ class RosGateway:
self._last_joint_sample = 0.
self._grippers = {}
self._arm_status = {}
self._robot_clock = {}
self._tcp_poses = {}
self._robot_seen = {}
self._motion_watch = set()
self._clients = {}
self._goals = {}
self._known_goals = set()
@@ -44,10 +50,19 @@ class RosGateway:
from station.subscriptions import SubscriptionSet
self._tactile_images = SubscriptionSet()
self._tactile_on = False
self._compressed_flow = (site.get("tactile") or {}).get("preview_transport") == "compressed_flow"
self._tactile_frames = {}
self._tactile_force = {}
self._force_sequence={side:0 for side in ('left','right')}
self._force_epoch={side:0 for side in ('left','right')}
self._tactile_force_history = {side:deque(maxlen=2100) for side in ("left","right")}
self.arms = listed_arms(site)
self.active_arm = arm_short_id((site.get('robot') or {}).get('sn'))
from rclpy.qos import qos_profile_sensor_data
camera_names={cam.get('name',name) for name,cam in (site.get('cameras') or {}).items() if isinstance(cam,dict)}
for name in camera_names:
self.node.create_subscription(CameraInfo,f'/{name}/color/camera_info',
lambda msg,name=name:self._camera_frame(name,msg),qos_profile_sensor_data)
self.node.create_subscription(JointState, '/joint_states', self._joint, qos_profile_sensor_data)
from action_msgs.msg import GoalStatusArray
from rclpy.qos import QoSProfile, DurabilityPolicy, ReliabilityPolicy
@@ -57,6 +72,8 @@ class RosGateway:
self.node.create_subscription(ArmStatus, '/arm_status', lambda msg: self._arm(self.active_arm, msg), 10)
for arm in self.arms:
aid = arm['id']
self.node.create_subscription(RobotStates, f"/{arm['sn'].replace('-', '_')}/flexiv_robot_states",
lambda msg, a=aid: self._native_state(a, msg), qos_profile_sensor_data)
self.node.create_subscription(GripperStatus, f'/station/grippers/arm_{aid}/status',
lambda msg, a=aid: self._grip(a, msg), 10)
self.node.create_subscription(ArmStatus, f"/{arm['sn'].replace(chr(45), chr(95))}/arm_status", lambda msg, a=aid: self._arm(a, msg), 10)
@@ -64,13 +81,31 @@ class RosGateway:
self.node.create_subscription(GripperStatus, f"/dual_gripper_driver/{arm['side']}/status",
lambda msg, a=aid: self._grip(a, msg, 'ROS 旧驱动(只读)'), 10)
self.node.create_subscription(Float32MultiArray, '/gripper/states', self._legacy_grip, 10)
self.node.create_subscription(GripperStatus, '/dh_pgea/status',
lambda msg: self._grip(self._parallel_arm(), msg), 10)
from arm_msgs.msg import TactileStatus
from geometry_msgs.msg import WrenchStamped
for side in ('left', 'right'):
self.node.create_subscription(WrenchStamped,f'/tactile/{side}/wrench',
lambda msg,side=side:self._tactile_wrench(side,msg),qos_profile_sensor_data)
self.node.create_subscription(TactileStatus, f'/tactile/{side}/status',
lambda msg, side=side: self._tactile(side, msg), 10)
self._thread = threading.Thread(target=self.executor.spin, daemon=True, name='station-ros')
self._thread.start()
def _camera_frame(self,name,msg):
with self._lock:
self._camera_info[name]=(time.monotonic(),int(msg.width),int(msg.height))
def camera_health(self,name):
with self._lock:
entry=self._camera_info.get(name)
return {'fresh':bool(entry and time.monotonic()-entry[0]<2.),
'width':entry[1] if entry else 0,'height':entry[2] if entry else 0}
def clear_camera_health(self,name):
with self._lock:self._camera_info.pop(name,None)
def _goal_status(self, msg):
with self._lock:
self._active_goals={bytes(item.goal_info.goal_id.uuid) for item in msg.status_list if item.status in (1,2,3)}
@@ -95,11 +130,49 @@ class RosGateway:
self._joints[arm['id']] = (now, q)
self._joint_history.setdefault(arm['id'], deque(maxlen=80)).append((now, q))
def _native_state(self, arm, msg):
# The native driver updates this structure only while connected/operational.
# ROS header stamps can keep advancing while the hardware sample is frozen.
now = time.monotonic()
with self._lock:
if now - self._robot_seen.get(arm, 0.) < .05: return
self._robot_seen[arm] = now
stamp = int(msg.robot_timestamp.sec) * 1000000000 + int(msg.robot_timestamp.nanosec)
previous = self._robot_clock.get(arm)
advanced = now if previous and stamp > previous[0] > 0 else (
previous[1] if previous and stamp == previous[0] else 0.)
self._robot_clock[arm] = (stamp, advanced)
from station.operator_arm_state import tcp_display_pose
p, q = msg.tcp_pose.pose.position, msg.tcp_pose.pose.orientation
self._tcp_poses[arm] = tcp_display_pose((p.x,p.y,p.z),(q.x,q.y,q.z,q.w))
def motion_interruption(self, arm):
"""Consume a loss of native operational feedback after a motion starts.
This cancels application producers as well as the hardware abort. A new
Enable does not put the old task back into the worker queue.
"""
with self._lock:
if arm not in self._motion_watch: return None
native = self._robot_clock.get(arm)
status = self._arm_status.get(arm)
fault = status and time.monotonic()-status[0] < 2 and status[1]['fault']
if fault or not native or time.monotonic()-native[1] > .75:
self._motion_watch.discard(arm)
return '机械臂运行反馈中断或故障,已取消旧任务;重新使能后请重新选择动作'
return None
def _arm(self, arm, msg):
with self._lock:
self._arm_status[arm] = (time.monotonic(), {'mode': msg.mode, 'fault': msg.mode == 'error',
'error_message': msg.error_message})
def _parallel_arm(self):
for arm in self.arms:
if not arm.get('rotary'):
return arm['id']
return self.active_arm
def _grip(self, arm, msg, source="ROS 常驻驱动"):
with self._lock:
self._grippers[arm] = (time.monotonic(), {
@@ -119,7 +192,7 @@ class RosGateway:
if current and time.monotonic() - current[0] < 2 and current[1].get('source') != 'ROS 旧驱动(只读)':
return
self._grippers[self.active_arm] = (time.monotonic(), {
'connected': True, 'initialized': init >= .5, 'permille': pos, 'force': force,
'connected': len(msg.data)<6 or msg.data[5]>=.5, 'initialized': init >= .5, 'permille': pos, 'force': force,
'grip_state': int(state), 'moving': int(state) == 0, 'gripped': int(state) == 2,
'source': 'ROS 旧驱动(只读)'})
@@ -128,20 +201,68 @@ class RosGateway:
if msg.connected:
site_catalog._push_force(side, {'t':time.time(), 'fnormal':msg.fnormal,
'fshearx':msg.fshearx, 'fsheary':msg.fsheary})
with self._lock: calibrated=self._tactile_force.get(side)
fresh=bool(msg.connected and calibrated and time.monotonic()-calibrated[0]<.75)
with site_catalog._LOCK:
site_catalog._TACTILE[side].update(connected=bool(msg.connected),fps=float(msg.fps),error=msg.error_msg,
force_n=calibrated[1] if fresh else None)
if not msg.connected:
with self._lock:
self._tactile_frames.pop(side+'_shear',None)
self._tactile_force.pop(side,None)
def _tactile_wrench(self, side, msg):
f=msg.wrench.force
if not all(math.isfinite(v) for v in (f.x,f.y,f.z)):return
with self._lock:
self._tactile_force[side]=(time.monotonic(),dict(fnormal=f.z,fshearx=f.x,fsheary=f.y))
self._force_sequence[side]+=1
self._tactile_force_history[side].append(dict(seq=self._force_sequence[side],t=time.time(),fnormal=f.z,fshearx=f.x,fsheary=f.y))
def tactile_history(self):
cutoff=time.time()-60.
with self._lock:return {side:[r for r in rows if r['t']>=cutoff] for side,rows in self._tactile_force_history.items()}
def tactile_sample_packet(self, after):
with self._lock:
return {'samples':{side:[r for r in list(rows)[-1800:] if r['seq']>int(after.get(side,0))]
for side,rows in self._tactile_force_history.items()},
'epochs':dict(self._force_epoch)}
def clear_tactile_reference(self, side):
from station import site_catalog
with self._lock:
self._force_epoch[side]+=1
self._tactile_force_history[side].clear()
self._tactile_force.pop(side,None);self._tactile_frames.pop(side+'_shear',None)
with site_catalog._LOCK:
site_catalog._HIST[side].clear()
site_catalog._TACTILE[side]['force_n']=None
def watch_tactile(self, on):
from sensor_msgs.msg import Image
from rclpy.qos import qos_profile_sensor_data
self._tactile_on=bool(on)
wanted={f'{side}_{kind}' for side in ('left','right') for kind in ('shear','thermal')} if on else set()
kinds = ('shear',) if self._compressed_flow else ('shear','thermal')
wanted={f'{side}_{kind}' for side in ('left','right') for kind in kinds} if on else set()
def create(key):
side,kind=key.split('_')
if self._compressed_flow:
from sensor_msgs.msg import CompressedImage
return self.node.create_subscription(CompressedImage,f'/tactile/{side}/flow/compressed',
lambda msg,key=key:self._tactile_jpeg(key,msg),qos_profile_sensor_data)
topic=f'/tactile/{side}/'+('vector' if kind=='shear' else 'image')
return self.node.create_subscription(Image,topic,lambda msg,key=key:self._tactile_image(key,msg),qos_profile_sensor_data)
self._tactile_images.sync(wanted,create,self.node.destroy_subscription)
if not on:
with self._lock: self._tactile_frames.clear()
def _tactile_jpeg(self, key, msg):
if not self._tactile_on or msg.format.lower() not in ('jpeg','jpg'): return
blob=bytes(msg.data)
if len(blob)>1024*1024: return
with self._lock: self._tactile_frames[key]=(time.monotonic(),blob)
def _tactile_image(self, key, msg):
if not self._tactile_on: return
from station.image_preview import jpeg_preview
@@ -176,10 +297,20 @@ class RosGateway:
# Joint messages prove position freshness, not enabled/Remote state.
state = self._arm_status.get(aid)
state_live = bool(state and now - state[0] < 2)
row.update(source='ROS /joint_states' if live else '', stack_up=live,
enabled=None, summary='关节状态在线;使能状态未知' if live else '未收到本臂关节状态',
native = self._robot_clock.get(aid)
operational = bool(live and native and native[1] > 0 and now - native[1] < 1.)
fault = state[1]['fault'] if state_live else None
enabled = False if fault else True if operational else None
summary = (state[1]['error_message'] or '机械臂故障') if fault else (
'原生驱动的机器人时间戳持续更新,已确认运行状态' if operational else
'关节状态在线;缺少新鲜原生驱动反馈' if live else '未收到本臂关节状态')
row.update(source='ROS 原生驱动' if operational else 'ROS /joint_states' if live else '', stack_up=live,
enabled=enabled, fault=fault, summary=summary,
enabled_source='原生驱动 operational 状态下更新的机器人时间戳' if operational else '',
level='fail' if fault else 'ok' if operational else 'warn',
mode=state[1]['mode'] if state_live else '', pose_live=live, q=q,
prompt_hot=False, prompt='使能/Remote 状态由控制驱动确认,工位不另开 RDK 连接')
prompt_hot=False, prompt=summary)
row['tcp_pose'] = self._tcp_poses.get(aid) if native and native[1] > 0 and now-native[1] < .75 else None
arms[aid] = row
entry = self._grippers.get(aid)
fresh = bool(entry and now-entry[0] < 2)
@@ -190,12 +321,16 @@ class RosGateway:
def graph(self):
return {'nodes': self.node.get_node_names(),
'node_paths': [(namespace.rstrip('/') + '/' + name) for name, namespace in self.node.get_node_names_and_namespaces()],
'services': [name for name, _ in self.node.get_service_names_and_types()],
'topics': [name for name, _ in self.node.get_topic_names_and_types()]}
def publisher_count(self, topic):
return self.node.count_publishers(topic)
def publisher_nodes(self, topic):
return [info.node_name for info in self.node.get_publishers_info_by_topic(topic)]
def call(self, service_type, name, request, event, timeout=10):
key = (service_type, name)
with self._lock:
@@ -219,12 +354,12 @@ class RosGateway:
time.sleep(.02)
return future.result()
def require_arm(self, arm, event):
def require_arm(self, arm, event, *, resume=True):
with self._controller_lock:
if event.is_set(): raise Cancelled('运动已取消')
return self._require_arm(arm, event)
return self._require_arm(arm, event, resume=resume)
def _require_arm(self, arm, event):
def _require_arm(self, arm, event, *, resume=True):
from controller_manager_msgs.srv import ListControllers
with self._lock:
if self._uncertain_since is not None:
@@ -237,13 +372,20 @@ class RosGateway:
arms, _ = self.states()
if not arms.get(arm, {}).get('pose_live'):
raise RuntimeError('本臂关节状态未就绪,拒绝发送运动命令')
if resume:
with self._lock:
native = self._robot_clock.get(arm)
if native and native[1] > 0:
if time.monotonic()-native[1] > .75:
raise RuntimeError('机械臂运行反馈已中断,拒绝恢复任务')
self._motion_watch.add(arm)
result = self.call(ListControllers, '/controller_manager/list_controllers', ListControllers.Request(), event, 3)
controller = next((c for c in result.controller if c.name == 'rizon_arm_controller'), None)
if controller and controller.state == 'inactive' and arm in self._paused_by_station:
if resume and controller and controller.state == 'inactive' and arm in self._paused_by_station:
self._switch_controller(True, event)
result = self.call(ListControllers, '/controller_manager/list_controllers', ListControllers.Request(), event, 3)
controller = next((c for c in result.controller if c.name == 'rizon_arm_controller'), None)
if controller is None or controller.state != 'active':
if controller is None or controller.state not in (('active',) if resume else ('active', 'inactive')):
raise RuntimeError('rizon_arm_controller 未激活')
selected = next(a for a in self.arms if a['id']==arm)
claimed = {name.split('/')[0] for name in controller.claimed_interfaces}
+40 -19
View File
@@ -11,6 +11,7 @@ import sys
import tempfile
import threading
import time
from collections import deque
from pathlib import Path
from arm_application.site_io import ros2_root
@@ -33,6 +34,8 @@ class RuntimeClient:
self.socket=self.directory/'service.sock'
self._lock=threading.RLock();self._stop=threading.Event()
self._snapshot={};self._frames={};self._catalog={};self.error='后端启动中'
self._force_history={side:deque(maxlen=1800) for side in ('left','right')}
self._force_cursors={};self._force_epochs={}
with (self.directory/'backend.log').open('ab') as output:
args=[sys.executable,'-m','station.control_service','--socket',str(self.socket),'--parent-pid',str(os.getpid())]
if offline: args.append('--offline')
@@ -49,6 +52,7 @@ class RuntimeClient:
if time.monotonic()>deadline: raise RuntimeError(f'工位后端启动超时:{self.directory}/backend.log')
time.sleep(.05)
self._thread=threading.Thread(target=self._poll,daemon=True,name='station-state-client');self._thread.start()
self._frame_thread=threading.Thread(target=self._poll_frames,daemon=True,name='station-tactile-frames');self._frame_thread.start()
except Exception:
self.close();raise
@@ -56,15 +60,37 @@ class RuntimeClient:
while not self._stop.is_set():
try:
snap=rpc.call(self.socket,'snapshot',timeout=2)
# Preview data is a separate bounded channel and never part of a command reply.
raw=rpc.call(self.socket,'tactile_frames',timeout=2) if (snap.get('view') or {}).get('on') else {}
frames={key:base64.b64decode(value,validate=True) for key,value in raw.items()}
with self._lock:
self._snapshot=snap;self._frames=frames;self.error=''
self._snapshot=snap;self.error=''
except Exception as exc:
with self._lock: self.error=f'工位后端连接异常:{exc}'
self._stop.wait(.2)
def _poll_frames(self):
# Frame cadence is independent of the 5 Hz status/catalog channel.
while not self._stop.is_set():
started=time.monotonic()
with self._lock: on=bool((self._snapshot.get('view') or {}).get('on'))
try:
with self._lock: after=dict(self._force_cursors)
packet=rpc.call(self.socket,'tactile_preview',{'after':after},timeout=1) if on else {}
frames={key:base64.b64decode(value,validate=True) for key,value in packet.get('frames',{}).items()}
with self._lock:
self._frames=frames
for side,points in packet.get('samples',{}).items():
epoch=packet.get('epochs',{}).get(side)
if self._force_epochs.get(side)!=epoch:
self._force_history[side].clear();self._force_epochs[side]=epoch
for point in points:
if point['seq']>self._force_cursors.get(side,0):
self._force_history[side].append(point);self._force_cursors[side]=point['seq']
except Exception:
with self._lock:
self._frames={}
for rows in self._force_history.values():rows.clear()
self._force_cursors={}
self._stop.wait(max(.005,1./30.-(time.monotonic()-started)) if on else .2)
def snapshot(self):
with self._lock:
snap=copy.deepcopy(self._snapshot)
@@ -74,6 +100,10 @@ class RuntimeClient:
if error or age>3:
snap.setdefault('backend',{})['error']=error or '后端状态超过 3 秒未更新'
# Never display stale samples as live/connected after a backend failure.
monitoring = snap.get('backend',{}).get('monitoring',{})
for row in monitoring.get('devices',[]): row.update(health='unknown',enabled=None,detail='后端状态过期')
for row in monitoring.get('processes',[]): row.update(state='unknown',running=False)
monitoring['active_tasks'] = []
for row in snap.get('arm_live',{}).values(): row.update(pose_live=False,enabled=None,stack_up=False,summary='状态已过期')
for row in snap.get('gripper_live',{}).values(): row.update(connected=False,pose_live=False,summary='状态已过期')
return snap
@@ -106,6 +136,8 @@ class RuntimeClient:
pass
thread=getattr(self,'_thread',None)
if thread and thread is not threading.current_thread(): thread.join(timeout=3)
frame_thread=getattr(self,'_frame_thread',None)
if frame_thread and frame_thread is not threading.current_thread(): frame_thread.join(timeout=2)
if hasattr(self,'_lease') and not self._lease.closed: self._lease.close()
@@ -149,21 +181,6 @@ def notify_gauge_finished(): return rpc.call(_runtime.socket,'gauge_finished')
def reset_gauge_pass(): return rpc.call(_runtime.socket,'gauge_reset')
def command_lane(action):
if action.startswith('gripper'): return 'grip'
if action.startswith('camera'): return 'camera'
if action.startswith('tactile'): return 'tactile'
if action=='stop_commands': return 'stop'
return 'teach' if action=='handeye_teach' else 'arm'
def command_blocked_message(running,lane):
if lane=='stop': return ''
if lane in running or (lane in ('arm','teach') and running.intersection({'arm','teach'})):
return '设备正在执行上一条任务'
return ''
class TeachClient:
def _arm(self,sn):
from arm_application.site_io import arm_short_id
@@ -177,6 +194,10 @@ class TeachClient:
class TactileClient:
def samples(self,side):
if not _runtime:return []
with _runtime._lock:return list(_runtime._force_history.get(side,[]))
def state(self):
if not _runtime: return {}
with _runtime._lock: return copy.deepcopy(_runtime._snapshot.get("view",{}))
+7 -6
View File
@@ -246,14 +246,15 @@ _ACTIONS = {'real_up': {'title': '上电控制栈', 'moves': True, 'blurb': '连
'camera_d435': {'title': '手外 D435', 'moves': False, 'blurb': '只拉起顶视 D435。彩色和深度显示在程序里,不另开窗口。'},
'tactile_up': {'title': '视触觉三力', 'moves': False, 'blurb': '发布左右指法向力与两路切向力。'},
'gripper_up': {'title': '夹爪节点', 'moves': False, 'blurb': '只拉起夹爪驱动,不主动张合。'},
'gripper_probe': {'title': '夹爪探测', 'moves': False, 'blurb': '读夹爪是否在线,不动手指。驱动没在跑时先拉起,不回零。'},
'gripper_open': {'title': '夹爪张开', 'moves': True, 'blurb': '张开当前臂夹爪。驱动没在跑时先拉起,不回零。'},
'gripper_close': {'title': '夹爪闭合', 'moves': True, 'blurb': '闭合当前臂夹爪。驱动没在跑时先拉起,不回零。'},
'gripper_probe': {'title': '夹爪探测', 'moves': False, 'blurb': '拉起 dh_pgea 并读状态,不动手指、不回零。'},
'gripper_open': {'title': '夹爪全开', 'moves': True, 'blurb': '调用 /gripper/open。不需要机械臂使能,也不需要先到位。驱动没在跑时先拉起 dh_pgea。'},
'gripper_close': {'title': '夹爪全闭', 'moves': True, 'blurb': '调用 /gripper/close。不需要机械臂使能,也不需要先到位。驱动没在跑时先拉起 dh_pgea。'},
'gripper_init': {'title': '夹爪到位', 'moves': True, 'blurb': '到位是回零标定,不是使能。大寰会先张开满行程,夹着工件时不要点。'},
'gripper_rotate': {'title': '右爪转 90°', 'moves': True, 'blurb': '右臂旋转爪转到 90°。左臂平行爪没有这一轴。'},
'pipeline': {'title': '跟踪并对孔', 'moves': True, 'blurb': '跟踪和对孔一次跑完。搜孔轻压已取消,这一步不含旋入。会动臂。'},
'pipeline': {'title': '检测、对孔与旋转', 'moves': True, 'blurb': '依次完成跟踪、对孔下降与 A7 四轮工序;任一步失败中止。会动臂和夹爪。'},
'stage_track': {'title': '只测跟踪', 'moves': True, 'blurb': '只跑视觉跟踪。从当前位姿开始,会动臂。要先有控制栈和相机。'},
'stage_locate': {'title': '只测对孔', 'moves': True, 'blurb': '只跑二阶段:朝下、悬停、拍照、对准孔。会动臂。'},
'stage_screw': {'title': '只测旋转装配', 'moves': True, 'blurb': '按现场配置的拧入深度和螺距把螺纹规旋进去。会动臂。臂应已在孔口。'},
'stage_locate': {'title': '只测对孔', 'moves': True, 'blurb': '新版二阶段:精对孔并下降至配置的进孔高度,不自动旋转。会动臂。'},
'stage_screw': {'title': '只测旋转装配', 'moves': True, 'blurb': 'A7 首轮正反转,再做四轮松爪、微抬、空回、降回与合爪旋转。须先完成对孔。'},
'reset_home': {'title': '复位到 home', 'moves': True, 'blurb': '先停跟踪,再回到命名位姿 home。要控制栈已经起来。会动臂。'},
'gauge_dry': {'title': '装配干跑', 'moves': False, 'blurb': 'S00 到 S11 只打印计划,不发运动指令。'},
'gauge_run': {'title': '装配真跑', 'moves': True, 'blurb': '通止规状态机。会动臂、会拧。'},
+43 -6
View File
@@ -6,6 +6,8 @@ import threading
import time
import uuid
from concurrent.futures import ThreadPoolExecutor
from station.command_policy import conflict_message
from station.operation_log import OperationLog
class Cancelled(RuntimeError):
@@ -13,7 +15,8 @@ class Cancelled(RuntimeError):
class TaskManager:
def __init__(self, workers=8):
def __init__(self, workers=8, log_path=None):
self.events = OperationLog(log_path)
self._lock = threading.RLock()
self._pool = ThreadPoolExecutor(max_workers=workers, thread_name_prefix="station-command")
self._tasks = {}
@@ -21,37 +24,48 @@ class TaskManager:
self._owners = {}
self._closed = False
def submit(self, name, resources, fn):
def submit(self, name, resources, fn, *, context=None):
with self._lock:
if self._closed:
raise RuntimeError("工位后端正在退出")
resources = set(resources)
busy = resources.intersection(self._owners)
if busy:
raise RuntimeError("设备正在执行任务:" + ", ".join(sorted(busy)))
owners = [self._tasks[token] for token in sorted({self._owners[r] for r in busy})]
raise RuntimeError(conflict_message(owners, busy))
token = uuid.uuid4().hex
event = threading.Event()
self._events[token] = event
self._tasks[token] = {"id": token, "name": name, "state": "queued", "created": time.time(), "result": None}
self._tasks[token] = {"id": token, "name": name, "state": "queued", "created": time.time(), "result": None,
"resources": sorted(resources), "context": dict(context or {})}
for resource in resources:
self._owners[resource] = token
self._event(token, "accepted", "已接收,等待执行")
self._pool.submit(self._run, token, resources, event, fn)
return token
def _run(self, token, resources, event, fn):
with self._lock:
self._tasks[token]["state"] = "running"
self._tasks[token].update(state="running",phase="执行中")
self._event(token, "running", "开始执行")
try:
if event.is_set():
raise Cancelled("任务已取消")
result = fn(event)
state = "succeeded" if result.get("ok") else "cancelled" if event.is_set() else "failed"
state = "cancelled" if event.is_set() else "succeeded" if result.get("ok") else "failed"
except Cancelled as exc:
state, result = "cancelled", {"ok": False, "message": str(exc)}
except Exception as exc:
state, result = "failed", {"ok": False, "message": str(exc)}
with self._lock:
if event.is_set():
state = 'cancelled'
result = {**result, 'ok': False, 'message': '任务已取消,不会在重新使能后恢复'}
self._tasks[token].update(state=state, result=result, finished=time.time())
word = {'succeeded':'完成','failed':'失败','cancelled':'已取消'}[state]
message = result.get('message') or result.get('output') or '无附加信息'
if result.get('log'): message = str(message) + ' · 日志:' + str(result['log'])
self._event(token, state, word + ':' + str(message))
for resource in resources:
if self._owners.get(resource) == token:
del self._owners[resource]
@@ -61,16 +75,39 @@ class TaskManager:
self._tasks.pop(key, None)
self._events.pop(key, None)
def _event(self, token, kind, message):
row = self._tasks[token]
self.events.add(kind, message, action=row['name'],
arm=row.get('context', {}).get('arm', ''), task_id=token)
def snapshot(self, token=None):
with self._lock:
return copy.deepcopy(self._tasks.get(token) if token else list(self._tasks.values()))
def set_phase(self, event, phase):
with self._lock:
for token, current in self._events.items():
if current is event and self._tasks[token]['state'] in ('queued','running'):
if self._tasks[token].get('phase') != phase:
self._event(token, 'phase', phase)
self._tasks[token]['phase'] = phase
return
def cancel_resources(self, resources):
with self._lock:
tokens = {self._owners[r] for r in resources if r in self._owners}
for token in tokens: self.cancel(token)
def cancel(self, token=None):
with self._lock:
keys = [token] if token else list(self._events)
for key in keys:
if key in self._events:
self._events[key].set()
if self._tasks[key]["state"] in ("queued", "running"):
if not self._tasks[key].get('cancel_requested'):
self._event(key, 'cancelling', '请求取消,正在停止并清理')
self._tasks[key]["cancel_requested"] = True
def close(self):
with self._lock:
+96
View File
@@ -0,0 +1,96 @@
"""Run new device nodes on a private ROS domain, with every device mocked."""
import threading
import time
from unittest.mock import patch
import cv2
import numpy as np
import rclpy
from rclpy.node import Node
from rclpy.executors import MultiThreadedExecutor
from rclpy.qos import qos_profile_sensor_data
from sensor_msgs.msg import CompressedImage
from geometry_msgs.msg import WrenchStamped
from arm_msgs.msg import TactileStatus
from arm_msgs.srv import GripperExecute
from std_srvs.srv import Trigger
from arm_application.dh_pgea import GripperState
import arm_application.gripper_node as gripper
from arm_application.tactile_flow_node import TactileFlowNode
class FakeGripper:
writes=[];fail_force=False
def __init__(self,**kwargs):self.state=GripperState(init_state=1,grip_state=1,position=300)
def open(self):pass
def close(self):pass
def get_run_states(self,**kwargs):return self.state
def set_target_force(self,v):self.writes.append(('force',v));return not self.fail_force
def set_target_speed(self,v):self.writes.append(('speed',v));return True
def set_target_position(self,v):self.writes.append(('position',v));self.state.position=v;return True
rclpy.init(args=['--ros-args','-r','__ns:=/isolated_devices'])
gripper.DhPgeaGripper=FakeGripper
jaw=gripper.GripperNode()
with patch.object(TactileFlowNode,'_capture',return_value=None):sensor=TactileFlowNode()
client_node=Node('device_test_client');statuses=[];frames=[];forces=[]
client_node.create_subscription(TactileStatus,'/tactile/left/status',statuses.append,10)
client_node.create_subscription(WrenchStamped,'/tactile/left/wrench',forces.append,qos_profile_sensor_data)
client_node.create_subscription(CompressedImage,'/tactile/left/flow/compressed',frames.append,qos_profile_sensor_data)
client=client_node.create_client(GripperExecute,'/isolated_devices/execute')
executor=MultiThreadedExecutor(num_threads=2)
for node in (jaw,sensor,client_node):executor.add_node(node)
thread=threading.Thread(target=executor.spin,daemon=True);thread.start()
try:
assert client.wait_for_service(timeout_sec=3)
def request(command,pos=0.,force=33.,speed=25.):
req=GripperExecute.Request();req.command.command=command;req.command.position=pos;req.command.force=force;req.command.speed=speed
future=client.call_async(req);end=time.monotonic()+3
while not future.done() and time.monotonic()<end:time.sleep(.01)
assert future.done();return future.result()
assert request('connect').success and FakeGripper.writes==[]
assert not request('init').success and FakeGripper.writes==[]
assert not request('move',float('nan')).success and FakeGripper.writes==[]
assert request('move',7.,25.,12.).success
assert FakeGripper.writes==[('force',25),('speed',12),('position',350)]
FakeGripper.writes=[];FakeGripper.fail_force=True
assert not request('move',5.).success
assert FakeGripper.writes==[('force',33)]
ok,encoded=cv2.imencode('.jpg',np.zeros((64,64,3),np.uint8));assert ok
end=time.monotonic()+3
while time.monotonic()<end and not frames:
with sensor._lock:
sensor._sample=(time.monotonic(),dict(fps=30.,fnormal=123.,fshearx=36000.,fsheary=-36000.,contact=True))
sensor._error='';sensor._jpeg=(time.monotonic(),encoded.tobytes())
time.sleep(.02)
assert frames and any(s.connected and s.fnormal==123. for s in statuses)
end=time.monotonic()+2
while not forces and time.monotonic()<end:time.sleep(.01)
assert forces and abs(forces[-1].wrench.force.x-1.19)<1e-6
assert abs(forces[-1].wrench.force.y+1.19)<1e-6
assert forces[-1].wrench.force.z==0.
assert cv2.imdecode(np.frombuffer(frames[-1].data,np.uint8),cv2.IMREAD_COLOR).shape==(64,64,3)
reset_client=client_node.create_client(Trigger,'/tactile/left/reset_reference')
assert reset_client.wait_for_service(timeout_sec=2)
resets=[]
class FakeSensor:
def reset(self):resets.append('reset')
def reset_worker():
if sensor._reference_reset.wait(1.):sensor._apply_reference_reset(FakeSensor())
with sensor._lock:sensor._sample=(time.monotonic(),dict(fps=30.,fnormal=0.,fshearx=0.,fsheary=0.,contact=False))
worker=threading.Thread(target=reset_worker);worker.start()
future=reset_client.call_async(Trigger.Request());end=time.monotonic()+3
while not future.done() and time.monotonic()<end:time.sleep(.01)
worker.join(2)
assert future.done() and future.result().success and resets==['reset']
sensor._reference_reset.set();sensor._abort_reference_reset()
sensor._apply_reference_reset(FakeSensor())
assert resets==['reset'] and not sensor._reference_result[0]
with sensor._lock:sensor._sample=None;sensor._jpeg=None;sensor._error='USB offline'
end=time.monotonic()+2
while time.monotonic()<end and not any(not s.connected and s.error_msg=='USB offline' for s in statuses):time.sleep(.02)
assert any(not s.connected and s.error_msg=='USB offline' for s in statuses)
print('new gripper typed control, no-auto-init, failed-force guard, tactile JPEG and offline status PASS',flush=True)
finally:
executor.shutdown(timeout_sec=3);thread.join(timeout=3)
for node in (jaw,sensor,client_node):node.destroy_node()
rclpy.shutdown()
+127
View File
@@ -12,11 +12,47 @@ assert app.property('darkTheme') is True
assert window._theme_selector.currentData()=='dark'
window._apply_theme('light',persist=False)
assert app.property('darkTheme') is False
# A first status message and its expiry must not resize the dashboard.
app.processEvents()
content_before=window.centralWidget().geometry()
window.statusBar().showMessage('刷新相机 · 状态提示 '*30)
app.processEvents()
assert window.centralWidget().geometry()==content_before
window.statusBar().clearMessage();app.processEvents()
assert window.centralWidget().geometry()==content_before
assert window._theme_selector.currentData()=='light'
assert 'rclpy' not in sys.modules
assert 'serial' not in sys.modules
assert 'station.operator_console' not in sys.modules
assert set(window._pages) == {'main','calibration','settings','ros_monitor'}
# Offline display uses the configured HOME, while real feedback always wins.
assert window._home_poses[window._arm] != [0.]*7
assert window._arm_sketch._pose_key[0] == tuple(round(q,3) for q in window._home_poses[window._arm])
assert not window._arm_sketch._live
from unittest.mock import patch
live_snap=dict(window._snap)
live_snap['arm_live']={window._arm:{'pose_live':True,'q':[.12]*7}}
with patch.object(ui,'status',return_value=live_snap):
window._refresh()
assert window._arm_sketch._pose_key[0]==(.12,)*7
assert window._arm_sketch._live
window._refresh()
# Calibration follows arm/mode and shares the existing camera preview path.
from unittest.mock import patch
window._show_page('calibration')
window._snap['camera_ownership']=[{'id':cam['id'],'owned':True} for cam in ui.camera_view.targets()]
with patch.object(ui.camera_view, 'watch') as watch:
window._calibration_mode.setCurrentIndex(0)
window._sync_camera_watch()
wrist=window._calibration_camera()
assert wrist and wrist['arm']==window._arm
assert wrist['id']+'_color' in watch.call_args.args[0]
window._calibration_mode.setCurrentIndex(1)
outside=window._calibration_camera()
assert outside and not outside.get('arm')
assert outside['id']+'_color' in watch.call_args.args[0]
window._calibration_mode.setCurrentIndex(0)
window._show_page('main')
# A photo must preserve the current RGB frame; no capture/device operation is needed.
import tempfile
from unittest.mock import patch
@@ -34,6 +70,66 @@ with tempfile.TemporaryDirectory() as pictures:
assert selected['id'] in window._camera_windows
window._camera_windows[selected['id']].close()
assert not window._camera_windows
# Device controls only submit explicit actions; changing settings never writes to hardware.
assert not hasattr(window,'_grip_force') and not hasattr(window,'_grip_target')
assert window._gripper_probe_button.text()=='连接 / 探测'
assert window._sensor_refresh_button.text()=='刷新传感器'
assert window._sensor_reset_button.text()=='重置底图'
assert window._sensor_reset_button.size()==window._sensor_refresh_button.size()
assert window._gripper_probe_button.size()==window._sensor_refresh_button.size()
assert window._gripper_probe_button.height()==28
original_arm=window._arm
window._set_arm('91')
assert window._gripper_sketch._cad_static and window._gripper_sketch._gl_count>0
assert window._gripper_sketch._caption()==''
window._set_arm(original_arm)
assert not window._gripper_sketch._cad_static
assert window._gripper_sketch._caption()==''
from PySide6.QtCore import QByteArray,QBuffer,QIODevice
sample_image=ui.QImage(32,32,ui.QImage.Format.Format_RGB888);sample_image.fill(ui.QColor('#1683ff'))
encoded=QByteArray();buffer=QBuffer(encoded);buffer.open(QIODevice.OpenModeFlag.WriteOnly)
assert sample_image.save(buffer,'JPG');buffer.close()
fake_snap=dict(window._snap)
fake_snap['backend']={**fake_snap.get('backend',{}),'error':''}
fake_snap['arm_live']={original_arm:{'tcp_pose':[500.,120.,325.,179.2,.5,88.1]}}
fake_snap['tactile']={'left':{'connected':True,'fps':29.,'fnormal':12000.,'fshearx':20000.,'fsheary':-10000.,
'force_n':{'fnormal':.477,'fshearx':.65,'fsheary':-.31}}}
fake_snap['history_n']={'left':[{'t':time.time()-.1,'fnormal':.477,'fshearx':.65,'fsheary':-.31}]}
with patch.object(ui,'status',return_value=fake_snap),patch.object(ui.runtime.tactile_view,'frame',return_value=bytes(encoded)):
window._refresh();window._paint_images()
assert [v.text() for v in window._tcp_values]==['500.0','120.0','325.0','179.2','0.5','88.1']
assert '(29fps)' in window._flow_slots[0][0].text()
assert '·' not in window._flow_slots[0][0].text()
assert '当前法向力 0.48N' in window._flow_slots[0][0].text()
assert not hasattr(window,'_flow_details')
assert window._flow_axis_charts['left']['x']._samples[-1]['fshearx']==.65
assert window._flow_axis_charts['left']['y']._samples[-1]['fsheary']==-.31
assert window._flow_axis_charts['left']['x']._scale==window._flow_axis_charts['left']['y']._scale==10.
assert window._flow_axis_charts['left']['x'].MAX_SAMPLES==150
app.processEvents()
for triplet in window._flow_triplets.values():
rectangles=[item.geometry() for item in triplet.items]
assert all(rect.width()==rect.height() for rect in rectangles),rectangles
assert len({rect.width() for rect in rectangles})==1,rectangles
assert len({rect.y() for rect in rectangles})==1,rectangles
assert not window._flow_slots[0][1]._source.isNull()
# Recent samples are spread across a shorter window without altering source data.
chart=window._flow_axis_charts['left']['x']
history=[{'seq':i,'fshearx':.2,'fsheary':-.2} for i in range(1800)]
chart.set_samples(history)
assert len(chart._samples)==150 and chart._samples[0]['seq']==1650
assert history[-1]['fshearx']==.2
assert ui._display_force(.299)==ui._display_force(-.299)==0.
assert ui._display_force(.3)==.3 and ui._display_force(-.3)==-.3
assert window._flow_slots[0][0].textFormat()==ui.Qt.TextFormat.PlainText
title=window._flow_slots[0][0]
triplet=window._flow_triplets['left']
assert title.geometry().top()>=triplet.geometry().bottom()
fake_snap['history_n']['left'][-1]['fnormal']=.25
window._refresh();window._paint_images()
assert '当前法向力 0N' in title.text()
errors=[];ticks=[]
duration=float(os.environ.get("ORISYS_GUI_TEST_SECONDS","2.2"))
started=time.monotonic()
@@ -50,6 +146,20 @@ original=sys.excepthook
def fail(*args): errors.append(str(args[1]));original(*args)
sys.excepthook=fail
beat=ui.QTimer();beat.timeout.connect(tick);beat.start(50)
# The GUI must not serialize read-only requests behind a long arm operation.
parallel=[]
def fake_command(action,aid,*args,**kwargs):
parallel.append((action,time.monotonic()))
if action=='stage_track':
time.sleep(.65)
parallel.append(('motion_finished',time.monotonic()))
return {'ok':True,'message':'isolated test'}
ui.run_action=fake_command
ui.stop_started=lambda *args:(parallel.append(('stop',time.monotonic())),{'ok':True,'message':'isolated stop'})[1]
ui.QTimer.singleShot(220,lambda:window._ask('stage_track'))
ui.QTimer.singleShot(260,lambda:window._ask('handeye_show'))
ui.QTimer.singleShot(280,lambda:window._ask('stage_track'))
ui.QTimer.singleShot(300,window._stop_arm)
ui.QTimer.singleShot(200,lambda:window._start(lambda:(time.sleep(.7),{'ok':True,'message':'test'})[1],'test'))
ui.QTimer.singleShot(500,lambda:window._show_page('calibration'))
ui.QTimer.singleShot(1000,lambda:window._show_page('calibration'))
@@ -59,6 +169,23 @@ ui.QTimer.singleShot(round(duration*1000),window.close)
ui.QTimer.singleShot(round((duration+15)*1000),app.quit)
app.exec()
assert len(ticks)>20,len(ticks)
assert [name for name,_ in parallel].count('stage_track')==1,parallel
assert dict(parallel)['handeye_show']<dict(parallel)['motion_finished'],parallel
assert dict(parallel)['stop']<dict(parallel)['motion_finished'],parallel
text = window._detail.toPlainText()
assert '[点击] 检测跟踪' in text,text
assert '[未重复发送]' in text,text
assert 'isolated test' in text,text
# Repeated refreshes cannot duplicate backend lifecycle events.
window._snap['operation_log']={'session':'log-test','events':[
{'seq':1,'time':time.time(),'action':'stage_track','arm':'89','task_id':'123456789','message':'收到操作请求'},
{'seq':2,'time':time.time(),'action':'stage_track','arm':'89','task_id':'123456789','message':'识别阶段'}]}
window._sync_operation_log();window._sync_operation_log()
assert window._detail.toPlainText().count('识别阶段')==1
assert '#12345678' in window._detail.toPlainText()
window._ran({'ok':False,'operation':'对孔测试','message':'执行失败','detail':'测试用内部错误明细','log':'/tmp/isolated-motion.log'})
assert '测试用内部错误明细' in window._detail.toPlainText()
assert '/tmp/isolated-motion.log' in window._detail.toPlainText()
assert not errors,errors
assert runtime._runtime is None
assert not any(job.isRunning() for job in window._jobs.values())
+85 -1
View File
@@ -64,6 +64,7 @@ def main():
node.sync_streams();assert node._streams.items=={}
camera.watch({'left_color'},set());node.sync_streams()
assert set(node._streams.items)=={'left_color'}
assert all(node._accept('left_color') for _ in range(10)), 'color preview must preserve input cadence'
from sensor_msgs.msg import Image
from rclpy.qos import qos_profile_sensor_data
publisher=node.create_publisher(Image,'/left/color/image_raw',qos_profile_sensor_data)
@@ -95,6 +96,26 @@ def main():
node.destroy_node();rclpy.shutdown()
# Real-camera UI preview uses a compressed transport; perception stays raw.
import cv2,numpy as np
from sensor_msgs.msg import CompressedImage
camera.watch({'compressed_test_color'},set())
node=camera._open_node([{'id':'compressed_test','role':'wrist','preview_transport':'compressed'}])
try:
node.sync_streams()
publisher=node.create_publisher(CompressedImage,'/compressed_test/color/preview/compressed',qos_profile_sensor_data)
ok,jpg=cv2.imencode('.jpg',np.full((720,1280,3),(20,80,150),np.uint8));assert ok
sample=CompressedImage();sample.format='jpeg';sample.data=jpg.tobytes()
deadline=time.monotonic()+3
while 'compressed_test_color' not in camera.previews() and time.monotonic()<deadline:
publisher.publish(sample);rclpy.spin_once(node,timeout_sec=.05);camera.publish_latest()
blob,w,h=camera.previews()['compressed_test_color']
assert (w,h)==(1280,720),(w,h)
assert len(blob)==w*h*3
print('ROS compressed preview preserves camera resolution PASS',flush=True)
finally:
camera.watch(set(),set());node.destroy_node();rclpy.shutdown()
# Exercise native Action feedback/cancellation without a robot controller.
from station.ros_gateway import RosGateway
from arm_application.site_io import load_site_dict
@@ -133,6 +154,31 @@ def main():
arm=next(a for a in gateway.arms if a['id']==gateway.active_arm)
msg=JointState();msg.name=[f"{arm['sn']}_joint{i}" for i in range(1,8)];msg.position=[0.]*7
try:
from unittest.mock import patch
from flexiv_msgs.msg import RobotStates
from station.operator_arm_state import tcp_display_pose
import math
assert tcp_display_pose((0.,0.,0.),(0.,0.,0.,0.)) is None
yaw_pose=tcp_display_pose((.1,.2,.3),(0.,0.,math.sqrt(.5),math.sqrt(.5)))
assert yaw_pose[:3]==[100.,200.,300.] and abs(yaw_pose[-1]-90.)<1e-8
native=RobotStates()
native.tcp_pose.pose.position.x=.5
native.tcp_pose.pose.orientation.w=1.
aid=arm['id']
with patch('station.ros_gateway.time.monotonic',return_value=100.):
gateway._joint(msg);native.robot_timestamp.sec=10;gateway._native_state(aid,native)
assert gateway.states()[0][aid]['enabled'] is None
with patch('station.ros_gateway.time.monotonic',return_value=100.1):
gateway._joint(msg);native.robot_timestamp.sec=11;gateway._native_state(aid,native)
assert gateway.states()[0][aid]['enabled'] is True
assert gateway.states()[0][aid]['tcp_pose']==[500.,0.,0.,0.,0.,0.]
with patch('station.ros_gateway.time.monotonic',return_value=102.):
gateway._joint(msg);gateway._native_state(aid,native)
assert gateway.states()[0][aid]['pose_live'] is True
assert gateway.states()[0][aid]['tcp_pose'] is None
assert gateway.states()[0][aid]['enabled'] is None, 'repeated cached hardware sample must not confirm enable'
gateway._robot_clock.clear()
gateway._last_joint_sample=0.
gateway._joint(msg)
assert gateway.move_joints(arm,[.01]*7,.15,threading.Event())['ok']
gateway._joint(msg)
@@ -152,7 +198,12 @@ def main():
assert gateway.confirm_stopped(arm)['ok']
assert controller_state[0]=='inactive'
assert switches[-1]==([],['rizon_arm_controller'])
# Only the next explicit command restores the controller.
# Enable/readiness checks must not resume a stopped trajectory.
switch_count = len(switches)
gateway.require_arm(arm['id'],threading.Event(),resume=False)
assert controller_state[0]=='inactive'
assert len(switches)==switch_count
# Only the next explicit motion command restores the controller.
gateway.require_arm(arm['id'],threading.Event())
assert controller_state[0]=='active'
assert switches[-1]==(['rizon_arm_controller'],[])
@@ -164,12 +215,45 @@ def main():
finally:
joint_stop.set();joint_thread.join()
print('ROS stop deactivation, stationary confirmation, explicit resume and arm ownership PASS',flush=True)
gateway._motion_watch.add(arm['id'])
gateway._robot_clock[arm['id']] = (123, time.monotonic())
assert gateway.motion_interruption(arm['id']) is None
gateway._robot_clock[arm['id']] = (123, time.monotonic()-1.)
assert gateway.motion_interruption(arm['id'])
assert gateway.motion_interruption(arm['id']) is None
# A recovered Enable sample does not recreate the cancelled motion watch/task.
gateway._robot_clock[arm['id']] = (124, time.monotonic())
assert arm['id'] not in gateway._motion_watch
gateway._robot_clock.clear()
print('ROS operational feedback loss cancels old motion once; no automatic resume PASS',flush=True)
from std_msgs.msg import Float32MultiArray
saved_gripper=gateway._grippers.pop(gateway.active_arm,None)
try:
legacy=Float32MultiArray();legacy.data=[1.,1.,300.,300.,33.,0.]
gateway._legacy_grip(legacy)
assert gateway.states()[1][gateway.active_arm]['connected'] is False
finally:
if saved_gripper:gateway._grippers[gateway.active_arm]=saved_gripper
from arm_msgs.msg import TactileStatus
from station import site_catalog
force=TactileStatus();force.connected=True;force.fnormal=1.25;force.fshearx=-.5;force.fsheary=.75
gateway._tactile('left',force)
assert site_catalog._TACTILE['left']['fnormal']==1.25
assert site_catalog._TACTILE['left']['fshearx']==-.5
from geometry_msgs.msg import WrenchStamped
calibrated=WrenchStamped();calibrated.wrench.force.x=.2;calibrated.wrench.force.y=-.3;calibrated.wrench.force.z=.4
gateway._tactile_wrench('left',calibrated);gateway._tactile('left',force)
assert site_catalog._TACTILE['left']['force_n']=={'fnormal':.4,'fshearx':.2,'fsheary':-.3}
assert site_catalog._TACTILE['left']['fnormal']==1.25
assert gateway.tactile_history()['left'][-1]['fshearx']==.2
packet=gateway.tactile_sample_packet({})
assert packet['samples']['left'][-1]['fshearx']==.2
seq=packet['samples']['left'][-1]['seq']
assert gateway.tactile_sample_packet({'left':seq})['samples']['left']==[]
gateway.clear_tactile_reference('left')
assert gateway.tactile_history()['left']==[]
assert gateway.tactile_sample_packet({})['epochs']['left']>packet['epochs']['left']
assert site_catalog._TACTILE['left']['force_n'] is None
gateway.watch_tactile(True)
gateway._tactile_image('left_thermal',message)
assert 'left_thermal' in gateway.tactile_frames()
+24
View File
@@ -0,0 +1,24 @@
from unittest import TestCase
from arm_application.camera_profiles import depth_profile
from arm_application.realsense_color_pub import stream_open_passes, usb2_needs_reset
class CameraProfilesTest(TestCase):
def test_1080p_color_keeps_native_720p_depth_at_same_rate(self):
modes={(1280,720,30),(848,480,30),(640,480,15)}
self.assertEqual(depth_profile((1920,1080,30),modes),(1280,720,30))
self.assertEqual(depth_profile((848,480,30),modes),(848,480,30))
with self.assertRaises(ValueError): depth_profile((1920,1080,60),modes)
def test_usb2_enumeration_requests_one_hardware_reset(self):
self.assertTrue(usb2_needs_reset('2.1'))
self.assertTrue(usb2_needs_reset('2.0'))
self.assertFalse(usb2_needs_reset('3.2'))
self.assertFalse(usb2_needs_reset(''))
def test_depth_is_exhausted_before_color_only_fallback(self):
names = [name for name, _modes in stream_open_passes(True, False, False)]
self.assertEqual(names, ['depth', 'color'])
depth_modes = next(modes for name, modes in stream_open_passes(True, False, True) if name == 'depth')
self.assertEqual(depth_modes, ((True, 'none'),))
self.assertTrue(all(with_depth for with_depth, _infra in depth_modes))
+5 -1
View File
@@ -10,10 +10,14 @@ class ClientStateTest(TestCase):
client=RuntimeClient.__new__(RuntimeClient)
client._lock=threading.RLock();client.error=''
client._snapshot={'now':time.time()-10,'arm_live':{'89':{'enabled':True,'pose_live':True}},
'gripper_live':{'89':{'connected':True,'pose_live':True}}}
'gripper_live':{'89':{'connected':True,'pose_live':True}},
'backend':{'monitoring':{'devices':[{'health':'live','enabled':True}],
'processes':[{'state':'running','running':True}]}}}
with patch('station.rpc.call',side_effect=AssertionError('paint path must not perform RPC')):
snapshot=client.snapshot()
self.assertFalse(snapshot['arm_live']['89']['pose_live'])
self.assertIsNone(snapshot['arm_live']['89']['enabled'])
self.assertFalse(snapshot['gripper_live']['89']['connected'])
self.assertEqual(snapshot['backend']['monitoring']['devices'][0]['health'],'unknown')
self.assertEqual(snapshot['backend']['monitoring']['processes'][0]['state'],'unknown')
self.assertTrue(client._snapshot['gripper_live']['89']['connected'])
+21
View File
@@ -0,0 +1,21 @@
import hashlib
import json
from pathlib import Path
from unittest import TestCase
import numpy as np
from station.operator_gripper_mesh import rgi_cad_gl
class GripperCadTest(TestCase):
def test_tessellation_is_from_supplied_step_and_units_are_metres(self):
folder=Path(__file__).resolve().parents[1]/'src/station/assets/models/rgi-100-30'
meta=json.loads((folder/'mesh.json').read_text())
self.assertEqual(hashlib.sha256((folder/'source.step').read_bytes()).hexdigest(),meta['source_sha256'])
self.assertEqual(meta['source_units'],'mm');self.assertEqual(meta['units'],'m')
self.assertFalse(meta['articulated'])
blob,count,center,radius=rgi_cad_gl()
vertices=np.frombuffer(blob,np.float32).reshape(-1,6)
self.assertEqual(count,meta['triangles']*3);self.assertEqual(len(vertices),count)
self.assertTrue(np.isfinite(vertices).all());self.assertGreater(radius,.01)
self.assertTrue(np.allclose(np.ptp(vertices[:,:3],axis=0)*1000,meta['dimensions_mm'],atol=.001))
self.assertLess(np.max(np.abs(vertices[:,:3])),1.)
+89 -6
View File
@@ -8,27 +8,40 @@ from station.control_service import StationService
class Gateway:
active_arm='89'
def __init__(self): self.started=None;self.requests=[]
def graph(self): return {'nodes':[],'topics':[],'services':['/station/grippers/arm_89/execute']}
def graph(self):
return {'nodes':['dh_pgea'],'topics':['/gripper/states'],
'services':['/gripper/open','/gripper/close','/gripper/initialize','/dh_pgea/execute']}
def publisher_count(self, topic): return 1 if topic=='/gripper/states' else 0
def states(self):
elapsed=time.monotonic()-self.started if self.started else 0
return {},{'89':{'connected':True,'moving':bool(self.started and elapsed<.25),
'grip_state':0 if self.started and elapsed<.25 else 1,'permille':500.}}
moving=bool(self.started and elapsed<getattr(self,'motion_duration',.05))
last=self.requests[-1][0] if self.requests else ''
if last=='/gripper/close':
permille=0. if not moving else 400.
elif last=='/gripper/open':
permille=1000. if not moving else 400.
else:
permille=500.
return {},{'89':{'connected':True,'moving':moving,
'grip_state':0 if moving else 1,'permille':permille,
'gripped':False,'opening_mm':permille/1000.*20}}
def call(self,typ,name,request,event,timeout):
self.requests.append(request);self.started=time.monotonic()
self.requests.append((name,request));self.started=time.monotonic()
return type('Reply',(),{'success':True,'message':'accepted'})()
def close(self): pass
class GripperCommandsTest(TestCase):
def test_waits_for_physical_completion_and_rejects_bounds(self):
gateway=Gateway()
gateway=Gateway();gateway.motion_duration=.25
with tempfile.TemporaryDirectory() as root:
service=StationService(root=root,gateway=gateway)
try:
arm=service.arms['89'];start=time.monotonic()
result=service._gripper('gripper_tune',arm,{'kind':'pos','pos':500,'force':33,'speed':25},threading.Event())
self.assertTrue(result['ok']);self.assertGreater(time.monotonic()-start,.2)
self.assertEqual(gateway.requests[0].command.position,arm['stroke_mm']/2)
self.assertEqual(gateway.requests[0][0],'/dh_pgea/execute')
self.assertEqual(gateway.requests[0][1].command.position,arm['stroke_mm']/2)
count=len(gateway.requests)
with self.assertRaises(ValueError):
service._gripper('gripper_tune',arm,{'kind':'pos','pos':500,'force':999,'speed':25},threading.Event())
@@ -49,3 +62,73 @@ class GripperCommandsTest(TestCase):
self.assertEqual(gateway.requests,[])
self.assertTrue(service._frozen_config.is_file())
finally: service.close()
def test_open_close_do_not_require_initialized(self):
gateway=Gateway()
original=gateway.states
def states():
arms,grips=original()
row=dict(grips['89']);row['initialized']=False
return arms,{'89':row}
gateway.states=states
with tempfile.TemporaryDirectory() as root:
service=StationService(root=root,gateway=gateway)
try:
closed=service._gripper('gripper_close',service.arms['89'],{},threading.Event())
self.assertTrue(closed['ok'])
self.assertEqual(gateway.requests[-1][0],'/gripper/close')
opened=service._gripper('gripper_open',service.arms['89'],{},threading.Event())
self.assertTrue(opened['ok'])
self.assertEqual(gateway.requests[-1][0],'/gripper/open')
finally: service.close()
def test_need_init_summary_does_not_block_open_close(self):
from station.operator_gripper_state import present
row=present('89',rotary=False,stroke_mm=20.0,
reading={'connected':True,'initialized':False,'permille':981,
'grip_state':1,'source':'ROS'})
self.assertIn('零点未标定', row['summary'])
self.assertIn('开口 19.6 mm', row['summary'])
self.assertNotIn('不会动', row['summary'])
self.assertNotIn('请先点到位', row['summary'])
def test_open_close_init_call_dh_pgea_triggers_and_close_must_reach_zero(self):
gateway=Gateway()
with tempfile.TemporaryDirectory() as root:
service=StationService(root=root,gateway=gateway)
try:
arm=service.arms['89']
closed=service._gripper('gripper_close',arm,{},threading.Event())
self.assertTrue(closed['ok'])
self.assertEqual(gateway.requests[-1][0],'/gripper/close')
opened=service._gripper('gripper_open',arm,{},threading.Event())
self.assertTrue(opened['ok'])
self.assertEqual(gateway.requests[-1][0],'/gripper/open')
inited=service._gripper('gripper_init',arm,{},threading.Event())
self.assertTrue(inited['ok'])
self.assertEqual(gateway.requests[-1][0],'/gripper/initialize')
finally: service.close()
def test_close_fails_when_firmware_says_arrived_but_opening_remains(self):
gateway=Gateway()
def states():
return {},{'89':{'connected':True,'moving':False,'grip_state':1,'permille':130.,
'gripped':False,'opening_mm':2.6}}
gateway.states=states
with tempfile.TemporaryDirectory() as root:
service=StationService(root=root,gateway=gateway)
try:
result=service._gripper('gripper_close',service.arms['89'],{},threading.Event())
self.assertFalse(result['ok'])
self.assertIn('未闭合到位', result['message'])
self.assertIn('2.6', result['message'])
finally: service.close()
def test_gripper_plan_starts_dh_pgea_without_auto_init(self):
from station.launch_plans import gripper_plan
argv=gripper_plan({'id':'89','rotary':False,'port':'/dev/ttyUSB0','stroke_mm':20.0,'gripper':'PGEA-100-20-W'},
{'baud':115200,'force_pct':20,'speed_pct':50})
joined=' '.join(str(part) for part in argv)
self.assertIn('arm_application.dh_pgea_node', joined)
self.assertIn('auto_init:=false', joined)
self.assertNotIn('gripper_node', joined)
+34
View File
@@ -0,0 +1,34 @@
"""Only commands writing the same device conflict; all functions are fake."""
import threading
import time
from unittest import TestCase
from station.command_policy import resources_for
from station.tasks import TaskManager
class ModuleIndependenceTest(TestCase):
def test_four_modules_execute_concurrently(self):
manager=TaskManager();release=threading.Event();started={}
def operation(name,event):
started[name].set();release.wait(2)
return {'ok':True}
try:
tokens=[]
for name in ('real_up','camera_refresh','gripper_open','tactile_refresh'):
started[name]=threading.Event()
tokens.append(manager.submit(name,resources_for(name,'89'),lambda event,name=name:operation(name,event)))
self.assertTrue(all(flag.wait(1) for flag in started.values()))
self.assertTrue(all(manager.snapshot(t)['state']=='running' for t in tokens))
with self.assertRaises(RuntimeError):
manager.submit('gripper_close',resources_for('gripper_close','89'),lambda event:{'ok':True})
finally:
release.set();manager.close()
def test_only_composite_a7_workflow_also_owns_gripper(self):
grip=resources_for('gripper_open','89')
for action in ('real_up','reset_home','teach_goto','stage_track','stage_locate'):
self.assertFalse(resources_for(action,'89') & grip,action)
for action in ('stage_screw','pipeline'):
self.assertTrue(resources_for(action,'89') & grip,action)
self.assertFalse(resources_for(action,'89') & resources_for('tactile_reset','89'),action)
self.assertFalse(resources_for(action,'89') & resources_for('camera_refresh','89'),action)
+179 -5
View File
@@ -11,7 +11,7 @@ class Gateway:
def __init__(self): self.calls=[];self.fail_stop=False
def graph(self): return {'nodes': [], 'topics': [], 'services': []}
def states(self): return {}, {}
def require_arm(self, aid, event): self.calls.append(('require',aid))
def require_arm(self, aid, event, *, resume=True): self.calls.append(('require',aid) if resume else ('check_only',aid))
def move_joints(self, arm, joints, speed, event):
self.calls.append(('home',arm['id'],list(joints),speed));return {'ok':True}
def stop_arm(self, arm):
@@ -25,6 +25,7 @@ class Gateway:
class MotionCommandsTest(TestCase):
def setUp(self):
self.gateway=Gateway();self.service=StationService(gateway=self.gateway)
self.service.site.pop('remote_camera_preview',None)
def tearDown(self): self.service.close()
def test_camera_refresh_checks_both_cameras_without_robot_control(self):
with patch.object(self.service,'_camera',side_effect=[RuntimeError('D405 missing'),{'ok':True,'message':'ready'}]) as camera:
@@ -38,19 +39,51 @@ class MotionCommandsTest(TestCase):
with patch.object(self.gateway,'graph',return_value={'nodes':['controller_manager'],'topics':[],'services':[]}),patch.object(self.service.processes,'start') as start:
result=self.service.execute('real_up','89','eye_in_hand',{},threading.Event())
self.assertTrue(result['ok']);start.assert_not_called()
self.assertEqual(self.gateway.calls,[('require','89')])
self.assertEqual(self.gateway.calls,[('check_only','89')])
self.assertIsNone(self.service._motion_arm)
def test_detection_and_alignment_are_separate_stages(self):
commands=[]
def run(key, argv, event, env, cwd):
def run(key, argv, event, env, cwd, **kwargs):
commands.append(argv);return {'ok':True}
with patch.object(self.service,'_camera'),patch.object(self.service,'_environment',return_value={}),patch.object(self.service.processes,'run',side_effect=run):
self.service.execute('stage_track','89','eye_in_hand',{},threading.Event())
self.service.execute('stage_locate','89','eye_in_hand',{},threading.Event())
self.assertIn('skip_stage1:=false',commands[0]);self.assertIn('skip_stage2:=true',commands[0])
self.assertIn('skip_stage3:=true',commands[0]);self.assertIn('d435_move_only:=false',commands[0])
self.assertIn('run_a7_after_insert:=false',commands[0]);self.assertIn('completion_linger_sec:=0.3',commands[0]);self.assertIn('skip_stage3:=true',commands[0]);self.assertIn('d435_move_only:=false',commands[0])
self.assertIn('skip_stage1:=true',commands[1]);self.assertIn('skip_stage2:=false',commands[1])
self.assertIn('no_descent:=true',commands[1]);self.assertIn('skip_stage3:=true',commands[1])
self.assertIn('run_a7_after_insert:=false',commands[1]);self.assertIn('no_descent:=false',commands[1]);self.assertIn('skip_stage3:=true',commands[1])
def test_rotation_is_a7_only_and_does_not_open_cameras(self):
with patch.object(self.service,'_rotation_ready'),patch.object(self.service,'_camera') as camera, \
patch.object(self.service,'_environment',return_value={}),patch.object(self.service.processes,'run',return_value={'ok':True}) as run:
self.assertTrue(self.service.execute('stage_screw','89','eye_in_hand',{},threading.Event())['ok'])
self.assertEqual(run.call_args.args[1][-2:],['-m','arm_application.a7_rotation_node'])
camera.assert_not_called()
def test_full_flow_runs_rotation_only_after_alignment_success(self):
from station.tasks import Cancelled
for first_ok,cancelled,expected_runs in ((True,False,2),(False,False,1),(True,True,1)):
event=threading.Event();commands=[]
def run(key,argv,*args,**kwargs):
commands.append(argv)
if cancelled:event.set()
return {'ok':first_ok}
with patch.object(self.service,'_rotation_ready'),patch.object(self.service,'_camera'), \
patch.object(self.service,'_environment',return_value={}),patch.object(self.service.processes,'run',side_effect=run):
if cancelled:
with self.assertRaises(Cancelled):self.service.execute('pipeline','89','eye_in_hand',{},event)
else:self.service.execute('pipeline','89','eye_in_hand',{},event)
self.assertEqual(len(commands),expected_runs)
self.assertIn('no_descent:=false',commands[0])
self.assertIn('run_a7_after_insert:=false',commands[0])
if expected_runs==2:self.assertEqual(commands[1][-1],'arm_application.a7_rotation_node')
def test_rotation_requires_own_typed_gripper_before_any_task(self):
with patch.object(self.service.processes,'run') as run:
with self.assertRaisesRegex(RuntimeError,'平行夹爪'):
self.service.execute('stage_screw','89','eye_in_hand',{},threading.Event())
run.assert_not_called()
def test_home_uses_each_arms_named_initial_pose(self):
import yaml
for aid,arm in self.service.arms.items():
@@ -72,3 +105,144 @@ class MotionCommandsTest(TestCase):
self.assertTrue(self.service.stop_commands('89')['ok']);self.assertEqual(self.service._stop_error,'')
def test_shutdown_cancel_does_not_stop_unrelated_controller(self):
self.assertTrue(self.service.stop_commands()['ok']);self.assertEqual(self.gateway.calls,[])
def test_external_camera_is_not_adopted_or_started_again(self):
env={'SITE_CAM_SERIAL':'test-serial','SITE_CAM_NAME':'d405_left'}
with patch.object(self.service,'_environment',return_value=env), \
patch.object(self.gateway,'publisher_count',return_value=1,create=True), \
patch.object(self.gateway,'publisher_nodes',return_value=['foreign_camera'],create=True), \
patch.object(self.service.processes,'start') as start:
with self.assertRaisesRegex(RuntimeError,'外部'):
self.service._camera(self.service.arms['89'],False,threading.Event())
start.assert_not_called()
def test_camera_process_without_frames_is_not_ready(self):
with patch.object(self.service,'_camera_ownership',return_value={'owned':True,'conflict':False}), \
patch.object(self.gateway,'camera_health',return_value={'fresh':False},create=True):
with self.assertRaisesRegex(RuntimeError,'没有新帧'):
self.service._ensure_camera('d405_left',[],{},threading.Event())
def test_camera_refresh_preserves_healthy_matching_stream(self):
with patch.object(self.service,'_camera_ownership',return_value={'owned':True,'conflict':False}), \
patch.object(self.gateway,'camera_health',return_value={'fresh':True,'width':1280,'height':720},create=True), \
patch.object(self.gateway,'publisher_count',return_value=1,create=True), \
patch.object(self.service.processes,'stop') as stop:
result=self.service._ensure_camera('d405_left',['-p','width:=1280','-p','height:=720'],{},threading.Event(),refresh=True)
self.assertTrue(result['ok']);stop.assert_not_called()
def test_camera_refresh_keeps_usb2_fallback_resolution(self):
with patch.object(self.service,'_camera_ownership',return_value={'owned':True,'conflict':False}), \
patch.object(self.gateway,'camera_health',return_value={'fresh':True,'width':640,'height':480},create=True), \
patch.object(self.gateway,'publisher_count',return_value=1,create=True), \
patch.object(self.service.processes,'stop') as stop:
result=self.service._ensure_camera('d405_left',['-p','width:=1280','-p','height:=720'],{},threading.Event(),refresh=True)
self.assertTrue(result['ok']);stop.assert_not_called()
def test_camera_refresh_reconnects_only_camera_even_while_arm_is_enabling(self):
from unittest.mock import Mock
self.service._motion_arm='89'
active=[True]
def snapshot():return {'camera:d405_left':{'running':active[0]}}
def stop(key):
self.assertEqual(key,'camera:d405_left');active[0]=False
def start(*args):active[0]=True;return Mock(),Path('/tmp/fake-camera.log')
with patch.object(self.service,'_camera_ownership',side_effect=[
{'owned':True,'conflict':False}, {'owned':False,'conflict':False},
{'owned':True,'conflict':False}]), \
patch.object(self.gateway,'camera_health',side_effect=[{'fresh':False},{'fresh':True}],create=True), \
patch.object(self.gateway,'clear_camera_health',create=True), \
patch.object(self.gateway,'publisher_count',return_value=0,create=True), \
patch.object(self.service.processes,'snapshot',side_effect=snapshot), \
patch.object(self.service.processes,'stop',side_effect=stop), \
patch.object(self.service.processes,'start',side_effect=start):
self.assertTrue(self.service._ensure_camera('d405_left',[],{},threading.Event(),refresh=True)['ok'])
self.assertEqual(self.gateway.calls,[])
def test_same_name_does_not_prove_camera_ownership(self):
name='d405_left'
with patch.object(self.gateway,'publisher_nodes',return_value=[self.service._camera_node_name(name)],create=True):
self.assertFalse(self.service._camera_ownership(name,{})['owned'])
self.assertTrue(self.service._camera_ownership(name,{'camera:'+name:{'running':True}})['owned'])
with patch.object(self.gateway,'publisher_nodes',return_value=[self.service._camera_node_name(name),'foreign_camera'],create=True):
self.assertFalse(self.service._camera_ownership(name,{'camera:'+name:{'running':True}})['owned'])
def test_independent_commands_can_run_during_tracking(self):
import time
gate=threading.Event()
def execute(action,arm,mode,extras,event):
if action=='stage_track': gate.wait(2)
return {'ok':True}
with patch.object(self.service,'execute',side_effect=execute):
motion=self.service.submit('stage_track','89')
try:
with self.assertRaisesRegex(RuntimeError,'检测跟踪'):
self.service.submit('reset_home','89')
with self.assertRaisesRegex(RuntimeError,'检测跟踪'):
self.service.submit('reset_home','91')
tokens=[self.service.submit('gripper_open','89'),self.service.submit('tactile_reset','89'),
self.service.submit('camera_refresh','89'),
self.service.submit('handeye_show','89'),
self.service.submit('gripper_open','91')]
deadline=time.monotonic()+1
while time.monotonic()<deadline and any(self.service.tasks.snapshot(t)['state'] in ('queued','running') for t in tokens): time.sleep(.01)
self.assertTrue(all(self.service.tasks.snapshot(t)['state']=='succeeded' for t in tokens))
self.assertIn(self.service.tasks.snapshot(motion)['state'],('queued','running'))
finally: gate.set()
def test_feedback_interruption_cancels_task_before_stop_and_cleans_up(self):
started=threading.Event(); release=threading.Event()
def operation(event):
started.set(); release.wait(2); return {'ok':True}
token=self.service.tasks.submit('stage_track',{'robot-control'},operation,context={'arm':'89'})
self.assertTrue(started.wait(1))
def stop(arm):
self.assertTrue(self.service.tasks.snapshot(token)['cancel_requested'])
release.set(); return {'ok':True}
with patch.object(self.gateway,'motion_interruption',return_value='feedback lost',create=True),patch.object(self.service,'stop_commands',side_effect=stop) as stopped:
self.service._check_motion_interruption(); stopped.assert_called_once_with('89')
self.assertEqual(self.service.error,'')
self.assertEqual(self.service._arm_errors['89'],'feedback lost')
import time
deadline=time.monotonic()+2
while self.service.tasks.snapshot(token)['state']=='running' and time.monotonic()<deadline: time.sleep(.01)
self.assertEqual(self.service.tasks.snapshot(token)['state'],'cancelled')
def test_rejected_command_is_logged_before_returning_error(self):
with self.assertRaisesRegex(ValueError,'未注册'):
self.service.submit('invalid_action','89')
events=self.service.snapshot()['operation_log']['events']
own=[r for r in events if r['action']=='invalid_action']
self.assertEqual([r['kind'] for r in own],['received','rejected'])
self.assertIn('未注册',own[-1]['message'])
def test_sensor_refresh_restarts_only_owned_sensor_nodes(self):
from unittest.mock import Mock
records={'tactile:left':{'running':True},'tactile:right':{'running':True},
'stack:89':{'running':True},'gripper:89':{'running':True}}
with patch.object(self.service.processes,'snapshot',return_value=records), \
patch.object(self.service.processes,'stop') as stop, \
patch.object(self.service.processes,'start') as start, \
patch.object(self.service,'_environment',return_value={}), \
patch.object(self.gateway,'publisher_nodes',return_value=[],create=True), \
patch.object(self.gateway,'watch_tactile',create=True):
result=self.service.execute('tactile_refresh','89','eye_in_hand',{},threading.Event())
self.assertTrue(result['ok'])
self.assertEqual([c.args[0] for c in stop.call_args_list],['tactile:left','tactile:right'])
self.assertEqual(start.call_count,2)
with patch.object(self.service,'execute',return_value={'ok':True}):
token=self.service.submit('tactile_refresh','89')
self.assertIsNotNone(self.service.tasks.snapshot(token))
def test_reference_reset_calls_only_owned_sensor_service_without_restarting(self):
reply=type('Reply',(),{'success':True,'message':'reference reset'})()
with patch.object(self.service.processes,'snapshot',return_value={'tactile:left':{'running':True}}), \
patch.object(self.service.processes,'stop') as stop, \
patch.object(self.service.processes,'start') as start, \
patch.object(self.gateway,'call',return_value=reply,create=True) as call, \
patch.object(self.gateway,'clear_tactile_reference',create=True) as clear:
result=self.service.execute('tactile_reset','89','eye_in_hand',{},threading.Event())
self.assertTrue(result['ok']);self.assertIn('right',result['message'])
self.assertEqual(call.call_args.args[1],'/tactile/left/reset_reference')
clear.assert_called_once_with('left');start.assert_not_called();stop.assert_not_called()
from station.command_policy import resources_for
for action in ('stage_track','stage_locate','stage_screw','pipeline'):
self.assertFalse(resources_for('tactile_reset','89') & resources_for(action,'89'))
+18
View File
@@ -0,0 +1,18 @@
from unittest import TestCase
from station.node_inventory import inventory, node_group
class NodeInventoryTest(TestCase):
def test_namespaces_duplicates_and_discovery_do_not_imply_ownership_or_health(self):
result = inventory({'node_paths':['/left/servo_node','/right/servo_node','/left/servo_node','/_ros2cli_1']},
{'motion:89':{'pid':123,'running':True,'owner':'station'}}, [],
{'89':{'pose_live':False,'enabled':None}}, {})
left = next(n for n in result['nodes'] if n['name']=='/left/servo_node')
self.assertEqual(left['instances'],2)
self.assertEqual(left['owner'],'unverified')
self.assertEqual(result['duplicates'],['/left/servo_node'])
self.assertEqual(result['devices'][0]['health'],'offline')
self.assertEqual(result['processes'][0]['owner'],'station')
self.assertEqual(node_group('/right/rizon_arm_controller'),'运动控制')
self.assertEqual(node_group('/perception_node'),'算法任务')
self.assertEqual(node_group('/realsense_color_pub'),'设备驱动')
+35
View File
@@ -0,0 +1,35 @@
import json
import tempfile
import threading
import time
from pathlib import Path
from unittest import TestCase
from station.operation_log import OperationLog
from station.tasks import TaskManager
class OperationLogTest(TestCase):
def test_memory_bounded_but_disk_retains_every_event(self):
with tempfile.TemporaryDirectory() as folder:
log = OperationLog(Path(folder)/'events.jsonl', limit=2)
for i in range(5): log.add('received', str(i), action='stage_track', arm='89')
snap=log.snapshot()
self.assertEqual([r['seq'] for r in snap['events']], [4,5])
rows=[json.loads(line) for line in Path(snap['path']).read_text().splitlines()]
self.assertEqual([r['seq'] for r in rows],list(range(1,6)))
def test_fast_task_keeps_all_transitions_between_gui_polls(self):
manager=TaskManager()
try:
def execute(event):
manager.set_phase(event,'识别目标')
manager.set_phase(event,'识别目标')
return {'ok':True,'message':'执行完成'}
token=manager.submit('stage_track', {'robot-control'},execute,context={'arm':'89'})
deadline=time.monotonic()+2
while manager.snapshot(token)['state'] in ('queued','running') and time.monotonic()<deadline:
time.sleep(.01)
events=manager.events.snapshot()['events']
self.assertEqual([r['kind'] for r in events],['accepted','running','phase','succeeded'])
self.assertTrue(all(r['task_id']==token and r['arm']=='89' for r in events))
finally: manager.close()
@@ -0,0 +1,47 @@
"""Compile/run the actual hardware guard without ROS or a robot connection."""
import subprocess
import tempfile
from pathlib import Path
from unittest import TestCase
class PositionCommandFenceTest(TestCase):
def test_reject_old_targets_after_enable_and_native_movel(self):
root = Path(__file__).resolve().parents[2]
include = root / 'src/flexiv_ros2/flexiv_hardware/include'
code = r"""
#include <cassert>
#include <limits>
#include "flexiv_hardware/position_command_fence.hpp"
int main() {
flexiv_hardware::MotionExecutionLatch latch;
assert(!latch.observe(true, true, false)); // not activated
latch.activate(); assert(latch.observe(true, true, false));
assert(!latch.observe(true, false, false)); // physical E-stop
for (int i=0; i<2000; ++i) assert(!latch.observe(true, true, false)); // Enable cannot resume
latch.activate(); assert(latch.observe(true, true, false));
assert(!latch.observe(true, true, true)); // fault
assert(!latch.observe(true, true, false)); // clear fault cannot resume
latch.activate(); assert(!latch.observe(false, true, false)); // disconnect
assert(!latch.observe(true, true, false));
flexiv_hardware::PositionCommandFence fence;
std::vector<double> current(7, .74), old(7, 0.);
for (int i=0; i<2000; ++i) assert(!fence.accept(old, current));
assert(!fence.accept({}, current));
auto invalid = current; invalid[0] = std::numeric_limits<double>::quiet_NaN();
assert(!fence.accept(invalid, current));
assert(fence.accept(current, current));
assert(fence.accept(old, current)); // normal trajectory after handshake
fence.reset(); // native MoveL / controller activation
for (int i=0; i<2000; ++i) assert(!fence.accept(old, current));
auto start = current; start[0] += .0001;
assert(fence.accept(start, current));
assert(fence.accept(old, current));
}
"""
with tempfile.TemporaryDirectory() as folder:
source = Path(folder)/'fence.cpp'; binary = Path(folder)/'fence'
source.write_text(code)
built = subprocess.run(['g++','-std=c++17','-I',str(include),str(source),'-o',str(binary)],capture_output=True,text=True,timeout=30)
self.assertEqual(built.returncode,0,built.stderr)
result = subprocess.run([str(binary)],capture_output=True,text=True,timeout=5)
self.assertEqual(result.returncode,0,result.stderr)
+46
View File
@@ -25,3 +25,49 @@ class PreviewCacheTest(TestCase):
preview._ingest()
self.assertEqual(preview.previews(),{})
self.assertIn('未更新',preview.state()['error'])
def test_unchanged_shared_frame_is_not_copied_again(self):
from types import SimpleNamespace
preview.watch({'left_color'},set())
header=bytearray(preview._SHM_HEAD.size)
preview._SHM_HEAD.pack_into(header,0,2,1280,720,1280*720*3)
with tempfile.TemporaryDirectory() as folder:
payload={'frames':{'left_color':{'name':'owned_test','store':'left_color','kind':'rgb'}},
'meta':{},'ages':{'left_color':0},'exported_monotonic':time.monotonic()}
(Path(folder)/'meta.json').write_text(json.dumps(payload))
with patch.object(preview,'_BRIDGE_DIR',Path(folder)), \
patch.object(preview,'_SHM_SEQ',{'owned_test':2}), \
patch.object(preview,'_attach',return_value=SimpleNamespace(buf=header)), \
patch.object(preview,'_shm_read',side_effect=AssertionError('unchanged frame copied')):
preview._ingest()
def test_full_hd_is_preserved_and_larger_frames_fit_transport(self):
import numpy as np
image=np.zeros((1080,1920,3),np.uint8)
self.assertEqual(preview.rgb_preview(image,bgr=False).shape,image.shape)
portrait=preview.rgb_preview(np.zeros((1280,720,3),np.uint8),bgr=False)
self.assertLessEqual(portrait.shape[0],1080)
self.assertLessEqual(portrait.nbytes,preview._SHM_MAX)
def test_refresh_worker_exit_does_not_kill_preview(self):
import os,sys,subprocess,threading
real_popen=subprocess.Popen
spawning=[]
def popen(argv,**kwargs):
spawning.append(threading.current_thread().name)
command=[sys.executable,'-m','station.child_process',str(os.getpid()),
sys.executable,'-c','import time;time.sleep(20)']
proc=real_popen(command,**kwargs)
time.sleep(.2) # Allow the child to install its parent-death watch.
return proc
with patch.object(preview,'stream_targets',return_value=[{'id':'fake','label':'fake'}]), \
patch.object(preview.subprocess,'Popen',side_effect=popen), \
patch.object(preview,'_poll_loop',side_effect=lambda:preview._STOP.wait(5)):
worker=threading.Thread(target=preview.start,name='short-refresh-worker')
worker.start();worker.join(3)
try:
self.assertFalse(worker.is_alive())
self.assertEqual(spawning,['cam-poll'])
time.sleep(.3)
self.assertIsNone(preview._BRIDGE.poll())
finally:preview.stop()
+40
View File
@@ -19,3 +19,43 @@ class ProcessesTest(TestCase):
processes.run('long',[sys.executable,'-c','import time;time.sleep(30)'],event)
self.assertFalse(processes.snapshot()['long']['running'])
finally: processes.close()
def test_each_run_has_its_own_log_slice_and_explicit_owner(self):
with tempfile.TemporaryDirectory() as directory:
processes=ProcessSupervisor(directory)
try:
processes.run('tool:89',[sys.executable,'-c','print("OLD_FAILURE")'],threading.Event())
result=processes.run('tool:89',[sys.executable,'-c','print("NEW_RESULT")'],threading.Event())
self.assertIn('NEW_RESULT',result['detail']);self.assertNotIn('OLD_FAILURE',result['detail'])
row=processes.snapshot()['tool:89']
self.assertEqual(row['owner'],'station');self.assertEqual(row['state'],'exited')
finally: processes.close()
def test_success_waits_for_orphaned_child_cleanup(self):
from pathlib import Path
from arm_application.process_lifecycle import _stat
with tempfile.TemporaryDirectory() as directory:
processes=ProcessSupervisor(directory)
pidfile=Path(directory)/'pid'
source="import subprocess,sys,time;from pathlib import Path;p=subprocess.Popen([sys.executable,'-c','import time;time.sleep(120)'],start_new_session=True);Path(sys.argv[1]).write_text(str(p.pid))"
try:
result=processes.run('motion:89',[sys.executable,'-c',source,str(pidfile)],threading.Event(),timeout=12)
self.assertTrue(result['ok'],result)
child=int(pidfile.read_text());state=_stat(child)
self.assertTrue(state is None or state[2]=='Z','task reported success with a live orphan')
finally: processes.close()
def test_process_cleanup_phase_is_reported_before_resource_release(self):
from pathlib import Path
with tempfile.TemporaryDirectory() as directory:
processes=ProcessSupervisor(directory)
ready=Path(directory)/'ready'
child="import signal,time,sys;from pathlib import Path;signal.signal(signal.SIGINT,lambda *a:(time.sleep(.4),sys.exit(0)));Path(sys.argv[1]).write_text('ready');time.sleep(20)"
source="import subprocess,sys,time;from pathlib import Path;subprocess.Popen([sys.executable,'-c',sys.argv[1],sys.argv[2]],start_new_session=True)\nwhile not Path(sys.argv[2]).exists():time.sleep(.01)"
phases=[]
try:
result=processes.run('motion:89',[sys.executable,'-c',source,child,str(ready)],threading.Event(),timeout=8,on_phase=phases.append)
self.assertTrue(result['ok'],result)
self.assertIn('收尾',phases)
self.assertFalse(processes.snapshot()['motion:89']['running'])
finally: processes.close()
@@ -0,0 +1,53 @@
import os
import tempfile
from pathlib import Path
from types import SimpleNamespace
from unittest import TestCase
from unittest.mock import patch
import threading
from station.remote_camera import settings,bridge_environment
from station.operator_camera_view import stream_targets
from station.control_service import StationService
from arm_application.site_io import load_site_dict
class RemotePreviewTest(TestCase):
def site(self):
site=load_site_dict();site['remote_camera_preview']={
'enabled':True,'domain':0,'host':'192.168.2.10','interface_address':'192.168.2.50',
'cameras':['d405_left','d435'],'transport':'raw'}
return site
def test_only_explicit_remote_streams_use_raw(self):
streams=stream_targets(self.site())
self.assertEqual({s['id'] for s in streams},{'d405_left','d435'})
self.assertTrue(all(s['preview_transport']=='raw' and s['source']=='remote' for s in streams))
def test_remote_environment_is_limited_to_preview_child(self):
parent={'ROS_DOMAIN_ID':'42','ROS_LOCALHOST_ONLY':'1','REAL_ROS_DOMAIN_ID':'42',
'ROS_DISCOVERY_SERVER':'some-other-server'}
before=dict(parent)
with tempfile.TemporaryDirectory() as folder:
env=bridge_environment(self.site(),parent,folder)
self.assertEqual(parent,before)
self.assertEqual((env['ROS_DOMAIN_ID'],env['ROS_LOCALHOST_ONLY']),('0','0'))
self.assertEqual(env['REAL_ROS_DOMAIN_ID'],'42')
self.assertNotIn('ROS_DISCOVERY_SERVER',env)
xml=Path(env['FASTRTPS_DEFAULT_PROFILES_FILE']).read_text()
self.assertIn('<address>192.168.2.50</address>',xml)
self.assertIn('<address>192.168.2.10</address>',xml)
self.assertEqual(bridge_environment({},parent,'/unused'),parent)
def test_bad_address_is_rejected_before_creating_profile(self):
site=self.site();site['remote_camera_preview']['host']='bad<xml>'
with self.assertRaises(ValueError):settings(site)
def test_refresh_does_not_touch_acquisition_or_robot(self):
gateway=SimpleNamespace(states=lambda:({},{}),graph=lambda:{'nodes':[],'topics':[],'services':[]},close=lambda:None)
service=StationService(gateway=gateway);service.site.update(self.site())
try:
with patch.object(service,'_camera') as camera,patch.object(service.processes,'start') as start:
result=service.execute('camera_refresh','89','eye_in_hand',{},threading.Event())
self.assertTrue(result['ok']);camera.assert_not_called();start.assert_not_called()
finally:service.close()
+22
View File
@@ -0,0 +1,22 @@
import os
import subprocess
from pathlib import Path
from unittest import TestCase
class RosDomainTest(TestCase):
def policy(self, **changes):
env={k:v for k,v in os.environ.items() if k not in ('REAL_ROS_DOMAIN_ID','REAL_ROS_LOCALHOST_ONLY')}
env.update(changes)
root=Path(__file__).resolve().parents[2]
return subprocess.run(['bash','-c','source "$1"; ros_domain_force_real || exit $?; printf "%s %s" "$ROS_DOMAIN_ID" "$ROS_LOCALHOST_ONLY"','bash',str(root/'scripts/lib/ros_domain.sh')],env=env,capture_output=True,text=True,timeout=3)
def test_inherited_global_and_simulation_domains_are_not_adopted(self):
for inherited in ('0','77','123'):
value=self.policy(ROS_DOMAIN_ID=inherited,ROS_LOCALHOST_ONLY='0')
self.assertEqual(value.returncode,0,value.stderr)
self.assertEqual(value.stdout,'42 1')
def test_explicit_override_and_invalid_values(self):
value=self.policy(REAL_ROS_DOMAIN_ID='231',REAL_ROS_LOCALHOST_ONLY='0')
self.assertEqual(value.stdout,'231 0')
for domain in ('-1','233','abc','77'):
self.assertNotEqual(self.policy(REAL_ROS_DOMAIN_ID=domain).returncode,0)
+7
View File
@@ -12,3 +12,10 @@ class RosInterfacesTest(TestCase):
capture_output=True,text=True,timeout=25)
self.assertEqual(result.returncode,0,result.stdout+'\n'+result.stderr)
self.assertIn('dynamic camera subscriptions PASS',result.stdout)
def test_new_device_nodes_with_fake_hardware(self):
env={**os.environ,'ROS_DOMAIN_ID':'232','ROS_LOCALHOST_ONLY':'1'}
result=subprocess.run([sys.executable,str(Path(__file__).with_name('device_nodes_fixture.py'))],
env=env,capture_output=True,text=True,timeout=20)
self.assertEqual(result.returncode,0,result.stdout+'\n'+result.stderr)
self.assertIn('offline status PASS',result.stdout)
+31
View File
@@ -0,0 +1,31 @@
from pathlib import Path
from unittest import TestCase
from arm_application.tactile_calibration import ForceCurve,TactileCalibration
class TactileCalibrationTest(TestCase):
def setUp(self):
path=Path(__file__).resolve().parents[2]/'src/arm_application/config/tactile/force_calibration.json'
self.calibration=TactileCalibration(path)
def test_calibration_knots_units_and_shear_sign(self):
self.assertAlmostEqual(self.calibration.normal.convert(10000),.377)
self.assertAlmostEqual(self.calibration.normal.convert(30000),1.377)
self.assertAlmostEqual(self.calibration.shear.convert(36000),1.19)
self.assertAlmostEqual(self.calibration.shear.convert(-36000),-1.19)
self.assertEqual(self.calibration.normal.convert(0),0.)
self.assertEqual(self.calibration.shear.convert(-500),0.)
def test_interpolation_and_conversion_never_replace_raw_values(self):
raw={'fnormal':20000.,'fshearx':52000.,'fsheary':-52000.}
saved=dict(raw);force=self.calibration.convert(raw)
self.assertAlmostEqual(force['fnormal'],.877)
self.assertAlmostEqual(force['fshearx'],1.625)
self.assertAlmostEqual(force['fsheary'],-1.625)
self.assertEqual(raw,saved)
with self.assertRaises(ValueError):self.calibration.normal.convert(float('nan'))
def test_invalid_or_disabled_curve_does_not_mislabel_raw_as_newtons(self):
with self.assertRaises(ValueError):ForceCurve.from_config({'enabled':False})
with self.assertRaises(ValueError):ForceCurve.from_config({'enabled':True,'method':'piecewise_linear',
'points':[{'raw':1,'force_n':0},{'raw':1,'force_n':1}]})
+60
View File
@@ -0,0 +1,60 @@
import tempfile
from pathlib import Path
from types import SimpleNamespace
from unittest import TestCase
import numpy as np
from arm_application.tactile_flow import tactile_devices,select_source,flow_preview,read_sample
class TactileFlowTest(TestCase):
def test_discovery_filters_realsense_and_duplicate_interfaces(self):
with tempfile.TemporaryDirectory() as folder:
root=Path(folder);sys=root/'class';sys.mkdir()
for name,vendor,index in [('video0','8086','0'),('video1','1bcf','0'),('video2','1bcf','1'),('video3','0bda','0')]:
entry=sys/name;entry.mkdir();(entry/'index').write_text(index)
usb=root/('usb-'+name);usb.mkdir();(usb/'idVendor').write_text(vendor)
(entry/'device').symlink_to(usb,target_is_directory=True)
found=tactile_devices(sys,Path('/dev'))
self.assertEqual(found,['/dev/video1'])
self.assertEqual(select_source('left',{},found),'/dev/video1')
self.assertIsNone(select_source('right',{},found))
self.assertEqual(select_source('right',{'right_video':'/dev/v4l/by-path/sensor'},found),'/dev/v4l/by-path/sensor')
def test_actual_vector_field_changes_preview_and_rejects_bad_shapes(self):
flow=np.zeros((64,64,2),np.float32)
rest=flow_preview(flow);flow[...,0]=12
motion=flow_preview(flow)
self.assertEqual(motion.shape,(360,360,3));self.assertFalse(np.array_equal(rest,motion))
with self.assertRaises(ValueError):flow_preview(np.zeros((8,8)))
def test_uses_raw_force_channels_and_reads_flow_only_once(self):
info=SimpleNamespace(FPS=1,FNORMAL=2,FSHEARX=3,FSHEARY=4,FNORMAL_RAW=5,FSHEARX_RAW=6,FSHEARY_RAW=7,VRAW=8)
calls=[]
def read(*keys):
calls.append(keys);return 30.,200.,3.,-4.,np.zeros((8,8,2))
row=read_sample(SimpleNamespace(info=info,read_info=read))
self.assertEqual(calls,[(1,5,6,7,8)])
self.assertEqual(row['fnormal'],200.);self.assertEqual(row['fsheary'],-4.)
def test_preview_polling_is_independent_of_slow_state_poll(self):
import base64,threading,time
from unittest.mock import patch
from station.runtime_client import RuntimeClient
client=RuntimeClient.__new__(RuntimeClient)
client._lock=threading.RLock();client._stop=threading.Event()
from collections import deque
client._force_history={'left':deque(maxlen=1800),'right':deque(maxlen=1800)}
client._force_cursors={};client._force_epochs={}
client._snapshot={'view':{'on':True}};client._frames={};client.socket=Path('/unused')
calls=[]
def rpc_call(*args,**kwargs):
calls.append(args[1]);return {'frames':{'left_shear':base64.b64encode(b'jpeg').decode()},
'samples':{'left':[{'seq':1,'t':time.time(),'fnormal':.5,'fshearx':.2,'fsheary':-.3}]},'epochs':{'left':0}}
with patch('station.runtime_client.rpc.call',side_effect=rpc_call):
thread=threading.Thread(target=client._poll_frames);thread.start()
time.sleep(.16);client._stop.set();thread.join(1)
self.assertGreaterEqual(len(calls),3)
self.assertEqual(set(calls),{'tactile_preview'})
self.assertEqual(client._frames['left_shear'],b'jpeg')
self.assertEqual(len(client._force_history['left']),1)
self.assertEqual(client._force_cursors['left'],1)
+33 -1
View File
@@ -17,7 +17,9 @@ class TasksTest(TestCase):
def test_resources_held_until_completion(self):
gate=threading.Event()
one=self.manager.submit('move',{'arm:89'},lambda cancel:(gate.wait(2),{'ok':True})[1])
one=self.manager.submit('move',{'arm:89'},lambda cancel:(gate.wait(2),{'ok':True})[1],context={'arm':'89'})
self.assertEqual(self.manager.snapshot(one)['resources'],['arm:89'])
self.assertEqual(self.manager.snapshot(one)['context']['arm'],'89')
with self.assertRaises(RuntimeError): self.manager.submit('move-again',{'arm:89'},lambda e:{'ok':True})
other=self.manager.submit('camera',{'camera'},lambda e:{'ok':True})
self.assertEqual(self.wait(other)['state'],'succeeded')
@@ -33,3 +35,33 @@ class TasksTest(TestCase):
token=self.manager.submit('reject',{'arm'},fail)
result=self.wait(token)
self.assertEqual(result['state'],'failed');self.assertIn('driver rejected',result['result']['message'])
def test_busy_message_identifies_cleanup_and_stop_cancels_only_motion(self):
gate=threading.Event()
motion=self.manager.submit('stage_locate',{'robot-control','arm:89'},lambda e:(gate.wait(2),{'ok':True})[1],context={'arm':'89'})
camera=self.manager.submit('camera_refresh',{'camera-setup:89'},lambda e:(gate.wait(2),{'ok':True})[1])
try:
deadline=time.monotonic()+1
while self.manager.snapshot(motion)['state']=='queued' and time.monotonic()<deadline: time.sleep(.01)
self.assertEqual(self.manager.snapshot(motion)['state'],'running')
self.manager.set_phase(self.manager._events[motion],'收尾')
with self.assertRaisesRegex(RuntimeError,'机械臂 89.*对孔.*收尾'):
self.manager.submit('reset_home',{'robot-control'},lambda e:{'ok':True})
self.manager.cancel_resources({'robot-control'})
self.assertTrue(self.manager._events[motion].is_set())
self.assertFalse(self.manager._events[camera].is_set())
finally: gate.set()
def test_cancelled_task_cannot_report_late_success_or_resume(self):
started=threading.Event(); release=threading.Event(); calls=[]
def old_task(event):
calls.append('old'); started.set(); release.wait(2)
return {'ok':True}
token=self.manager.submit('stage_track',{'robot-control'},old_task)
self.assertTrue(started.wait(1))
self.manager.cancel_resources({'robot-control'}); release.set()
result=self.wait(token)
self.assertEqual(result['state'],'cancelled'); self.assertFalse(result['result']['ok'])
fresh=self.manager.submit('real_up',{'robot-control'},lambda e:{'ok':True})
self.assertEqual(self.wait(fresh)['state'],'succeeded')
self.assertEqual(calls,['old'])