Update workstation UI, ROS task control and device integration
This commit is contained in:
+207
-4
@@ -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` 并重启工位即可恢复本站采集模式。
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
Binary file not shown.
File diff suppressed because one or more lines are too long
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
@@ -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()}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]}
|
||||
@@ -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)
|
||||
@@ -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; }
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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}
|
||||
|
||||
@@ -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",{}))
|
||||
|
||||
@@ -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': '通止规状态机。会动臂、会拧。'},
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
@@ -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'])
|
||||
|
||||
@@ -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.)
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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'))
|
||||
|
||||
@@ -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'),'设备驱动')
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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}]})
|
||||
@@ -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)
|
||||
@@ -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'])
|
||||
|
||||
Reference in New Issue
Block a user