Compare commits
15 Commits
74761bb5da
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| abea2d7517 | |||
| acccb94807 | |||
| c41a03df44 | |||
| e0162b72b3 | |||
| 9a01ce851d | |||
| 5193f537b0 | |||
| 070d2ba5c0 | |||
| ac41d04a54 | |||
| 5c5eaf850d | |||
| 4aae6219cc | |||
| 35bdb08d11 | |||
| 0952958af7 | |||
| 2cd42f04e5 | |||
| d120ef2a39 | |||
| d82128da4a |
@@ -1,42 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(git commit -m ':*)",
|
||||
"Bash(/bin/bash -lc 'DOTNET_CLI_HOME=/tmp NUGET_PACKAGES=/tmp/nuget-packages dotnet build FlyshotReplacement.sln -v minimal 2>&1')",
|
||||
"Bash(/bin/bash -lc 'DOTNET_CLI_HOME=/tmp NUGET_PACKAGES=/tmp/nuget-packages dotnet test tests/Flyshot.Server.IntegrationTests/Flyshot.Server.IntegrationTests.csproj -v minimal 2>&1')",
|
||||
"Bash(/bin/bash -lc 'DOTNET_CLI_HOME=/tmp NUGET_PACKAGES=/tmp/nuget-packages dotnet test FlyshotReplacement.sln --no-build -v minimal 2>&1')",
|
||||
"Bash(DOTNET_CLI_HOME=/tmp NUGET_PACKAGES=/tmp/nuget-packages dotnet build FlyshotReplacement.sln --no-restore -v minimal)",
|
||||
"Bash(DOTNET_CLI_HOME=/tmp NUGET_PACKAGES=/tmp/nuget-packages dotnet test tests/Flyshot.Server.IntegrationTests/Flyshot.Server.IntegrationTests.csproj --no-build -v minimal)",
|
||||
"Bash(python -c \"import json; json.load\\(open\\('.vscode/launch.json'\\)\\); json.load\\(open\\('.vscode/tasks.json'\\)\\); print\\('JSON valid.'\\)\")",
|
||||
"Bash(python -c \"import json; json.load\\(open\\('.vscode/launch.json', encoding='utf-8'\\)\\); json.load\\(open\\('.vscode/tasks.json', encoding='utf-8'\\)\\); print\\('JSON valid.'\\)\")",
|
||||
"Bash(/bin/bash -lc 'DOTNET_CLI_HOME=/tmp NUGET_PACKAGES=/tmp/nuget-packages dotnet build FlyshotReplacement.sln --no-restore -v minimal')",
|
||||
"Bash(/bin/bash -lc 'DOTNET_CLI_HOME=/tmp NUGET_PACKAGES=/tmp/nuget-packages dotnet test tests/Flyshot.Core.Tests/Flyshot.Core.Tests.csproj -v minimal')",
|
||||
"Bash(/bin/bash -lc 'DOTNET_CLI_HOME=/tmp NUGET_PACKAGES=/tmp/nuget-packages dotnet test tests/Flyshot.Server.IntegrationTests/Flyshot.Server.IntegrationTests.csproj -v minimal')",
|
||||
"Bash(/bin/bash -lc 'DOTNET_CLI_HOME=/tmp NUGET_PACKAGES=/tmp/nuget-packages dotnet build FlyshotReplacement.sln --no-restore -v minimal 2>&1')",
|
||||
"Bash(taskkill /PID 120812 /F)",
|
||||
"Bash(/bin/bash -lc 'DOTNET_CLI_HOME=/tmp NUGET_PACKAGES=/tmp/nuget-packages dotnet build flyshot-replacement.sln --no-restore -v minimal 2>&1')",
|
||||
"Bash(/bin/bash -lc 'DOTNET_CLI_HOME=/tmp NUGET_PACKAGES=/tmp/nuget-packages dotnet build /d/Dev/Codes/rvbust-code/FlyingShotPkg_3.15_VDA/flyshot-replacement/FlyshotReplacement.sln --no-restore -v minimal 2>&1')",
|
||||
"Bash(capinfos \"20260430.pcap\")",
|
||||
"Bash(tshark -r \"20260430.pcap\" -T fields -e frame.number -e frame.time_relative -e ip.src -e ip.dst -e udp.srcport -e udp.dstport -e data.len)",
|
||||
"Bash(tshark -r \"20260430.pcap\" -Y \"udp.port==60015\" -T fields -e frame.number -e frame.time_relative -e ip.src -e udp.srcport -e udp.length -e data.len)",
|
||||
"Bash(tshark -r \"20260430.pcap\" -Y \"udp.port==60015 and udp.srcport==60015\" -T fields -e frame.number -e frame.time_relative -e udp.length)",
|
||||
"Bash(tshark -r \"20260430.pcap\" -Y \"udp.port==60015 and udp.dstport==60015\" -T fields -e frame.number -e frame.time_relative -e udp.length)",
|
||||
"Bash(tshark -r \"20260430.pcap\" -Y \"udp.port==60015\" -T fields -e frame.number -e frame.time_relative -e ip.src -e ip.dst -e udp.srcport -e udp.dstport -e udp.length -e frame.protocols)",
|
||||
"Bash(tshark -r \"20260430.pcap\" -Y \"udp.port==60015 and frame.number==1\" -V)",
|
||||
"Bash(tshark -r \"20260430.pcap\" -Y \"udp.port==60015 and udp.dstport!=60015\" -T fields -e frame.number -e frame.time_relative -e ip.dst -e udp.dstport)",
|
||||
"Bash(tshark -r \"20260430.pcap\" -Y \"udp.dstport==60015\" -T fields -e frame.number -e frame.time_relative -e ip.src -e udp.length)",
|
||||
"Bash(tshark -r \"20260430.pcap\" -Y \"frj519\" -T fields -e frj519.type -e frj519.seq -e frj519.status -e frj519.j1 -e frj519.j2 -e frj519.j3 -e frj519.j4 -e frj519.j5 -e frj519.j6 -e frj519.j7 -e frj519.j8 -e frj519.j9 -e frj519.timestamp)",
|
||||
"Bash(tshark -r \"20260430.pcap\" -Y \"frj519\" -T fields -e frj519.type -e frj519.seq -e frj519.status -e frj519.j1 -e frj519.j2 -e frj519.j3 -e frj519.j4 -e frj519.j5 -e frj519.j6 -e frj519.j7 -e frj519.j8 -e frj519.j9 -e frj519.timestamp -e frj519.x -e frj519.y -e frj519.z -e frj519.w -e frj519.p -e frj519.r)",
|
||||
"Bash(tshark -r \"20260430.pcap\" -Y \"frj519\" -T fields -E header=y)",
|
||||
"Bash(tshark -G fields)",
|
||||
"Bash(tshark -r \"20260430.pcap\" -Y \"frj519\" -T fields -e frame.time_relative -e data)",
|
||||
"Bash(tshark -r \"20260430.pcap\" -Y \"udp.dstport==60015\" -T fields -e frame.number -e frame.time_relative -e data)",
|
||||
"Bash(tshark -r \"20260430.pcap\" -Y \"udp.port==60015 and udp.srcport==60015\" -T fields -e frame.number -e frame.time_relative -e data)",
|
||||
"Bash(tshark -r \"20260430.pcap\" --disable-protocol frj519 -Y \"udp.dstport==60015\" -T fields -e frame.number -e frame.time_relative -e data)",
|
||||
"Bash(/bin/bash -lc 'DOTNET_CLI_HOME=/tmp NUGET_PACKAGES=/tmp/nuget-packages dotnet build src/Flyshot.Runtime.Fanuc/Flyshot.Runtime.Fanuc.csproj --no-restore -v minimal 2>&1 | tail -20')",
|
||||
"Bash(/bin/bash -lc 'DOTNET_CLI_HOME=/tmp NUGET_PACKAGES=/tmp/nuget-packages dotnet build FlyshotReplacement.sln --no-restore -v minimal 2>&1 | tail -25')",
|
||||
"Bash(/bin/bash -lc 'DOTNET_CLI_HOME=/tmp NUGET_PACKAGES=/tmp/nuget-packages dotnet build FlyshotReplacement.sln --no-restore -v minimal 2>&1 | tail -15')",
|
||||
"Bash(/bin/bash -lc 'DOTNET_CLI_HOME=/tmp NUGET_PACKAGES=/tmp/nuget-packages dotnet test tests/Flyshot.Core.Tests/Flyshot.Core.Tests.csproj --no-restore -v minimal 2>&1 | tail -30')"
|
||||
]
|
||||
}
|
||||
}
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -400,3 +400,8 @@ Config/Data/*
|
||||
.dotnet-home/*
|
||||
codex-dotnet-home/*
|
||||
.dotnet-sdk8/*
|
||||
|
||||
# Flyshot 混淆发布产物、映射文件和临时工作目录
|
||||
outputs/obfuscated/
|
||||
outputs/obfuscation-maps/
|
||||
.tmp/obfuscation/
|
||||
|
||||
15
.vscode/tasks.json
vendored
15
.vscode/tasks.json
vendored
@@ -142,19 +142,16 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
// Release 配置发布到 publish/,用于现场部署包打包
|
||||
// 调用环境中的 Obfuscar,生成 Windows/Linux 两套自包含混淆发布包
|
||||
"label": "publish",
|
||||
"command": "dotnet",
|
||||
"command": "pwsh",
|
||||
"type": "process",
|
||||
"args": [
|
||||
"publish",
|
||||
"${workspaceFolder}/src/Flyshot.Server.Host/Flyshot.Server.Host.csproj",
|
||||
"-c",
|
||||
"Release",
|
||||
"-o",
|
||||
"${workspaceFolder}/publish"
|
||||
"-NoProfile",
|
||||
"-File",
|
||||
"${workspaceFolder}/scripts/Publish-Obfuscated.ps1"
|
||||
],
|
||||
"problemMatcher": "$msCompile"
|
||||
"problemMatcher": []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
"jerk_limit": 1,
|
||||
"adapt_icsp_try_num": 5,
|
||||
"planning_speed_scale": 0.74227,
|
||||
"smooth_start_stop_timing": false
|
||||
"speed_ratio": 1,
|
||||
"smooth_start_stop_timing": true
|
||||
},
|
||||
"flying_shots": {
|
||||
"UTTC_MS11": {
|
||||
@@ -299,6 +300,155 @@
|
||||
],
|
||||
[]
|
||||
]
|
||||
},
|
||||
"WhP_test": {
|
||||
"traj_waypoints": [
|
||||
[
|
||||
1.056731,
|
||||
0.011664811,
|
||||
-0.017892333,
|
||||
-0.01516874,
|
||||
0.021492079,
|
||||
0.009567846
|
||||
],
|
||||
[
|
||||
0.2153671,
|
||||
0.015257306,
|
||||
-0.024938405,
|
||||
-0.059105303,
|
||||
0.06653474,
|
||||
-0.005172012
|
||||
],
|
||||
[
|
||||
0.47362572,
|
||||
0.015260376,
|
||||
-0.024971163,
|
||||
-0.059100218,
|
||||
0.06653733,
|
||||
-0.00518099
|
||||
],
|
||||
[
|
||||
-0.0861789,
|
||||
0.024431378,
|
||||
0.15767263,
|
||||
-0.059098743,
|
||||
0.06653532,
|
||||
-0.0051812795
|
||||
],
|
||||
[
|
||||
-0.029219253,
|
||||
0.024431676,
|
||||
0.24181713,
|
||||
-0.81706756,
|
||||
0.06653719,
|
||||
-0.0051850453
|
||||
],
|
||||
[
|
||||
1.056731,
|
||||
0.011664811,
|
||||
-0.017892333,
|
||||
-0.01516874,
|
||||
0.021492079,
|
||||
0.009567846
|
||||
]
|
||||
],
|
||||
"shot_flags": [
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false
|
||||
],
|
||||
"offset_values": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"addr": [
|
||||
[],
|
||||
[
|
||||
2,
|
||||
3,
|
||||
4
|
||||
],
|
||||
[
|
||||
2,
|
||||
4,
|
||||
3
|
||||
],
|
||||
[
|
||||
1,
|
||||
2,
|
||||
3
|
||||
],
|
||||
[
|
||||
1,
|
||||
2,
|
||||
3
|
||||
],
|
||||
[]
|
||||
]
|
||||
},
|
||||
"demo-flyshot": {
|
||||
"traj_waypoints": [
|
||||
[
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0.001,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0.002,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0.003,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
],
|
||||
"shot_flags": [
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
],
|
||||
"offset_values": [
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"addr": [
|
||||
[],
|
||||
[
|
||||
7,
|
||||
8
|
||||
],
|
||||
[],
|
||||
[]
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
52
README.md
52
README.md
@@ -21,8 +21,12 @@
|
||||
- 2026-04-28 UTTC 抓包确认:UDP 60015 命令 `target[0..5]` 为关节角度制 `deg`,`JointDetialTraj.txt` 为弧度制 `rad`,`speed_ratio=0.7` 体现为 UDP 下发时间轴约 `1.427730x` 拉伸;2026-04-30 实体机确认 `speed_ratio` 不影响生成的 `JointTraj.txt` 规划时长,当前实际生成约 `7.4s` 轨迹。
|
||||
- 2026-04-30 本机 `50001/TCP+JSON` 抓包确认:`ExecuteFlyShotTraj(save_traj=true,use_cache=false)` 请求只显式携带规划方法、保存、缓存和等待参数,不携带 `JointLimits / acc_limit / jerk_limit / velocity / acceleration / jerk`。因此旧系统不可见的有效规划限制不再继续假设来自公开链路,新系统按 replacement-only 内部参数限制规划加速度。
|
||||
- 真机 Socket 客户端已具备基础连接、程序启停、速度倍率/TCP/IO 参数命令和 J519 状态包驱动发送能力;稠密轨迹下发已按 `speed_ratio` 做执行时间缩放,并已用 0.5/0.7/1.0 三份 UTTC 抓包固化 J519 golden tests。真实 R30iB 全流程现场联调仍需执行。
|
||||
- 2026-05-11 对 `UTTC_MS11` 的独立 `ConfigRoot` 重新生成确认:flyshot 链路若继续用离散 `jerk` 作为阻断条件,会把新结果拉长到约 `16.576s / 33.152s`;只保留速度、加速度校验并跳过 flyshot jerk 阻断后,新 `1.0x / 0.5x` 时长回落到约 `7.552s / 15.104s`,与旧抓包主段 `7.408s / 14.808s` 的差异已很小,当前按可忽略处理。详见 `docs/uttc-ms11-jerk-validation-decision-20260511.md`。
|
||||
- `MoveJoint` 已按 `2026042802-mvpoint*.pcap` 复刻为点到点临时轨迹:当前关节到目标关节的关节空间直线,五次 smoothstep 起停,按 `status=15` 运动窗口复现 `40/55/77` 点,并由 J519 层完成 `rad -> deg` 下发。
|
||||
- 单程序只对应一台机器人,上传/删除/恢复飞拍轨迹统一读写运行目录 `Config/RobotConfig.json`,不再创建独立轨迹存储文件。
|
||||
- 服务启动时支持真机优先初始化:在 `ControllerClientCompat.AutoInit` 配置 `RobotName` 和 `RobotIp`,启动后执行 `SetUpRobot → Connect → EnableRobot(8)`;连接失败后进入 `OfflineReady`,模型仍可用于离线飞拍规划。
|
||||
- 飞拍资产与机器人型号解耦:未加载模型时也可上传、列出和删除;模型自由度与关节限制延迟到规划阶段校验。
|
||||
- `/save_traj_info/` 已成为离线规划入口,返回最终 8ms 发送点列、ShotEvents、触发绑定和全部导出文件绝对路径;`/execute_flyshot/` 仍只允许 `RealReady`。
|
||||
|
||||
单位约定总览:
|
||||
|
||||
@@ -65,17 +69,22 @@
|
||||
- [x] 将 `ExecuteTrajectory` / `ExecuteFlyShotTraj` 接入 FANUC 运行时链路
|
||||
- [x] 落地 Web 状态页
|
||||
- [x] 落地浏览器内 OpenAPI 自动驱动的接口调试页(`/debug`),与 `Swagger:Enabled` 同步可见
|
||||
- [x] 调试页的人类可读说明文本已静态内嵌到 `debug.html`,即使发布产物删除 XML 文档也能继续展示接口提示
|
||||
- [x] 固化 `10010 / 10012 / 60015` FANUC 基础协议帧编解码,确认 `10010` 状态帧为 90B
|
||||
- [x] 使用本地 TCP/UDP 模拟器覆盖命令通道、状态通道和 J519 基础收发
|
||||
- [x] 补齐 `Get/SetSpeedRatio`、`Get/SetTCP`、`Get/SetIO` 真机命令体与响应解析
|
||||
- [x] 保留新 HTTP 接口路线,明确不再实现旧 `50001/TCP+JSON` 网关
|
||||
- [x] 将飞拍轨迹持久化收敛到运行目录 `Config/RobotConfig.json`
|
||||
- [x] 服务启动自动初始化机器人:通过 `ControllerClientCompat.AutoInit` 配置节指定 `RobotName` 和 `RobotIp`,启动时自动执行初始化链路
|
||||
- [x] 完成真机优先与离线飞拍规划改造:`RealReady / OfflineReady / ModelLoadFailed` 状态、固定轨迹资产、完整离线规划响应和 Debug 页面
|
||||
- [x] 删除生产仿真切换 HTTP 入口及旧 `/init_mpc_robt`,统一使用 `/init_mpc_robot`
|
||||
|
||||
剩余 Todo:
|
||||
|
||||
1. 配置与测试基线
|
||||
- [x] 修正 `ConfigCompatibilityTests` 当前样本路径漂移:`Rvbust/EOL10_EAU_0/RobotConfig.json` 不再包含 `001`,应改用稳定样本或更新断言。
|
||||
- [x] 将 `RobotConfig.json` 中的 `use_do`、`io_keep_cycles`、`acc_limit`、`jerk_limit`、`adapt_icsp_try_num` 全部贯通到规划和执行链路。
|
||||
- [x] 恢复 `use_do` 的输出选择语义:`true` 使用 DO(2),`false` 使用 RO(9),两种模式均保留飞拍脉冲与清零;见 [输出选择说明](docs/use-do-output-selection.md)。
|
||||
- [x] 将上传飞拍轨迹统一保存到运行目录 `Config/RobotConfig.json` 的 `flying_shots` 节点。
|
||||
- [x] 为新 HTTP API 补一份当前现场调用顺序文档,替代旧 `ControllerClient` 工作流:见 `docs/fanuc-field-runtime-workflow.md`。
|
||||
|
||||
@@ -109,6 +118,7 @@
|
||||
- [x] 将飞拍 IO 触发的 `write_io_type/index/mask/value` 与现场控制柜实际 IO 地址逐项对齐;UTTC golden tests 确认 17 个触发点对应 17 个 UDP IO set 脉冲、17 个 clear 帧,mask 集合为 `10/12/14`。
|
||||
- [x] 将 `MoveJoint` 从单点最终目标改为临时 PTP 稠密轨迹:按 `status=15` 运动窗口统计,speed=1 抓包 40 点,speed=0.7 抓包 55 点,speed=0.5 抓包 77 点,路径为关节空间直线 + smoothstep 进度。
|
||||
- [x] `ExecuteFlyShotTraj(move_to_start=true)` 复用临时 PTP 稠密轨迹移动到规划起点,并等待运行时完成后再启动飞拍轨迹,避免第一帧 J519 目标突变导致控制柜报警。
|
||||
- [x] `MoveJoint` 和飞拍预移动统一按 `planning_speed_scale * speed_ratio` 生成点对点轨迹,速度、加速度、跃度分别按有效倍率的一、二、三次方缩放,发送周期保持不变。
|
||||
- [x] `ExecuteFlyShotTraj(wait=true)` 等待正式飞拍轨迹执行完成后再返回;HTTP `/execute_flyshot/` 已接入旧抓包中的 `wait` 字段,默认值为 `true`。
|
||||
|
||||
6. 真机联调与运行安全
|
||||
@@ -119,6 +129,48 @@
|
||||
- [ ] 增加运行日志、协议摘要日志和状态快照导出,便于现场排查。
|
||||
|
||||
7. 发布与部署
|
||||
- [x] 增加 Obfuscar 跨平台混淆发布脚本,全部 `Flyshot.*` 自研程序集会生成一份不区分运行平台的 .NET 8 框架依赖部署包。
|
||||
- [ ] 固化 Windows / Linux 启动脚本和 systemd 服务配置。
|
||||
- [ ] 补充生产配置模板、端口说明和现场部署检查表。
|
||||
- [ ] 给 Web 状态页增加程序状态和最近报警显示;J519 状态位已通过快照和状态页显示。
|
||||
|
||||
## 混淆发布
|
||||
|
||||
发布脚本直接依赖构建环境,不在仓库中引用或还原 Obfuscar 包。首次使用前安装全局工具:
|
||||
|
||||
```powershell
|
||||
dotnet tool install --global Obfuscar.GlobalTool --version 2.2.50
|
||||
```
|
||||
|
||||
安装后确认 `obfuscar.console` 已加入当前终端的 `PATH`,然后在仓库根目录执行:
|
||||
|
||||
```powershell
|
||||
pwsh -NoProfile -File .\scripts\Publish-Obfuscated.ps1
|
||||
```
|
||||
|
||||
也可以在 VS Code 中运行 `publish` 任务。脚本不再接受 RID 或运行平台参数,默认生成:
|
||||
|
||||
- `outputs/obfuscated/framework-dependent/`:Windows 与 Linux 共用的 .NET 8 框架依赖混淆部署包。
|
||||
- `outputs/obfuscation-maps/framework-dependent/Mapping.txt`:仅供内部还原异常堆栈使用,不得交付给用户。
|
||||
|
||||
Visual Studio 的 `FolderProfile` 已接入同一套混淆流程。在“发布”页面选择 `FolderProfile` 并点击“发布”后,Visual Studio 会生成 .NET 8 框架依赖多文件包,再自动混淆发布目录中的全部 `Flyshot.*.dll`。如果环境缺少 `obfuscar.console`、缺少对应的 .NET 8 共享框架或混淆失败,Visual Studio 发布会直接失败,不会静默留下未保护产物。
|
||||
|
||||
混淆范围包含发布目录中的全部 `Flyshot.*.dll` 自研程序集,并校验当前八个核心程序集不可缺失;后续新增的 `Flyshot.*` 模块也会自动纳入。第三方程序集不会被处理;公共 API、属性和事件名称会保留,以维持 ASP.NET Core 控制器发现、依赖注入、配置绑定、JSON 序列化和 Swagger 行为,内部类型、私有方法及字段会执行重命名。
|
||||
|
||||
若环境缺少 `obfuscar.console`、发布目录缺少任一自研程序集、混淆器返回失败或未生成完整输出,脚本会返回非零退出码并停止,不会把未完整保护的产物作为成功结果交付。
|
||||
|
||||
混淆完成后会删除发布目录中的 `Flyshot*.xml` XML 文档和 `Flyshot*.pdb` 调试符号,避免反编译器自动显示源码文档注释。生产环境 Swagger 因此不再展示来自 XML 文档的接口说明,但接口路由和调用不受影响。
|
||||
|
||||
启动部署包时必须先进入对应 RID 的发布目录,确保宿主能从当前目录加载 `appsettings.json`、`NLog.config` 和 `Config/`:
|
||||
|
||||
```powershell
|
||||
Set-Location .\outputs\obfuscated\framework-dependent
|
||||
dotnet .\Flyshot.Server.Host.dll
|
||||
```
|
||||
|
||||
```bash
|
||||
cd outputs/obfuscated/framework-dependent
|
||||
dotnet Flyshot.Server.Host.dll
|
||||
```
|
||||
|
||||
Windows 与 Linux 目标机器都必须预先安装 .NET 8 ASP.NET Core Runtime。
|
||||
|
||||
200
analysis/read_fanuc_allowable_limits.py
Normal file
200
analysis/read_fanuc_allowable_limits.py
Normal file
@@ -0,0 +1,200 @@
|
||||
#!/usr/bin/env python3
|
||||
"""读取 FANUC J519 允许速度/加速度/jerk 上限表。
|
||||
|
||||
本脚本只做 UDP packet type=3 查询,不启动运行时服务,也不写入机器人参数。
|
||||
响应中的 limit 值保留为控制器原始单位:速度 deg/s、加速度 deg/s^2、jerk deg/s^3。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import socket
|
||||
import struct
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
LIMIT_TYPES = {
|
||||
0: ("velocity", "deg/s"),
|
||||
1: ("acceleration", "deg/s^2"),
|
||||
2: ("jerk", "deg/s^3"),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LimitTable:
|
||||
"""保存单个轴、单种限制类型的 20 档 no-payload / max-payload 上限表。"""
|
||||
|
||||
axis: int
|
||||
limit_type: int
|
||||
vmax_mm_s: int
|
||||
intermediate_check_s: int
|
||||
no_payload: tuple[float, ...]
|
||||
max_payload: tuple[float, ...]
|
||||
|
||||
|
||||
def pack_request(axis: int, limit_type: int) -> bytes:
|
||||
"""按手册 Table 5 封装允许上限表查询请求。"""
|
||||
|
||||
return struct.pack(">IIII", 3, 1, axis, limit_type)
|
||||
|
||||
|
||||
def parse_response(packet: bytes, expected_axis: int, expected_type: int) -> LimitTable:
|
||||
"""按手册 Table 6 解析允许上限表响应。"""
|
||||
|
||||
if len(packet) != 184:
|
||||
raise ValueError(f"响应长度应为 184 字节,实际为 {len(packet)} 字节。")
|
||||
|
||||
packet_type, version, axis, limit_type, vmax, check_time = struct.unpack_from(">IIIIII", packet, 0)
|
||||
if packet_type != 3 or version != 1:
|
||||
raise ValueError(f"响应头不正确:packet_type={packet_type}, version={version}。")
|
||||
if axis != expected_axis or limit_type != expected_type:
|
||||
raise ValueError(
|
||||
f"响应与请求不匹配:期望 axis={expected_axis}, type={expected_type},"
|
||||
f"实际 axis={axis}, type={limit_type}。"
|
||||
)
|
||||
|
||||
values = struct.unpack_from(">40f", packet, 24)
|
||||
return LimitTable(
|
||||
axis=axis,
|
||||
limit_type=limit_type,
|
||||
vmax_mm_s=vmax,
|
||||
intermediate_check_s=check_time,
|
||||
no_payload=tuple(values[:20]),
|
||||
max_payload=tuple(values[20:]),
|
||||
)
|
||||
|
||||
|
||||
def query_table(sock: socket.socket, robot_ip: str, port: int, axis: int, limit_type: int) -> LimitTable:
|
||||
"""发送单个查询请求并等待对应响应。"""
|
||||
|
||||
sock.sendto(pack_request(axis, limit_type), (robot_ip, port))
|
||||
packet, address = sock.recvfrom(2048)
|
||||
if address[0] != robot_ip:
|
||||
raise ValueError(f"收到非目标机器人响应:{address[0]}:{address[1]}。")
|
||||
return parse_response(packet, axis, limit_type)
|
||||
|
||||
|
||||
def iter_requested_tables(
|
||||
robot_ip: str,
|
||||
port: int,
|
||||
timeout_s: float,
|
||||
axes: range,
|
||||
limit_types: tuple[int, ...],
|
||||
) -> list[LimitTable]:
|
||||
"""按轴和限制类型顺序读取所有上限表。"""
|
||||
|
||||
tables: list[LimitTable] = []
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
|
||||
sock.settimeout(timeout_s)
|
||||
for axis in axes:
|
||||
for limit_type in limit_types:
|
||||
tables.append(query_table(sock, robot_ip, port, axis, limit_type))
|
||||
return tables
|
||||
|
||||
|
||||
def write_csv(path: Path, tables: list[LimitTable]) -> None:
|
||||
"""把 20 档速度区间和两套 payload 表写成扁平 CSV。"""
|
||||
|
||||
with path.open("w", newline="", encoding="utf-8") as file:
|
||||
writer = csv.writer(file)
|
||||
writer.writerow(
|
||||
[
|
||||
"axis",
|
||||
"limit_type",
|
||||
"unit",
|
||||
"vmax_mm_s",
|
||||
"intermediate_check_s",
|
||||
"speed_bin_index",
|
||||
"speed_bin_upper_mm_s",
|
||||
"no_payload_limit",
|
||||
"max_payload_limit",
|
||||
]
|
||||
)
|
||||
for table in tables:
|
||||
limit_name, unit = LIMIT_TYPES[table.limit_type]
|
||||
for index, (no_payload, max_payload) in enumerate(zip(table.no_payload, table.max_payload), start=1):
|
||||
writer.writerow(
|
||||
[
|
||||
table.axis,
|
||||
limit_name,
|
||||
unit,
|
||||
table.vmax_mm_s,
|
||||
table.intermediate_check_s,
|
||||
index,
|
||||
table.vmax_mm_s * index / 20.0,
|
||||
f"{no_payload:.9g}",
|
||||
f"{max_payload:.9g}",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def print_summary(tables: list[LimitTable]) -> None:
|
||||
"""打印每张表的首末档,便于现场快速确认返回值是否正常。"""
|
||||
|
||||
for table in tables:
|
||||
limit_name, unit = LIMIT_TYPES[table.limit_type]
|
||||
print(
|
||||
f"axis={table.axis} type={limit_name:<12} unit={unit:<8} "
|
||||
f"vmax={table.vmax_mm_s}mm/s check={table.intermediate_check_s}s "
|
||||
f"no_payload[1]={table.no_payload[0]:.6g} no_payload[20]={table.no_payload[-1]:.6g} "
|
||||
f"max_payload[1]={table.max_payload[0]:.6g} max_payload[20]={table.max_payload[-1]:.6g}"
|
||||
)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
"""解析命令行参数。"""
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Read FANUC J519 packet type=3 allowable upper limit tables.",
|
||||
)
|
||||
parser.add_argument("robot_ip", help="机器人控制柜 IP,例如 192.168.10.11。")
|
||||
parser.add_argument("--port", type=int, default=60015, help="J519 UDP 端口,默认 60015。")
|
||||
parser.add_argument("--timeout", type=float, default=2.0, help="单次响应超时秒数,默认 2.0。")
|
||||
parser.add_argument("--axis-start", type=int, default=1, help="起始轴号,默认 1。")
|
||||
parser.add_argument("--axis-end", type=int, default=9, help="结束轴号,默认 9。")
|
||||
parser.add_argument(
|
||||
"--types",
|
||||
default="0,1,2",
|
||||
help="限制类型列表:0=velocity, 1=acceleration, 2=jerk;默认 0,1,2。",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--csv",
|
||||
type=Path,
|
||||
default=Path("analysis/fanuc_allowable_limit_tables.csv"),
|
||||
help="CSV 输出路径。",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""脚本入口。"""
|
||||
|
||||
args = parse_args()
|
||||
limit_types = tuple(int(item.strip()) for item in args.types.split(",") if item.strip())
|
||||
unknown_types = [item for item in limit_types if item not in LIMIT_TYPES]
|
||||
if unknown_types:
|
||||
print(f"未知限制类型:{unknown_types}", file=sys.stderr)
|
||||
return 2
|
||||
if not 1 <= args.axis_start <= args.axis_end <= 9:
|
||||
print("轴范围必须满足 1 <= axis-start <= axis-end <= 9。", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
tables = iter_requested_tables(
|
||||
args.robot_ip,
|
||||
args.port,
|
||||
args.timeout,
|
||||
range(args.axis_start, args.axis_end + 1),
|
||||
limit_types,
|
||||
)
|
||||
args.csv.parent.mkdir(parents=True, exist_ok=True)
|
||||
write_csv(args.csv, tables)
|
||||
print_summary(tables)
|
||||
print(f"\nCSV 已写入:{args.csv}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
BIN
docs/0.7x.pcap
Normal file
BIN
docs/0.7x.pcap
Normal file
Binary file not shown.
109
docs/collaborative-robot-flyshot-start-api.md
Normal file
109
docs/collaborative-robot-flyshot-start-api.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# 协作机器人飞拍接口
|
||||
|
||||
本文仅保留协作机器人后端需要调用的两个接口:设置运行速度、启动已存在的飞拍任务。不包含初始化、连接、使能、任务上传或任务管理。
|
||||
|
||||
服务地址示例:`http://<飞拍服务地址>:<端口>`。
|
||||
|
||||
## 1. 设置运行速度
|
||||
|
||||
```http
|
||||
POST /set_speedRatio/
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
请求体:
|
||||
|
||||
```json
|
||||
{
|
||||
"speed": 0.7
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 范围 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `speed` | number | `0.01` ~ `1.0` | 运行速度倍率;`1.0` 为全速,`0.7` 为 70% 速度。 |
|
||||
|
||||
成功响应:`200 OK`
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "set_speedRatio executed",
|
||||
"returnCode": 0
|
||||
}
|
||||
```
|
||||
|
||||
参数不在范围内时返回 `400 Bad Request`:
|
||||
|
||||
```json
|
||||
{
|
||||
"detail": "Speed ratio must be between 0.01 and 1.0."
|
||||
}
|
||||
```
|
||||
|
||||
应在启动飞拍前设置速度。当前接口不约定任务执行过程中的动态改速。
|
||||
|
||||
## 2. 启动飞拍任务
|
||||
|
||||
```http
|
||||
POST /execute_flyshot/
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
请求体:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "协作机器人飞拍任务名",
|
||||
"move_to_start": true,
|
||||
"method": "icsp",
|
||||
"save_traj": false,
|
||||
"use_cache": true,
|
||||
"wait": false
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `name` | string | 已存在的飞拍任务名称。 |
|
||||
| `move_to_start` | boolean | 是否先移动到飞拍轨迹起点;建议为 `true`。 |
|
||||
| `method` | string | 轨迹规划方法;使用 `"icsp"`。 |
|
||||
| `save_traj` | boolean | 是否保存规划导出信息;启动任务时使用 `false`。 |
|
||||
| `use_cache` | boolean | 是否复用规划缓存;建议为 `true`。 |
|
||||
| `wait` | boolean | `false` 表示启动后立即返回;本接口使用 `false`。 |
|
||||
|
||||
成功响应:`200 OK`
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "FlyShot executed",
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
`wait=false` 时,`200` 仅表示任务已接受并开始调度,不表示飞拍已经完成。
|
||||
|
||||
启动失败时返回 `500 Internal Server Error`:
|
||||
|
||||
```json
|
||||
{
|
||||
"detail": "具体失败原因"
|
||||
}
|
||||
```
|
||||
|
||||
## 3. 调用顺序
|
||||
|
||||
```text
|
||||
POST /set_speedRatio/ → 收到 returnCode = 0 → POST /execute_flyshot/
|
||||
```
|
||||
|
||||
curl 示例:
|
||||
|
||||
```bash
|
||||
curl -X POST 'http://<飞拍服务地址>:<端口>/set_speedRatio/' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"speed":0.7}'
|
||||
|
||||
curl -X POST 'http://<飞拍服务地址>:<端口>/execute_flyshot/' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"name":"协作机器人飞拍任务名","move_to_start":true,"method":"icsp","save_traj":false,"use_cache":true,"wait":false}'
|
||||
```
|
||||
@@ -7,32 +7,27 @@
|
||||
推荐使用聚合端点完成当前现场的一次性初始化:
|
||||
|
||||
```bash
|
||||
POST /init_mpc_robt
|
||||
POST /init_mpc_robot
|
||||
{
|
||||
"server_ip": "127.0.0.1",
|
||||
"port": 50001,
|
||||
"robot_name": "FANUC_LR_Mate_200iD",
|
||||
"robot_ip": "192.168.10.11",
|
||||
"sim": false
|
||||
"robot_ip": "192.168.10.11"
|
||||
}
|
||||
```
|
||||
|
||||
该端点内部顺序:
|
||||
|
||||
1. `ConnectServer(server_ip, port)`:兼容旧参数形状,仅记录服务连接语义。
|
||||
2. `SetUpRobot(robot_name)`:加载机器人配置、关节限制和伺服周期。
|
||||
3. `SetActiveController(sim)`:选择仿真或 FANUC 真机运行时。
|
||||
4. `Connect(robot_ip)`:真机模式下依次建立 `TCP 10010` 状态通道、`TCP 10012` 命令通道、`UDP 60015` J519 运动通道。
|
||||
5. `EnableRobot(2)`:真机模式下执行 `StopProg("RVBUSTSM") -> Reset -> GetProgStatus("RVBUSTSM") -> StartProg("RVBUSTSM")`,随后允许 J519 在收到机器人 UDP status 包后回发下一帧命令。
|
||||
1. `SetUpRobot(robot_name)`:加载机器人配置、关节限制和伺服周期,状态进入 `OfflineReady`。
|
||||
2. `Connect(robot_ip)`:依次建立 `TCP 10010` 状态通道、`TCP 10012` 命令通道、`UDP 60015` J519 运动通道。
|
||||
3. `EnableRobot(8)`:启动 `RVBUSTSM` 和 J519 运动循环,成功后进入 `RealReady`。
|
||||
|
||||
连接或使能失败时接口返回 400,但模型保持加载,状态进入 `OfflineReady`;只有模型加载失败才进入 `ModelLoadFailed`。
|
||||
|
||||
也可以使用拆分端点按同样顺序调用:
|
||||
|
||||
```text
|
||||
POST /connect_server/?server_ip=127.0.0.1&port=50001
|
||||
POST /setup_robot/?robot_name=FANUC_LR_Mate_200iD
|
||||
POST /set_active_controller/?sim=false
|
||||
POST /connect_robot/?ip=192.168.10.11
|
||||
GET /enable_robot/?buffer_size=2
|
||||
GET /enable_robot/?buffer_size=8
|
||||
```
|
||||
|
||||
## 2. 参数设置
|
||||
@@ -98,6 +93,8 @@ POST /move_joint/
|
||||
|
||||
## 4. 飞拍轨迹
|
||||
|
||||
飞拍资产固定保存在 `Config/RobotConfig.json`。上传、列出和删除不要求模型已加载,也不要求真机连接;`setup_robot` 不会清空已上传轨迹。
|
||||
|
||||
上传:
|
||||
|
||||
```bash
|
||||
@@ -122,6 +119,18 @@ POST /is_flyShotTrajValid/
|
||||
}
|
||||
```
|
||||
|
||||
离线生成最终发送轨迹:
|
||||
|
||||
```bash
|
||||
POST /save_traj_info/
|
||||
{
|
||||
"name": "UTTC_MS11",
|
||||
"method": "self-adapt-icsp"
|
||||
}
|
||||
```
|
||||
|
||||
该接口只要求模型已加载,不要求连接或使能。响应包含规划时长、最终发送时长、关键/稠密点数量、完整最终 8ms 点列、IO mask/value、ShotEvents、触发采样点绑定和全部导出文件绝对路径。`/debug` 中的“离线飞拍规划”面板提供同一能力,并明确不会向机器人发送命令。
|
||||
|
||||
执行:
|
||||
|
||||
```bash
|
||||
@@ -142,7 +151,7 @@ POST /execute_flyshot/
|
||||
2. 使用 `icsp` 或 `self-adapt-icsp` 规划关节轨迹;规划阶段先应用 `acc_limit / jerk_limit`,再应用 replacement-only 的规划加速度校准参数。
|
||||
3. 生成 `TrajectoryDoEvent`,把拍照触发绑定到轨迹时间。
|
||||
4. 若 `move_to_start=true`,先从运行时当前关节位置生成临时 PTP 稠密轨迹移动到规划轨迹起点,并等待运行时 `IsInMotion=false` 后再启动飞拍轨迹,避免第一帧 J519 目标从当前位置跳到起点。
|
||||
5. 真机模式下把规划输出的 `rad` 稠密轨迹按 J519 轨迹时间步长重采样并转成 `deg`,命令实际发包由机器人 UDP status 包驱动。
|
||||
5. 把规划输出的 `rad` 连续轨迹构建为最终 8ms 发送队列并转成 `deg`;离线规划和真机执行复用同一份结果。
|
||||
6. 若 `wait=true`,正式飞拍轨迹启动后继续等待运行时 `IsInMotion=false`,机器人执行完整条飞拍轨迹后 HTTP 才返回;`wait=false` 时启动后立即返回。
|
||||
7. 启动前若已有 J519 响应且 `accept_cmd` 或 `sysrdy` 未就绪,则拒绝执行。
|
||||
8. 周期命令中嵌入 IO 脉冲;当前 UTTC 抓包确认 mask 集合为 `10/12/14`,共 17 个 set 脉冲和 17 个 clear 帧。
|
||||
@@ -157,9 +166,10 @@ GET /disable_robot/
|
||||
POST /disconnect_robot/
|
||||
```
|
||||
|
||||
真机模式下:
|
||||
真机运行时:
|
||||
|
||||
- `StopMove()` 取消当前稠密轨迹生成任务并停止 J519 状态包驱动发送。
|
||||
- 正常飞拍、MoveJoint、MovePose 结束时,默认在 J519 队列末尾追加 `LastData=1` 结束帧;结束帧发出后进入仅监控状态,继续接收 J519 状态包,不再回发保姿命令。
|
||||
- `StopMove()` 取消当前稠密轨迹生成任务并走强制停止路径,可停止 J519 状态包驱动发送。
|
||||
- `DisableRobot()` 发送 J519 packet type 2 状态输出停止包,然后 `StopProg("RVBUSTSM")`。
|
||||
- `Disconnect()` 关闭状态、命令和 J519 三条通道,并清理本地运行状态。
|
||||
|
||||
@@ -178,5 +188,5 @@ POST /disconnect_robot/
|
||||
- 主运行窗口命令序号连续,无重复 seq;J519 客户端单元测试覆盖按最新 status sequence 回发命令。
|
||||
- 响应 `status=15` 段覆盖主运行窗口,响应相对命令滞后 2 到 8 帧。
|
||||
- 实发点位相对重采样期望的全局 RMS 小于 `0.012deg`,最大绝对误差小于 `0.07deg`。
|
||||
- `lastData=0`,结束运动当前依赖 J519 packet type 2 状态输出停止包;`../j519 协议.pcap` 中另有 1 个 `LastData=1` 后紧跟 type 2 的样本,停止语义后续单独验证。
|
||||
- 历史抓包主运行窗口 `lastData=0`;当前实现默认在生成的新 J519 队列末尾追加一帧 `LastData=1` 结束命令,随后保留状态监控且不发送 packet type 2。packet type 2 仅用于禁用、异常停止、断开等强制停止路径;如现场需要旧保持行为,可通过内部开关关闭结束帧路径。
|
||||
- IO 脉冲数量和 mask 集合 `10/12/14` 与抓包一致。
|
||||
|
||||
@@ -82,7 +82,7 @@
|
||||
|
||||
文档把它定义为“停止状态包输出”的控制包,不是命令流正常终止的首选动作。命令流结束应通过 command packet 的 `Last data=1` 表达。
|
||||
|
||||
当前实现:`FanucJ519Client.StopMotionAsync()` 当前会停止状态包驱动发送并发送 packet type `2`,而稠密轨迹执行期间保持 `LastData=0`。这是与 FANUC 文档最明显的语义差异之一;已有多数 UTTC 抓包显示主运行窗口 `LastData=0`,但 `../j519 协议.pcap` 中存在 1 个 `LastData=1` 后紧跟 packet type `2` 的样本,后续应单独校准停止语义。
|
||||
当前实现:`FanucJ519Client.StopMotionAsync()` 仍保留 packet type `2` 作为禁用、异常中断或断开前的强制停止路径。正常稠密运动结束由内部开关 `EndCommandStreamAfterQueueDrained` 控制,默认会在队列末尾追加一帧 `LastData=1` 的结束命令;该帧发出后客户端进入仅监控状态,继续接收 J519 状态包用于位置监控,不再持续回发保姿命令。关闭该开关时保留旧行为:队列结束后继续保持最后目标或反馈位置。`LastData` 是单帧 command packet 字段,不是全局状态;下一轮运动重新装载的新队列会从 `LastData=0` 的普通命令帧开始。
|
||||
|
||||
## 4. 通信时序重点
|
||||
|
||||
@@ -100,7 +100,7 @@
|
||||
1. `FanucJ519Client` 已改为收到机器人 132B status 包后立即回发当前命令,不再由上位机本地固定 8ms 发送循环主动发包。
|
||||
2. 命令包 sequence 已按刚收到的 status packet sequence 写入,避免第一帧从本地 `0` 起步。
|
||||
3. `FanucControllerRuntime.ExecuteTrajectory()` 启动前会检查已有 J519 响应中的 `AcceptsCommand` 和 `SystemReady`;但如果还没收到状态包,则会放行,后续命令仍要等第一帧 status 到达才会发出。
|
||||
4. 当前稠密轨迹结束不发送 `LastData=1`,而是依赖停止 J519 状态包驱动发送和 packet type `2` stop 控制包。
|
||||
4. 默认新路径会在稠密轨迹队列末尾追加独立的 `LastData=1` 结束帧;结束后只监控状态,不发送 packet type `2`。旧保持路径仍可通过内部开关回退。
|
||||
|
||||
序号和节拍已经按手册方向校准;停止语义仍需在真实 R30iB 联调中继续确认。
|
||||
|
||||
@@ -220,13 +220,13 @@
|
||||
主要差异/风险:
|
||||
|
||||
1. 当前未实现命令缓冲预填,也未读取 `$PKT_STACK / $START_MOVE`。
|
||||
2. 当前停止运动依赖 packet type `2` stop 控制包,没有稳定发送 `LastData=1` 的最后 command packet;这与手册标准结束语义不同。
|
||||
2. 默认正常运动结束已经通过追加 `LastData=1` 结束帧靠近手册语义;packet type `2` 仍仅作为强制停止状态输出路径。
|
||||
3. 当前未实现 packet type `3` 的速度/加速度/jerk 限制表查询,也未实现 payload/speed 20 档动态限制算法。
|
||||
4. 当前没有自动校验 `AUTO / 100% OVERRIDE / brake control / resume offset / payload` 等控制器前置状态。
|
||||
5. 当前没有报警码读取和 `MOTN-* / PRIO-*` 映射。
|
||||
|
||||
建议后续联调优先级:
|
||||
|
||||
1. 验证运动结束是否必须补 `LastData=1`;如果当前 stop 控制包能稳定工作,也应在文档中标为现场兼容路径,而不是手册标准路径。
|
||||
1. 联调验证 `LastData=1` 后机器人是否稳定退出命令消费并继续输出状态包;如现场程序仍要求持续命令包,可通过内部开关回退旧保持路径。
|
||||
2. 抓一次报警现场包,确认 `MOTN-600/603/617` 等是否能从包序号与状态位直接定位。
|
||||
3. 如果后续追求更稳的真实机运行,补 packet type `3` 限制表查询,并把规划器的速度、加速度、jerk 校验与 FANUC 手册算法靠近。
|
||||
|
||||
138
docs/figures/icsp-algorithm-ppt.svg
Normal file
138
docs/figures/icsp-algorithm-ppt.svg
Normal file
@@ -0,0 +1,138 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="900" viewBox="0 0 1600 900" role="img" aria-labelledby="title desc">
|
||||
<title id="title">飞拍轨迹规划逻辑</title>
|
||||
<desc id="desc">用于 PPT 展示的飞拍轨迹规划逻辑图,展示输入、三次样条、约束检查、逐段缩放迭代、输出时间轴与采样层关系。</desc>
|
||||
<defs>
|
||||
<marker id="arrow-blue" markerWidth="14" markerHeight="14" refX="11" refY="7" orient="auto" markerUnits="strokeWidth">
|
||||
<path d="M2,2 L12,7 L2,12 Z" fill="#2563eb" />
|
||||
</marker>
|
||||
<marker id="arrow-green" markerWidth="14" markerHeight="14" refX="11" refY="7" orient="auto" markerUnits="strokeWidth">
|
||||
<path d="M2,2 L12,7 L2,12 Z" fill="#059669" />
|
||||
</marker>
|
||||
<marker id="arrow-amber" markerWidth="14" markerHeight="14" refX="11" refY="7" orient="auto" markerUnits="strokeWidth">
|
||||
<path d="M2,2 L12,7 L2,12 Z" fill="#d97706" />
|
||||
</marker>
|
||||
<linearGradient id="title-grad" x1="0" x2="1" y1="0" y2="0">
|
||||
<stop offset="0" stop-color="#1d4ed8" />
|
||||
<stop offset="0.55" stop-color="#0f766e" />
|
||||
<stop offset="1" stop-color="#7c3aed" />
|
||||
</linearGradient>
|
||||
<filter id="soft-shadow" x="-10%" y="-10%" width="120%" height="130%">
|
||||
<feDropShadow dx="0" dy="8" stdDeviation="8" flood-color="#0f172a" flood-opacity="0.12" />
|
||||
</filter>
|
||||
<style>
|
||||
.bg { fill: #f8fafc; }
|
||||
.title { font: 700 42px "Microsoft YaHei", "Segoe UI", Arial, sans-serif; fill: #0f172a; letter-spacing: 0; }
|
||||
.subtitle { font: 400 23px "Microsoft YaHei", "Segoe UI", Arial, sans-serif; fill: #475569; letter-spacing: 0; }
|
||||
.section-title { font: 700 25px "Microsoft YaHei", "Segoe UI", Arial, sans-serif; fill: #0f172a; letter-spacing: 0; }
|
||||
.label { font: 600 21px "Microsoft YaHei", "Segoe UI", Arial, sans-serif; fill: #0f172a; letter-spacing: 0; }
|
||||
.small { font: 400 18px "Microsoft YaHei", "Segoe UI", Arial, sans-serif; fill: #334155; letter-spacing: 0; }
|
||||
.tiny { font: 400 15px "Microsoft YaHei", "Segoe UI", Arial, sans-serif; fill: #475569; letter-spacing: 0; }
|
||||
.formula { font: 500 20px "Consolas", "Microsoft YaHei", monospace; fill: #0f172a; letter-spacing: 0; }
|
||||
.math { font: 500 25px "Cambria Math", "Times New Roman", "Microsoft YaHei", serif; fill: #0f172a; letter-spacing: 0; }
|
||||
.math-small { font: 500 21px "Cambria Math", "Times New Roman", "Microsoft YaHei", serif; fill: #0f172a; letter-spacing: 0; }
|
||||
.box { fill: #ffffff; stroke: #cbd5e1; stroke-width: 2; rx: 18; filter: url(#soft-shadow); }
|
||||
.box-blue { fill: #eff6ff; stroke: #93c5fd; stroke-width: 2; rx: 18; }
|
||||
.box-green { fill: #ecfdf5; stroke: #86efac; stroke-width: 2; rx: 18; }
|
||||
.box-amber { fill: #fffbeb; stroke: #fcd34d; stroke-width: 2; rx: 18; }
|
||||
.box-purple { fill: #f5f3ff; stroke: #c4b5fd; stroke-width: 2; rx: 18; }
|
||||
.pill { rx: 22; }
|
||||
.line { fill: none; stroke: #2563eb; stroke-width: 4; marker-end: url(#arrow-blue); }
|
||||
.line-green { fill: none; stroke: #059669; stroke-width: 4; marker-end: url(#arrow-green); }
|
||||
.line-amber { fill: none; stroke: #d97706; stroke-width: 4; marker-end: url(#arrow-amber); }
|
||||
.loop { fill: none; stroke: #d97706; stroke-width: 4; stroke-dasharray: 10 8; marker-end: url(#arrow-amber); }
|
||||
.axis { stroke: #64748b; stroke-width: 2; }
|
||||
.grid { stroke: #e2e8f0; stroke-width: 1; }
|
||||
.curve { fill: none; stroke: #2563eb; stroke-width: 5; stroke-linecap: round; }
|
||||
.curve2 { fill: none; stroke: #0f766e; stroke-width: 4; stroke-linecap: round; }
|
||||
.dot { fill: #1d4ed8; stroke: #ffffff; stroke-width: 4; }
|
||||
.dot-green { fill: #059669; stroke: #ffffff; stroke-width: 4; }
|
||||
.accent-bar { fill: url(#title-grad); rx: 4; }
|
||||
.divider { stroke: #cbd5e1; stroke-width: 2; stroke-dasharray: 8 8; }
|
||||
</style>
|
||||
</defs>
|
||||
|
||||
<rect class="bg" width="1600" height="900" />
|
||||
<rect class="accent-bar" x="80" y="58" width="540" height="8" />
|
||||
<text class="title" x="80" y="120">飞拍轨迹规划逻辑</text>
|
||||
<text class="subtitle" x="80" y="158">用三次样条连接示教点,通过反复拉长超限段,让整条轨迹满足关节约束。</text>
|
||||
|
||||
<!-- 主流程卡片 -->
|
||||
<rect class="box-blue" x="80" y="215" width="255" height="180" />
|
||||
<text class="section-title" x="112" y="258">输入</text>
|
||||
<text class="small" x="112" y="312">示教点</text>
|
||||
<text class="small" x="112" y="346">关节约束</text>
|
||||
|
||||
<path class="line" d="M335 305 H420" />
|
||||
|
||||
<rect class="box" x="420" y="215" width="285" height="180" />
|
||||
<text class="section-title" x="452" y="258">初始时间轴</text>
|
||||
<text class="small" x="452" y="318">按相邻点距离</text>
|
||||
<text class="small" x="452" y="352">估算每段时长</text>
|
||||
|
||||
<path class="line" d="M705 305 H790" />
|
||||
|
||||
<rect class="box-green" x="790" y="215" width="305" height="180" />
|
||||
<text class="section-title" x="822" y="258">三次样条</text>
|
||||
<text class="small" x="822" y="318">连接所有示教点</text>
|
||||
<text class="small" x="822" y="352">形成连续轨迹</text>
|
||||
|
||||
<path class="line" d="M1095 305 H1180" />
|
||||
|
||||
<rect class="box-amber" x="1180" y="215" width="340" height="180" />
|
||||
<text class="section-title" x="1212" y="258">约束检查</text>
|
||||
<text class="small" x="1212" y="312">速度 / 加速度 / jerk</text>
|
||||
<text class="small" x="1212" y="346">找出超限段</text>
|
||||
|
||||
<!-- 曲线示意 -->
|
||||
<rect class="box" x="105" y="455" width="585" height="285" />
|
||||
<text class="section-title" x="135" y="497">轨迹形状:经过原始示教点</text>
|
||||
<line class="grid" x1="150" y1="680" x2="650" y2="680" />
|
||||
<line class="grid" x1="150" y1="620" x2="650" y2="620" />
|
||||
<line class="grid" x1="150" y1="560" x2="650" y2="560" />
|
||||
<line class="axis" x1="150" y1="690" x2="650" y2="690" />
|
||||
<line class="axis" x1="150" y1="690" x2="150" y2="535" />
|
||||
<path class="curve" d="M165 665 C205 618, 240 565, 285 585 C340 610, 375 695, 425 645 C475 595, 500 515, 558 548 C595 570, 620 625, 640 600" />
|
||||
<circle class="dot" cx="165" cy="665" r="9" />
|
||||
<circle class="dot" cx="285" cy="585" r="9" />
|
||||
<circle class="dot" cx="425" cy="645" r="9" />
|
||||
<circle class="dot" cx="558" cy="548" r="9" />
|
||||
<circle class="dot" cx="640" cy="600" r="9" />
|
||||
<text class="tiny" x="160" y="720">q0</text>
|
||||
<text class="tiny" x="277" y="720">q1</text>
|
||||
<text class="tiny" x="417" y="720">q2</text>
|
||||
<text class="tiny" x="550" y="720">q3</text>
|
||||
<text class="tiny" x="630" y="720">qn</text>
|
||||
<text class="small" x="135" y="777">点位不变,主要调整每段用时。</text>
|
||||
|
||||
<!-- 核心公式卡片 -->
|
||||
<rect class="box-purple" x="735" y="455" width="785" height="285" />
|
||||
<text class="section-title" x="765" y="497">迭代逻辑</text>
|
||||
<text class="label" x="780" y="560">1. 计算每段是否超限</text>
|
||||
<text class="label" x="780" y="620">2. 超限段按比例拉长时间</text>
|
||||
<text class="label" x="780" y="680">3. 重新生成样条,再检查一次</text>
|
||||
<text class="math-small" x="1195" y="590">
|
||||
<tspan>scale</tspan><tspan baseline-shift="sub" font-size="14">i</tspan>
|
||||
<tspan> = max(s</tspan><tspan baseline-shift="sub" font-size="14">v</tspan><tspan>, s</tspan><tspan baseline-shift="sub" font-size="14">a</tspan><tspan>, s</tspan><tspan baseline-shift="sub" font-size="14">j</tspan><tspan>)</tspan>
|
||||
</text>
|
||||
<text class="math-small" x="1195" y="650">
|
||||
<tspan>T</tspan><tspan baseline-shift="sub" font-size="14">i</tspan>
|
||||
<tspan> ← T</tspan><tspan baseline-shift="sub" font-size="14">i</tspan>
|
||||
<tspan> * scale</tspan><tspan baseline-shift="sub" font-size="14">i</tspan>
|
||||
</text>
|
||||
|
||||
<!-- 迭代回路 -->
|
||||
<path class="loop" d="M1350 395 C1350 430, 1270 435, 1190 425 C1075 410, 988 410, 945 397" />
|
||||
<text class="small" x="1015" y="435" fill="#92400e">未收敛就继续拉长、重算</text>
|
||||
|
||||
<!-- 输出层 -->
|
||||
<rect class="box-green" x="80" y="805" width="330" height="58" />
|
||||
<text class="label" x="110" y="842">输出时间轴</text>
|
||||
<path class="line-green" d="M410 834 H515" />
|
||||
<rect class="box" x="515" y="790" width="430" height="88" />
|
||||
<text class="label" x="545" y="826">按周期采样</text>
|
||||
<text class="tiny" x="545" y="856">后续模块生成稠密轨迹点</text>
|
||||
<path class="line-green" d="M945 834 H1050" />
|
||||
<rect class="box-amber" x="1050" y="790" width="470" height="88" />
|
||||
<text class="label" x="1080" y="826">运行时下发</text>
|
||||
<text class="tiny" x="1080" y="856">进入飞拍执行链路</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 8.3 KiB |
@@ -47,10 +47,10 @@
|
||||
本设计目标如下:
|
||||
|
||||
- 保持 `planning_speed_scale` 只属于规划层。
|
||||
- `speedRatio` 只作用于飞拍执行层,且仅对下一次启动的飞拍任务生效。
|
||||
- 任意有效 `speedRatio` 下,最终真实发送的 `8ms` 点列必须满足逐周期 `vel/acc/jerk` 约束。
|
||||
- `speedRatio` 是运行时统一执行倍率,可影响下一次启动的飞拍、普通轨迹和 `MoveJoint`。
|
||||
- 飞拍链路在任意有效 `speedRatio` 下,最终真实发送的 `8ms` 点列必须满足逐周期 `vel/acc/jerk` 约束。
|
||||
- 不依赖“插值后自然会平滑”的经验假设。
|
||||
- 当 `speedRatio = 0.5` 等较慢倍率导致候选点列不满足约束时,系统自动拉长执行时长,直到点列通过校验。
|
||||
- 当请求 `speedRatio` 下的最终发送队列不满足约束时,拒绝执行并输出超限诊断;不在执行侧偷偷改写本次请求倍率。
|
||||
- 不破坏现有 `shot timeline / sample offset` 语义。
|
||||
- `speedRatio = 1.0` 时行为应与当前基线一致或可解释等价。
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
|
||||
本次设计明确不包含以下内容:
|
||||
|
||||
- 不修改 `move_joint` 的调速和生成机制。
|
||||
- 不恢复旧的发送阶段临场回采样机制;普通轨迹和 `MoveJoint` 允许把 `speedRatio` 前移到规划或临时轨迹生成阶段折算。
|
||||
- 不讨论 GUI、多机器人或旧 `50001/TCP+JSON` 网关恢复。
|
||||
- 不修改 `planning_speed_scale` 的含义。
|
||||
- 不在任务执行中途支持 `speedRatio` 热切换。
|
||||
@@ -77,7 +77,7 @@
|
||||
- 输入规划层稠密轨迹、执行时读取到的 `speedRatio`、机器人关节限位和触发时间轴。
|
||||
- 生成最终 `8ms` 发送队列。
|
||||
- 对最终队列做逐周期 `vel/acc/jerk` 校验。
|
||||
- 如果不通过,则自动拉长执行时长后重建队列,直到通过。
|
||||
- 如果不通过,则拒绝执行,提示降低 `planning_speed_scale` 重新规划或显式设置更低的 `speedRatio`。
|
||||
3. 发送段
|
||||
- 运行时只消费已经准备好的最终 `8ms` 队列。
|
||||
- 运行时不再根据 `speedRatio` 对 `DenseJointTrajectory` 临场做线性回采样。
|
||||
@@ -90,7 +90,7 @@
|
||||
|
||||
## 6. 统一语义
|
||||
|
||||
本次只改飞拍任务,但仍需把飞拍内部语义说清楚,避免再次混淆规划倍率和执行倍率。
|
||||
本次以飞拍任务为主线,但 `speedRatio` 允许作为运行时统一执行倍率影响普通轨迹和 `MoveJoint`,需要把三类入口的边界说清楚,避免再次混淆规划倍率和执行倍率。
|
||||
|
||||
### 6.1 `planning_speed_scale`
|
||||
|
||||
@@ -101,17 +101,17 @@
|
||||
|
||||
### 6.2 `speedRatio`
|
||||
|
||||
- 只影响飞拍执行准备阶段。
|
||||
- 只对下一次启动的飞拍任务生效。
|
||||
- 表示“用户期望的执行层时间推进速度”。
|
||||
- 该值先用于构建候选发送队列。
|
||||
- 如果候选发送队列在离散 `8ms` 点列上不满足约束,则系统自动进一步拉长执行时长,直到满足约束。
|
||||
- 影响下一次启动的运动任务,不在任务执行中途热切换。
|
||||
- 对飞拍任务,表示用户期望的执行层时间推进速度,可前移到规划/执行准备阶段生成最终发送队列。
|
||||
- 对普通轨迹和 `MoveJoint`,允许在规划或临时 PTP 轨迹生成阶段折算进有效关节限位,使运行时只消费已经生成好的 `8ms` 稠密点列。
|
||||
- `speedRatio = 0.8` 表示执行节奏接近把同几何轨迹时间轴整体拉长 `1 / 0.8`。
|
||||
- 例如 `planning_speed_scale = 0.9` 且 `speedRatio = 0.8` 时,最终执行节奏应接近同几何轨迹在 `planning_speed_scale ≈ 0.72` 下的结果。
|
||||
|
||||
也就是说:
|
||||
|
||||
- `speedRatio` 是执行目标倍率。
|
||||
- 但最终真实执行时长允许比该目标更保守。
|
||||
- 保守的原因不是放宽标准,而是严格保证离散动力学约束。
|
||||
- `speedRatio` 是确定的执行倍率,不是校验失败后的自动搜索变量。
|
||||
- `finalSpeedRatio` 正常必须等于请求值。
|
||||
- 若该倍率下最终队列超限,问题暴露给调用方,而不是由执行侧自动改成另一个倍率。
|
||||
|
||||
## 7. 最终发送序列生成
|
||||
|
||||
@@ -124,54 +124,33 @@
|
||||
|
||||
职责:
|
||||
|
||||
- 输入规划层 `DenseJointTrajectory`。
|
||||
- 输入 `durationSeconds`、`servoPeriodSeconds`、`speedRatio`。
|
||||
- 输入规划层连续 `PlannedTrajectory`,优先使用经过执行侧定时处理后的 `ExecutionTrajectory`。
|
||||
- 输入 `servoPeriodSeconds`、`speedRatio`。
|
||||
- 输出最终真实发送的 `8ms` 点列。
|
||||
- 输出与该点列一致的触发绑定结果。
|
||||
- 输出校验与自动拉长过程中的诊断信息。
|
||||
- 输出请求倍率、最终倍率和历史倍率改写次数等诊断信息;当前语义下最终倍率等于请求倍率、改写次数为 0。
|
||||
|
||||
### 7.2 第一版候选队列
|
||||
### 7.2 最终请求倍率队列
|
||||
|
||||
候选队列仍可以沿用当前时间轴语义作为起点:
|
||||
最终队列按固定时间轴语义生成:
|
||||
|
||||
- `sendTime = sampleIndex * 0.008`
|
||||
- `trajectoryTime = min(sendTime * speedRatio, durationSeconds)`
|
||||
- 关节目标从连续样条直接取点,并从 `rad` 转为 J519 需要的 `deg`
|
||||
|
||||
但该候选队列只作为“第一轮尝试”,不能直接视为最终执行结果。
|
||||
该队列就是本次请求倍率下的最终发送队列,后续只做校验和触发绑定,不再进行二次滤波或倍率搜索。
|
||||
|
||||
### 7.3 自动拉长策略
|
||||
### 7.3 校验失败策略
|
||||
|
||||
当候选队列的离散 `vel/acc/jerk` 校验失败时:
|
||||
当最终队列的离散 `vel/acc/jerk` 校验失败时:
|
||||
|
||||
- 不修改规划层轨迹。
|
||||
- 不修改 `planning_speed_scale`。
|
||||
- 不放宽校验阈值。
|
||||
- 只在执行侧拉长最终发送时长,然后重新构建候选队列。
|
||||
- 不降低本次 `speedRatio`。
|
||||
- 直接拒绝执行并保留首个超限诊断。
|
||||
|
||||
可实现为等价的两种方式之一:
|
||||
|
||||
1. 延长物理发送总点数,使发送总时长变长。
|
||||
2. 在保持 `8ms` 周期不变的前提下,降低执行侧有效轨迹时间推进速度。
|
||||
|
||||
本质上二者等价,建议统一落成第二种表达:
|
||||
|
||||
- 对外仍说“自动拉长执行时长”。
|
||||
- 对内通过更保守的 `trajectoryTime(sendTime)` 映射来实现。
|
||||
|
||||
### 7.4 自动拉长的迭代规则
|
||||
|
||||
建议采用单调保守策略:
|
||||
|
||||
1. 先按请求 `speedRatio` 构建第 1 版候选队列。
|
||||
2. 对候选队列做离散校验。
|
||||
3. 若失败,则按固定步长或倍率逐轮拉长,再次构建候选队列。
|
||||
4. 一旦通过,立即停止迭代,产出最终队列。
|
||||
5. 若超过安全迭代上限仍未通过,则拒绝执行并输出首个超限诊断。
|
||||
|
||||
这样可以保证:
|
||||
|
||||
- 自动拉长过程是可解释、可记录的。
|
||||
- 不会因为局部修补而引入新的不可预测尖峰。
|
||||
这样可以避免把 `speedRatio` 误实现成安全优化器。现场若希望从 `planning_speed_scale = 0.9` 的 10s 轨迹降到接近 `planning_speed_scale = 0.7` 的执行效果,应显式设置类似 `speedRatio = 0.8`,而不是让系统在失败后自动猜一个更低倍率。
|
||||
|
||||
## 8. 逐周期约束校验
|
||||
|
||||
@@ -201,10 +180,11 @@
|
||||
|
||||
### 8.3 校验失败的处理
|
||||
|
||||
飞拍链路按本次确认采用 A:
|
||||
飞拍链路按当前修正语义采用“确定倍率 + 失败拒绝”:
|
||||
|
||||
- 若失败,优先自动拉长执行时长重试。
|
||||
- 只有在超过拉长上限后仍失败时才拒绝执行。
|
||||
- 若请求倍率下失败,立即拒绝执行。
|
||||
- 不进行执行侧自动倍率搜索。
|
||||
- 调用方可以选择降低 `planning_speed_scale` 重新规划,或显式设置更低的 `speedRatio` 后再次执行。
|
||||
|
||||
拒绝执行时必须输出:
|
||||
|
||||
@@ -212,7 +192,8 @@
|
||||
- 时间窗
|
||||
- 指标类型
|
||||
- `actual / limit / ratio`
|
||||
- 失败发生在哪一轮自动拉长尝试中
|
||||
- 请求 `speedRatio`
|
||||
- 当前 `finalSpeedRatio`,正常等于请求值
|
||||
|
||||
## 9. 触发时序与绑定
|
||||
|
||||
@@ -238,15 +219,15 @@
|
||||
|
||||
- 触发绑定和真实发送完全一致。
|
||||
- 导出工件中的 `ShotEvents.json` 与真实执行时序一致。
|
||||
- 自动拉长后触发点仍可被精确追溯到最终发送点索引。
|
||||
- 触发点可被精确追溯到本次请求倍率下的最终发送点索引。
|
||||
|
||||
### 9.3 关于触发数量与顺序
|
||||
|
||||
自动拉长执行时长时:
|
||||
请求倍率改变最终发送时长时:
|
||||
|
||||
- 触发数量不能变。
|
||||
- 触发顺序不能变。
|
||||
- 只允许触发在最终发送队列中的绑定索引后移或保持可解释等价。
|
||||
- 触发在最终发送队列中的绑定索引应随时间轴拉长后移或保持可解释等价。
|
||||
|
||||
## 10. 运行时职责调整
|
||||
|
||||
@@ -284,9 +265,19 @@
|
||||
|
||||
## 11. 导出工件与日志
|
||||
|
||||
### 11.1 工件统一基于最终发送队列
|
||||
### 11.1 旧规划导出和实发诊断必须分开
|
||||
|
||||
以下文件必须全部从最终发送队列生成:
|
||||
`saveTrajectory` 目录中存在两类不同用途的文件,不能再混成同一条时间轴:
|
||||
|
||||
第一类是旧格式规划导出,用于和旧 RVBUST 导出的规划轨迹、离线分析脚本做对比。它们只受 `planning_speed_scale` 和规划/整形参数影响,不受运行时 `speedRatio` 影响:
|
||||
|
||||
- `JointTraj.txt`
|
||||
- `JointDetialTraj.txt`
|
||||
- `CartTraj.txt`
|
||||
- `CartDetialTraj.txt`
|
||||
- `JointDetialTraj.analysis.txt`
|
||||
|
||||
第二类是实发诊断,用于观察当前 `speedRatio` 下真实准备发送的 8ms 队列和触发绑定。它们必须全部从最终发送队列生成:
|
||||
|
||||
- `ActualSendJointTraj.txt`
|
||||
- `ActualSendTiming.txt`
|
||||
@@ -295,7 +286,8 @@
|
||||
|
||||
不允许再出现:
|
||||
|
||||
- 导出文件基于一套样本,
|
||||
- 旧规划导出被运行时 `speedRatio` 改写,
|
||||
- `ActualSend*` 基于一套样本,
|
||||
- 真实发送队列又基于另一套样本。
|
||||
|
||||
### 11.2 推荐新增日志字段
|
||||
@@ -304,8 +296,8 @@
|
||||
|
||||
- 请求 `speedRatio`
|
||||
- 规划层轨迹时长
|
||||
- 第一轮候选队列点数
|
||||
- 第一轮候选队列是否通过校验
|
||||
- 最终发送队列点数
|
||||
- 最终发送队列是否通过校验
|
||||
- 若失败,首个失败窗口的:
|
||||
- 关节轴
|
||||
- 时间区间
|
||||
@@ -313,7 +305,7 @@
|
||||
- `actual`
|
||||
- `limit`
|
||||
- `ratio`
|
||||
- 自动拉长轮数
|
||||
- 倍率改写次数,当前正常为 0
|
||||
- 最终采用的执行时长
|
||||
- 最终发送队列点数
|
||||
- 最终触发绑定数量
|
||||
@@ -324,16 +316,16 @@
|
||||
- `Information`
|
||||
- 记录执行请求、最终采用结果、最终通过结论
|
||||
- `Warning`
|
||||
- 记录第一轮候选失败与自动拉长启动
|
||||
- 记录请求倍率下最终发送队列校验失败
|
||||
- `Debug`
|
||||
- 记录每一轮拉长的中间参数与详细差分统计
|
||||
- 记录最终发送队列的时间映射与详细差分统计
|
||||
|
||||
## 12. 验收口径
|
||||
|
||||
### 12.1 功能验收
|
||||
|
||||
- 飞拍 `speedRatio` 可在线设置。
|
||||
- 该值对下一次启动的飞拍任务生效。
|
||||
- `speedRatio` 可在线设置。
|
||||
- 该值对下一次启动的飞拍、普通轨迹或 `MoveJoint` 生效。
|
||||
- 不需要修改 `planning_speed_scale`。
|
||||
|
||||
### 12.2 约束验收
|
||||
@@ -345,8 +337,9 @@
|
||||
|
||||
### 12.3 工件与日志验收
|
||||
|
||||
- `JointTraj.txt / JointDetialTraj.txt / CartTraj.txt / CartDetialTraj.txt` 不随运行时 `speedRatio` 改变。
|
||||
- `ActualSend*` 文件能反映最终真实发送点位与时间映射。
|
||||
- 日志能定位自动拉长前后的关键参数和校验结果。
|
||||
- 日志能定位请求倍率、最终发送队列和校验失败窗口。
|
||||
|
||||
### 12.4 回归验收
|
||||
|
||||
@@ -362,8 +355,10 @@
|
||||
|
||||
- 飞拍执行侧最终发送序列生成器测试
|
||||
- `speedRatio` 非法边界值测试
|
||||
- 第一轮候选失败后自动拉长成功测试
|
||||
- 请求倍率下队列超限时拒绝执行测试
|
||||
- `speedRatio = 1.0 / 0.8 / 0.5` 的逐周期限值通过测试
|
||||
- `planning_speed_scale = 0.9` 加 `speedRatio = 0.8` 的执行时间轴等价于约 `0.72` 规划倍率的回归测试
|
||||
- 普通轨迹和 `MoveJoint` 在倍率乘积相同时生成等价或可解释等价的稠密执行轨迹
|
||||
- 触发绑定始终与最终发送队列一致的测试
|
||||
|
||||
### 13.2 编排测试
|
||||
@@ -372,6 +367,8 @@
|
||||
|
||||
- `ExecuteFlyShotTraj` 进入运行时前,已经拿到最终发送队列
|
||||
- `FanucControllerRuntime` 不再自行按 `_speedRatio` 对飞拍轨迹回采样
|
||||
- `ExecuteTrajectory` 和 `MoveJoint` 的调速在规划/生成阶段完成,运行时不再做临场倍率重采样
|
||||
- `SaveTrajectoryInfo` 的旧格式规划导出不随运行时 `speedRatio` 改变,而 `ActualSend*` 仍随运行时 `speedRatio` 改变
|
||||
|
||||
### 13.3 集成与黄金样本
|
||||
|
||||
@@ -401,13 +398,13 @@
|
||||
新增飞拍执行侧最终发送序列生成器及相关结果类型,负责:
|
||||
|
||||
- 候选队列构建
|
||||
- 自动拉长
|
||||
- 离散校验
|
||||
- 触发绑定输入准备
|
||||
|
||||
### 14.2 `src/Flyshot.ControllerClientCompat/`
|
||||
|
||||
在飞拍执行准备阶段调用该生成器,把最终发送队列放入执行结果或新的执行上下文对象。
|
||||
普通轨迹规划入口和 `MoveJoint` 临时轨迹生成入口可以读取当前 `speedRatio`,并在进入运行时前折算成已调速的稠密轨迹。
|
||||
|
||||
### 14.3 `src/Flyshot.Runtime.Fanuc/FanucControllerRuntime.cs`
|
||||
|
||||
@@ -418,7 +415,10 @@
|
||||
|
||||
### 14.4 `src/Flyshot.ControllerClientCompat/FlyshotTrajectoryArtifactWriter.cs`
|
||||
|
||||
`ActualSend*` 导出改为复用最终发送队列,确保导出工件与运行时一致。
|
||||
`FlyshotTrajectoryArtifactWriter` 接收两份视图:
|
||||
|
||||
- 旧格式规划导出视图:写 `JointTraj.txt`、`JointDetialTraj.txt`、`CartTraj.txt`、`CartDetialTraj.txt`
|
||||
- 实发诊断视图:写 `ActualSendJointTraj.txt`、`ActualSendTiming.txt`、`ActualSendJerkStats.txt`、`ShotEvents.json`
|
||||
|
||||
### 14.5 `tests/Flyshot.Core.Tests/`
|
||||
|
||||
@@ -426,14 +426,15 @@
|
||||
|
||||
## 15. 风险与注意事项
|
||||
|
||||
### 15.1 不能把自动拉长误解为“偷偷改 speedRatio”
|
||||
### 15.1 不能把 speedRatio 做成安全优化器
|
||||
|
||||
对外语义仍应保留:
|
||||
对外语义必须保持:
|
||||
|
||||
- 用户请求的是某个 `speedRatio`
|
||||
- 系统为了满足离散动力学约束,自动采用了更保守的最终执行时长
|
||||
- 系统按这个 `speedRatio` 构建最终发送队列
|
||||
- 若队列超限,则拒绝执行并要求调用方显式选择新的规划倍率或执行倍率
|
||||
|
||||
日志里必须把这件事说清楚,不能让现场误以为参数未生效。
|
||||
不能因为校验失败就在执行侧自动把 `0.8` 改成 `0.2`;这会让 `speedRatio` 和 `planning_speed_scale` 的关系失去可解释性。
|
||||
|
||||
### 15.2 不能让触发绑定脱离最终队列
|
||||
|
||||
@@ -448,10 +449,12 @@
|
||||
本次飞拍调速设计的核心结论是:
|
||||
|
||||
- `planning_speed_scale` 继续只属于规划层。
|
||||
- `speedRatio` 只属于飞拍执行层,并且只对下一次启动的飞拍任务生效。
|
||||
- `speedRatio` 是运行时统一执行倍率,并且只对下一次启动的运动任务生效。
|
||||
- 普通轨迹和 `MoveJoint` 允许把 `speedRatio` 前移到规划或临时轨迹生成阶段,运行时只发送已生成好的 `8ms` 点列。
|
||||
- 飞拍执行必须先生成最终 `8ms` 发送队列,再对该队列做逐周期 `vel/acc/jerk` 校验。
|
||||
- 若 `speedRatio < 1` 导致第一轮候选队列不满足约束,则自动拉长执行时长后重建,直到通过。
|
||||
- `speedRatio` 按 `trajectoryTime = sendTime * speedRatio` 确定性拉长执行时间轴,`finalSpeedRatio` 不应被自动改写。
|
||||
- 若请求倍率下最终队列不满足约束,则拒绝执行并提示降低 `planning_speed_scale` 重新规划或显式设置更低 `speedRatio`。
|
||||
- 运行时不再对飞拍轨迹做发送前临场回采样。
|
||||
- 触发绑定、导出工件与真实发送必须全部统一到同一份最终发送队列上。
|
||||
- 旧格式规划导出必须保持不受运行时 `speedRatio` 影响;`ActualSend*` 和 `ShotEvents.json` 必须统一到真实发送队列上。
|
||||
|
||||
该方案可以在不修改 `move_joint`、不放宽校验阈值、不中断现有触发语义的前提下,把飞拍 `speedRatio` 调速从“经验插值”收敛为“可校验、可追踪、可自动保守化”的执行机制。
|
||||
该方案可以在不放宽校验阈值、不中断现有触发语义的前提下,把 `speedRatio` 调速从“发送阶段经验插值”收敛为“规划/准备阶段完成、运行时确定发送”的执行机制。
|
||||
|
||||
573
docs/flyshot-system-guide.md
Normal file
573
docs/flyshot-system-guide.md
Normal file
@@ -0,0 +1,573 @@
|
||||
# 飞拍系统介绍说明与使用教程
|
||||
|
||||
## 1. 文档目的
|
||||
|
||||
本文档用于说明当前飞拍系统的整体组成、数据流、接口边界和现场使用步骤。文档原稿采用 Markdown 编写,适合后续导入或整理为飞书文档;流程图使用 Mermaid 源码保留,导入飞书时可按需要渲染为图片后插入。
|
||||
|
||||
系统当前分为三个主要单元:
|
||||
|
||||
| 单元 | 工程位置 | 主要职责 |
|
||||
| --- | --- | --- |
|
||||
| 上位机单元 | `D:\Dev\Codes\MFD_Solution\FlyShot` | 控制相机、接收 PLC/MES 信息、发起飞拍任务、保存图片、调用 AI 检测、汇总结果并回传 MES/PLC |
|
||||
| 飞拍控制单元 | `D:\Dev\Codes\rvbust-code\FlyingShotPkg_3.15_VDA\flyshot-replacement` | 提供 HTTP 控制接口,负责机器人初始化、轨迹上传、ICSP/self-adapt-ICSP 规划、触发时序生成和 FANUC 运动执行 |
|
||||
| YOLO AI 识别模块 | 由上位机配置访问 | 接收图片检测请求,完成 AI 推理,返回图片级 OK/NG、原因、参数明细和结果图路径 |
|
||||
|
||||
## 2. 系统总览
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
PLC["PLC / OPC UA<br/>任务触发、状态反馈"]
|
||||
MES["MES<br/>换型下发、PartProcessed 上报"]
|
||||
PC["上位机单元 FlyShot<br/>WPF 主控程序"]
|
||||
Camera["工业相机<br/>采图与本地存图"]
|
||||
Control["飞拍控制单元<br/>ASP.NET Core HTTP API"]
|
||||
Robot["FANUC 机器人<br/>TCP 10010/10012 + UDP 60015"]
|
||||
AI["YOLO AI 识别模块<br/>HTTP + 文件传输目录"]
|
||||
Storage["本地/远端图片目录<br/>原图、结果图、任务 JSON"]
|
||||
Notify["飞书 / 审核平台 / ClickHouse<br/>异常通知与追溯"]
|
||||
|
||||
PLC <--> PC
|
||||
MES <--> PC
|
||||
PC --> Camera
|
||||
Camera --> PC
|
||||
PC <--> Control
|
||||
Control <--> Robot
|
||||
PC <--> AI
|
||||
PC --> Storage
|
||||
AI --> Storage
|
||||
PC --> Notify
|
||||
```
|
||||
|
||||
### 2.1 上位机单元
|
||||
|
||||
上位机是现场操作和生产流程的核心入口,当前是 WPF MVVM 程序。它负责:
|
||||
|
||||
- 初始化相机并注册图像回调。
|
||||
- 读取 `appConfig.json`、`Config\TypeData`、`Config\FlyTask` 中的现场配置。
|
||||
- 通过 `MpcService` 调用飞拍控制单元 HTTP 接口。
|
||||
- 按飞拍任务配置设置相机曝光、增益和拍摄索引。
|
||||
- 将图片保存到 `CameraSavePath` 下的任务目录。
|
||||
- 通过 `ImageProcessingService` 上传图片到 AI 服务器目录并调用 AI 接口。
|
||||
- 将 AI 结果合并回任务对象,保存任务 JSON。
|
||||
- 根据结果回写 PLC、发送 MES `PartProcessed`、写入 ClickHouse、必要时发送飞书 NG 报警。
|
||||
|
||||
### 2.2 飞拍控制单元
|
||||
|
||||
飞拍控制单元是新的 replacement 服务端。它不恢复旧 `50001/TCP+JSON` 网关,而是提供 ASP.NET Core HTTP API。它负责:
|
||||
|
||||
- 机器人模型加载与初始化。
|
||||
- 轨迹上传、查询、删除。
|
||||
- 普通轨迹 `ExecuteTrajectory`。
|
||||
- 飞拍轨迹 `ExecuteFlyShotTraj`。
|
||||
- 通过 `icsp` 或 `self-adapt-icsp` 生成关节轨迹。
|
||||
- 根据 `shot_flags / offset_values / addrs` 生成拍照触发时间轴。
|
||||
- 将规划结果转换为 8ms 发送队列,通过 J519 通道控制机器人运动和 IO 脉冲。
|
||||
- 提供 `/status.html` 状态页和 `/debug.html` 调试页。
|
||||
|
||||
### 2.3 YOLO AI 识别模块
|
||||
|
||||
YOLO AI 模块由上位机调用。上位机当前不是直接把所有图片以 multipart 方式传给 AI,而是:
|
||||
|
||||
1. 将图片上传到配置的 AI 输入目录。
|
||||
2. 构造包含模型版本、配置版本、产品 ID 和图片明细的 JSON 请求。
|
||||
3. 调用 `Ai_Model.ApiUrl`。
|
||||
4. 根据 AI 响应中的 `SubImgInfo` 下载结果图。
|
||||
5. 将图片级结果、原因、参数明细写回任务结果。
|
||||
6. 清理远端上传图和结果图。
|
||||
|
||||
## 3. 生产主流程
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant PLC as PLC
|
||||
participant PC as 上位机 FlyShot
|
||||
participant CTRL as 飞拍控制单元
|
||||
participant ROBOT as FANUC 机器人
|
||||
participant CAM as 工业相机
|
||||
participant AI as YOLO AI
|
||||
participant MES as MES
|
||||
|
||||
PLC->>PC: StartFlyShotTask 上升沿 + 产品/任务信息
|
||||
PC->>PC: 初始化 WorkStationTask / FlyShotTask
|
||||
PC->>CAM: 注册回调、设置曝光/增益
|
||||
PC->>CTRL: POST /execute_flyshot/
|
||||
CTRL->>ROBOT: move_to_start + J519 飞拍轨迹
|
||||
ROBOT-->>CAM: 轨迹 IO 脉冲触发拍照
|
||||
CAM-->>PC: 图像回调
|
||||
PC->>PC: 保存原图并加入待处理队列
|
||||
PC->>AI: 上传图片 + 发送 AI 请求
|
||||
AI-->>PC: 返回 OK/NG、原因、结果图路径
|
||||
PC->>PC: 下载结果图并汇总任务结果
|
||||
PC-->>PLC: TaskCompleted / TaskResult / WorkStationResult
|
||||
PC->>MES: PartProcessed 结果报文
|
||||
```
|
||||
|
||||
## 4. 关键配置
|
||||
|
||||
### 4.1 上位机配置
|
||||
|
||||
上位机配置文件:
|
||||
|
||||
- 基础配置:`D:\Dev\Codes\MFD_Solution\FlyShot\appConfig.json`
|
||||
- 调试覆盖:`D:\Dev\Codes\MFD_Solution\FlyShot\appConfig.Debug.json`
|
||||
|
||||
常用配置项:
|
||||
|
||||
| 配置路径 | 示例值 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `MesConfig.EnableMes` | `true` | 是否启用 MES 通信 |
|
||||
| `MesConfig.ServerIp` / `ServerPort` | `174.34.82.193` / `55065` | MES 服务地址 |
|
||||
| `MesConfig.LineNo` / `ProcessNo` / `StatNo` | `6958` / `993002` / `9930` | MES 位置上下文 |
|
||||
| `MesConfig.TelegramFolder` | `D:\MesTelegram` | MES 报文保存目录 |
|
||||
| `CameraIp` / `LocalIp` | `192.168.11.11` / `192.168.11.188` | 工业相机与本机网卡地址 |
|
||||
| `CameraSavePath` | `D:\Images\flyShot` | 飞拍原图、结果图和任务 JSON 保存根目录 |
|
||||
| `MPC.MpcInterfaceIp` | `192.168.11.12` | 飞拍控制单元 HTTP 地址 |
|
||||
| `MPC.MpcInterfacePort` | `9001` | 上位机调用的飞拍控制单元端口 |
|
||||
| `MPC.RobotName` | `LR_Mate_200iD_7L` | 机器人型号/名称 |
|
||||
| `MPC.RobotIp` | `192.168.10.11` | FANUC 控制柜 IP |
|
||||
| `Ai_Model.ApiUrl` | `http://127.0.0.1:8124/corebg` | AI HTTP 接口地址 |
|
||||
| `Ai_Model.AI_Model_Version` | `PEU` | AI 模型版本 |
|
||||
| `Ai_Model.AI_configVersion` | `peu_20241011` | AI 配置版本 |
|
||||
| `Ai_Model.ai_upload_path` | `/mnt/d/ai_images/ai-feed` | AI 输入图片目录 |
|
||||
| `Ai_Model.ai_result_path` | `/mnt/d/ai_images` | AI 结果图片目录 |
|
||||
| `FileTransfer.DefaultService` | `Primary` | 当前使用的文件传输服务 |
|
||||
| `PLC.EnablePlc` | `true` | 是否启用 PLC 通信 |
|
||||
| `PLC.OpcUaServer` | `opc.tcp://174.34.178.68:4840` | OPC UA 服务地址 |
|
||||
|
||||
> 注意:如果飞拍控制单元实际监听端口是 `5000`,上位机的 `MPC.MpcInterfacePort` 需要同步改为 `5000`,或通过反向代理/部署配置把控制单元暴露为 `9001`。
|
||||
|
||||
### 4.2 飞拍控制单元配置
|
||||
|
||||
飞拍控制单元配置文件:
|
||||
|
||||
- `D:\Dev\Codes\rvbust-code\FlyingShotPkg_3.15_VDA\flyshot-replacement\src\Flyshot.Server.Host\appsettings.json`
|
||||
|
||||
常用配置项:
|
||||
|
||||
| 配置路径 | 示例值 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `ControllerClientCompat.AutoInit.RobotName` | `FANUC_LR_Mate_200iD_7L` | 服务启动自动初始化的机器人名称 |
|
||||
| `ControllerClientCompat.AutoInit.RobotIp` | `192.168.10.11` | 服务启动自动连接的机器人 IP |
|
||||
| `Kestrel.Endpoints.Http.Url` | `http://0.0.0.0:5000` | HTTP 服务监听地址 |
|
||||
| `Swagger.Enabled` | `true` | 是否启用 Swagger 和调试接口 |
|
||||
| `Cors.AllowedOrigins` | `*` | 允许跨域调用的来源 |
|
||||
|
||||
## 5. 飞拍控制单元 HTTP 接口
|
||||
|
||||
### 5.1 初始化机器人
|
||||
|
||||
推荐使用聚合初始化接口:
|
||||
|
||||
```http
|
||||
POST /init_mpc_robot
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"robot_name": "FANUC_LR_Mate_200iD_7L",
|
||||
"robot_ip": "192.168.10.11"
|
||||
}
|
||||
```
|
||||
|
||||
内部执行顺序:
|
||||
|
||||
1. `SetUpRobot`:加载机器人模型和关节限制。
|
||||
2. `Connect`:建立 FANUC 状态、命令、J519 三条通道。
|
||||
3. `EnableRobot(8)`:启动机器人程序和运动循环。
|
||||
|
||||
也可以拆分调用:
|
||||
|
||||
```text
|
||||
POST /setup_robot/?robot_name=FANUC_LR_Mate_200iD_7L
|
||||
POST /connect_robot/?ip=192.168.10.11
|
||||
GET /enable_robot/?buffer_size=8
|
||||
```
|
||||
|
||||
### 5.2 上传飞拍轨迹
|
||||
|
||||
```http
|
||||
POST /upload_flyshot/
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"name": "UTTC_MS11",
|
||||
"waypoints": [[0, 0, 0, 0, 0, 0]],
|
||||
"shot_flags": [true],
|
||||
"offset_values": [0],
|
||||
"addrs": [[1, 3]]
|
||||
}
|
||||
```
|
||||
|
||||
字段说明:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `name` | string | 飞拍轨迹名称,上位机执行时使用 |
|
||||
| `waypoints` | number[][] | 机器人关节路径点 |
|
||||
| `shot_flags` | bool[] | 每个路径点是否拍照 |
|
||||
| `offset_values` | number[] | 拍照触发偏移周期 |
|
||||
| `addrs` | number[][] | 拍照触发 IO 地址组 |
|
||||
|
||||
### 5.3 执行飞拍轨迹
|
||||
|
||||
上位机 `MpcService.ExecuteFlyShot` 当前默认发送:
|
||||
|
||||
```http
|
||||
POST /execute_flyshot/
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"name": "UTTC_MS11",
|
||||
"move_to_start": true,
|
||||
"method": "icsp",
|
||||
"save_traj": false,
|
||||
"use_cache": false,
|
||||
"wait": false
|
||||
}
|
||||
```
|
||||
|
||||
字段说明:
|
||||
|
||||
| 字段 | 推荐值 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `name` | 轨迹名称 | 必须已上传或存在于控制单元缓存 |
|
||||
| `move_to_start` | `true` | 执行前先移动到规划起点,避免第一帧目标突变 |
|
||||
| `method` | `icsp` 或 `self-adapt-icsp` | 轨迹规划方法 |
|
||||
| `save_traj` | `false` | 是否保存本次规划轨迹 |
|
||||
| `use_cache` | `false` 或 `true` | 是否复用缓存轨迹 |
|
||||
| `wait` | `false` 或 `true` | 是否等待整条轨迹完成后再返回 |
|
||||
|
||||
### 5.4 常用调试接口
|
||||
|
||||
| 接口 | 方法 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| `/healthz` | GET | 服务健康检查 |
|
||||
| `/status.html` | GET | 浏览器状态页 |
|
||||
| `/debug.html` | GET | 浏览器接口调试页 |
|
||||
| `/api/status/snapshot` | GET | 当前兼容层和运行时状态快照 |
|
||||
| `/robot_info/` | GET | 查询机器人信息 |
|
||||
| `/set_speedRatio/` | POST | 设置速度倍率 |
|
||||
| `/list_flyShotTraj/` | GET | 查询可执行飞拍轨迹名称 |
|
||||
| `/delete_flyshot/` | POST | 删除飞拍轨迹 |
|
||||
| `/stop_move/` | GET | 停止当前运动 |
|
||||
| `/disable_robot/` | GET | 禁用机器人 |
|
||||
| `/disconnect_robot/` | POST | 断开机器人连接 |
|
||||
|
||||
## 6. 上位机使用教程
|
||||
|
||||
### 6.1 启动前检查
|
||||
|
||||
1. 确认工业相机、机器人控制柜、AI 服务、PLC、MES 网络可达。
|
||||
2. 检查上位机 `appConfig.json`:
|
||||
- `CameraIp` 和 `LocalIp` 是否匹配现场网段。
|
||||
- `CameraSavePath` 是否存在或可创建。
|
||||
- `MPC.MpcInterfaceIp` / `MpcInterfacePort` 是否指向飞拍控制单元。
|
||||
- `Ai_Model.ApiUrl` 是否指向 YOLO AI 接口。
|
||||
- `FileTransfer` 是否能访问 AI 上传和结果目录。
|
||||
3. 检查飞拍控制单元是否启动:
|
||||
- 浏览器访问 `http://<控制单元IP>:<端口>/healthz`。
|
||||
- 浏览器访问 `http://<控制单元IP>:<端口>/status.html`。
|
||||
4. 检查机器人程序、控制柜状态和安全围栏。
|
||||
|
||||
### 6.2 上位机启动流程
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["启动 FlyShot 上位机"] --> B["读取 appConfig.json"]
|
||||
B --> C["初始化 MES<br/>可按 EnableMes 关闭"]
|
||||
B --> D["初始化相机"]
|
||||
B --> E["查询飞拍控制单元轨迹列表"]
|
||||
E --> F["设置机器人速度倍率<br/>默认 0.5"]
|
||||
D --> G["确认系统状态栏无关键 NG"]
|
||||
F --> G
|
||||
G --> H["等待手动或 PLC 触发任务"]
|
||||
```
|
||||
|
||||
启动后,上位机会执行:
|
||||
|
||||
- 初始化 MES 连接。
|
||||
- 初始化相机。
|
||||
- 查询飞拍控制单元中的飞拍任务列表。
|
||||
- 设置机器人速度倍率。
|
||||
- 创建或检查图片保存目录。
|
||||
|
||||
### 6.3 手动执行飞拍任务
|
||||
|
||||
适用于调试、首件验证和现场排查。
|
||||
|
||||
1. 打开上位机。
|
||||
2. 确认相机初始化成功。
|
||||
3. 确认飞拍任务列表已加载。
|
||||
4. 输入或扫码产品标识。
|
||||
5. 选择飞拍任务名称。
|
||||
6. 点击手动开始。
|
||||
7. 上位机执行:
|
||||
- 初始化工位任务。
|
||||
- 注册相机回调。
|
||||
- 调用飞拍控制单元 `/execute_flyshot/`。
|
||||
- 接收相机回调并保存图片。
|
||||
- 批量调用 AI 检测。
|
||||
- 汇总 OK/NG。
|
||||
- 保存任务 JSON。
|
||||
- 如有 NG,打开人工复判界面并发送通知。
|
||||
|
||||
### 6.4 PLC 自动执行飞拍任务
|
||||
|
||||
适用于正式生产。
|
||||
|
||||
PLC 通过 OPC UA 写入任务触发信号,上位机收到 `StartFlyShotTask` 上升沿后执行任务。
|
||||
|
||||
PLC 输入给 PC 的核心信号:
|
||||
|
||||
| 信号 | 说明 |
|
||||
| --- | --- |
|
||||
| `StartFlyShotTask` | 飞拍任务启动,上升沿触发 |
|
||||
| `WorkStationId` | 工位号 |
|
||||
| `FlyShotTaskName` | 飞拍任务名称 |
|
||||
| `CurrentTaskIndex` | 当前任务序号 |
|
||||
| `TotalTaskCount` | 当前工位任务总数 |
|
||||
| `ProductTypeNo` | 产品型号 |
|
||||
| `ProductIdentifier` | 产品唯一标识 |
|
||||
| `ProductBatch` | 产品批次 |
|
||||
| `ResetSystem` | 系统重置信号 |
|
||||
|
||||
PC 回写给 PLC 的核心信号:
|
||||
|
||||
| 信号 | 说明 |
|
||||
| --- | --- |
|
||||
| `TaskCompleted` | 当前任务完成 |
|
||||
| `TaskResult` | 当前任务结果,true=OK,false=NG |
|
||||
| `CompletedTaskIndex` | 已完成任务序号 |
|
||||
| `TaskProcessingTime` | 当前任务耗时 |
|
||||
| `WorkStationCompleted` | 工位所有任务是否完成 |
|
||||
| `WorkStationResult` | 工位总结果 |
|
||||
| `SystemReady` | 系统是否就绪 |
|
||||
| `SystemBusy` | 系统是否忙碌 |
|
||||
| `ErrorCode` | 错误码 |
|
||||
| `ErrorMessage` | 错误信息 |
|
||||
| `PcHeartbeat` | PC 心跳 |
|
||||
|
||||
### 6.5 图片和结果保存规则
|
||||
|
||||
上位机会基于 `CameraSavePath` 生成任务目录:
|
||||
|
||||
```text
|
||||
{CameraSavePath}\{yyyyMMdd}\{TypeNo-TypeVar}\{ProductIdentifier}\{FlyShotName}\
|
||||
```
|
||||
|
||||
任务目录内通常包含:
|
||||
|
||||
| 内容 | 说明 |
|
||||
| --- | --- |
|
||||
| 原始图片 | 相机回调保存的图片 |
|
||||
| `result` 子目录 | AI 下载后的结果图 |
|
||||
| `flyShotTask@...json` | 单个飞拍任务详情 |
|
||||
| `WorkStationTask@...json` | 工位任务汇总 |
|
||||
|
||||
AI 结果图命名会带上检测结果,例如:
|
||||
|
||||
```text
|
||||
原图名@OK.jpeg
|
||||
原图名@NG.jpeg
|
||||
```
|
||||
|
||||
## 7. YOLO AI 识别流程
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["相机回调保存原图"] --> B["上位机收集待处理图片"]
|
||||
B --> C["达到 BatchSize 或采集结束"]
|
||||
C --> D["上传原图到 Ai_Model.ai_upload_path"]
|
||||
D --> E["构造 AiProcessRequestModel"]
|
||||
E --> F["POST Ai_Model.ApiUrl"]
|
||||
F --> G["解析 ProductResult / SubImgInfo / FailureModes"]
|
||||
G --> H["从 Ai_Model.ai_result_path 下载结果图"]
|
||||
H --> I["写回 ImageProcessOutcome"]
|
||||
I --> J["更新 UI 与任务结果"]
|
||||
J --> K["清理 AI 远端临时文件"]
|
||||
```
|
||||
|
||||
### 7.1 AI 请求结构
|
||||
|
||||
AI 请求的典型结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"ai_modelVersion": "PEU",
|
||||
"ai_configVersion": "peu_20241011",
|
||||
"key": "empty",
|
||||
"product_id": "20250228121929",
|
||||
"sub_img_info": [
|
||||
{
|
||||
"index": 1,
|
||||
"view": "pipe",
|
||||
"object": "spring",
|
||||
"image_path": "000001_161008_1.jpeg",
|
||||
"ai_image_path": null,
|
||||
"ai_result": null,
|
||||
"ai_reason": null,
|
||||
"ai_param_list:": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
字段说明:
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `ai_modelVersion` | AI 模型版本,对应上位机 `Ai_Model.AI_Model_Version` |
|
||||
| `ai_configVersion` | AI 配置版本,对应上位机 `Ai_Model.AI_configVersion` |
|
||||
| `product_id` | 本次 AI 任务 ID,通常由时间戳生成 |
|
||||
| `sub_img_info[].index` | 图片序号 |
|
||||
| `sub_img_info[].view` | 视角,例如 `pipe`、`surface`、`connector` |
|
||||
| `sub_img_info[].object` | 检测对象,例如 `spring`、`top`、`side` |
|
||||
| `sub_img_info[].image_path` | 上传到 AI 输入目录后的图片文件名 |
|
||||
|
||||
### 7.2 AI 响应处理
|
||||
|
||||
上位机按 `index` 将 AI 响应和原图对应起来,主要读取:
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `ProductResult` | 产品级检测结果 |
|
||||
| `ErrorInfo` | AI 处理错误信息 |
|
||||
| `AiFailureModel` | AI 不良模式集合 |
|
||||
| `SubImgInfo[].AiResult` | 单张图片结果,`OK` 视为 OK,其他非空值视为 NG |
|
||||
| `SubImgInfo[].AiReason` | 单张图片 NG 原因 |
|
||||
| `SubImgInfo[].AiParamList` | AI 参数明细 |
|
||||
| `SubImgInfo[].AiImagePath` | AI 结果图远端路径 |
|
||||
|
||||
## 8. MES 交互说明
|
||||
|
||||
### 8.1 换型下发
|
||||
|
||||
上位机启动或手动换型时,会根据 `MesConfig.TypeNo` 和 `MesConfig.TypeVar` 发送 `DataDownloadRequired`。
|
||||
|
||||
主要用途:
|
||||
|
||||
- 获取或刷新当前型号配置。
|
||||
- 更新本地 `TypeNo` / `TypeVar`。
|
||||
- 加载对应 `Config\TypeData` 文件。
|
||||
|
||||
### 8.2 结果上报
|
||||
|
||||
飞拍任务完成后,上位机会发送 `PartProcessed`。
|
||||
|
||||
上报内容包括:
|
||||
|
||||
- 产品唯一标识。
|
||||
- 产品型号和变体。
|
||||
- 工位结果,`1=OK`,`2=NG`。
|
||||
- 图片相对路径。
|
||||
- 每张图片的 AI 结果和人工复判结果。
|
||||
- 汇总后的 NIO bits。
|
||||
|
||||
## 9. 常见使用场景
|
||||
|
||||
### 9.1 首次联调
|
||||
|
||||
1. 启动飞拍控制单元。
|
||||
2. 访问 `/healthz` 确认服务正常。
|
||||
3. 访问 `/status.html` 查看模型和机器人状态。
|
||||
4. 调用 `/init_mpc_robot` 初始化机器人。
|
||||
5. 调用 `/list_flyShotTraj/` 确认可用轨迹。
|
||||
6. 启动上位机。
|
||||
7. 检查上位机日志中相机、MPC、MES、PLC 状态。
|
||||
8. 使用手动模式执行一次飞拍任务。
|
||||
9. 检查图片目录、AI 结果图、任务 JSON 和 MES/PLC 反馈。
|
||||
|
||||
### 9.2 正式生产
|
||||
|
||||
1. 启动飞拍控制单元并确认状态正常。
|
||||
2. 启动上位机并确认系统就绪。
|
||||
3. PLC 写入产品和任务信息。
|
||||
4. PLC 触发 `StartFlyShotTask` 上升沿。
|
||||
5. 上位机执行飞拍、AI 检测和结果汇总。
|
||||
6. 上位机回写 PLC 当前任务和工位结果。
|
||||
7. 上位机发送 MES `PartProcessed`。
|
||||
8. PLC 根据 `WorkStationCompleted` 和 `WorkStationResult` 决定后续流程。
|
||||
|
||||
### 9.3 离线排查 AI
|
||||
|
||||
1. 准备一组本地图片。
|
||||
2. 在上位机调试页面或相关调试入口选择图片。
|
||||
3. 确认 `Ai_Model.ApiUrl`、`ai_upload_path`、`ai_result_path` 正确。
|
||||
4. 执行 AI 请求。
|
||||
5. 检查 AI 响应、结果图下载、上位机日志和远端文件清理情况。
|
||||
|
||||
## 10. 故障排查
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["发现任务失败或结果异常"] --> B{"失败阶段"}
|
||||
B -->|相机无图| C["检查 CameraIp / LocalIp / 相机连接 / 触发源 / 曝光增益"]
|
||||
B -->|控制单元失败| D["检查 /healthz /status.html / MpcInterfaceIp / 端口 / 机器人状态"]
|
||||
B -->|机器人报警| E["检查 FANUC 控制柜 / RVBUSTSM / J519 通道 / 安全状态"]
|
||||
B -->|AI 无结果| F["检查 Ai_Model.ApiUrl / 文件传输目录 / AI 服务日志 / 结果图路径"]
|
||||
B -->|PLC 不触发| G["检查 OPC UA 地址 / ActiveProfile / StartFlyShotTask 上升沿 / SystemReady"]
|
||||
B -->|MES 失败| H["检查 EnableMes / ServerIp / ServerPort / TelegramFolder / 报文响应"]
|
||||
C --> I["查看上位机日志和图片目录"]
|
||||
D --> I
|
||||
E --> I
|
||||
F --> I
|
||||
G --> I
|
||||
H --> I
|
||||
```
|
||||
|
||||
### 10.1 相机问题
|
||||
|
||||
| 现象 | 排查点 |
|
||||
| --- | --- |
|
||||
| 无图像回调 | 相机 IP、本机网卡、相机初始化、回调注册 |
|
||||
| 图片曝光异常 | 飞拍点位配置中的曝光和增益 |
|
||||
| 图片保存失败 | `CameraSavePath` 权限、磁盘空间、路径是否存在 |
|
||||
|
||||
### 10.2 飞拍控制单元问题
|
||||
|
||||
| 现象 | 排查点 |
|
||||
| --- | --- |
|
||||
| 上位机无法连接 MPC | `MPC.MpcInterfaceIp` / `MpcInterfacePort` 是否指向控制单元 |
|
||||
| `/execute_flyshot/` 失败 | 轨迹是否已上传、机器人是否初始化、`method` 是否支持 |
|
||||
| 机器人不运动 | `/status.html` 状态、FANUC 通道、使能状态、控制柜报警 |
|
||||
| 轨迹执行后无拍照 | `shot_flags`、`offset_values`、`addrs`、J519 IO 脉冲 |
|
||||
|
||||
### 10.3 AI 问题
|
||||
|
||||
| 现象 | 排查点 |
|
||||
| --- | --- |
|
||||
| AI 请求失败 | `Ai_Model.ApiUrl`、AI 服务是否启动、HTTP 是否可达 |
|
||||
| AI 找不到图片 | `ai_upload_path`、文件传输服务、远端目录权限 |
|
||||
| 结果图下载失败 | `ai_result_path`、`AiImagePath`、SFTP/SMB/LOCAL 配置 |
|
||||
| 结果和图片错位 | 检查 AI 响应中的 `index` 是否和请求一致 |
|
||||
|
||||
### 10.4 PLC/MES 问题
|
||||
|
||||
| 现象 | 排查点 |
|
||||
| --- | --- |
|
||||
| PLC 不触发任务 | `PLC.EnablePlc`、OPC UA 地址、变量映射、上升沿保持时间 |
|
||||
| PLC 反馈不更新 | 输出变量是否可写、`SystemBusy/SystemReady` 状态 |
|
||||
| MES 连接失败 | `MesConfig.EnableMes`、IP、端口、防火墙 |
|
||||
| MES 报文异常 | `TelegramFolder` 下保存的 XML 报文和响应 |
|
||||
|
||||
## 11. 交付与飞书整理建议
|
||||
|
||||
建议后续整理为飞书文档时采用以下方式:
|
||||
|
||||
1. 以本文档 Markdown 作为源稿。
|
||||
2. 将 Mermaid 流程图渲染为 PNG 或 SVG 图片后插入飞书。
|
||||
3. 保留接口表、配置表和操作步骤,适合现场培训。
|
||||
4. 如果需要正式发布版本,可再从 Markdown 生成 Word,再导入飞书云文档。
|
||||
5. 每次接口、配置或流程变更后,优先更新 Markdown 源稿,再同步飞书版本。
|
||||
|
||||
建议拆分飞书目录:
|
||||
|
||||
```text
|
||||
飞拍系统说明
|
||||
├─ 1. 系统总览
|
||||
├─ 2. 生产主流程
|
||||
├─ 3. 上位机使用教程
|
||||
├─ 4. 飞拍控制单元使用教程
|
||||
├─ 5. YOLO AI 接口说明
|
||||
├─ 6. PLC 与 MES 交互
|
||||
└─ 7. 常见问题排查
|
||||
```
|
||||
257
docs/marketing/飞拍控制系统宣传文稿.md
Normal file
257
docs/marketing/飞拍控制系统宣传文稿.md
Normal file
@@ -0,0 +1,257 @@
|
||||
# 智能视觉质检系统宣传文稿
|
||||
|
||||
## 一句话定位
|
||||
|
||||
智能视觉质检系统是一套覆盖“边缘取图、AI 检测、人工复核、第三方对接、结果闭环”的工业视觉质量管理平台,帮助产线把图像采集、缺陷识别和复检工作流整合为一条连续、可追踪、可协同的质检链路。
|
||||
|
||||
## 宣传主文案
|
||||
|
||||
在工业视觉检测现场,真正影响效率的不只是模型能否识别缺陷,还包括图片如何稳定采集、AI 结果如何进入业务系统、人工如何快速复核、第三方系统如何查询结果,以及异常任务如何被追踪和处理。
|
||||
|
||||
智能视觉质检系统围绕这一完整链路设计:前端由边缘飞拍系统负责在机器人运动过程中完成连续取图;中间由 AI 检测服务对图片进行模型推理,输出 OK/NG、缺陷区域和结构化检测信息;上层由复检工作流平台承接图片、AI 结果和人工判断,完成任务管理、复核流转、结果查询和第三方接口对接。
|
||||
|
||||
这不是单个检测工具,而是一整套面向产线落地的视觉质检系统。它把“取图、识别、复核、上传、查询、归档”串成统一流程,让现场从分散的图片文件、模型结果和人工沟通,升级为可管理、可复核、可追溯的质量工作流。
|
||||
|
||||
## 系统组成
|
||||
|
||||
### 1. 边缘飞拍取图层
|
||||
|
||||
飞拍系统负责在机器人运动过程中完成边缘段取图。它根据检测路径和拍照节拍,在连续运动中触发相机采集图片,减少停顿拍照对产线节拍的影响。
|
||||
|
||||
核心作用:
|
||||
|
||||
- 支持机器人运动中连续取图。
|
||||
- 支持按路径、节拍或检测点触发拍照。
|
||||
- 适合边缘、焊缝、孔位、外观等路径型检测场景。
|
||||
- 为后续 AI 检测提供稳定、连续、带位置语义的图片输入。
|
||||
|
||||
### 2. AI 检测服务层
|
||||
|
||||
AI 检测服务负责接收图片并执行模型推理。系统支持通过接口提交图片,可按业务需要调用单模型或多模型检测,并返回结构化结果。
|
||||
|
||||
核心作用:
|
||||
|
||||
- 接收产线图片进行 AI 检测。
|
||||
- 输出 OK/NG、缺陷区域、模型结果和处理信息。
|
||||
- 支持多模型组合检测,适配不同检测面、不同缺陷类型。
|
||||
- 通过标准接口与上层复检平台、现场系统集成。
|
||||
|
||||
### 3. 人工复核与工作流层
|
||||
|
||||
复检工作流平台负责承接 AI 检测结果和图片数据,形成可操作的人工复核流程。它提供任务管理、图片浏览、AI 结果展示、人工判定、历史查询、第三方接口对接和结果归档能力。
|
||||
|
||||
核心作用:
|
||||
|
||||
- 管理待复核、已完成、历史任务。
|
||||
- 支持原图、AI 标注图和检测元数据统一展示。
|
||||
- 支持人工 OK/NG 判断与批量提交。
|
||||
- 支持第三方系统上传任务、查询最新复检结果。
|
||||
- 形成从图片到判定结果的完整追溯链。
|
||||
|
||||
## 端到端业务闭环
|
||||
|
||||
```text
|
||||
机器人/相机飞拍取图
|
||||
↓
|
||||
图片进入 AI 检测服务
|
||||
↓
|
||||
AI 输出 OK/NG 与缺陷信息
|
||||
↓
|
||||
复检平台生成审核任务
|
||||
↓
|
||||
人工查看原图与 AI 标注图
|
||||
↓
|
||||
人工确认最终结果
|
||||
↓
|
||||
第三方系统查询或接收复检结果
|
||||
↓
|
||||
质检数据归档与追溯
|
||||
```
|
||||
|
||||
这条链路把现场设备、AI 推理、人工经验和业务系统连接在一起,使视觉检测不再停留在单点识别,而是进入可运行的质量管理流程。
|
||||
|
||||
## 核心能力
|
||||
|
||||
### 1. 连续取图能力
|
||||
|
||||
系统支持机器人边运动边拍照,适合路径型工件和多点位检测。通过拍照节拍配置,可根据产线速度、相机曝光和检测需求调整采集频率。
|
||||
|
||||
### 2. AI 自动检测能力
|
||||
|
||||
系统支持图片接口化检测,能够将模型推理结果结构化输出。AI 检测结果可直接进入复检平台,作为人工判断的重要依据。
|
||||
|
||||
### 3. 人工复核能力
|
||||
|
||||
复检平台提供面向操作员的图片审核界面,可集中查看任务、图片、AI 标注图、缺陷信息和历史记录。人工可快速完成 OK/NG 判定,减少线下沟通和文件查找。
|
||||
|
||||
### 4. 第三方接口能力
|
||||
|
||||
系统可对接第三方业务系统,支持任务上传、结果查询和复检状态同步。产线系统、PLC 上位系统、MES 或其他业务平台可以通过接口获取复检结果。
|
||||
|
||||
### 5. 工作流闭环能力
|
||||
|
||||
系统将图片采集、AI 检测、人工复核和结果回传组织成统一流程。每个任务都有状态,每张图片都有来源和判定,每次操作都可追踪。
|
||||
|
||||
### 6. 多场景扩展能力
|
||||
|
||||
整体架构可适配不同检测产品、不同图片来源、不同 AI 模型和不同复核策略。既可以服务单工位,也可以扩展到多工位、多模型、多业务线。
|
||||
|
||||
## 展示重点
|
||||
|
||||
### 系统能力总览
|
||||
|
||||
| 层级 | 作用 | 对外表达 |
|
||||
| --- | --- | --- |
|
||||
| 边缘取图 | 机器人运动中连续采集图片 | 让检测图片稳定进入系统 |
|
||||
| AI 检测 | 对图片进行模型推理 | 自动识别缺陷并输出结构化结果 |
|
||||
| 人工复核 | 对 AI 结果进行确认 | 让关键判定有人工闭环 |
|
||||
| 第三方接口 | 任务上传与结果查询 | 与产线和业务系统打通 |
|
||||
| 工作流平台 | 任务、图片、结果统一管理 | 形成可追踪的质量流程 |
|
||||
|
||||
### 拍照节拍展示
|
||||
|
||||
对外展示时,可以用“每秒拍照数量”表达边缘取图能力,同时避免写成固定承诺。
|
||||
|
||||
| 模式 | 每秒拍照数量示意 | 适用场景 |
|
||||
| --- | ---: | --- |
|
||||
| 稳定检测模式 | 5-10 张/秒 | 常规外观、定位、尺寸检测 |
|
||||
| 高速检测模式 | 10-20 张/秒 | 连续路径、多点位检测 |
|
||||
| 高密度采集模式 | 20+ 张/秒 | 短路径密集取像、节拍优化展示 |
|
||||
|
||||
备注:实际拍照数量取决于机器人速度、相机曝光时间、光源响应、图像处理耗时和现场安全策略。
|
||||
|
||||
### 时序图展示
|
||||
|
||||
```text
|
||||
机器人运动轨迹 ────────────────────────────────>
|
||||
|
||||
边缘飞拍触发 ▲ ▲ ▲ ▲ ▲
|
||||
图1 图2 图3 图4 图5
|
||||
|
||||
AI 检测服务 检测 检测 检测 检测 检测
|
||||
|
||||
复检工作流 生成任务 → 图片审核 → 人工判定 → 结果回传
|
||||
```
|
||||
|
||||
配套说明文案:
|
||||
|
||||
系统将取图、检测和复核组织在同一条业务时间线上。边缘端负责稳定采集,AI 服务负责自动识别,复检平台负责人工确认和结果流转,最终形成完整的质检闭环。
|
||||
|
||||
## 建议 PPT 结构
|
||||
|
||||
### 1. 封面
|
||||
|
||||
标题:智能视觉质检系统
|
||||
|
||||
副标题:从边缘取图到 AI 检测,再到人工复核与结果闭环
|
||||
|
||||
### 2. 行业痛点
|
||||
|
||||
标题:视觉检测难点不只在 AI 模型
|
||||
|
||||
要点:
|
||||
|
||||
- 图片来源分散,取图节拍难统一。
|
||||
- AI 结果需要人工确认和业务留痕。
|
||||
- 产线系统需要查询最终复检结果。
|
||||
- 图片、模型结果、人工判断缺少统一工作流。
|
||||
|
||||
### 3. 系统总体架构
|
||||
|
||||
标题:三层协同,形成端到端质检链路
|
||||
|
||||
建议图形:
|
||||
|
||||
```text
|
||||
边缘飞拍取图层
|
||||
↓
|
||||
AI 检测服务层
|
||||
↓
|
||||
人工复核与工作流层
|
||||
↓
|
||||
第三方系统 / 产线系统
|
||||
```
|
||||
|
||||
### 4. 边缘取图能力
|
||||
|
||||
标题:机器人运动中连续采集检测图片
|
||||
|
||||
展示内容:
|
||||
|
||||
- 运动轨迹。
|
||||
- 拍照触发点。
|
||||
- 每秒拍照数量示意。
|
||||
- 取图与检测位置绑定。
|
||||
|
||||
### 5. AI 检测能力
|
||||
|
||||
标题:接口化 AI 检测,支持多模型结果输出
|
||||
|
||||
展示内容:
|
||||
|
||||
- 图片输入。
|
||||
- 模型推理。
|
||||
- OK/NG 结果。
|
||||
- 缺陷区域信息。
|
||||
- 处理后标注图。
|
||||
|
||||
### 6. 人工复核能力
|
||||
|
||||
标题:AI 结果进入复检工作台
|
||||
|
||||
展示内容:
|
||||
|
||||
- 当前待复核任务。
|
||||
- 原图与 AI 标注图对比。
|
||||
- 人工 OK/NG 判定。
|
||||
- 批量提交与历史查询。
|
||||
|
||||
### 7. 第三方接口与业务闭环
|
||||
|
||||
标题:与产线系统打通最终复检结果
|
||||
|
||||
展示内容:
|
||||
|
||||
- 第三方上传任务。
|
||||
- 查询最新复检结果。
|
||||
- 状态同步。
|
||||
- 结果归档。
|
||||
|
||||
### 8. 典型应用场景
|
||||
|
||||
标题:适用于多类工业视觉质检场景
|
||||
|
||||
要点:
|
||||
|
||||
- 焊缝与边缘检测。
|
||||
- PCB / 工件外观检测。
|
||||
- 孔位与装配位置检测。
|
||||
- 多点位连续取像。
|
||||
- AI 初判 + 人工终判场景。
|
||||
|
||||
### 9. 收尾页
|
||||
|
||||
标题:让视觉检测从单点识别走向完整工作流
|
||||
|
||||
推荐短句:
|
||||
|
||||
智能视觉质检系统把边缘取图、AI 检测、人工复核和第三方对接整合为统一流程,让质量判断更高效、更清晰、更可追踪。
|
||||
|
||||
## 展会讲解词
|
||||
|
||||
这套智能视觉质检系统不是单独展示一个 AI 模型,也不是只展示一个拍照工具,而是展示从现场取图到最终复检结果的完整链路。
|
||||
|
||||
在边缘端,机器人通过飞拍方式在运动过程中采集检测图片,适合边缘段、路径型、多点位的工业检测场景。图片采集完成后,进入 AI 检测服务,由模型自动判断 OK/NG,并输出缺陷区域、标注图和结构化结果。
|
||||
|
||||
随后,这些图片和 AI 结果会进入复检工作流平台。操作员可以在界面中查看原图、AI 标注图和检测信息,完成最终人工判断。对于产线或第三方业务系统,平台提供任务上传和结果查询能力,使复检结果能够回到业务流程中。
|
||||
|
||||
因此,客户看到的是一整套系统能力:前端能稳定取图,中间能自动检测,上层能人工复核,外部系统还能查询最终结果。它解决的是工业视觉检测从“拍到图、看出缺陷”到“形成可追踪质量结论”的完整问题。
|
||||
|
||||
## 对外短标题
|
||||
|
||||
- 智能视觉质检系统
|
||||
- 从边缘取图到复检闭环
|
||||
- AI 检测 + 人工复核一体化
|
||||
- 让视觉检测进入可追踪工作流
|
||||
- 机器人飞拍取图,AI 自动识别,人工确认结果
|
||||
- 面向工业产线的端到端视觉质量平台
|
||||
657
docs/status-jog-joystick-demo.html
Normal file
657
docs/status-jog-joystick-demo.html
Normal file
@@ -0,0 +1,657 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>状态页摇杆点动 Demo</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f5f7fb;
|
||||
--surface: #ffffff;
|
||||
--line: #d8dee9;
|
||||
--text: #172033;
|
||||
--muted: #5b667a;
|
||||
--accent: #007c89;
|
||||
--accent-dark: #005d67;
|
||||
--steel: #2f3b4f;
|
||||
--steel-soft: #e7ebf1;
|
||||
--good: #12805c;
|
||||
--warn: #b7791f;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: "Segoe UI", "Microsoft YaHei", Arial, sans-serif;
|
||||
font-size: 15px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
header {
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
width: min(1180px, calc(100% - 32px));
|
||||
margin: 0 auto;
|
||||
padding: 18px 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 36px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid rgba(18, 128, 92, 0.28);
|
||||
border-radius: 999px;
|
||||
background: rgba(18, 128, 92, 0.08);
|
||||
color: var(--good);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
background: var(--good);
|
||||
}
|
||||
|
||||
main {
|
||||
width: min(1180px, calc(100% - 32px));
|
||||
margin: 22px auto;
|
||||
}
|
||||
|
||||
.summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.metric,
|
||||
.panel {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.metric {
|
||||
min-height: 86px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.value {
|
||||
margin-top: 8px;
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 24px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.panel h2 {
|
||||
margin: 0;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
font-size: 16px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.jog-panel {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.jog-body {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 18px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.jog-toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(180px, 1fr)) minmax(220px, 1fr);
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: #fbfcfe;
|
||||
}
|
||||
|
||||
.step-field {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.step-field input {
|
||||
min-height: 38px;
|
||||
width: 100%;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: #ffffff;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.jog-readout {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
min-height: 38px;
|
||||
color: var(--muted);
|
||||
font-family: Consolas, "Cascadia Mono", monospace;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.stick-card {
|
||||
min-width: 0;
|
||||
border: 1px solid #ccd4df;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f3f6fa 100%);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.stick-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 16px 0;
|
||||
}
|
||||
|
||||
.stick-title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.stick-unit {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
font-family: Consolas, "Cascadia Mono", monospace;
|
||||
}
|
||||
|
||||
.stick-console {
|
||||
display: grid;
|
||||
grid-template-columns: 72px 1fr 72px;
|
||||
grid-template-rows: 72px 72px 72px;
|
||||
gap: 8px;
|
||||
width: min(100%, 344px);
|
||||
margin: 18px auto 16px;
|
||||
padding: 14px;
|
||||
border: 1px solid #c2cad7;
|
||||
border-radius: 999px;
|
||||
background:
|
||||
radial-gradient(circle at 50% 50%, #ffffff 0 26%, #e6ebf2 27% 48%, #cdd5e0 49% 50%, #edf1f6 51% 100%);
|
||||
}
|
||||
|
||||
.axis-button {
|
||||
min-width: 0;
|
||||
min-height: 64px;
|
||||
border: 1px solid #99a8ba;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #e5ebf2 100%);
|
||||
color: var(--steel);
|
||||
font: inherit;
|
||||
font-weight: 750;
|
||||
cursor: pointer;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
box-shadow: 0 2px 0 #b7c1ce;
|
||||
}
|
||||
|
||||
.axis-button:hover,
|
||||
.axis-button.active {
|
||||
border-color: var(--accent);
|
||||
background: linear-gradient(180deg, #10a4b3 0%, var(--accent) 100%);
|
||||
color: #ffffff;
|
||||
box-shadow: 0 2px 0 var(--accent-dark);
|
||||
}
|
||||
|
||||
.axis-button:focus-visible {
|
||||
outline: 3px solid rgba(0, 124, 137, 0.3);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.axis-button.up {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
.axis-button.left {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
}
|
||||
|
||||
.axis-button.right {
|
||||
grid-column: 3;
|
||||
grid-row: 2;
|
||||
}
|
||||
|
||||
.axis-button.down {
|
||||
grid-column: 2;
|
||||
grid-row: 3;
|
||||
}
|
||||
|
||||
.center-stack {
|
||||
display: grid;
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 6px;
|
||||
place-items: center;
|
||||
padding: 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(47, 59, 79, 0.08);
|
||||
}
|
||||
|
||||
.mini-button {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
border: 1px solid #99a8ba;
|
||||
border-radius: 999px;
|
||||
background: var(--surface);
|
||||
color: var(--steel);
|
||||
font: inherit;
|
||||
font-weight: 750;
|
||||
cursor: pointer;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.mini-button:hover,
|
||||
.mini-button.active {
|
||||
border-color: var(--warn);
|
||||
background: #b7791f;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.hint-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
padding: 0 16px 16px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hint-row span {
|
||||
min-height: 28px;
|
||||
padding: 5px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--steel-soft);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.log-panel {
|
||||
margin-top: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.log-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.log-head h2 {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.clear-button {
|
||||
min-height: 34px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: #ffffff;
|
||||
color: var(--muted);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.clear-button:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.request-log {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
max-height: 300px;
|
||||
margin: 0;
|
||||
padding: 12px 16px 16px;
|
||||
overflow: auto;
|
||||
background: #fbfcfe;
|
||||
font-family: Consolas, "Cascadia Mono", monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
display: grid;
|
||||
grid-template-columns: 86px 96px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
padding: 9px 10px;
|
||||
border: 1px solid #e1e7ef;
|
||||
border-radius: 6px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.log-entry:first-child {
|
||||
border-color: rgba(0, 124, 137, 0.35);
|
||||
background: rgba(0, 124, 137, 0.04);
|
||||
}
|
||||
|
||||
.log-time,
|
||||
.log-axis {
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.log-payload {
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
@media (max-width: 920px) {
|
||||
.summary,
|
||||
.jog-body,
|
||||
.jog-toolbar {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.jog-readout {
|
||||
justify-content: flex-start;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.topbar {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.stick-console {
|
||||
grid-template-columns: 60px 1fr 60px;
|
||||
grid-template-rows: 60px 60px 60px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.axis-button {
|
||||
min-height: 56px;
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="topbar">
|
||||
<h1>Flyshot Replacement 状态监控</h1>
|
||||
<div class="status-pill"><span class="dot"></span><span>Connected</span></div>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<div class="summary">
|
||||
<div class="metric">
|
||||
<div class="label">机器人</div>
|
||||
<div class="value">FANUC</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="label">速度倍率</div>
|
||||
<div class="value">0.70</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="label">运动中</div>
|
||||
<div class="value">否</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="label">TCP 位姿</div>
|
||||
<div class="value" style="font-size: 16px;">120.0, 80.0, 320.0</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="panel jog-panel">
|
||||
<h2>直角坐标点动</h2>
|
||||
<div class="jog-toolbar">
|
||||
<label class="step-field" for="linear-step">
|
||||
<span>平移步长 mm</span>
|
||||
<input id="linear-step" type="number" min="0.1" max="10" step="0.1" value="1">
|
||||
</label>
|
||||
<label class="step-field" for="angular-step">
|
||||
<span>姿态步长 deg</span>
|
||||
<input id="angular-step" type="number" min="0.1" max="10" step="0.1" value="1">
|
||||
</label>
|
||||
<div id="readout" class="jog-readout">等待点动输入</div>
|
||||
</div>
|
||||
|
||||
<div class="jog-body">
|
||||
<article class="stick-card">
|
||||
<div class="stick-head">
|
||||
<h3 class="stick-title">XYZ 平移摇杆</h3>
|
||||
<span class="stick-unit">mm</span>
|
||||
</div>
|
||||
<div class="stick-console" aria-label="XYZ 平移摇杆">
|
||||
<button class="axis-button up" type="button" data-axis="y" data-direction="1">Y+</button>
|
||||
<button class="axis-button left" type="button" data-axis="x" data-direction="-1">X-</button>
|
||||
<div class="center-stack">
|
||||
<button class="mini-button" type="button" data-axis="z" data-direction="1">Z+</button>
|
||||
<button class="mini-button" type="button" data-axis="z" data-direction="-1">Z-</button>
|
||||
</div>
|
||||
<button class="axis-button right" type="button" data-axis="x" data-direction="1">X+</button>
|
||||
<button class="axis-button down" type="button" data-axis="y" data-direction="-1">Y-</button>
|
||||
</div>
|
||||
<div class="hint-row">
|
||||
<span>X 左右</span>
|
||||
<span>Y 上下</span>
|
||||
<span>Z 中心</span>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="stick-card">
|
||||
<div class="stick-head">
|
||||
<h3 class="stick-title">WPR 姿态摇杆</h3>
|
||||
<span class="stick-unit">deg</span>
|
||||
</div>
|
||||
<div class="stick-console" aria-label="WPR 姿态摇杆">
|
||||
<button class="axis-button up" type="button" data-axis="p" data-direction="1">P+</button>
|
||||
<button class="axis-button left" type="button" data-axis="w" data-direction="-1">W-</button>
|
||||
<div class="center-stack">
|
||||
<button class="mini-button" type="button" data-axis="r" data-direction="-1">R-</button>
|
||||
<button class="mini-button" type="button" data-axis="r" data-direction="1">R+</button>
|
||||
</div>
|
||||
<button class="axis-button right" type="button" data-axis="w" data-direction="1">W+</button>
|
||||
<button class="axis-button down" type="button" data-axis="p" data-direction="-1">P-</button>
|
||||
</div>
|
||||
<div class="hint-row">
|
||||
<span>W 左右</span>
|
||||
<span>P 上下</span>
|
||||
<span>R 中心</span>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel log-panel">
|
||||
<div class="log-head">
|
||||
<h2>请求日志</h2>
|
||||
<button id="clear-log" class="clear-button" type="button">清空</button>
|
||||
</div>
|
||||
<div id="request-log" class="request-log" aria-live="polite">
|
||||
<div class="log-entry">
|
||||
<span class="log-time">--:--:--</span>
|
||||
<span class="log-axis">等待输入</span>
|
||||
<span class="log-payload">按住任意摇杆方向后,这里会记录每次模拟 MovePose 请求。</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const readout = document.getElementById("readout");
|
||||
const buttons = Array.from(document.querySelectorAll("[data-axis]"));
|
||||
const linearStepInput = document.getElementById("linear-step");
|
||||
const angularStepInput = document.getElementById("angular-step");
|
||||
const requestLog = document.getElementById("request-log");
|
||||
const clearLog = document.getElementById("clear-log");
|
||||
const axisIndexes = { x: 0, y: 1, z: 2, w: 3, p: 4, r: 5 };
|
||||
const poseKeys = ["x", "y", "z", "w", "p", "r"];
|
||||
const maxLogCount = 80;
|
||||
let timer = null;
|
||||
let activeButton = null;
|
||||
let requestCount = 0;
|
||||
let currentPose = {
|
||||
x: 120.0,
|
||||
y: 80.0,
|
||||
z: 320.0,
|
||||
w: 0.0,
|
||||
p: 45.0,
|
||||
r: 0.0
|
||||
};
|
||||
|
||||
function labelFor(button) {
|
||||
const axis = button.dataset.axis.toUpperCase();
|
||||
const sign = Number(button.dataset.direction) > 0 ? "+" : "-";
|
||||
return axis + sign;
|
||||
}
|
||||
|
||||
function clampStep(input, min, max) {
|
||||
const value = Number(input.value);
|
||||
if (!Number.isFinite(value)) {
|
||||
input.value = String(min);
|
||||
return min;
|
||||
}
|
||||
|
||||
const clamped = Math.min(Math.max(value, min), max);
|
||||
input.value = String(clamped);
|
||||
return clamped;
|
||||
}
|
||||
|
||||
function stepFor(axis) {
|
||||
return axis === "x" || axis === "y" || axis === "z"
|
||||
? clampStep(linearStepInput, 0.1, 10)
|
||||
: clampStep(angularStepInput, 0.1, 10);
|
||||
}
|
||||
|
||||
function buildMovePoseBody(button) {
|
||||
const axis = button.dataset.axis;
|
||||
const direction = Number(button.dataset.direction);
|
||||
const key = poseKeys[axisIndexes[axis]];
|
||||
currentPose[key] = Number((currentPose[key] + direction * stepFor(axis)).toFixed(6));
|
||||
return {
|
||||
x: currentPose.x,
|
||||
y: currentPose.y,
|
||||
z: currentPose.z,
|
||||
w: currentPose.w,
|
||||
p: currentPose.p,
|
||||
r: currentPose.r
|
||||
};
|
||||
}
|
||||
|
||||
function appendLog(button, body) {
|
||||
requestCount += 1;
|
||||
if (requestCount === 1) {
|
||||
requestLog.replaceChildren();
|
||||
}
|
||||
|
||||
const row = document.createElement("div");
|
||||
row.className = "log-entry";
|
||||
|
||||
const time = document.createElement("span");
|
||||
time.className = "log-time";
|
||||
time.textContent = new Date().toLocaleTimeString("zh-CN", { hour12: false });
|
||||
|
||||
const axis = document.createElement("span");
|
||||
axis.className = "log-axis";
|
||||
axis.textContent = "#" + String(requestCount).padStart(3, "0") + " " + labelFor(button);
|
||||
|
||||
const payload = document.createElement("span");
|
||||
payload.className = "log-payload";
|
||||
payload.textContent = "POST /move_pose/ " + JSON.stringify(body);
|
||||
|
||||
row.append(time, axis, payload);
|
||||
requestLog.prepend(row);
|
||||
|
||||
while (requestLog.children.length > maxLogCount) {
|
||||
requestLog.lastElementChild.remove();
|
||||
}
|
||||
}
|
||||
|
||||
function tick(button) {
|
||||
const body = buildMovePoseBody(button);
|
||||
readout.textContent = "模拟发送: " + labelFor(button) + " -> " + JSON.stringify(body);
|
||||
appendLog(button, body);
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (timer !== null) {
|
||||
window.clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
|
||||
if (activeButton) {
|
||||
activeButton.classList.remove("active");
|
||||
activeButton = null;
|
||||
}
|
||||
}
|
||||
|
||||
buttons.forEach(function (button) {
|
||||
button.addEventListener("pointerdown", function (event) {
|
||||
event.preventDefault();
|
||||
stop();
|
||||
activeButton = button;
|
||||
button.classList.add("active");
|
||||
tick(button);
|
||||
timer = window.setInterval(function () {
|
||||
tick(button);
|
||||
}, 250);
|
||||
});
|
||||
button.addEventListener("pointerup", stop);
|
||||
button.addEventListener("pointerleave", stop);
|
||||
button.addEventListener("pointercancel", stop);
|
||||
});
|
||||
|
||||
clearLog.addEventListener("click", function () {
|
||||
requestCount = 0;
|
||||
requestLog.innerHTML = '<div class="log-entry"><span class="log-time">--:--:--</span><span class="log-axis">已清空</span><span class="log-payload">新的点动请求会继续记录在这里。</span></div>';
|
||||
readout.textContent = "等待点动输入";
|
||||
});
|
||||
|
||||
window.addEventListener("blur", stop);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,55 @@
|
||||
# Flyshot 跨平台混淆发布 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 为全部 `Flyshot.*` 自研程序集建立依赖环境 Obfuscar 的跨平台框架依赖混淆发布流程。
|
||||
|
||||
**Architecture:** 使用一个 PowerShell 入口编排 `dotnet publish`、绝对路径 Obfuscar XML 生成、混淆执行、产物完整性检查和映射文件隔离。发布脚本不修改项目运行时代码,也不引入混淆器包依赖。
|
||||
|
||||
**Tech Stack:** PowerShell 7、.NET 8 SDK、Obfuscar.GlobalTool 2.2.50、ASP.NET Core 自包含发布。
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 发布脚本契约测试
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/Publishing/Publish-Obfuscated.Tests.ps1`
|
||||
|
||||
- [ ] 编写测试夹具,创建包含八个 `Flyshot.*.dll` 和一个第三方 DLL 的临时发布目录。
|
||||
- [ ] 调用尚不存在的配置生成入口,断言测试因缺少 `scripts/Publish-Obfuscated.ps1` 而失败。
|
||||
- [ ] 保留测试入口,使后续实现可以在 `-GenerateConfigOnly` 模式下验证配置内容而不执行真实发布。
|
||||
|
||||
### Task 2: 双平台混淆发布脚本
|
||||
|
||||
**Files:**
|
||||
- Create: `scripts/Publish-Obfuscated.ps1`
|
||||
|
||||
- [ ] 定义带完整注释的参数、静态程序集清单和辅助函数。
|
||||
- [ ] 检查 PowerShell、`dotnet` 与 `obfuscar.console` 环境命令。
|
||||
- [ ] 使用 `dotnet publish -c Release -r <rid> --self-contained true -p:PublishSingleFile=false` 生成原始产物。
|
||||
- [ ] 为八个 `Flyshot.*.dll` 生成使用绝对路径的 Obfuscar XML。
|
||||
- [ ] 执行 Obfuscar,并检查每个输入模块都有混淆输出。
|
||||
- [ ] 将混淆 DLL/PDB 替换进发布目录,并将 `Mapping.txt` 隔离到 `outputs/obfuscation-maps/<rid>/`。
|
||||
- [ ] 不传 RID,生成一份 Windows/Linux 共用的 .NET 8 框架依赖发布包。
|
||||
|
||||
### Task 3: 发布任务与文档
|
||||
|
||||
**Files:**
|
||||
- Modify: `.vscode/tasks.json`
|
||||
- Modify: `README.md`
|
||||
|
||||
- [ ] 将 VS Code `publish` 任务改为调用 PowerShell 混淆发布脚本。
|
||||
- [ ] 在 README 发布章节记录环境安装、发布命令、输出目录和保护边界。
|
||||
|
||||
### Task 4: 验证
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/superpowers/plans/2026-06-22-cross-platform-obfuscation.md`
|
||||
|
||||
- [ ] 运行脚本契约测试并确认通过。
|
||||
- [ ] 运行 `dotnet test FlyshotReplacement.sln -c Release -v minimal`。
|
||||
- [ ] 运行 `dotnet build FlyshotReplacement.sln -c Release --no-restore -v minimal`。
|
||||
- [ ] 环境安装 Obfuscar 后执行 `pwsh -File scripts/Publish-Obfuscated.ps1`。
|
||||
- [ ] 启动 Windows 混淆产物并请求 `/healthz`。
|
||||
- [ ] 检查 Linux 混淆产物结构;存在 WSL 时启动并请求 `/healthz`。
|
||||
- [ ] 检查 `git diff`,确认没有第三方程序集或映射文件进入部署包。
|
||||
@@ -0,0 +1,32 @@
|
||||
# 真机优先与离线飞拍规划改造计划
|
||||
|
||||
## 已实施目标
|
||||
|
||||
- 启动及 `/init_mpc_robot` 固定执行“加载模型 → 连接真机 → `EnableRobot(8)`”。
|
||||
- 模型加载成功但连接或使能失败时进入 `OfflineReady`,HTTP 宿主继续运行。
|
||||
- 飞拍资产固定保存到 `Config/RobotConfig.json`,上传、列出、删除不依赖模型或真机状态。
|
||||
- `/save_traj_info/` 只要求模型已加载,返回完整最终发送点列、触发绑定和导出文件绝对路径。
|
||||
- `/execute_flyshot/` 仍要求 `RealReady`,离线规划不会伪造机器人连接或使能。
|
||||
- `/debug` 提供模型加载、轨迹上传/选择、离线生成、分页点列和导出路径查看。
|
||||
|
||||
## 状态约定
|
||||
|
||||
- `LoadingModel`:正在加载机器人模型。
|
||||
- `Connecting`:模型已加载,正在连接或使能真机。
|
||||
- `RealReady`:真机已连接且使能,允许真实运动和 IO。
|
||||
- `OfflineReady`:模型已加载但真机不可用,允许离线规划。
|
||||
- `ModelLoadFailed`:机器人模型不可用,禁止规划和执行。
|
||||
|
||||
## HTTP 约定
|
||||
|
||||
- 保留 `/setup_robot/`,仅加载模型,不连接、不使能,也不清空飞拍资产。
|
||||
- 新增 `/init_mpc_robot`,请求体只包含 `robot_name`、`robot_ip`。
|
||||
- 删除 `/init_mpc_robt`、`/connect_server/`、版本查询、环境文件、TCP 显示、`is_setup` 和仿真控制器切换路由。
|
||||
- `/upload_flyshot/` 在上传阶段只校验资产结构和有限数值;自由度与模型限制在规划阶段校验。
|
||||
- `/save_traj_info/` 返回规划摘要、关键/稠密轨迹、最终发送点、ShotEvents、触发绑定和导出路径。
|
||||
|
||||
## 验证
|
||||
|
||||
- 核心测试覆盖离线资产生命周期、模型重载保留资产、连接失败降级和完整离线规划结果。
|
||||
- 集成测试覆盖删除路由 404、新初始化接口、状态快照和 Debug 离线规划页面。
|
||||
- 最终执行核心测试、集成测试和解决方案构建。
|
||||
@@ -0,0 +1,44 @@
|
||||
# Flyshot 跨平台混淆发布设计
|
||||
|
||||
## 目标
|
||||
|
||||
为全部 `Flyshot.*` 自研程序集增加统一的 Obfuscar 混淆发布流程,生成一份 Windows 与 Linux 共用的 .NET 8 框架依赖部署包。混淆流程必须显式失败,不能在工具缺失或混淆失败时留下看似成功的未保护发布包。
|
||||
|
||||
## 工具与环境边界
|
||||
|
||||
- 使用环境中安装的 Obfuscar 全局工具,命令名为 `obfuscar.console`。
|
||||
- 仓库不增加 Obfuscar NuGet `PackageReference`,也不增加 `.config/dotnet-tools.json`。
|
||||
- 构建环境需预先执行 `dotnet tool install --global Obfuscar.GlobalTool --version 2.2.50`。
|
||||
- 发布脚本只依赖 PowerShell 7、.NET 8 SDK 和环境中的 Obfuscar。
|
||||
|
||||
## 发布结构
|
||||
|
||||
发布脚本不接受运行平台或 RID,固定执行一次框架依赖发布:
|
||||
|
||||
1. 使用 `dotnet publish` 生成自包含、多文件的原始发布目录。
|
||||
2. 动态生成包含绝对路径的 Obfuscar XML 配置。
|
||||
3. 校验当前八个核心程序集不可缺失,并将发布目录中的全部 `Flyshot.*.dll` 加入混淆模块列表。
|
||||
4. Obfuscar 输出到独立临时目录。
|
||||
5. 仅在混淆成功且输出程序集完整时,用混淆程序集替换原始发布目录中的对应文件。
|
||||
6. 将映射文件移入不随部署包交付的 `outputs/obfuscation-maps/<rid>/`。
|
||||
7. 最终部署包输出到 `outputs/obfuscated/framework-dependent/`。
|
||||
|
||||
## 混淆边界
|
||||
|
||||
- `KeepPublicApi=true`:保留公共 API 名称,保护 ASP.NET Core 控制器发现、配置绑定、DI、JSON DTO、Swagger 和程序集间调用。
|
||||
- `HidePrivateApi=true`:重命名内部类型、私有方法和字段。
|
||||
- `RenameProperties=false`、`RenameEvents=false`:避免配置绑定、JSON 序列化和框架约定受属性或事件重命名影响。
|
||||
- 不处理 `Microsoft.*`、`System.*`、NLog、Swashbuckle 等第三方程序集。
|
||||
- 所有 `Flyshot.*.dll` 必须被发现并加入配置;缺少任何当前核心程序集都视为发布失败,未来新增模块会自动纳入。
|
||||
|
||||
## 验证
|
||||
|
||||
- 脚本契约测试验证:RID 列表、环境工具缺失失败、全部自研程序集进入配置、第三方程序集不进入配置、映射文件不进入部署目录。
|
||||
- 执行现有解决方案测试与 Release 构建。
|
||||
- 真实执行框架依赖发布及混淆,并分别在 Windows 与 Linux 的 .NET 8 运行时上启动。
|
||||
- Windows 包实际启动并请求 `/healthz`。
|
||||
- Linux 包至少验证 ELF 启动文件、依赖文件和全部混淆程序集完整;环境存在 WSL 时再实际启动并请求 `/healthz`。
|
||||
|
||||
## 风险与限制
|
||||
|
||||
Obfuscar 能显著提高普通反编译的阅读成本,但不能绝对阻止有经验的逆向分析。保留公共 API 是当前 ASP.NET Core 稳定运行所需的安全边界;若未来需要更强的控制流混淆、字符串加密或防篡改,应评估商业混淆器。
|
||||
14
docs/use-do-output-selection.md
Normal file
14
docs/use-do-output-selection.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# 飞拍 DO / RO 输出选择
|
||||
|
||||
`RobotConfig.json` 的 `robot.use_do` 用于选择飞拍输出类型:
|
||||
|
||||
- `true`:使用 DO,J519 `writeIoType=2`。
|
||||
- `false`:使用 RO,J519 `writeIoType=9`。
|
||||
|
||||
两种模式都根据 `shot_flags / offset_values / addr` 生成触发事件,保持 `io_keep_cycles` 的脉冲保持与清零行为。`use_do=false` 不再关闭拍照触发。
|
||||
|
||||
触发时间轴将输出选择固化到每个事件中;FANUC 运行时根据事件选择写入类型,触发帧和清零帧使用相同类型。未携带输出选择的已有事件默认使用 DO,普通无触发运动保持原行为。
|
||||
|
||||
本次不修改配置中的 `addr`,不增加 RO 点位范围校验,现场点位由调用方自行调试。手动 HTTP `SetIo` 仍使用显式 `io_type`。
|
||||
|
||||
验证覆盖配置到事件的传递,以及 DO / RO 两种模式下的 J519 脉冲与清零。软件测试不代表末端接线和真实相机触发已完成验收。
|
||||
151
docs/uttc-ms11-jerk-validation-decision-20260511.md
Normal file
151
docs/uttc-ms11-jerk-validation-decision-20260511.md
Normal file
@@ -0,0 +1,151 @@
|
||||
# UTTC_MS11 跳过 flyshot jerk 阻断的结论记录
|
||||
|
||||
## 背景
|
||||
|
||||
2026-05-11 对 `UTTC_MS11` 做了两轮独立 `ConfigRoot` 重新生成与对比:
|
||||
|
||||
1. 保持 flyshot 离散校验中的 `vel/acc/jerk` 全部生效。
|
||||
2. 临时只对 flyshot 链路跳过 `jerk` 阻断,仍保留速度与加速度校验。
|
||||
|
||||
两轮都使用当前程序重新生成:
|
||||
|
||||
- `POST /set_speedRatio/`
|
||||
- `POST /save_traj_info/`
|
||||
|
||||
并与以下旧抓包侧结果做对比:
|
||||
|
||||
- `../Rvbust/uttc-20260428/2026042802-1_joint_segments/segment_02/JointDetialTraj.txt`
|
||||
- `../Rvbust/uttc-20260428/2026042802-0.5_joint_segments/segment_02/JointDetialTraj.txt`
|
||||
- `../Rvbust/uttc-20260428/1倍速度 角度坐标点/真实轨迹JointDetialTraj.txt`
|
||||
|
||||
## 直接结论
|
||||
|
||||
对当前 `UTTC_MS11` 现场样本,flyshot 链路若继续用离散 `jerk` 作为阻断条件,会触发大量时间轴 stretch,使新结果远慢于旧抓包。
|
||||
|
||||
当 flyshot 链路只保留速度、加速度校验并跳过 `jerk` 阻断后:
|
||||
|
||||
- 新 `1.0x` 实发时长从 `16.576s` 回落到 `7.552s`
|
||||
- 新 `0.5x` 实发时长从 `33.152s` 回落到 `15.104s`
|
||||
- 与旧抓包主运动段相比,只剩很小的时长差异,可接受并忽略
|
||||
|
||||
因此,当前项目对 `UTTC_MS11` 的实际结论定为:
|
||||
|
||||
- flyshot 链路临时不再把离散 `jerk` 作为阻断条件
|
||||
- 仍保留速度、加速度校验
|
||||
- `jerk` 诊断文件继续保留,用于分析,不再作为该路径是否可运行的唯一否决条件
|
||||
|
||||
## 证据
|
||||
|
||||
### 1. 开启 jerk 阻断时的现象
|
||||
|
||||
重新生成结果目录:
|
||||
|
||||
- `../temp/regen-20260511-110950/`
|
||||
|
||||
关键结果:
|
||||
|
||||
- `1.0x ActualSendTiming.txt`:`2073` 行,`send_duration=16.576s`
|
||||
- `0.5x ActualSendTiming.txt`:`4145` 行,`send_duration=33.152s`
|
||||
|
||||
对比旧抓包主段:
|
||||
|
||||
- 旧 `1.0x`:`7.408109s`
|
||||
- 旧 `0.5x`:`14.808342s`
|
||||
|
||||
可见新结果明显被拉长。
|
||||
|
||||
### 2. 触发 stretch 的第一条失败证据
|
||||
|
||||
日志文件:
|
||||
|
||||
- `../temp/regen-20260511-110950/host.log`
|
||||
|
||||
第一条离散失败不是加速度,而是开头窗口的 `Joint1 Jerk`:
|
||||
|
||||
- `轨迹 UTTC_MS11 第 3 行 Joint1 Jerk超限`
|
||||
- `time=0.008000->0.016000s`
|
||||
- `actual=-1732.421875`
|
||||
- `limit=91.698044`
|
||||
- `ratio=18.8927`
|
||||
|
||||
后续 `CreateLimitCompliantDenseTrajectory(...)` 按 `1.01` 倍持续拉长时间轴,直到通过离散校验,最终把总时长推到约 `16.57s`。
|
||||
|
||||
### 3. 只跳过 flyshot jerk 阻断后的结果
|
||||
|
||||
重新生成结果目录:
|
||||
|
||||
- `../temp/regen-nojerk-20260511-113258/`
|
||||
|
||||
关键结果:
|
||||
|
||||
- `1.0x ActualSendTiming.txt`:`945` 行,`send_duration=7.552s`
|
||||
- `0.5x ActualSendTiming.txt`:`1889` 行,`send_duration=15.104s`
|
||||
|
||||
与旧抓包主段对比:
|
||||
|
||||
| 项目 | 旧抓包时长 | 新结果时长 | 差值 |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| `1.0x` | `7.408109s` | `7.552s` | `0.143891s` |
|
||||
| `0.5x` | `14.808342s` | `15.104s` | `0.295658s` |
|
||||
|
||||
该差异已经很小,当前按“可忽略”处理。
|
||||
|
||||
## 轨迹误差变化
|
||||
|
||||
在同一套对比脚本口径下:
|
||||
|
||||
### 开启 jerk 阻断时
|
||||
|
||||
- `JointDetialTraj` 新 vs 旧真实轨迹:全局 RMS 约 `46.664 deg`
|
||||
- `ActualSendJointTraj 1.0x` 新 vs 旧抓包:全局 RMS 约 `46.665 deg`
|
||||
- `ActualSendJointTraj 0.5x` 新 vs 旧抓包:全局 RMS 约 `46.667 deg`
|
||||
|
||||
### 跳过 flyshot jerk 阻断后
|
||||
|
||||
- `JointDetialTraj` 新 vs 旧真实轨迹:全局 RMS 约 `8.411 deg`
|
||||
- `ActualSendJointTraj 1.0x` 新 vs 旧抓包:全局 RMS 约 `8.412 deg`
|
||||
- `ActualSendJointTraj 0.5x` 新 vs 旧抓包:全局 RMS 约 `8.412 deg`
|
||||
|
||||
说明这次差异收敛主要来自去掉 flyshot jerk 阻断后的 stretch,而不是 `speed_ratio` 链路本身被重新设计。
|
||||
|
||||
## 对 jerk 的解释
|
||||
|
||||
这次调整不等于“现场 jerk 完全合规”,而是明确区分:
|
||||
|
||||
1. `jerk` 仍然继续计算和导出
|
||||
2. `jerk` 仍然可作为诊断指标
|
||||
3. 但对当前 `UTTC_MS11` flyshot 现场样本,不再把 `jerk` 作为阻断执行的唯一判据
|
||||
|
||||
重新生成后的分析文件仍显示:
|
||||
|
||||
- 加速度 ratio 明显在限值内
|
||||
- `jerk` ratio 依然可能大于 `1`
|
||||
|
||||
这与旧抓包整理结果的观察一致:当前现场样本里,“加速度合规但跃度超限”是可接受现象,不能单凭 jerk 超限就否决 flyshot 轨迹。
|
||||
|
||||
## 当前代码决策范围
|
||||
|
||||
本次代码调整范围刻意收窄为:
|
||||
|
||||
- 只对 flyshot 链路关闭 `jerk` 阻断
|
||||
- 保留 flyshot 的速度、加速度校验
|
||||
- 不修改 `move_joint` 的 jerk 约束逻辑
|
||||
|
||||
对应入口包括:
|
||||
|
||||
- `ControllerClientTrajectoryOrchestrator.CreateLimitCompliantDenseTrajectory(...)`
|
||||
- `FlyshotExecutionSendSequenceBuilder.Build(...)`
|
||||
- `FlyshotTrajectoryArtifactWriter.WriteActualSendArtifacts(...)`
|
||||
|
||||
`move_joint` 仍保持原先的 jerk 受限实现,不共享这次豁免策略。
|
||||
|
||||
## 当前验收判断
|
||||
|
||||
对 `UTTC_MS11`:
|
||||
|
||||
- 只保留 flyshot 速度/加速度校验
|
||||
- 跳过 flyshot jerk 阻断
|
||||
- 新 `1.0x / 0.5x` 与旧抓包主段的剩余时长差异已经很小
|
||||
- 当前判断:这点差异可以忽略
|
||||
|
||||
后续除非现场再次提供新的反证,否则本结论作为当前项目实现与验收的记录保留。
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"sdk": {
|
||||
"version": "8.0.420",
|
||||
"rollForward": "disable"
|
||||
"version": "8.0.422",
|
||||
"rollForward": "latestPatch"
|
||||
}
|
||||
}
|
||||
|
||||
635
scripts/Publish-Obfuscated.ps1
Normal file
635
scripts/Publish-Obfuscated.ps1
Normal file
@@ -0,0 +1,635 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
发布并混淆 Flyshot 跨平台框架依赖部署包。
|
||||
|
||||
.DESCRIPTION
|
||||
脚本先通过 dotnet publish 生成不区分运行平台的框架依赖产物,再调用环境中安装的
|
||||
Obfuscar 全局工具混淆全部 Flyshot.* 自研程序集。第三方程序集不会进入混淆模块,
|
||||
映射文件会保存到独立目录,不会随部署包交付。目标机器需要安装 .NET 8 运行时。
|
||||
|
||||
.PARAMETER OutputRoot
|
||||
最终混淆部署包目录。
|
||||
|
||||
.PARAMETER MappingRoot
|
||||
Obfuscar 映射文件的保存根目录。该目录不得作为部署包交付。
|
||||
|
||||
.PARAMETER GenerateConfigOnly
|
||||
仅生成 Obfuscar 配置,供自动化测试和问题诊断使用,不执行发布或混淆。
|
||||
|
||||
.PARAMETER InputDirectory
|
||||
GenerateConfigOnly 模式下的程序集输入目录。
|
||||
|
||||
.PARAMETER ObfuscatedDirectory
|
||||
GenerateConfigOnly 模式下的混淆输出目录。
|
||||
|
||||
.PARAMETER ConfigPath
|
||||
GenerateConfigOnly 模式下生成的 Obfuscar XML 文件路径。
|
||||
|
||||
.PARAMETER ObfuscateExistingPublish
|
||||
仅混淆已经由 Visual Studio 或 dotnet publish 生成的现有发布目录,不再次执行发布。
|
||||
|
||||
.PARAMETER ExistingPublishDirectory
|
||||
ObfuscateExistingPublish 模式下需要原地替换自研程序集的发布目录。
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$OutputRoot,
|
||||
[string]$MappingRoot,
|
||||
[switch]$GenerateConfigOnly,
|
||||
[string]$InputDirectory,
|
||||
[string]$ObfuscatedDirectory,
|
||||
[string]$ConfigPath,
|
||||
[switch]$ObfuscateExistingPublish,
|
||||
[string]$ExistingPublishDirectory
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
# 该清单固定了当前解决方案必须保护的全部自研程序集,避免新增或丢失模块时静默发布。
|
||||
$script:FlyshotAssemblyNames = @(
|
||||
'Flyshot.Server.Host.dll'
|
||||
'Flyshot.ControllerClientCompat.dll'
|
||||
'Flyshot.Core.Config.dll'
|
||||
'Flyshot.Core.Domain.dll'
|
||||
'Flyshot.Core.Planning.dll'
|
||||
'Flyshot.Core.Triggering.dll'
|
||||
'Flyshot.Runtime.Fanuc.dll'
|
||||
'Flyshot.Runtime.Common.dll'
|
||||
)
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
将路径转换为规范化绝对路径。
|
||||
|
||||
.PARAMETER Path
|
||||
需要规范化的文件或目录路径,可以是相对路径或绝对路径。
|
||||
|
||||
.OUTPUTS
|
||||
返回由 System.IO.Path 解析后的绝对路径字符串。
|
||||
#>
|
||||
function ConvertTo-AbsolutePath
|
||||
{
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$Path
|
||||
)
|
||||
|
||||
return [System.IO.Path]::GetFullPath($Path)
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
检查发布目录是否包含全部预期的 Flyshot 自研程序集。
|
||||
|
||||
.PARAMETER Directory
|
||||
待检查的发布输入目录。
|
||||
|
||||
.OUTPUTS
|
||||
按固定清单顺序返回每个程序集的绝对路径。
|
||||
|
||||
.EXCEPTION
|
||||
当目录不存在或任一预期程序集缺失时抛出异常,阻止生成未完整保护的发布包。
|
||||
#>
|
||||
function Get-FlyshotAssemblyPaths
|
||||
{
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$Directory
|
||||
)
|
||||
|
||||
$absoluteDirectory = ConvertTo-AbsolutePath -Path $Directory
|
||||
if (-not (Test-Path -LiteralPath $absoluteDirectory -PathType Container))
|
||||
{
|
||||
throw "程序集输入目录不存在:$absoluteDirectory"
|
||||
}
|
||||
|
||||
foreach ($assemblyName in $script:FlyshotAssemblyNames)
|
||||
{
|
||||
$assemblyPath = Join-Path $absoluteDirectory $assemblyName
|
||||
if (-not (Test-Path -LiteralPath $assemblyPath -PathType Leaf))
|
||||
{
|
||||
throw "发布目录缺少必须混淆的自研程序集:$assemblyName"
|
||||
}
|
||||
}
|
||||
|
||||
# 固定清单负责阻止当前核心模块缺失,通配发现负责自动覆盖未来新增的 Flyshot 自研程序集。
|
||||
$assemblyPaths = Get-ChildItem -LiteralPath $absoluteDirectory -Filter 'Flyshot.*.dll' -File |
|
||||
Sort-Object Name |
|
||||
ForEach-Object { ConvertTo-AbsolutePath -Path $_.FullName }
|
||||
|
||||
return @($assemblyPaths)
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
发现当前环境中与项目目标框架匹配的 .NET 8 共享框架目录。
|
||||
|
||||
.DESCRIPTION
|
||||
框架依赖发布不会把 Microsoft.NETCore.App 与 Microsoft.AspNetCore.App 的程序集复制到发布目录。
|
||||
Obfuscar 仍需要这些目录解析继承关系,因此通过 dotnet --list-runtimes 动态发现实际安装位置。
|
||||
|
||||
.OUTPUTS
|
||||
返回 Microsoft.NETCore.App 与 Microsoft.AspNetCore.App 最新 .NET 8 运行时的绝对目录。
|
||||
|
||||
.EXCEPTION
|
||||
当环境未安装所需的 .NET 8 共享框架时抛出异常,避免生成无法解析依赖的混淆配置。
|
||||
#>
|
||||
function Get-DotNetSharedFrameworkSearchPaths
|
||||
{
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
|
||||
$runtimeEntries = & dotnet --list-runtimes
|
||||
if ($LASTEXITCODE -ne 0)
|
||||
{
|
||||
throw "无法查询已安装的 .NET 运行时,dotnet --list-runtimes 退出码为 $LASTEXITCODE。"
|
||||
}
|
||||
|
||||
$requiredFrameworks = @(
|
||||
'Microsoft.NETCore.App'
|
||||
'Microsoft.AspNetCore.App'
|
||||
)
|
||||
|
||||
$searchPaths = foreach ($frameworkName in $requiredFrameworks)
|
||||
{
|
||||
$candidates = foreach ($runtimeEntry in $runtimeEntries)
|
||||
{
|
||||
if ($runtimeEntry -match '^(?<name>\S+)\s+(?<version>8\.\d+\.\d+)\s+\[(?<root>.+)\]$')
|
||||
{
|
||||
if ($Matches.name -eq $frameworkName)
|
||||
{
|
||||
[pscustomobject]@{
|
||||
Version = [version]$Matches.version
|
||||
Path = Join-Path $Matches.root $Matches.version
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$selectedRuntime = $candidates |
|
||||
Sort-Object Version -Descending |
|
||||
Select-Object -First 1
|
||||
|
||||
if ($null -eq $selectedRuntime -or -not (Test-Path -LiteralPath $selectedRuntime.Path -PathType Container))
|
||||
{
|
||||
throw "环境缺少 Obfuscar 解析依赖所需的 .NET 8 共享框架:$frameworkName"
|
||||
}
|
||||
|
||||
ConvertTo-AbsolutePath -Path $selectedRuntime.Path
|
||||
}
|
||||
|
||||
return @($searchPaths)
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
生成仅包含 Flyshot 自研程序集的 Obfuscar XML 配置。
|
||||
|
||||
.PARAMETER SourceDirectory
|
||||
包含发布程序集及其依赖项的输入目录。
|
||||
|
||||
.PARAMETER TargetDirectory
|
||||
Obfuscar 写入混淆程序集的独立输出目录。
|
||||
|
||||
.PARAMETER DestinationPath
|
||||
要写入的 Obfuscar XML 配置文件路径。
|
||||
|
||||
.OUTPUTS
|
||||
返回生成后的 Obfuscar XML 配置绝对路径。
|
||||
#>
|
||||
function New-ObfuscarConfiguration
|
||||
{
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$SourceDirectory,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[string]$TargetDirectory,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[string]$DestinationPath
|
||||
)
|
||||
|
||||
$absoluteSourceDirectory = ConvertTo-AbsolutePath -Path $SourceDirectory
|
||||
$absoluteTargetDirectory = ConvertTo-AbsolutePath -Path $TargetDirectory
|
||||
$absoluteDestinationPath = ConvertTo-AbsolutePath -Path $DestinationPath
|
||||
$assemblyPaths = Get-FlyshotAssemblyPaths -Directory $absoluteSourceDirectory
|
||||
$assemblySearchPaths = Get-DotNetSharedFrameworkSearchPaths
|
||||
|
||||
$destinationParent = Split-Path -Parent $absoluteDestinationPath
|
||||
New-Item -ItemType Directory -Path $destinationParent -Force | Out-Null
|
||||
New-Item -ItemType Directory -Path $absoluteTargetDirectory -Force | Out-Null
|
||||
|
||||
$settings = [ordered]@{
|
||||
InPath = $absoluteSourceDirectory
|
||||
OutPath = $absoluteTargetDirectory
|
||||
KeepPublicApi = 'true'
|
||||
HidePrivateApi = 'true'
|
||||
RenameProperties = 'false'
|
||||
RenameEvents = 'false'
|
||||
RenameFields = 'true'
|
||||
SkipGenerated = 'true'
|
||||
SkipSpecialName = 'true'
|
||||
}
|
||||
|
||||
$xmlSettings = [System.Xml.XmlWriterSettings]::new()
|
||||
$xmlSettings.Indent = $true
|
||||
$xmlSettings.Encoding = [System.Text.UTF8Encoding]::new($false)
|
||||
|
||||
$writer = [System.Xml.XmlWriter]::Create($absoluteDestinationPath, $xmlSettings)
|
||||
try
|
||||
{
|
||||
$writer.WriteStartDocument()
|
||||
$writer.WriteStartElement('Obfuscator')
|
||||
|
||||
foreach ($setting in $settings.GetEnumerator())
|
||||
{
|
||||
$writer.WriteStartElement('Var')
|
||||
$writer.WriteAttributeString('name', [string]$setting.Key)
|
||||
$writer.WriteAttributeString('value', [string]$setting.Value)
|
||||
$writer.WriteEndElement()
|
||||
}
|
||||
|
||||
foreach ($assemblySearchPath in $assemblySearchPaths)
|
||||
{
|
||||
# 框架依赖发布不携带共享框架 DLL,Obfuscar 通过这些目录解析 ASP.NET Core 与运行时依赖。
|
||||
$writer.WriteStartElement('AssemblySearchPath')
|
||||
$writer.WriteAttributeString('path', $assemblySearchPath)
|
||||
$writer.WriteEndElement()
|
||||
}
|
||||
|
||||
foreach ($assemblyPath in $assemblyPaths)
|
||||
{
|
||||
# 使用明确的绝对路径,兼容 Obfuscar 当前版本并避免工作目录差异。
|
||||
$writer.WriteStartElement('Module')
|
||||
$writer.WriteAttributeString('file', $assemblyPath)
|
||||
|
||||
# 匿名类型会被 ASP.NET Core 的 System.Text.Json 反射序列化,必须完整保留类型及构造参数元数据。
|
||||
$writer.WriteStartElement('SkipType')
|
||||
$writer.WriteAttributeString('name', '^<>f__AnonymousType.*')
|
||||
$writer.WriteAttributeString('skipMethods', 'true')
|
||||
$writer.WriteAttributeString('skipFields', 'true')
|
||||
$writer.WriteAttributeString('skipProperties', 'true')
|
||||
$writer.WriteAttributeString('skipEvents', 'true')
|
||||
$writer.WriteEndElement()
|
||||
|
||||
$writer.WriteEndElement()
|
||||
}
|
||||
|
||||
$writer.WriteEndElement()
|
||||
$writer.WriteEndDocument()
|
||||
}
|
||||
finally
|
||||
{
|
||||
$writer.Dispose()
|
||||
}
|
||||
|
||||
return $absoluteDestinationPath
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
确认发布依赖的外部命令已安装并可从 PATH 调用。
|
||||
|
||||
.PARAMETER CommandName
|
||||
要检查的命令名称。
|
||||
|
||||
.PARAMETER InstallHint
|
||||
命令缺失时展示给构建人员的安装提示。
|
||||
#>
|
||||
function Assert-EnvironmentCommand
|
||||
{
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$CommandName,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[string]$InstallHint
|
||||
)
|
||||
|
||||
if (-not (Get-Command $CommandName -ErrorAction SilentlyContinue))
|
||||
{
|
||||
throw "环境缺少命令 '$CommandName'。$InstallHint"
|
||||
}
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
执行外部命令并在非零退出码时终止发布。
|
||||
|
||||
.PARAMETER FilePath
|
||||
需要执行的外部命令。
|
||||
|
||||
.PARAMETER ArgumentList
|
||||
按顺序传入外部命令的参数数组。
|
||||
#>
|
||||
function Invoke-CheckedCommand
|
||||
{
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$FilePath,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[string[]]$ArgumentList
|
||||
)
|
||||
|
||||
& $FilePath @ArgumentList
|
||||
if ($LASTEXITCODE -ne 0)
|
||||
{
|
||||
throw "命令执行失败,退出码为 $LASTEXITCODE:$FilePath $($ArgumentList -join ' ')"
|
||||
}
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
将 Obfuscar 输出安全替换进发布目录并隔离映射文件。
|
||||
|
||||
.PARAMETER PublishedDirectory
|
||||
dotnet publish 生成的最终部署目录。
|
||||
|
||||
.PARAMETER ObfuscatedOutputDirectory
|
||||
Obfuscar 独立输出目录。
|
||||
|
||||
.PARAMETER RuntimeMappingDirectory
|
||||
框架依赖发布对应的映射文件保存目录。
|
||||
#>
|
||||
function Install-ObfuscatedAssemblies
|
||||
{
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$PublishedDirectory,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[string]$ObfuscatedOutputDirectory,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[string]$RuntimeMappingDirectory
|
||||
)
|
||||
|
||||
$assemblyNames = Get-FlyshotAssemblyPaths -Directory $PublishedDirectory |
|
||||
ForEach-Object { [System.IO.Path]::GetFileName($_) }
|
||||
|
||||
foreach ($assemblyName in $assemblyNames)
|
||||
{
|
||||
$obfuscatedAssemblyPath = Join-Path $ObfuscatedOutputDirectory $assemblyName
|
||||
if (-not (Test-Path -LiteralPath $obfuscatedAssemblyPath -PathType Leaf))
|
||||
{
|
||||
throw "Obfuscar 未生成预期程序集:$assemblyName"
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($assemblyName in $assemblyNames)
|
||||
{
|
||||
Copy-Item `
|
||||
-LiteralPath (Join-Path $ObfuscatedOutputDirectory $assemblyName) `
|
||||
-Destination (Join-Path $PublishedDirectory $assemblyName) `
|
||||
-Force
|
||||
|
||||
$pdbName = [System.IO.Path]::ChangeExtension($assemblyName, '.pdb')
|
||||
$obfuscatedPdbPath = Join-Path $ObfuscatedOutputDirectory $pdbName
|
||||
if (Test-Path -LiteralPath $obfuscatedPdbPath -PathType Leaf)
|
||||
{
|
||||
Copy-Item `
|
||||
-LiteralPath $obfuscatedPdbPath `
|
||||
-Destination (Join-Path $PublishedDirectory $pdbName) `
|
||||
-Force
|
||||
}
|
||||
}
|
||||
|
||||
$mappingPath = Join-Path $ObfuscatedOutputDirectory 'Mapping.txt'
|
||||
if (-not (Test-Path -LiteralPath $mappingPath -PathType Leaf))
|
||||
{
|
||||
throw 'Obfuscar 未生成 Mapping.txt,无法确认混淆映射结果。'
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path $RuntimeMappingDirectory -Force | Out-Null
|
||||
Copy-Item -LiteralPath $mappingPath -Destination (Join-Path $RuntimeMappingDirectory 'Mapping.txt') -Force
|
||||
|
||||
# 部署目录不得包含符号映射,避免把重命名关系直接交付给最终用户。
|
||||
$publishedMappingPath = Join-Path $PublishedDirectory 'Mapping.txt'
|
||||
if (Test-Path -LiteralPath $publishedMappingPath)
|
||||
{
|
||||
Remove-Item -LiteralPath $publishedMappingPath -Force
|
||||
}
|
||||
|
||||
# XML 文档包含源码 /// 注释,PDB 包含调试符号;生产混淆包不得向反编译器暴露这些辅助信息。
|
||||
Get-ChildItem -LiteralPath $PublishedDirectory -File |
|
||||
Where-Object {
|
||||
$_.Name -like 'Flyshot*.xml' -or
|
||||
$_.Name -like 'Flyshot*.pdb'
|
||||
} |
|
||||
Remove-Item -Force
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
发布并混淆 Flyshot 框架依赖主程序。
|
||||
|
||||
.PARAMETER RepositoryRoot
|
||||
Flyshot Replacement 仓库根目录。
|
||||
|
||||
.PARAMETER PublishedOutputDirectory
|
||||
最终混淆部署包目录。
|
||||
|
||||
.PARAMETER MappingOutputRoot
|
||||
全部混淆映射文件的根目录。
|
||||
#>
|
||||
function Publish-ObfuscatedFrameworkDependent
|
||||
{
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$RepositoryRoot,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[string]$PublishedOutputDirectory,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[string]$MappingOutputRoot
|
||||
)
|
||||
|
||||
$hostProject = Join-Path $RepositoryRoot 'src\Flyshot.Server.Host\Flyshot.Server.Host.csproj'
|
||||
$workingDirectory = Join-Path $RepositoryRoot '.tmp\obfuscation\framework-dependent'
|
||||
$obfuscatedOutputDirectory = Join-Path $workingDirectory 'obfuscated'
|
||||
$configurationPath = Join-Path $workingDirectory 'obfuscar.xml'
|
||||
$runtimeMappingDirectory = Join-Path $MappingOutputRoot 'framework-dependent'
|
||||
|
||||
# 每次发布使用干净目录,避免旧程序集或旧映射掩盖本轮失败。
|
||||
foreach ($directory in @($PublishedOutputDirectory, $workingDirectory, $runtimeMappingDirectory))
|
||||
{
|
||||
if (Test-Path -LiteralPath $directory)
|
||||
{
|
||||
Remove-Item -LiteralPath $directory -Recurse -Force
|
||||
}
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path $PublishedOutputDirectory -Force | Out-Null
|
||||
|
||||
Write-Host '正在发布跨平台框架依赖包 ...'
|
||||
Invoke-CheckedCommand -FilePath 'dotnet' -ArgumentList @(
|
||||
'publish'
|
||||
$hostProject
|
||||
'-c'
|
||||
'Release'
|
||||
'--self-contained'
|
||||
'false'
|
||||
'-p:PublishSingleFile=false'
|
||||
'-p:UseAppHost=false'
|
||||
'-p:DebugType=none'
|
||||
'-o'
|
||||
$PublishedOutputDirectory
|
||||
)
|
||||
|
||||
New-ObfuscarConfiguration `
|
||||
-SourceDirectory $PublishedOutputDirectory `
|
||||
-TargetDirectory $obfuscatedOutputDirectory `
|
||||
-DestinationPath $configurationPath | Out-Null
|
||||
|
||||
Write-Host '正在混淆框架依赖包中的 Flyshot 自研程序集 ...'
|
||||
Invoke-CheckedCommand -FilePath 'obfuscar.console' -ArgumentList @($configurationPath)
|
||||
|
||||
Install-ObfuscatedAssemblies `
|
||||
-PublishedDirectory $PublishedOutputDirectory `
|
||||
-ObfuscatedOutputDirectory $obfuscatedOutputDirectory `
|
||||
-RuntimeMappingDirectory $runtimeMappingDirectory
|
||||
|
||||
Write-Host "已生成跨平台框架依赖混淆包:$PublishedOutputDirectory"
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
混淆 Visual Studio 已生成的现有发布目录。
|
||||
|
||||
.PARAMETER PublishedDirectory
|
||||
包含全部发布文件的目录;混淆成功后会原地替换其中的 Flyshot 自研程序集。
|
||||
|
||||
.PARAMETER RepositoryRoot
|
||||
Flyshot Replacement 仓库根目录。
|
||||
|
||||
.PARAMETER MappingOutputRoot
|
||||
混淆映射文件的根目录;映射文件不会进入发布目录。
|
||||
#>
|
||||
function Protect-ExistingPublishedDirectory
|
||||
{
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$PublishedDirectory,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[string]$RepositoryRoot,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[string]$MappingOutputRoot
|
||||
)
|
||||
|
||||
$absolutePublishedDirectory = ConvertTo-AbsolutePath -Path $PublishedDirectory
|
||||
$workingDirectory = Join-Path $RepositoryRoot '.tmp\obfuscation\visual-studio\framework-dependent'
|
||||
$obfuscatedOutputDirectory = Join-Path $workingDirectory 'obfuscated'
|
||||
$configurationPath = Join-Path $workingDirectory 'obfuscar.xml'
|
||||
$runtimeMappingDirectory = Join-Path $MappingOutputRoot 'framework-dependent'
|
||||
|
||||
if (-not (Test-Path -LiteralPath $absolutePublishedDirectory -PathType Container))
|
||||
{
|
||||
throw "Visual Studio 发布目录不存在:$absolutePublishedDirectory"
|
||||
}
|
||||
|
||||
# 后处理必须使用独立输出目录,避免 Obfuscar 读写同一个程序集导致文件锁或半成品。
|
||||
foreach ($directory in @($workingDirectory, $runtimeMappingDirectory))
|
||||
{
|
||||
if (Test-Path -LiteralPath $directory)
|
||||
{
|
||||
Remove-Item -LiteralPath $directory -Recurse -Force
|
||||
}
|
||||
}
|
||||
|
||||
New-ObfuscarConfiguration `
|
||||
-SourceDirectory $absolutePublishedDirectory `
|
||||
-TargetDirectory $obfuscatedOutputDirectory `
|
||||
-DestinationPath $configurationPath | Out-Null
|
||||
|
||||
Write-Host "正在混淆 Visual Studio 发布目录中的 Flyshot 自研程序集:$absolutePublishedDirectory"
|
||||
Invoke-CheckedCommand -FilePath 'obfuscar.console' -ArgumentList @($configurationPath)
|
||||
|
||||
Install-ObfuscatedAssemblies `
|
||||
-PublishedDirectory $absolutePublishedDirectory `
|
||||
-ObfuscatedOutputDirectory $obfuscatedOutputDirectory `
|
||||
-RuntimeMappingDirectory $runtimeMappingDirectory
|
||||
|
||||
Write-Host "Visual Studio 发布目录混淆完成:$absolutePublishedDirectory"
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if ($GenerateConfigOnly)
|
||||
{
|
||||
foreach ($requiredValue in @{
|
||||
InputDirectory = $InputDirectory
|
||||
ObfuscatedDirectory = $ObfuscatedDirectory
|
||||
ConfigPath = $ConfigPath
|
||||
}.GetEnumerator())
|
||||
{
|
||||
if ([string]::IsNullOrWhiteSpace([string]$requiredValue.Value))
|
||||
{
|
||||
throw "GenerateConfigOnly 模式缺少参数:$($requiredValue.Key)"
|
||||
}
|
||||
}
|
||||
|
||||
New-ObfuscarConfiguration `
|
||||
-SourceDirectory $InputDirectory `
|
||||
-TargetDirectory $ObfuscatedDirectory `
|
||||
-DestinationPath $ConfigPath | Out-Null
|
||||
exit 0
|
||||
}
|
||||
|
||||
$repositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
|
||||
if ([string]::IsNullOrWhiteSpace($OutputRoot))
|
||||
{
|
||||
$OutputRoot = Join-Path $repositoryRoot 'outputs\obfuscated\framework-dependent'
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($MappingRoot))
|
||||
{
|
||||
$MappingRoot = Join-Path $repositoryRoot 'outputs\obfuscation-maps'
|
||||
}
|
||||
|
||||
Assert-EnvironmentCommand -CommandName 'dotnet' -InstallHint '请安装仓库 global.json 指定的 .NET SDK。'
|
||||
Assert-EnvironmentCommand `
|
||||
-CommandName 'obfuscar.console' `
|
||||
-InstallHint '请执行:dotnet tool install --global Obfuscar.GlobalTool --version 2.2.50'
|
||||
|
||||
if ($ObfuscateExistingPublish)
|
||||
{
|
||||
if ([string]::IsNullOrWhiteSpace($ExistingPublishDirectory))
|
||||
{
|
||||
throw 'ObfuscateExistingPublish 模式缺少参数:ExistingPublishDirectory'
|
||||
}
|
||||
|
||||
Protect-ExistingPublishedDirectory `
|
||||
-PublishedDirectory $ExistingPublishDirectory `
|
||||
-RepositoryRoot $repositoryRoot `
|
||||
-MappingOutputRoot (ConvertTo-AbsolutePath -Path $MappingRoot)
|
||||
exit 0
|
||||
}
|
||||
|
||||
Publish-ObfuscatedFrameworkDependent `
|
||||
-RepositoryRoot $repositoryRoot `
|
||||
-PublishedOutputDirectory (ConvertTo-AbsolutePath -Path $OutputRoot) `
|
||||
-MappingOutputRoot (ConvertTo-AbsolutePath -Path $MappingRoot)
|
||||
|
||||
Write-Host 'Flyshot 跨平台框架依赖混淆发布包已生成。'
|
||||
exit 0
|
||||
}
|
||||
catch
|
||||
{
|
||||
Write-Error $_
|
||||
exit 1
|
||||
}
|
||||
@@ -20,6 +20,11 @@ public sealed class ControllerClientCompatOptions
|
||||
/// </summary>
|
||||
public string? WorkspaceRoot { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置服务启动时自动初始化机器人的配置;为 null 时跳过自动初始化。
|
||||
/// </summary>
|
||||
public AutoInitOptions? AutoInit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 解析运行配置根目录,确保运行时默认不再依赖源码仓库位置。
|
||||
/// </summary>
|
||||
@@ -44,3 +49,19 @@ public sealed class ControllerClientCompatOptions
|
||||
: Path.GetFullPath(WorkspaceRoot);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 表示服务启动时自动初始化机器人的配置项。
|
||||
/// </summary>
|
||||
public sealed class AutoInitOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取或设置机器人名称,用于加载机器人配置文件(如 LR_Mate_200iD)。
|
||||
/// </summary>
|
||||
public string RobotName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置机器人 IP 地址,用于连接真实 FANUC 控制器。
|
||||
/// </summary>
|
||||
public string RobotIp { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ public sealed class ControllerClientCompatRobotCatalog
|
||||
|
||||
if (!SupportedRobotModelFileMap.TryGetValue(robotName, out var modelFileName))
|
||||
{
|
||||
throw new InvalidOperationException($"Unsupported robot name: {robotName}");
|
||||
throw new InvalidOperationException($"不支持的机器人名称 Unsupported robot name: {robotName}");
|
||||
}
|
||||
|
||||
var modelPath = ResolveModelPath(modelFileName);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,102 @@
|
||||
namespace Flyshot.ControllerClientCompat;
|
||||
|
||||
/// <summary>
|
||||
/// 保存状态页需要的 ControllerClient 兼容层元数据快照,避免状态页连续读取多个受保护字段。
|
||||
/// </summary>
|
||||
public sealed record ControllerClientStatusSnapshotMetadata
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化一份兼容层状态元数据快照。
|
||||
/// </summary>
|
||||
/// <param name="isSetup">当前是否已经完成机器人初始化。</param>
|
||||
/// <param name="robotName">当前机器人名称;未初始化时为空。</param>
|
||||
/// <param name="degreesOfFreedom">当前机器人自由度;未初始化时为 0。</param>
|
||||
/// <param name="uploadedTrajectories">当前已上传轨迹名称快照。</param>
|
||||
/// <param name="serverVersion">兼容服务端版本。</param>
|
||||
/// <param name="clientVersion">兼容客户端版本。</param>
|
||||
public ControllerClientStatusSnapshotMetadata(
|
||||
bool isSetup,
|
||||
string? robotName,
|
||||
int degreesOfFreedom,
|
||||
IReadOnlyList<string> uploadedTrajectories,
|
||||
string serverVersion,
|
||||
string clientVersion,
|
||||
string operatingMode = "ModelLoadFailed",
|
||||
string initializationStage = "NotStarted",
|
||||
string? robotIp = null,
|
||||
string? lastConnectionError = null,
|
||||
DateTimeOffset? lastConnectionErrorAt = null,
|
||||
DateTimeOffset? lastInitializationAttemptAt = null,
|
||||
bool canPlanOffline = false,
|
||||
bool canExecuteRealMotion = false)
|
||||
{
|
||||
IsSetup = isSetup;
|
||||
RobotName = robotName;
|
||||
DegreesOfFreedom = degreesOfFreedom;
|
||||
UploadedTrajectories = uploadedTrajectories.ToArray();
|
||||
ServerVersion = serverVersion;
|
||||
ClientVersion = clientVersion;
|
||||
OperatingMode = operatingMode;
|
||||
InitializationStage = initializationStage;
|
||||
RobotIp = robotIp;
|
||||
LastConnectionError = lastConnectionError;
|
||||
LastConnectionErrorAt = lastConnectionErrorAt;
|
||||
LastInitializationAttemptAt = lastInitializationAttemptAt;
|
||||
CanPlanOffline = canPlanOffline;
|
||||
CanExecuteRealMotion = canExecuteRealMotion;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取当前是否已经完成机器人初始化。
|
||||
/// </summary>
|
||||
public bool IsSetup { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取当前机器人名称;未初始化时为空。
|
||||
/// </summary>
|
||||
public string? RobotName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取当前机器人自由度;未初始化时为 0。
|
||||
/// </summary>
|
||||
public int DegreesOfFreedom { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取已上传轨迹名称数组快照。
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> UploadedTrajectories { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取兼容服务端版本。
|
||||
/// </summary>
|
||||
public string ServerVersion { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取兼容客户端版本。
|
||||
/// </summary>
|
||||
public string ClientVersion { get; }
|
||||
|
||||
/// <summary>获取当前运行模式。</summary>
|
||||
public string OperatingMode { get; }
|
||||
|
||||
/// <summary>获取当前初始化阶段。</summary>
|
||||
public string InitializationStage { get; }
|
||||
|
||||
/// <summary>获取最近尝试连接的机器人 IP。</summary>
|
||||
public string? RobotIp { get; }
|
||||
|
||||
/// <summary>获取最后一次真机连接或使能错误。</summary>
|
||||
public string? LastConnectionError { get; }
|
||||
|
||||
/// <summary>获取最后一次真机连接或使能错误发生时间。</summary>
|
||||
public DateTimeOffset? LastConnectionErrorAt { get; }
|
||||
|
||||
/// <summary>获取最后一次初始化尝试时间。</summary>
|
||||
public DateTimeOffset? LastInitializationAttemptAt { get; }
|
||||
|
||||
/// <summary>获取当前是否允许离线规划。</summary>
|
||||
public bool CanPlanOffline { get; }
|
||||
|
||||
/// <summary>获取当前是否允许真机执行。</summary>
|
||||
public bool CanExecuteRealMotion { get; }
|
||||
}
|
||||
@@ -55,21 +55,24 @@ public sealed class ControllerClientTrajectoryOrchestrator
|
||||
/// <param name="waypoints">普通轨迹关节路点。</param>
|
||||
/// <param name="options">执行参数。</param>
|
||||
/// <param name="planningSpeedScale">规划速度倍率。</param>
|
||||
/// <param name="speedRatio">本次执行速度倍率;普通轨迹在规划阶段折算进有效限速倍率。</param>
|
||||
/// <returns>包含规划轨迹、空触发时间轴和执行结果的结果包。</returns>
|
||||
public PlannedExecutionBundle PlanOrdinaryTrajectory(
|
||||
RobotProfile robot,
|
||||
IReadOnlyList<IReadOnlyList<double>> waypoints,
|
||||
TrajectoryExecutionOptions? options = null,
|
||||
double planningSpeedScale = 1.0)
|
||||
double planningSpeedScale = 1.0,
|
||||
double speedRatio = 1.0)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(robot);
|
||||
ArgumentNullException.ThrowIfNull(waypoints);
|
||||
options ??= new TrajectoryExecutionOptions();
|
||||
var planningRobot = ApplyPlanningSpeedScale(robot, planningSpeedScale);
|
||||
var effectivePlanningSpeedScale = ResolveEffectivePlanningSpeedScale(planningSpeedScale, speedRatio, "普通轨迹");
|
||||
var planningRobot = ApplyPlanningSpeedScale(robot, effectivePlanningSpeedScale);
|
||||
|
||||
_logger?.LogInformation(
|
||||
"PlanOrdinaryTrajectory 开始: 路点数={WaypointCount}, method={Method}, planningSpeedScale={PlanningSpeedScale}",
|
||||
waypoints.Count, options.Method, planningSpeedScale);
|
||||
"PlanOrdinaryTrajectory 开始: 路点数={WaypointCount}, method={Method}, planningSpeedScale={PlanningSpeedScale}, speedRatio={SpeedRatio}, effectivePlanningSpeedScale={EffectivePlanningSpeedScale}",
|
||||
waypoints.Count, options.Method, planningSpeedScale, speedRatio, effectivePlanningSpeedScale);
|
||||
|
||||
var program = CreateProgram(
|
||||
name: "ordinary-trajectory",
|
||||
@@ -101,7 +104,7 @@ public sealed class ControllerClientTrajectoryOrchestrator
|
||||
result.Duration.TotalSeconds,
|
||||
result.DenseJointTrajectory?.Count ?? 0);
|
||||
|
||||
return new PlannedExecutionBundle(plannedTrajectory, shotTimeline, result);
|
||||
return new PlannedExecutionBundle(plannedTrajectory, shotTimeline, result, executionTrajectory: executionTrajectory);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -112,24 +115,35 @@ public sealed class ControllerClientTrajectoryOrchestrator
|
||||
/// <param name="options">执行参数。</param>
|
||||
/// <param name="settings">兼容层机器人设置。</param>
|
||||
/// <param name="planningSpeedScale">规划速度倍率。</param>
|
||||
/// <param name="speedRatio">本次飞拍使用的速度倍率;为空时使用配置默认值。</param>
|
||||
/// <returns>包含规划轨迹、触发时间轴和执行结果的结果包。</returns>
|
||||
public PlannedExecutionBundle PlanUploadedFlyshot(
|
||||
RobotProfile robot,
|
||||
ControllerClientCompatUploadedTrajectory uploaded,
|
||||
FlyshotExecutionOptions? options = null,
|
||||
CompatibilityRobotSettings? settings = null,
|
||||
double? planningSpeedScale = null)
|
||||
double? planningSpeedScale = null,
|
||||
double? speedRatio = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(robot);
|
||||
ArgumentNullException.ThrowIfNull(uploaded);
|
||||
options ??= new FlyshotExecutionOptions();
|
||||
settings ??= CreateDefaultRobotSettings();
|
||||
var effectivePlanningSpeedScale = planningSpeedScale ?? settings.PlanningSpeedScale;
|
||||
var requestedSpeedRatio = speedRatio ?? settings.SpeedRatio;
|
||||
var configuredPlanningSpeedScale = planningSpeedScale ?? settings.PlanningSpeedScale;
|
||||
var effectivePlanningSpeedScale = ResolveEffectivePlanningSpeedScale(configuredPlanningSpeedScale, requestedSpeedRatio, "飞拍");
|
||||
var planningRobot = ApplyPlanningSpeedScale(robot, effectivePlanningSpeedScale);
|
||||
|
||||
_logger?.LogInformation(
|
||||
"PlanUploadedFlyshot 开始: name={Name}, waypoints={WaypointCount}, method={Method}, useCache={UseCache}, planningSpeedScale={PlanningSpeedScale}, smoothStartStopTiming={SmoothStartStopTiming}",
|
||||
uploaded.Name, uploaded.Waypoints.Count, options.Method, options.UseCache, effectivePlanningSpeedScale, settings.SmoothStartStopTiming);
|
||||
"PlanUploadedFlyshot 开始: name={Name}, waypoints={WaypointCount}, method={Method}, useCache={UseCache}, planningSpeedScale={PlanningSpeedScale}, speedRatio={SpeedRatio}, effectivePlanningSpeedScale={EffectivePlanningSpeedScale}, smoothStartStopTiming={SmoothStartStopTiming}",
|
||||
uploaded.Name,
|
||||
uploaded.Waypoints.Count,
|
||||
options.Method,
|
||||
options.UseCache,
|
||||
configuredPlanningSpeedScale,
|
||||
requestedSpeedRatio,
|
||||
effectivePlanningSpeedScale,
|
||||
settings.SmoothStartStopTiming);
|
||||
|
||||
var program = CreateProgram(
|
||||
name: uploaded.Name,
|
||||
@@ -170,7 +184,7 @@ public sealed class ControllerClientTrajectoryOrchestrator
|
||||
denseJointTrajectory,
|
||||
usedCache: false,
|
||||
triggerSampleIndexOffsetCycles: settings.TriggerSampleIndexOffsetCycles);
|
||||
var bundle = new PlannedExecutionBundle(plannedTrajectory, shotTimeline, result);
|
||||
var bundle = new PlannedExecutionBundle(plannedTrajectory, shotTimeline, result, executionTrajectory: smoothedExecutionTrajectory);
|
||||
|
||||
_logger?.LogInformation(
|
||||
"PlanUploadedFlyshot 完成: name={Name}, 时长={Duration}s, 触发事件数={TriggerCount}, 采样点数={SampleCount}",
|
||||
@@ -329,6 +343,23 @@ public sealed class ControllerClientTrajectoryOrchestrator
|
||||
adaptIcspTryNum: 5);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析飞拍生成轨迹时真正使用的规划速度倍率。
|
||||
/// </summary>
|
||||
/// <param name="planningSpeedScale">配置或调用方传入的规划倍率。</param>
|
||||
/// <param name="speedRatio">本次执行速度倍率。</param>
|
||||
/// <returns>用于缩放规划关节限制的有效倍率。</returns>
|
||||
private static double ResolveEffectivePlanningSpeedScale(double planningSpeedScale, double speedRatio, string trajectoryKind)
|
||||
{
|
||||
if (speedRatio <= 0.0 || double.IsNaN(speedRatio) || double.IsInfinity(speedRatio))
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(speedRatio), $"{trajectoryKind}速度倍率必须是有限正数。");
|
||||
}
|
||||
|
||||
// 生成轨迹时把执行倍率折算进规划限制,保证 planning_speed_scale * speed_ratio 相同的配置产出同一条轨迹。
|
||||
return planningSpeedScale * speedRatio;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 按运行配置决定是否对规划结果做执行前时间轴重映射。
|
||||
/// </summary>
|
||||
@@ -361,7 +392,7 @@ public sealed class ControllerClientTrajectoryOrchestrator
|
||||
return robot;
|
||||
}
|
||||
|
||||
// RVBUST 规划阶段会用独立限速倍率缩放有效限制;运行时 speedRatio 仍只负责 J519 下发重采样。
|
||||
// speed_ratio 已在调用方折算到规划倍率中;运行时只消费已经生成好的 8ms 稠密轨迹。
|
||||
var scaledLimits = robot.JointLimits
|
||||
.Select(limit => new JointLimit(
|
||||
limit.JointName,
|
||||
@@ -452,7 +483,8 @@ public sealed class ControllerClientTrajectoryOrchestrator
|
||||
TrajectoryLimitValidator.ValidateDenseJointTrajectory(
|
||||
executionTrajectory.Robot,
|
||||
denseJointTrajectory,
|
||||
trajectoryName: executionTrajectory.OriginalProgram.Name);
|
||||
trajectoryName: executionTrajectory.OriginalProgram.Name,
|
||||
validateJerk: false);
|
||||
return denseJointTrajectory;
|
||||
}
|
||||
catch (InvalidOperationException ex) when (iteration < MaxDenseLimitStretchIterations)
|
||||
|
||||
@@ -49,9 +49,27 @@ public sealed class FlyshotTrajectoryArtifactWriter
|
||||
/// </summary>
|
||||
/// <param name="trajectoryName">飞拍轨迹名称。</param>
|
||||
/// <param name="robot">当前机器人配置。</param>
|
||||
/// <param name="bundle">规划结果包。</param>
|
||||
/// <param name="speedRatio">导出 J519 实发采样点时使用的速度倍率。</param>
|
||||
/// <param name="bundle">规划结果包;旧规划导出和实发诊断使用同一份视图。</param>
|
||||
/// <param name="speedRatio">请求速度倍率,仅作为诊断列保留;最终采样倍率已在规划阶段处理。</param>
|
||||
public void WriteUploadedFlyshot(string trajectoryName, RobotProfile robot, PlannedExecutionBundle bundle, double speedRatio = 1.0)
|
||||
{
|
||||
WriteUploadedFlyshot(trajectoryName, robot, bundle, bundle, speedRatio);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将飞拍规划结果导出到 Config/Data/name,并显式区分旧规划导出视图和实发诊断视图。
|
||||
/// </summary>
|
||||
/// <param name="trajectoryName">飞拍轨迹名称。</param>
|
||||
/// <param name="robot">当前机器人配置。</param>
|
||||
/// <param name="exportPlanningBundle">旧格式规划导出使用的结果包,不应包含运行时 speedRatio 折算。</param>
|
||||
/// <param name="executionBundle">实发诊断使用的结果包,应与运行时最终发送队列保持一致。</param>
|
||||
/// <param name="speedRatio">请求速度倍率,仅作为 ActualSend 诊断列保留。</param>
|
||||
public void WriteUploadedFlyshot(
|
||||
string trajectoryName,
|
||||
RobotProfile robot,
|
||||
PlannedExecutionBundle exportPlanningBundle,
|
||||
PlannedExecutionBundle executionBundle,
|
||||
double speedRatio = 1.0)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(trajectoryName))
|
||||
{
|
||||
@@ -59,31 +77,32 @@ public sealed class FlyshotTrajectoryArtifactWriter
|
||||
}
|
||||
|
||||
ArgumentNullException.ThrowIfNull(robot);
|
||||
ArgumentNullException.ThrowIfNull(bundle);
|
||||
ArgumentNullException.ThrowIfNull(exportPlanningBundle);
|
||||
ArgumentNullException.ThrowIfNull(executionBundle);
|
||||
|
||||
var outputDir = Path.Combine(_options.ResolveConfigRoot(), "Data", SanitizeDirectoryName(trajectoryName));
|
||||
Directory.CreateDirectory(outputDir);
|
||||
if (bundle.Result.DenseJointTrajectory is null)
|
||||
if (exportPlanningBundle.Result.DenseJointTrajectory is null)
|
||||
{
|
||||
throw new InvalidOperationException("导出飞拍轨迹工件前必须先生成执行侧稠密轨迹。");
|
||||
throw new InvalidOperationException("导出飞拍旧格式规划工件前必须先生成规划明细轨迹。");
|
||||
}
|
||||
|
||||
// 明细文件现在定义为“执行侧 8ms 稠密轨迹的 16ms 低频视图”,避免再次从 PlannedTrajectory 生成另一条轨迹。
|
||||
// 旧格式规划文件固定使用不含运行时 speedRatio 的规划视图,避免和 ActualSend 实发诊断混在一起。
|
||||
var kinematicsModel = _robotModelLoader.LoadKinematicsModel(robot.ModelPath);
|
||||
var jointTrajectory = BuildJointRows(bundle.PlannedTrajectory);
|
||||
var jointTrajectory = BuildJointRows(exportPlanningBundle.PlannedTrajectory);
|
||||
_logger?.LogInformation("规划之后的轨迹点位数量为:{}", jointTrajectory.Count);
|
||||
var executionDenseTrajectory = bundle.Result.DenseJointTrajectory;
|
||||
var executionDenseTrajectory = exportPlanningBundle.Result.DenseJointTrajectory;
|
||||
var jointDetailTrajectory = DownsampleDenseRows(
|
||||
executionDenseTrajectory,
|
||||
samplePeriodSeconds: LegacyDetailSamplePeriodSeconds);
|
||||
var cartTrajectory = BuildCartesianRows(bundle.PlannedTrajectory, kinematicsModel);
|
||||
var cartTrajectory = BuildCartesianRows(exportPlanningBundle.PlannedTrajectory, kinematicsModel);
|
||||
var cartDetailTrajectory = BuildCartesianRowsFromJointDense(jointDetailTrajectory, kinematicsModel);
|
||||
|
||||
TrajectoryExporter.WriteJointTrajectory(Path.Combine(outputDir, "JointTraj.txt"), jointTrajectory);
|
||||
TrajectoryExporter.WriteJointDenseTrajectory(Path.Combine(outputDir, "JointDetialTraj.txt"), jointDetailTrajectory);
|
||||
TrajectoryExporter.WriteCartesianTrajectory(Path.Combine(outputDir, "CartTraj.txt"), cartTrajectory);
|
||||
TrajectoryExporter.WriteCartesianDenseTrajectory(Path.Combine(outputDir, "CartDetialTraj.txt"), cartDetailTrajectory);
|
||||
WriteActualSendArtifacts(outputDir, robot, bundle.Result, speedRatio);
|
||||
WriteActualSendArtifacts(outputDir, robot, executionBundle.Result, speedRatio);
|
||||
|
||||
_logger?.LogInformation(
|
||||
"saveTrajectory 已导出规划点位: name={TrajectoryName}, outputDir={OutputDir}, jointRows={JointRows}, detailRows={DetailRows}, speedRatio={SpeedRatio}",
|
||||
@@ -107,16 +126,12 @@ public sealed class FlyshotTrajectoryArtifactWriter
|
||||
return;
|
||||
}
|
||||
|
||||
if (preparedExecution is null && (speedRatio <= 0.0 || double.IsNaN(speedRatio) || double.IsInfinity(speedRatio)))
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(speedRatio), "speed_ratio 必须是有限正数。");
|
||||
}
|
||||
var samples = preparedExecution is null
|
||||
? J519SendTrajectorySampler.SampleDenseJointTrajectory(
|
||||
result.DenseJointTrajectory!,
|
||||
result.Duration.TotalSeconds,
|
||||
ActualSendServoPeriodSeconds,
|
||||
speedRatio)
|
||||
speedRatio: 1.0)
|
||||
: preparedExecution.Samples.Select(static sample => new J519SendSample(
|
||||
sample.SampleIndex,
|
||||
sample.SendTime,
|
||||
@@ -142,7 +157,8 @@ public sealed class FlyshotTrajectoryArtifactWriter
|
||||
TrajectoryLimitValidator.ValidateJ519SendSamples(
|
||||
robot,
|
||||
samples,
|
||||
trajectoryName: result.ProgramName);
|
||||
trajectoryName: result.ProgramName,
|
||||
validateJerk: false);
|
||||
var jointRows = new List<IReadOnlyList<double>>(samples.Count);
|
||||
List<IReadOnlyList<double>> timingRows = preparedExecution is null
|
||||
? new List<IReadOnlyList<double>>(samples.Count)
|
||||
@@ -160,7 +176,7 @@ public sealed class FlyshotTrajectoryArtifactWriter
|
||||
jointRows.Add(BuildActualSendJointRow(sample.SendTime, sample.JointsDegrees));
|
||||
if (preparedExecution is null)
|
||||
{
|
||||
timingRows.Add(J519SendTrajectorySampler.BuildTimingRow(sample));
|
||||
timingRows.Add(J519SendTrajectorySampler.BuildTimingRow(sample, speedRatio, stretchIterationCount: 0));
|
||||
|
||||
if (previousSendTime is not null && previousJoints is not null)
|
||||
{
|
||||
|
||||
@@ -34,14 +34,14 @@ public sealed class JsonFlyshotTrajectoryStore
|
||||
/// <param name="robotName">当前已初始化的机器人名称,仅用于日志诊断。</param>
|
||||
/// <param name="settings">当前机器人级兼容配置。</param>
|
||||
/// <param name="trajectory">要保存的已上传轨迹。</param>
|
||||
public void Save(string robotName, CompatibilityRobotSettings settings, ControllerClientCompatUploadedTrajectory trajectory)
|
||||
public void Save(CompatibilityRobotSettings settings, ControllerClientCompatUploadedTrajectory trajectory)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(settings);
|
||||
ArgumentNullException.ThrowIfNull(trajectory);
|
||||
|
||||
_logger?.LogInformation(
|
||||
"RobotConfig 保存轨迹: robot={RobotName}, name={TrajectoryName}, waypoints={WaypointCount}",
|
||||
robotName,
|
||||
"fixed-config",
|
||||
trajectory.Name,
|
||||
trajectory.Waypoints.Count);
|
||||
|
||||
@@ -82,19 +82,30 @@ public sealed class JsonFlyshotTrajectoryStore
|
||||
_logger?.LogInformation("RobotConfig 轨迹已保存到 {Path}", path);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 兼容旧调用签名;机器人名称不再参与固定配置文件分区。
|
||||
/// </summary>
|
||||
/// <param name="robotName">仅用于兼容的机器人名称。</param>
|
||||
/// <param name="settings">机器人级兼容配置。</param>
|
||||
/// <param name="trajectory">待保存轨迹。</param>
|
||||
public void Save(string robotName, CompatibilityRobotSettings settings, ControllerClientCompatUploadedTrajectory trajectory)
|
||||
{
|
||||
Save(settings, trajectory);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从统一 RobotConfig.json 删除指定名称的轨迹。
|
||||
/// </summary>
|
||||
/// <param name="robotName">当前已初始化的机器人名称,仅用于日志诊断。</param>
|
||||
/// <param name="trajectoryName">要删除的轨迹名称。</param>
|
||||
public void Delete(string robotName, string trajectoryName)
|
||||
public void Delete(string trajectoryName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(trajectoryName))
|
||||
{
|
||||
throw new ArgumentException("轨迹名称不能为空。", nameof(trajectoryName));
|
||||
}
|
||||
|
||||
_logger?.LogInformation("RobotConfig 删除轨迹: robot={RobotName}, name={TrajectoryName}", robotName, trajectoryName);
|
||||
_logger?.LogInformation("RobotConfig 删除轨迹: name={TrajectoryName}", trajectoryName);
|
||||
|
||||
var path = ResolveStorePath();
|
||||
if (!File.Exists(path))
|
||||
@@ -131,13 +142,23 @@ public sealed class JsonFlyshotTrajectoryStore
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 兼容旧调用签名;机器人名称不再参与固定配置文件分区。
|
||||
/// </summary>
|
||||
/// <param name="robotName">仅用于兼容的机器人名称。</param>
|
||||
/// <param name="trajectoryName">待删除轨迹名称。</param>
|
||||
public void Delete(string robotName, string trajectoryName)
|
||||
{
|
||||
Delete(trajectoryName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 加载统一 RobotConfig.json 中的所有轨迹,并回传机器人配置。
|
||||
/// </summary>
|
||||
/// <param name="robotName">当前已初始化的机器人名称,仅用于日志诊断。</param>
|
||||
/// <param name="settings">输出 RobotConfig.json 中的机器人配置;若文件不存在或解析失败则为 null。</param>
|
||||
/// <returns>按轨迹名称索引的已上传轨迹集合。</returns>
|
||||
public IReadOnlyDictionary<string, ControllerClientCompatUploadedTrajectory> LoadAll(string robotName, out CompatibilityRobotSettings? settings)
|
||||
public IReadOnlyDictionary<string, ControllerClientCompatUploadedTrajectory> LoadAll(out CompatibilityRobotSettings? settings)
|
||||
{
|
||||
var path = ResolveStorePath();
|
||||
if (!File.Exists(path))
|
||||
@@ -168,7 +189,7 @@ public sealed class JsonFlyshotTrajectoryStore
|
||||
|
||||
_logger?.LogInformation(
|
||||
"RobotConfig 加载完成: robot={RobotName}, 轨迹数={Count}, useDo={UseDo}, ioKeepCycles={IoKeepCycles}",
|
||||
robotName,
|
||||
"fixed-config",
|
||||
dict.Count,
|
||||
settings?.UseDo,
|
||||
settings?.IoKeepCycles);
|
||||
@@ -183,6 +204,19 @@ public sealed class JsonFlyshotTrajectoryStore
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 兼容旧调用签名;机器人名称不再参与固定配置文件分区。
|
||||
/// </summary>
|
||||
/// <param name="robotName">仅用于兼容的机器人名称。</param>
|
||||
/// <param name="settings">输出机器人配置。</param>
|
||||
/// <returns>固定 RobotConfig.json 中的全部飞拍资产。</returns>
|
||||
public IReadOnlyDictionary<string, ControllerClientCompatUploadedTrajectory> LoadAll(
|
||||
string robotName,
|
||||
out CompatibilityRobotSettings? settings)
|
||||
{
|
||||
return LoadAll(out settings);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 把机器人兼容配置序列化为 JSON 对象,字段名与旧版 RobotConfig.json 一致。
|
||||
/// </summary>
|
||||
@@ -198,6 +232,7 @@ public sealed class JsonFlyshotTrajectoryStore
|
||||
["jerk_limit"] = JsonValue.Create(settings.JerkLimitScale),
|
||||
["adapt_icsp_try_num"] = JsonValue.Create(settings.AdaptIcspTryNum),
|
||||
["planning_speed_scale"] = JsonValue.Create(settings.PlanningSpeedScale),
|
||||
["speed_ratio"] = JsonValue.Create(settings.SpeedRatio),
|
||||
["smooth_start_stop_timing"] = JsonValue.Create(settings.SmoothStartStopTiming)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,25 +17,6 @@ public interface IControllerClientCompatService
|
||||
/// </summary>
|
||||
bool IsSetUp { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 保存当前调用方期望连接的 replacement 服务端地址。
|
||||
/// </summary>
|
||||
/// <param name="serverIp">客户端传入的服务端 IP。</param>
|
||||
/// <param name="port">客户端传入的服务端端口。</param>
|
||||
void ConnectServer(string serverIp, int port);
|
||||
|
||||
/// <summary>
|
||||
/// 获取兼容服务端版本号。
|
||||
/// </summary>
|
||||
/// <returns>服务端版本号。</returns>
|
||||
string GetServerVersion();
|
||||
|
||||
/// <summary>
|
||||
/// 获取兼容客户端版本号。
|
||||
/// </summary>
|
||||
/// <returns>客户端版本号。</returns>
|
||||
string GetClientVersion();
|
||||
|
||||
/// <summary>
|
||||
/// 根据旧客户端使用的机器人名称完成机器人初始化。
|
||||
/// </summary>
|
||||
@@ -43,24 +24,12 @@ public interface IControllerClientCompatService
|
||||
void SetUpRobot(string robotName);
|
||||
|
||||
/// <summary>
|
||||
/// 根据旧客户端传入的环境文件完成机器人初始化。
|
||||
/// 按“加载模型、连接真机、使能机器人”的固定顺序执行初始化。
|
||||
/// </summary>
|
||||
/// <param name="envFile">环境文件路径。</param>
|
||||
void SetUpRobotFromEnv(string envFile);
|
||||
|
||||
/// <summary>
|
||||
/// 设置是否显示 TCP 坐标轴。
|
||||
/// </summary>
|
||||
/// <param name="isShow">是否显示 TCP。</param>
|
||||
/// <param name="axisLength">坐标轴长度。</param>
|
||||
/// <param name="axisSize">坐标轴线宽。</param>
|
||||
void SetShowTcp(bool isShow, double axisLength, int axisSize);
|
||||
|
||||
/// <summary>
|
||||
/// 记录当前激活的控制器类型。
|
||||
/// </summary>
|
||||
/// <param name="sim">是否为仿真控制器。</param>
|
||||
void SetActiveController(bool sim);
|
||||
/// <param name="robotName">机器人名称。</param>
|
||||
/// <param name="robotIp">机器人控制器 IP。</param>
|
||||
/// <param name="bufferSize">J519 发送缓冲区大小。</param>
|
||||
void InitializeRobot(string robotName, string robotIp, int bufferSize = 8);
|
||||
|
||||
/// <summary>
|
||||
/// 记录当前控制器已经建立连接。
|
||||
@@ -95,6 +64,12 @@ public interface IControllerClientCompatService
|
||||
/// <returns>控制器运行时状态快照。</returns>
|
||||
ControllerStateSnapshot GetControllerSnapshot();
|
||||
|
||||
/// <summary>
|
||||
/// 读取状态页所需的兼容层元数据快照。
|
||||
/// </summary>
|
||||
/// <returns>兼容层元数据快照。</returns>
|
||||
ControllerClientStatusSnapshotMetadata GetStatusSnapshotMetadata();
|
||||
|
||||
/// <summary>
|
||||
/// 获取当前速度倍率。
|
||||
/// </summary>
|
||||
@@ -157,6 +132,12 @@ public interface IControllerClientCompatService
|
||||
/// <param name="jointPositions">目标关节位置。</param>
|
||||
void MoveJoint(IReadOnlyList<double> jointPositions);
|
||||
|
||||
/// <summary>
|
||||
/// 按直角坐标移动到目标 TCP 位姿。
|
||||
/// </summary>
|
||||
/// <param name="pose">目标位姿 [x,y,z,w,p,r],单位为 mm/deg。</param>
|
||||
void MovePose(IReadOnlyList<double> pose);
|
||||
|
||||
/// <summary>
|
||||
/// 执行普通轨迹。
|
||||
/// </summary>
|
||||
@@ -194,7 +175,7 @@ public interface IControllerClientCompatService
|
||||
/// </summary>
|
||||
/// <param name="name">轨迹名称。</param>
|
||||
/// <param name="method">轨迹生成方法。</param>
|
||||
void SaveTrajectoryInfo(string name, string method = "icsp");
|
||||
OfflineFlyshotPlanningResult SaveTrajectoryInfo(string name, string method = "icsp");
|
||||
|
||||
/// <summary>
|
||||
/// 检查指定飞拍轨迹是否可执行。
|
||||
|
||||
@@ -53,7 +53,7 @@ internal static class MoveJointTrajectoryGenerator
|
||||
///
|
||||
/// 处理流程:
|
||||
/// 1. 根据关节限位计算连续时间律理论最短时长
|
||||
/// 2. 按 speedRatio 换算轨迹采样周期,并将时长对齐到整数个采样间隔
|
||||
/// 2. 按 speedRatio 缩放规划限位,并将时长对齐到固定伺服采样间隔
|
||||
/// 3. 用 7 阶平滑点到点时间律生成稠密轨迹点
|
||||
/// 4. 按离散点反查速度、加速度和 jerk,必要时拉长时长重算
|
||||
/// 5. 封装为 TrajectoryResult 返回
|
||||
@@ -61,7 +61,7 @@ internal static class MoveJointTrajectoryGenerator
|
||||
/// <param name="robot">机器人配置,含自由度数和关节限位。</param>
|
||||
/// <param name="startJoints">起始关节角(弧度)。</param>
|
||||
/// <param name="targetJoints">目标关节角(弧度)。</param>
|
||||
/// <param name="speedRatio">速度倍率,必须大于 0;当前链路中用于换算轨迹采样周期。</param>
|
||||
/// <param name="speedRatio">速度倍率,必须大于 0;在生成阶段折算进有效关节限位。</param>
|
||||
/// <param name="logger">可选的诊断日志。</param>
|
||||
public static TrajectoryResult CreateResult(
|
||||
RobotProfile robot,
|
||||
@@ -84,13 +84,14 @@ internal static class MoveJointTrajectoryGenerator
|
||||
throw new InvalidOperationException($"MoveJoint expects {robot.DegreesOfFreedom} joints.");
|
||||
}
|
||||
|
||||
var requestedDurationSeconds = ResolveDurationSeconds(robot, startJoints, targetJoints);
|
||||
var samplePeriodSeconds = robot.ServoPeriod.TotalSeconds * speedRatio;
|
||||
var planningRobot = ApplySpeedRatioToJointLimits(robot, speedRatio);
|
||||
var requestedDurationSeconds = ResolveDurationSeconds(planningRobot, startJoints, targetJoints);
|
||||
var samplePeriodSeconds = robot.ServoPeriod.TotalSeconds;
|
||||
var durationSeconds = AlignDurationToServoStep(requestedDurationSeconds, samplePeriodSeconds);
|
||||
var denseJointTrajectory = GenerateDenseTrajectory(startJoints, targetJoints, durationSeconds, samplePeriodSeconds);
|
||||
|
||||
var stretchCount = 0;
|
||||
while (!SatisfiesDiscreteJointLimits(robot, denseJointTrajectory))
|
||||
while (!SatisfiesDiscreteJointLimits(planningRobot, denseJointTrajectory))
|
||||
{
|
||||
stretchCount++;
|
||||
if (stretchCount > MaxDiscreteLimitStretchIterations)
|
||||
@@ -127,6 +128,34 @@ internal static class MoveJointTrajectoryGenerator
|
||||
denseJointTrajectory: denseJointTrajectory);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将执行速度倍率折算为 MoveJoint 规划限位,保持最终稠密轨迹仍按物理 8ms 周期生成。
|
||||
/// </summary>
|
||||
private static RobotProfile ApplySpeedRatioToJointLimits(RobotProfile robot, double speedRatio)
|
||||
{
|
||||
if (Math.Abs(speedRatio - 1.0) < 1e-12)
|
||||
{
|
||||
return robot;
|
||||
}
|
||||
|
||||
var scaledLimits = robot.JointLimits
|
||||
.Select(limit => new JointLimit(
|
||||
limit.JointName,
|
||||
limit.VelocityLimit * speedRatio,
|
||||
limit.AccelerationLimit * speedRatio * speedRatio,
|
||||
limit.JerkLimit * speedRatio * speedRatio * speedRatio))
|
||||
.ToArray();
|
||||
|
||||
return new RobotProfile(
|
||||
name: robot.Name,
|
||||
modelPath: robot.ModelPath,
|
||||
degreesOfFreedom: robot.DegreesOfFreedom,
|
||||
jointLimits: scaledLimits,
|
||||
jointCouplings: robot.JointCouplings,
|
||||
servoPeriod: robot.ServoPeriod,
|
||||
triggerPeriod: robot.TriggerPeriod);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据 7 阶平滑点到点时间律和每轴限位,计算 MoveJoint 理论最短时长。
|
||||
///
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
using Flyshot.Core.Domain;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Flyshot.ControllerClientCompat;
|
||||
|
||||
/// <summary>
|
||||
/// MovePose 直角坐标轨迹生成器。
|
||||
/// 将起始 TCP 位姿到目标 TCP 位姿的单段运动,按保守直角速度、加速度和 jerk 限制生成 8ms 稠密点。
|
||||
/// </summary>
|
||||
internal static class MovePoseTrajectoryGenerator
|
||||
{
|
||||
/// <summary>
|
||||
/// 直角位置轴最大速度,单位为 mm/s。
|
||||
/// </summary>
|
||||
private const double LinearVelocityLimit = 50.0;
|
||||
|
||||
/// <summary>
|
||||
/// 直角位置轴最大加速度,单位为 mm/s^2。
|
||||
/// </summary>
|
||||
private const double LinearAccelerationLimit = 250.0;
|
||||
|
||||
/// <summary>
|
||||
/// 直角位置轴最大 jerk,单位为 mm/s^3。
|
||||
/// </summary>
|
||||
private const double LinearJerkLimit = 1250.0;
|
||||
|
||||
/// <summary>
|
||||
/// 姿态轴最大速度,单位为 deg/s。
|
||||
/// </summary>
|
||||
private const double AngularVelocityLimit = 10.0;
|
||||
|
||||
/// <summary>
|
||||
/// 姿态轴最大加速度,单位为 deg/s^2。
|
||||
/// </summary>
|
||||
private const double AngularAccelerationLimit = 50.0;
|
||||
|
||||
/// <summary>
|
||||
/// 姿态轴最大 jerk,单位为 deg/s^3。
|
||||
/// </summary>
|
||||
private const double AngularJerkLimit = 250.0;
|
||||
|
||||
/// <summary>
|
||||
/// 7 阶平滑点到点时间律的一阶导数最大值。
|
||||
/// </summary>
|
||||
private const double SmoothPtpVelocityShapeCoefficient = 2.1875;
|
||||
|
||||
/// <summary>
|
||||
/// 7 阶平滑点到点时间律的二阶导数最大值。
|
||||
/// </summary>
|
||||
private const double SmoothPtpAccelerationShapeCoefficient = 7.513188404399293;
|
||||
|
||||
/// <summary>
|
||||
/// 7 阶平滑点到点时间律的三阶导数最大值。
|
||||
/// </summary>
|
||||
private const double SmoothPtpJerkShapeCoefficient = 52.5;
|
||||
|
||||
/// <summary>
|
||||
/// 单次 MovePose 最大采样点数上限,避免极低速度倍率生成过大的队列。
|
||||
/// </summary>
|
||||
private const int MaxMovePoseSampleCount = 1_000_000;
|
||||
|
||||
/// <summary>
|
||||
/// 离散限位校验允许的浮点容差。
|
||||
/// </summary>
|
||||
private const double DiscreteLimitTolerance = 1.000001;
|
||||
|
||||
/// <summary>
|
||||
/// 离散限位校验失败时最多拉长的采样周期次数。
|
||||
/// </summary>
|
||||
private const int MaxDiscreteLimitStretchIterations = 10_000;
|
||||
|
||||
/// <summary>
|
||||
/// 计算 MovePose 轨迹的完整结果。
|
||||
/// </summary>
|
||||
/// <param name="startPose">起始位姿 [x,y,z,w,p,r],单位为 mm/deg。</param>
|
||||
/// <param name="targetPose">目标位姿 [x,y,z,w,p,r],单位为 mm/deg。</param>
|
||||
/// <param name="servoPeriod">J519 伺服发送周期。</param>
|
||||
/// <param name="speedRatio">速度倍率,必须大于 0。</param>
|
||||
/// <param name="logger">可选诊断日志。</param>
|
||||
/// <returns>包含稠密直角轨迹的规划结果。</returns>
|
||||
public static TrajectoryResult CreateResult(
|
||||
IReadOnlyList<double> startPose,
|
||||
IReadOnlyList<double> targetPose,
|
||||
TimeSpan servoPeriod,
|
||||
double speedRatio,
|
||||
ILogger? logger = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(startPose);
|
||||
ArgumentNullException.ThrowIfNull(targetPose);
|
||||
EnsurePoseVector(startPose, nameof(startPose));
|
||||
EnsurePoseVector(targetPose, nameof(targetPose));
|
||||
|
||||
if (speedRatio <= 0.0 || double.IsNaN(speedRatio) || double.IsInfinity(speedRatio))
|
||||
{
|
||||
throw new InvalidOperationException("Speed ratio must be greater than zero for MovePose execution.");
|
||||
}
|
||||
|
||||
var samplePeriodSeconds = servoPeriod.TotalSeconds;
|
||||
if (samplePeriodSeconds <= 0.0 || double.IsNaN(samplePeriodSeconds) || double.IsInfinity(samplePeriodSeconds))
|
||||
{
|
||||
throw new InvalidOperationException("MovePose servo period must be a finite positive duration.");
|
||||
}
|
||||
|
||||
var requestedDurationSeconds = ResolveDurationSeconds(startPose, targetPose, speedRatio);
|
||||
var durationSeconds = AlignDurationToServoStep(requestedDurationSeconds, samplePeriodSeconds);
|
||||
var denseCartesianTrajectory = GenerateDenseTrajectory(startPose, targetPose, durationSeconds, samplePeriodSeconds);
|
||||
|
||||
var stretchCount = 0;
|
||||
while (!SatisfiesDefaultCartesianLimits(denseCartesianTrajectory, speedRatio))
|
||||
{
|
||||
stretchCount++;
|
||||
if (stretchCount > MaxDiscreteLimitStretchIterations)
|
||||
{
|
||||
throw new InvalidOperationException("MovePose duration cannot be stretched enough to satisfy Cartesian limits.");
|
||||
}
|
||||
|
||||
// 离散差分以真实下发点为准;不满足时逐周期拉长后重采样。
|
||||
durationSeconds = AlignDurationToServoStep(durationSeconds + samplePeriodSeconds, samplePeriodSeconds);
|
||||
denseCartesianTrajectory = GenerateDenseTrajectory(startPose, targetPose, durationSeconds, samplePeriodSeconds);
|
||||
}
|
||||
|
||||
logger?.LogDebug(
|
||||
"MovePoseTrajectoryGenerator: requestedDuration={RequestedDuration:F4}s, duration={Duration:F4}s, speedRatio={SpeedRatio}, samplePeriod={SamplePeriod:F6}s, sampleCount={SampleCount}, stretchCount={StretchCount}",
|
||||
requestedDurationSeconds,
|
||||
durationSeconds,
|
||||
speedRatio,
|
||||
samplePeriodSeconds,
|
||||
denseCartesianTrajectory.Count,
|
||||
stretchCount);
|
||||
|
||||
return new TrajectoryResult(
|
||||
programName: "move-pose",
|
||||
method: PlanningMethod.Doubles,
|
||||
isValid: true,
|
||||
duration: TimeSpan.FromSeconds(durationSeconds),
|
||||
shotEvents: Array.Empty<ShotEvent>(),
|
||||
triggerTimeline: Array.Empty<TrajectoryDoEvent>(),
|
||||
artifacts: Array.Empty<TrajectoryArtifact>(),
|
||||
failureReason: null,
|
||||
usedCache: false,
|
||||
originalWaypointCount: 2,
|
||||
plannedWaypointCount: denseCartesianTrajectory.Count,
|
||||
denseCartesianTrajectory: denseCartesianTrajectory);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 检查稠密直角轨迹是否满足默认六维速度、加速度和 jerk 限制。
|
||||
/// </summary>
|
||||
/// <param name="rows">稠密轨迹行,每行为 [time,x,y,z,w,p,r]。</param>
|
||||
/// <param name="speedRatio">生成该轨迹时使用的速度倍率。</param>
|
||||
/// <returns>满足限制时返回 true,否则返回 false。</returns>
|
||||
public static bool SatisfiesDefaultCartesianLimits(IReadOnlyList<IReadOnlyList<double>> rows, double speedRatio)
|
||||
{
|
||||
if (speedRatio <= 0.0 || double.IsNaN(speedRatio) || double.IsInfinity(speedRatio))
|
||||
{
|
||||
throw new InvalidOperationException("Speed ratio must be greater than zero for MovePose limit validation.");
|
||||
}
|
||||
|
||||
double? previousTime = null;
|
||||
double[]? previousPositions = null;
|
||||
double[]? previousVelocities = null;
|
||||
double[]? previousAccelerations = null;
|
||||
|
||||
foreach (var row in rows)
|
||||
{
|
||||
if (row.Count != 7)
|
||||
{
|
||||
throw new InvalidOperationException("MovePose dense trajectory rows must contain time plus six pose values.");
|
||||
}
|
||||
|
||||
var currentTime = row[0];
|
||||
var currentPositions = row.Skip(1).Take(6).ToArray();
|
||||
|
||||
if (previousTime is not null && previousPositions is not null)
|
||||
{
|
||||
var dt = currentTime - previousTime.Value;
|
||||
if (dt <= 0.0)
|
||||
{
|
||||
throw new InvalidOperationException("MovePose dense trajectory timestamps must be strictly increasing.");
|
||||
}
|
||||
|
||||
var velocities = new double[6];
|
||||
var accelerations = new double[6];
|
||||
for (var index = 0; index < 6; index++)
|
||||
{
|
||||
var limits = GetAxisLimits(index, speedRatio);
|
||||
velocities[index] = (currentPositions[index] - previousPositions[index]) / dt;
|
||||
if (Math.Abs(velocities[index]) > limits.Velocity * DiscreteLimitTolerance)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
accelerations[index] = previousVelocities is null
|
||||
? 0.0
|
||||
: (velocities[index] - previousVelocities[index]) / dt;
|
||||
if (Math.Abs(accelerations[index]) > limits.Acceleration * DiscreteLimitTolerance)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (previousAccelerations is not null)
|
||||
{
|
||||
var jerk = (accelerations[index] - previousAccelerations[index]) / dt;
|
||||
if (Math.Abs(jerk) > limits.Jerk * DiscreteLimitTolerance)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
previousVelocities = velocities;
|
||||
previousAccelerations = accelerations;
|
||||
}
|
||||
|
||||
previousTime = currentTime;
|
||||
previousPositions = currentPositions;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据 7 阶平滑点到点时间律和直角六维限制计算理论最短时长。
|
||||
/// </summary>
|
||||
private static double ResolveDurationSeconds(IReadOnlyList<double> startPose, IReadOnlyList<double> targetPose, double speedRatio)
|
||||
{
|
||||
var duration = 0.0;
|
||||
for (var index = 0; index < 6; index++)
|
||||
{
|
||||
var distance = Math.Abs(targetPose[index] - startPose[index]);
|
||||
if (distance <= 0.0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var limits = GetAxisLimits(index, speedRatio);
|
||||
var velocityDuration = distance * SmoothPtpVelocityShapeCoefficient / limits.Velocity;
|
||||
var accelerationDuration = Math.Sqrt(distance * SmoothPtpAccelerationShapeCoefficient / limits.Acceleration);
|
||||
var jerkDuration = Math.Cbrt(distance * SmoothPtpJerkShapeCoefficient / limits.Jerk);
|
||||
duration = Math.Max(duration, Math.Max(velocityDuration, Math.Max(accelerationDuration, jerkDuration)));
|
||||
}
|
||||
|
||||
return duration;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 生成从起始位姿到目标位姿的稠密等时间隔直角轨迹点序列。
|
||||
/// </summary>
|
||||
private static IReadOnlyList<IReadOnlyList<double>> GenerateDenseTrajectory(
|
||||
IReadOnlyList<double> startPose,
|
||||
IReadOnlyList<double> targetPose,
|
||||
double durationSeconds,
|
||||
double samplePeriodSeconds)
|
||||
{
|
||||
var sampleCount = ResolveSampleIntervalCount(durationSeconds, samplePeriodSeconds) + 1;
|
||||
var rows = new List<IReadOnlyList<double>>(checked((int)sampleCount));
|
||||
|
||||
for (var index = 0L; index < sampleCount; index++)
|
||||
{
|
||||
var time = Math.Min(index * samplePeriodSeconds, durationSeconds);
|
||||
rows.Add(CreateRow(time, durationSeconds, startPose, targetPose));
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将请求时长向上对齐到整数个伺服周期。
|
||||
/// </summary>
|
||||
private static double AlignDurationToServoStep(double durationSeconds, double samplePeriodSeconds)
|
||||
{
|
||||
return ResolveSampleIntervalCount(durationSeconds, samplePeriodSeconds) * samplePeriodSeconds;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 计算时长对应的采样间隔数,轨迹至少包含起点和终点两帧。
|
||||
/// </summary>
|
||||
private static long ResolveSampleIntervalCount(double durationSeconds, double samplePeriodSeconds)
|
||||
{
|
||||
var rawIntervals = durationSeconds / samplePeriodSeconds;
|
||||
if (double.IsNaN(rawIntervals) || double.IsInfinity(rawIntervals))
|
||||
{
|
||||
throw new InvalidOperationException("MovePose sample count is not representable.");
|
||||
}
|
||||
|
||||
var intervals = (long)Math.Ceiling(Math.Max(0.0, rawIntervals) - 1e-9);
|
||||
intervals = Math.Max(1, intervals);
|
||||
if (intervals + 1 > MaxMovePoseSampleCount)
|
||||
{
|
||||
throw new InvalidOperationException($"MovePose sample count must be between 2 and {MaxMovePoseSampleCount}.");
|
||||
}
|
||||
|
||||
return intervals;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 构造单个轨迹行:[time_seconds,x,y,z,w,p,r]。
|
||||
/// </summary>
|
||||
private static IReadOnlyList<double> CreateRow(
|
||||
double timeSeconds,
|
||||
double durationSeconds,
|
||||
IReadOnlyList<double> startPose,
|
||||
IReadOnlyList<double> targetPose)
|
||||
{
|
||||
var u = durationSeconds <= 0.0 ? 1.0 : Math.Clamp(timeSeconds / durationSeconds, 0.0, 1.0);
|
||||
var scale = MoveJointTrajectoryGenerator.EvaluateSmoothPtpPositionScale(u);
|
||||
var row = new double[7];
|
||||
row[0] = Math.Round(timeSeconds, 9);
|
||||
|
||||
for (var index = 0; index < 6; index++)
|
||||
{
|
||||
row[index + 1] = startPose[index] + ((targetPose[index] - startPose[index]) * scale);
|
||||
}
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取指定直角轴在当前速度倍率下的有效限制。
|
||||
/// </summary>
|
||||
private static CartesianAxisLimit GetAxisLimits(int index, double speedRatio)
|
||||
{
|
||||
var linearAxis = index < 3;
|
||||
var velocity = linearAxis ? LinearVelocityLimit : AngularVelocityLimit;
|
||||
var acceleration = linearAxis ? LinearAccelerationLimit : AngularAccelerationLimit;
|
||||
var jerk = linearAxis ? LinearJerkLimit : AngularJerkLimit;
|
||||
return new CartesianAxisLimit(
|
||||
velocity * speedRatio,
|
||||
acceleration * speedRatio * speedRatio,
|
||||
jerk * speedRatio * speedRatio * speedRatio);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 校验位姿向量必须为六维有限数。
|
||||
/// </summary>
|
||||
private static void EnsurePoseVector(IReadOnlyList<double> pose, string parameterName)
|
||||
{
|
||||
if (pose.Count != 6)
|
||||
{
|
||||
throw new ArgumentException("MovePose expects pose [x,y,z,w,p,r].", parameterName);
|
||||
}
|
||||
|
||||
if (pose.Any(static value => double.IsNaN(value) || double.IsInfinity(value)))
|
||||
{
|
||||
throw new ArgumentException("MovePose pose values must be finite.", parameterName);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 表示单个直角轴的有效速度、加速度和 jerk 限制。
|
||||
/// </summary>
|
||||
private readonly record struct CartesianAxisLimit(double Velocity, double Acceleration, double Jerk);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
using Flyshot.Core.Domain;
|
||||
|
||||
namespace Flyshot.ControllerClientCompat;
|
||||
|
||||
/// <summary>
|
||||
/// 表示离线飞拍规划返回给 HTTP、Debug 页面和导出链路的完整结果。
|
||||
/// </summary>
|
||||
public sealed class OfflineFlyshotPlanningResult
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化一份离线飞拍规划结果。
|
||||
/// </summary>
|
||||
/// <param name="trajectoryName">轨迹名称。</param>
|
||||
/// <param name="method">规划方法。</param>
|
||||
/// <param name="isValid">规划结果是否有效。</param>
|
||||
/// <param name="planningDurationSeconds">连续规划轨迹时长,单位为秒。</param>
|
||||
/// <param name="finalSendDurationSeconds">最终发送时长,单位为秒。</param>
|
||||
/// <param name="originalWaypointCount">原始示教路点数量。</param>
|
||||
/// <param name="plannedWaypointCount">规划关键路点数量。</param>
|
||||
/// <param name="densePointCount">稠密轨迹点数量。</param>
|
||||
/// <param name="speedRatio">本次最终发送使用的速度倍率。</param>
|
||||
/// <param name="servoPeriodSeconds">最终发送采样周期,单位为秒。</param>
|
||||
/// <param name="plannedJointTrajectory">关键关节轨迹,每行为时间加弧度制关节值。</param>
|
||||
/// <param name="denseJointTrajectory">稠密关节轨迹,每行为时间加弧度制关节值。</param>
|
||||
/// <param name="cartesianTrajectory">稠密笛卡尔轨迹。</param>
|
||||
/// <param name="finalSendPoints">最终 8ms 发送点列。</param>
|
||||
/// <param name="shotEvents">理论拍照事件。</param>
|
||||
/// <param name="triggerBindings">触发事件与最终发送点的绑定结果。</param>
|
||||
/// <param name="artifactPaths">导出文件名与绝对路径。</param>
|
||||
/// <param name="diagnostics">最终发送点列的速度、加速度和跃度统计。</param>
|
||||
public OfflineFlyshotPlanningResult(
|
||||
string trajectoryName,
|
||||
string method,
|
||||
bool isValid,
|
||||
double planningDurationSeconds,
|
||||
double finalSendDurationSeconds,
|
||||
int originalWaypointCount,
|
||||
int plannedWaypointCount,
|
||||
int densePointCount,
|
||||
double speedRatio,
|
||||
double servoPeriodSeconds,
|
||||
IEnumerable<IReadOnlyList<double>> plannedJointTrajectory,
|
||||
IEnumerable<IReadOnlyList<double>> denseJointTrajectory,
|
||||
IEnumerable<IReadOnlyList<double>> cartesianTrajectory,
|
||||
IEnumerable<OfflineFlyshotSendPoint> finalSendPoints,
|
||||
IEnumerable<ShotEvent> shotEvents,
|
||||
IEnumerable<OfflineFlyshotTriggerBinding> triggerBindings,
|
||||
IReadOnlyDictionary<string, string> artifactPaths,
|
||||
OfflineFlyshotDiagnostics diagnostics)
|
||||
{
|
||||
TrajectoryName = trajectoryName;
|
||||
Method = method;
|
||||
IsValid = isValid;
|
||||
PlanningDurationSeconds = planningDurationSeconds;
|
||||
FinalSendDurationSeconds = finalSendDurationSeconds;
|
||||
OriginalWaypointCount = originalWaypointCount;
|
||||
PlannedWaypointCount = plannedWaypointCount;
|
||||
DensePointCount = densePointCount;
|
||||
SpeedRatio = speedRatio;
|
||||
ServoPeriodSeconds = servoPeriodSeconds;
|
||||
PlannedJointTrajectory = plannedJointTrajectory.Select(static row => row.ToArray()).ToArray();
|
||||
DenseJointTrajectory = denseJointTrajectory.Select(static row => row.ToArray()).ToArray();
|
||||
CartesianTrajectory = cartesianTrajectory.Select(static row => row.ToArray()).ToArray();
|
||||
FinalSendPoints = finalSendPoints.ToArray();
|
||||
ShotEvents = shotEvents.ToArray();
|
||||
TriggerBindings = triggerBindings.ToArray();
|
||||
ArtifactPaths = new Dictionary<string, string>(artifactPaths, StringComparer.Ordinal);
|
||||
Diagnostics = diagnostics;
|
||||
}
|
||||
|
||||
/// <summary>获取轨迹名称。</summary>
|
||||
public string TrajectoryName { get; }
|
||||
|
||||
/// <summary>获取规划方法。</summary>
|
||||
public string Method { get; }
|
||||
|
||||
/// <summary>获取规划结果是否有效。</summary>
|
||||
public bool IsValid { get; }
|
||||
|
||||
/// <summary>获取连续规划轨迹时长,单位为秒。</summary>
|
||||
public double PlanningDurationSeconds { get; }
|
||||
|
||||
/// <summary>获取最终发送时长,单位为秒。</summary>
|
||||
public double FinalSendDurationSeconds { get; }
|
||||
|
||||
/// <summary>获取原始示教路点数量。</summary>
|
||||
public int OriginalWaypointCount { get; }
|
||||
|
||||
/// <summary>获取规划关键路点数量。</summary>
|
||||
public int PlannedWaypointCount { get; }
|
||||
|
||||
/// <summary>获取稠密轨迹点数量。</summary>
|
||||
public int DensePointCount { get; }
|
||||
|
||||
/// <summary>获取最终发送点数量。</summary>
|
||||
public int FinalSendPointCount => FinalSendPoints.Count;
|
||||
|
||||
/// <summary>获取本次最终发送使用的速度倍率。</summary>
|
||||
public double SpeedRatio { get; }
|
||||
|
||||
/// <summary>获取最终发送采样周期,单位为秒。</summary>
|
||||
public double ServoPeriodSeconds { get; }
|
||||
|
||||
/// <summary>获取关键关节轨迹。</summary>
|
||||
public IReadOnlyList<IReadOnlyList<double>> PlannedJointTrajectory { get; }
|
||||
|
||||
/// <summary>获取稠密关节轨迹。</summary>
|
||||
public IReadOnlyList<IReadOnlyList<double>> DenseJointTrajectory { get; }
|
||||
|
||||
/// <summary>获取稠密笛卡尔轨迹。</summary>
|
||||
public IReadOnlyList<IReadOnlyList<double>> CartesianTrajectory { get; }
|
||||
|
||||
/// <summary>获取最终 8ms 发送点列。</summary>
|
||||
public IReadOnlyList<OfflineFlyshotSendPoint> FinalSendPoints { get; }
|
||||
|
||||
/// <summary>获取理论拍照事件。</summary>
|
||||
public IReadOnlyList<ShotEvent> ShotEvents { get; }
|
||||
|
||||
/// <summary>获取触发事件与最终发送点的绑定结果。</summary>
|
||||
public IReadOnlyList<OfflineFlyshotTriggerBinding> TriggerBindings { get; }
|
||||
|
||||
/// <summary>获取导出文件名与绝对路径。</summary>
|
||||
public IReadOnlyDictionary<string, string> ArtifactPaths { get; }
|
||||
|
||||
/// <summary>获取最终发送点列的速度、加速度和跃度统计。</summary>
|
||||
public OfflineFlyshotDiagnostics Diagnostics { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 表示离线规划结果中的单个最终 J519 发送点。
|
||||
/// </summary>
|
||||
/// <param name="SampleIndex">发送周期序号。</param>
|
||||
/// <param name="SendTime">物理发送时间,单位为秒。</param>
|
||||
/// <param name="TrajectoryTime">映射到连续轨迹的采样时间,单位为秒。</param>
|
||||
/// <param name="JointsDegrees">角度制关节目标。</param>
|
||||
/// <param name="IoMask">该点写出的 IO 掩码。</param>
|
||||
/// <param name="IoValue">该点写出的 IO 值。</param>
|
||||
public sealed record OfflineFlyshotSendPoint(
|
||||
long SampleIndex,
|
||||
double SendTime,
|
||||
double TrajectoryTime,
|
||||
IReadOnlyList<double> JointsDegrees,
|
||||
ushort IoMask,
|
||||
ushort IoValue);
|
||||
|
||||
/// <summary>
|
||||
/// 表示离线规划结果中的触发采样点绑定。
|
||||
/// </summary>
|
||||
/// <param name="WaypointIndex">原始示教路点索引。</param>
|
||||
/// <param name="TriggerTime">理论触发时间,单位为秒。</param>
|
||||
/// <param name="SampleIndex">最终发送点数组索引。</param>
|
||||
/// <param name="SampleSequence">最终发送序号。</param>
|
||||
/// <param name="FoundInWindow">是否在理论搜索窗口内完成绑定。</param>
|
||||
/// <param name="IoMask">触发 IO 掩码。</param>
|
||||
/// <param name="IoValue">触发 IO 值。</param>
|
||||
public sealed record OfflineFlyshotTriggerBinding(
|
||||
int WaypointIndex,
|
||||
double TriggerTime,
|
||||
int SampleIndex,
|
||||
long SampleSequence,
|
||||
bool FoundInWindow,
|
||||
ushort IoMask,
|
||||
ushort IoValue);
|
||||
|
||||
/// <summary>
|
||||
/// 表示最终发送点列的离散运动学诊断统计。
|
||||
/// </summary>
|
||||
/// <param name="MaxAbsVelocityDegreesPerSecond">最大绝对速度,单位 deg/s。</param>
|
||||
/// <param name="MaxAbsAccelerationDegreesPerSecondSquared">最大绝对加速度,单位 deg/s²。</param>
|
||||
/// <param name="MaxAbsJerkDegreesPerSecondCubed">最大绝对跃度,单位 deg/s³。</param>
|
||||
public sealed record OfflineFlyshotDiagnostics(
|
||||
double MaxAbsVelocityDegreesPerSecond,
|
||||
double MaxAbsAccelerationDegreesPerSecondSquared,
|
||||
double MaxAbsJerkDegreesPerSecondCubed);
|
||||
@@ -12,26 +12,35 @@ public sealed class PlannedExecutionBundle
|
||||
/// <summary>
|
||||
/// 初始化一份执行规划结果包。
|
||||
/// </summary>
|
||||
/// <param name="plannedTrajectory">规划后的轨迹。</param>
|
||||
/// <param name="plannedTrajectory">规划后的原始连续轨迹。</param>
|
||||
/// <param name="shotTimeline">飞拍触发时间轴。</param>
|
||||
/// <param name="result">对运行时和监控层暴露的规划结果。</param>
|
||||
/// <param name="preparedExecution">飞拍链路预先准备好的最终发送队列。</param>
|
||||
/// <param name="executionTrajectory">与运行时结果同口径的连续执行轨迹;为空时复用 plannedTrajectory。</param>
|
||||
public PlannedExecutionBundle(
|
||||
PlannedTrajectory plannedTrajectory,
|
||||
ShotTimeline shotTimeline,
|
||||
TrajectoryResult result,
|
||||
FlyshotPreparedExecution? preparedExecution = null)
|
||||
FlyshotPreparedExecution? preparedExecution = null,
|
||||
PlannedTrajectory? executionTrajectory = null)
|
||||
{
|
||||
PlannedTrajectory = plannedTrajectory ?? throw new ArgumentNullException(nameof(plannedTrajectory));
|
||||
ShotTimeline = shotTimeline ?? throw new ArgumentNullException(nameof(shotTimeline));
|
||||
Result = result ?? throw new ArgumentNullException(nameof(result));
|
||||
PreparedExecution = preparedExecution;
|
||||
ExecutionTrajectory = executionTrajectory ?? plannedTrajectory;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取规划后的轨迹。
|
||||
/// 获取规划后的原始连续轨迹。
|
||||
/// </summary>
|
||||
public PlannedTrajectory PlannedTrajectory { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取与运行时结果同口径的连续执行轨迹,用于构建最终 8ms 发送队列。
|
||||
/// </summary>
|
||||
public PlannedTrajectory ExecutionTrajectory { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取飞拍触发时间轴。
|
||||
/// </summary>
|
||||
|
||||
@@ -21,6 +21,7 @@ public sealed class CompatibilityRobotSettings
|
||||
double jerkLimitScale,
|
||||
int adaptIcspTryNum,
|
||||
double planningSpeedScale = 1.0,
|
||||
double speedRatio = 1.0,
|
||||
bool smoothStartStopTiming = true)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(ioAddresses);
|
||||
@@ -50,6 +51,11 @@ public sealed class CompatibilityRobotSettings
|
||||
throw new ArgumentOutOfRangeException(nameof(planningSpeedScale), "规划速度倍率必须是有限正数。");
|
||||
}
|
||||
|
||||
if (speedRatio <= 0.0 || speedRatio > 1.0 || double.IsNaN(speedRatio) || double.IsInfinity(speedRatio))
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(speedRatio), "运行时速度倍率必须在 (0, 1] 范围内。");
|
||||
}
|
||||
|
||||
if (adaptIcspTryNum < 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(adaptIcspTryNum), "补点尝试次数不能为负数。");
|
||||
@@ -70,11 +76,12 @@ public sealed class CompatibilityRobotSettings
|
||||
JerkLimitScale = jerkLimitScale;
|
||||
AdaptIcspTryNum = adaptIcspTryNum;
|
||||
PlanningSpeedScale = planningSpeedScale;
|
||||
SpeedRatio = speedRatio;
|
||||
SmoothStartStopTiming = smoothStartStopTiming;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取是否启用伺服同步 IO。
|
||||
/// 获取飞拍同步输出类型选择:true 使用 DO,false 使用 RO;两者都启用触发。
|
||||
/// </summary>
|
||||
public bool UseDo { get; }
|
||||
|
||||
@@ -109,6 +116,11 @@ public sealed class CompatibilityRobotSettings
|
||||
/// </summary>
|
||||
public double PlanningSpeedScale { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取运行时默认速度倍率,会在 SetUpRobot 时同步到控制器运行时,供 MoveJoint 和后续执行链路使用。
|
||||
/// </summary>
|
||||
public double SpeedRatio { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取是否在飞拍执行前对整段时间轴做二次平滑起停重映射。
|
||||
/// </summary>
|
||||
@@ -202,6 +214,7 @@ public sealed class RobotConfigLoader
|
||||
jerkLimitScale: ReadDouble(robotElement, "jerk_limit", defaultValue: 1.0),
|
||||
adaptIcspTryNum: ReadInt(robotElement, "adapt_icsp_try_num", defaultValue: 0),
|
||||
planningSpeedScale: ReadDouble(robotElement, "planning_speed_scale", defaultValue: 1.0),
|
||||
speedRatio: ReadDouble(robotElement, "speed_ratio", defaultValue: 1.0),
|
||||
smoothStartStopTiming: ReadBoolean(robotElement, "smooth_start_stop_timing", defaultValue: true));
|
||||
|
||||
var programs = new Dictionary<string, FlyshotProgram>(StringComparer.Ordinal);
|
||||
@@ -213,8 +226,8 @@ public sealed class RobotConfigLoader
|
||||
}
|
||||
|
||||
_logger?.LogInformation(
|
||||
"RobotConfig 加载完成: resolvedPath={ResolvedPath}, useDo={UseDo}, ioKeepCycles={IoKeepCycles}, accLimit={AccLimit}, jerkLimit={JerkLimit}, planningSpeedScale={PlanningSpeedScale}, smoothStartStopTiming={SmoothStartStopTiming}, adaptIcspTryNum={AdaptIcspTryNum}, 程序数={ProgramCount}",
|
||||
resolvedConfigPath, robot.UseDo, robot.IoKeepCycles, robot.AccLimitScale, robot.JerkLimitScale, robot.PlanningSpeedScale, robot.SmoothStartStopTiming, robot.AdaptIcspTryNum, programs.Count);
|
||||
"RobotConfig 加载完成: resolvedPath={ResolvedPath}, useDo={UseDo}, ioKeepCycles={IoKeepCycles}, accLimit={AccLimit}, jerkLimit={JerkLimit}, planningSpeedScale={PlanningSpeedScale}, speedRatio={SpeedRatio}, smoothStartStopTiming={SmoothStartStopTiming}, adaptIcspTryNum={AdaptIcspTryNum}, 程序数={ProgramCount}",
|
||||
resolvedConfigPath, robot.UseDo, robot.IoKeepCycles, robot.AccLimitScale, robot.JerkLimitScale, robot.PlanningSpeedScale, robot.SpeedRatio, robot.SmoothStartStopTiming, robot.AdaptIcspTryNum, programs.Count);
|
||||
|
||||
return new LoadedRobotConfig(
|
||||
sourcePath: resolvedConfigPath,
|
||||
|
||||
@@ -13,9 +13,9 @@ public sealed class FlyshotPreparedExecution
|
||||
/// <param name="timingRows">与最终发送点列一致的时间映射诊断行。</param>
|
||||
/// <param name="jerkRows">与最终发送点列一致的跃度诊断行。</param>
|
||||
/// <param name="requestSpeedRatio">请求的执行倍率。</param>
|
||||
/// <param name="finalSpeedRatio">通过离散校验后实际采用的保守倍率。</param>
|
||||
/// <param name="finalSpeedRatio">最终发送队列采用的采样倍率;请求倍率已折算进规划时为 1。</param>
|
||||
/// <param name="finalDurationSeconds">最终发送总时长,单位为秒。</param>
|
||||
/// <param name="stretchIterationCount">自动拉长执行时长的迭代次数。</param>
|
||||
/// <param name="stretchIterationCount">历史诊断字段;当前执行侧不自动改写 speed_ratio,因此正常为 0。</param>
|
||||
public FlyshotPreparedExecution(
|
||||
IEnumerable<FlyshotPreparedSample> samples,
|
||||
IEnumerable<FlyshotPreparedTriggerBinding> triggerBindings,
|
||||
@@ -48,7 +48,7 @@ public sealed class FlyshotPreparedExecution
|
||||
|
||||
if (stretchIterationCount < 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(stretchIterationCount), "拉长迭代次数必须是非负整数。");
|
||||
throw new ArgumentOutOfRangeException(nameof(stretchIterationCount), "倍率改写迭代次数必须是非负整数。");
|
||||
}
|
||||
|
||||
Samples = samples.Select(static sample => sample).ToArray();
|
||||
@@ -87,7 +87,7 @@ public sealed class FlyshotPreparedExecution
|
||||
public double RequestSpeedRatio { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取通过离散校验后实际采用的保守倍率。
|
||||
/// 获取最终发送队列采用的采样倍率;请求倍率保留在 <see cref="RequestSpeedRatio"/> 中用于诊断。
|
||||
/// </summary>
|
||||
public double FinalSpeedRatio { get; }
|
||||
|
||||
@@ -97,7 +97,7 @@ public sealed class FlyshotPreparedExecution
|
||||
public double FinalDurationSeconds { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取自动拉长执行时长的迭代次数。
|
||||
/// 获取历史诊断字段;当前执行侧不自动改写 speed_ratio,因此正常为 0。
|
||||
/// </summary>
|
||||
public int StretchIterationCount { get; }
|
||||
}
|
||||
@@ -113,7 +113,7 @@ public sealed class FlyshotPreparedSample
|
||||
/// <param name="sampleIndex">发送周期序号。</param>
|
||||
/// <param name="sendTime">物理发送时间,单位为秒。</param>
|
||||
/// <param name="trajectoryTime">回映射到规划轨迹的采样时间,单位为秒。</param>
|
||||
/// <param name="speedRatio">生成该发送点时采用的执行倍率。</param>
|
||||
/// <param name="speedRatio">生成该发送点时采用的队列采样倍率,已规划调速路径通常为 1。</param>
|
||||
/// <param name="jointsDegrees">J519 下发使用的角度制关节目标。</param>
|
||||
public FlyshotPreparedSample(
|
||||
long sampleIndex,
|
||||
@@ -146,7 +146,7 @@ public sealed class FlyshotPreparedSample
|
||||
public double TrajectoryTime { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取生成该发送点时采用的执行倍率。
|
||||
/// 获取生成该发送点时采用的队列采样倍率。
|
||||
/// </summary>
|
||||
public double SpeedRatio { get; }
|
||||
|
||||
|
||||
@@ -24,7 +24,8 @@ public sealed class TrajectoryResult
|
||||
int plannedWaypointCount,
|
||||
int triggerSampleIndexOffsetCycles = 0,
|
||||
IEnumerable<IReadOnlyList<double>>? denseJointTrajectory = null,
|
||||
FlyshotPreparedExecution? preparedFlyshotExecution = null)
|
||||
FlyshotPreparedExecution? preparedFlyshotExecution = null,
|
||||
IEnumerable<IReadOnlyList<double>>? denseCartesianTrajectory = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(programName))
|
||||
{
|
||||
@@ -60,6 +61,7 @@ public sealed class TrajectoryResult
|
||||
var copiedTriggerTimeline = triggerTimeline.ToArray();
|
||||
var copiedArtifacts = artifacts.ToArray();
|
||||
var copiedDenseJointTrajectory = denseJointTrajectory?.Select(static row => row.ToArray()).ToArray();
|
||||
var copiedDenseCartesianTrajectory = denseCartesianTrajectory?.Select(static row => row.ToArray()).ToArray();
|
||||
|
||||
ProgramName = programName;
|
||||
Method = method;
|
||||
@@ -74,6 +76,7 @@ public sealed class TrajectoryResult
|
||||
PlannedWaypointCount = plannedWaypointCount;
|
||||
TriggerSampleIndexOffsetCycles = triggerSampleIndexOffsetCycles;
|
||||
DenseJointTrajectory = copiedDenseJointTrajectory;
|
||||
DenseCartesianTrajectory = copiedDenseCartesianTrajectory;
|
||||
PreparedFlyshotExecution = preparedFlyshotExecution;
|
||||
}
|
||||
|
||||
@@ -156,6 +159,13 @@ public sealed class TrajectoryResult
|
||||
[JsonPropertyName("denseJointTrajectory")]
|
||||
public IReadOnlyList<IReadOnlyList<double>>? DenseJointTrajectory { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取稠密直角坐标轨迹采样点,每行格式为 [time, x, y, z, w, p, r]。
|
||||
/// Null 表示本结果不是直角流式运动。
|
||||
/// </summary>
|
||||
[JsonPropertyName("denseCartesianTrajectory")]
|
||||
public IReadOnlyList<IReadOnlyList<double>>? DenseCartesianTrajectory { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the prepared flyshot execution queue when the flyshot chain has already built the final 8ms send sequence.
|
||||
/// </summary>
|
||||
@@ -164,20 +174,28 @@ public sealed class TrajectoryResult
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Describes a trigger event that the runtime must inject into the servo timeline.
|
||||
/// 表示运行时注入伺服时间轴的 DO 或 RO 输出触发事件。
|
||||
/// </summary>
|
||||
public sealed class TrajectoryDoEvent
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a validated runtime trigger event.
|
||||
/// 初始化经过校验的伺服同步输出触发事件。
|
||||
/// </summary>
|
||||
/// <param name="waypointIndex">请求拍照的原始示教点索引,从零开始。</param>
|
||||
/// <param name="triggerTime">离散化之前的理论触发时间,单位秒,必须非负。</param>
|
||||
/// <param name="offsetCycles">相对于示教点的触发偏移周期数。</param>
|
||||
/// <param name="holdCycles">输出保持的伺服周期数,必须非负。</param>
|
||||
/// <param name="addressGroup">要输出的 IO 点位组,不允许为空引用。</param>
|
||||
/// <param name="referenceJointsDegrees">用于选择最近发送采样点的关节角,单位度;允许 null。</param>
|
||||
/// <param name="useDo">true 使用 DO,false 使用 RO;默认 true 保留已有事件的 DO 行为。</param>
|
||||
public TrajectoryDoEvent(
|
||||
int waypointIndex,
|
||||
double triggerTime,
|
||||
int offsetCycles,
|
||||
int holdCycles,
|
||||
IoAddressGroup addressGroup,
|
||||
IEnumerable<double>? referenceJointsDegrees = null)
|
||||
IEnumerable<double>? referenceJointsDegrees = null,
|
||||
bool useDo = true)
|
||||
{
|
||||
if (waypointIndex < 0)
|
||||
{
|
||||
@@ -200,6 +218,7 @@ public sealed class TrajectoryDoEvent
|
||||
HoldCycles = holdCycles;
|
||||
AddressGroup = addressGroup ?? throw new ArgumentNullException(nameof(addressGroup));
|
||||
ReferenceJointsDegrees = referenceJointsDegrees?.ToArray();
|
||||
UseDo = useDo;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -238,6 +257,12 @@ public sealed class TrajectoryDoEvent
|
||||
/// </summary>
|
||||
[JsonPropertyName("referenceJointsDegrees")]
|
||||
public IReadOnlyList<double>? ReferenceJointsDegrees { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取飞拍输出选择:true 为 DO,false 为 RO;仅供运行时使用。
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public bool UseDo { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -3,42 +3,32 @@ using Flyshot.Core.Domain;
|
||||
namespace Flyshot.Core.Planning.Sampling;
|
||||
|
||||
/// <summary>
|
||||
/// 负责在飞拍进入运行时前构建最终 8ms 发送队列,并在必要时自动拉长执行时长直到通过离散限幅校验。
|
||||
/// 负责在飞拍进入运行时前构建最终 8ms 发送队列,并执行发送口径的离散限幅校验。
|
||||
/// </summary>
|
||||
public static class FlyshotExecutionSendSequenceBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// 自动拉长时每轮采用的保守倍率缩减系数。
|
||||
/// </summary>
|
||||
private const double StretchFactor = 0.95;
|
||||
|
||||
/// <summary>
|
||||
/// 自动拉长尝试的最大迭代次数。
|
||||
/// </summary>
|
||||
private const int MaxStretchIterations = 16;
|
||||
|
||||
/// <summary>
|
||||
/// 根据规划层稠密轨迹和执行层 speedRatio 构建最终发送队列。
|
||||
/// 根据连续规划轨迹构建最终发送队列;请求 speedRatio 只作为诊断信息保留。
|
||||
/// </summary>
|
||||
/// <param name="robot">机器人关节限值配置。</param>
|
||||
/// <param name="plannedTrajectory">规划阶段产出的连续轨迹。</param>
|
||||
/// <param name="result">规划结果。</param>
|
||||
/// <param name="servoPeriodSeconds">J519 物理发送周期,单位为秒。</param>
|
||||
/// <param name="requestedSpeedRatio">请求的执行倍率。</param>
|
||||
/// <param name="requestedSpeedRatio">请求的执行倍率,必须已经在规划阶段折算进轨迹。</param>
|
||||
/// <param name="samplingSpeedRatio">最终队列采样倍率;为空时使用 1.0,表示直接采样已调速后的规划轨迹。</param>
|
||||
/// <returns>通过离散校验后的飞拍最终发送结果。</returns>
|
||||
public static FlyshotPreparedExecution Build(
|
||||
RobotProfile robot,
|
||||
PlannedTrajectory plannedTrajectory,
|
||||
TrajectoryResult result,
|
||||
double servoPeriodSeconds,
|
||||
double requestedSpeedRatio)
|
||||
double requestedSpeedRatio,
|
||||
double? samplingSpeedRatio = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(robot);
|
||||
ArgumentNullException.ThrowIfNull(plannedTrajectory);
|
||||
ArgumentNullException.ThrowIfNull(result);
|
||||
|
||||
if (result.DenseJointTrajectory is null)
|
||||
{
|
||||
throw new InvalidOperationException("飞拍执行准备前必须先生成规划层稠密关节轨迹。");
|
||||
}
|
||||
|
||||
if (requestedSpeedRatio <= 0.0 || double.IsNaN(requestedSpeedRatio) || double.IsInfinity(requestedSpeedRatio))
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(requestedSpeedRatio), "speed_ratio 必须是有限正数。");
|
||||
@@ -49,47 +39,41 @@ public static class FlyshotExecutionSendSequenceBuilder
|
||||
throw new ArgumentOutOfRangeException(nameof(servoPeriodSeconds), "伺服周期必须是有限正数。");
|
||||
}
|
||||
|
||||
var effectiveSpeedRatio = requestedSpeedRatio;
|
||||
InvalidOperationException? firstFailure = null;
|
||||
|
||||
for (var iteration = 0; iteration <= MaxStretchIterations; iteration++)
|
||||
// 未显式指定独立采样倍率时,最终发送队列必须原样使用请求倍率。
|
||||
var finalSpeedRatio = samplingSpeedRatio ?? requestedSpeedRatio;
|
||||
if (finalSpeedRatio <= 0.0 || double.IsNaN(finalSpeedRatio) || double.IsInfinity(finalSpeedRatio))
|
||||
{
|
||||
var samples = J519SendTrajectorySampler.SampleDenseJointTrajectory(
|
||||
result.DenseJointTrajectory,
|
||||
result.Duration.TotalSeconds,
|
||||
servoPeriodSeconds,
|
||||
effectiveSpeedRatio);
|
||||
|
||||
try
|
||||
{
|
||||
TrajectoryLimitValidator.ValidateJ519SendSamples(
|
||||
robot,
|
||||
samples,
|
||||
trajectoryName: result.ProgramName);
|
||||
|
||||
return BuildPreparedExecution(
|
||||
result,
|
||||
samples,
|
||||
requestedSpeedRatio,
|
||||
effectiveSpeedRatio,
|
||||
iteration);
|
||||
}
|
||||
catch (InvalidOperationException exception)
|
||||
{
|
||||
firstFailure ??= exception;
|
||||
if (iteration >= MaxStretchIterations)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"飞拍最终发送队列离散限幅校验失败,已达到最大自动拉长次数 {MaxStretchIterations}。",
|
||||
firstFailure);
|
||||
}
|
||||
|
||||
// 只在执行侧进一步保守化 trajectoryTime(sendTime) 映射,不回写规划层轨迹。
|
||||
effectiveSpeedRatio *= StretchFactor;
|
||||
}
|
||||
throw new ArgumentOutOfRangeException(nameof(samplingSpeedRatio), "最终队列采样倍率必须是有限正数。");
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("飞拍最终发送队列构建失败。", firstFailure);
|
||||
var samples = J519SendTrajectorySampler.SamplePlannedTrajectory(
|
||||
plannedTrajectory,
|
||||
servoPeriodSeconds,
|
||||
finalSpeedRatio);
|
||||
|
||||
try
|
||||
{
|
||||
TrajectoryLimitValidator.ValidateJ519SendSamples(
|
||||
robot,
|
||||
samples,
|
||||
trajectoryName: result.ProgramName,
|
||||
validateJerk: true,
|
||||
strictJerkTolerance: true);
|
||||
}
|
||||
catch (InvalidOperationException exception)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"飞拍最终发送队列在请求 speed_ratio={requestedSpeedRatio:F6} 下离散限幅校验失败。" +
|
||||
"请降低 planning_speed_scale 或 speed_ratio 后重新规划;执行侧不会在发送阶段临场重采样。",
|
||||
exception);
|
||||
}
|
||||
|
||||
return BuildPreparedExecution(
|
||||
result,
|
||||
samples,
|
||||
requestedSpeedRatio,
|
||||
finalSpeedRatio,
|
||||
stretchIterationCount: 0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -121,7 +105,9 @@ public static class FlyshotExecutionSendSequenceBuilder
|
||||
binding.SampleIndex,
|
||||
binding.FoundInWindow))
|
||||
.ToArray();
|
||||
var timingRows = samples.Select(J519SendTrajectorySampler.BuildTimingRow).ToArray();
|
||||
var timingRows = samples
|
||||
.Select(sample => J519SendTrajectorySampler.BuildTimingRow(sample, requestedSpeedRatio, stretchIterationCount))
|
||||
.ToArray();
|
||||
var jerkRows = BuildJerkRows(samples);
|
||||
var finalDurationSeconds = preparedSamples.Length == 0 ? 0.0 : preparedSamples[^1].SendTime;
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
using Flyshot.Core.Planning;
|
||||
|
||||
namespace Flyshot.Core.Planning.Sampling;
|
||||
|
||||
/// <summary>
|
||||
/// 负责把规划层稠密关节轨迹重采样为 J519 物理发送周期上的角度制目标。
|
||||
/// 负责把规划层关节轨迹重采样为 J519 物理发送周期上的角度制目标。
|
||||
/// <para>
|
||||
/// 算法约定:
|
||||
/// 输入的稠密关节轨迹行格式固定为 [time, j1..jN],time 为规划轨迹时间,关节单位为弧度;
|
||||
@@ -13,10 +15,12 @@ namespace Flyshot.Core.Planning.Sampling;
|
||||
/// 末尾额外保留一个终点钳制周期,确保轨迹时长不是周期整数倍时仍会输出最终点。
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 飞拍最终发送队列优先从 <see cref="PlannedTrajectory"/> 重建连续三次样条并直接取点;
|
||||
/// 旧稠密点入口仅保留给运行时 fallback 和兼容导出。
|
||||
/// 第 k 个采样点的物理发送时间为 sendTime = k * servoPeriod;
|
||||
/// speedRatio 不改变物理发送周期,只用于把发送时间映射回规划轨迹时间:
|
||||
/// trajectoryTime = min(sendTime * speedRatio, duration)。
|
||||
/// 之后在原始稠密关节轨迹上按 trajectoryTime 做线性插值,并把每个关节从 rad 转为 deg。
|
||||
/// 之后在连续样条或旧稠密轨迹上求取关节,并把每个关节从 rad 转为 deg。
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 诊断行也在这里统一生成:Timing 行格式为 sample_index + send_time + trajectory_time + speed_ratio;
|
||||
@@ -26,6 +30,39 @@ namespace Flyshot.Core.Planning.Sampling;
|
||||
/// </summary>
|
||||
public static class J519SendTrajectorySampler
|
||||
{
|
||||
/// <summary>
|
||||
/// 根据连续规划样条和 J519 伺服周期生成完整实发采样序列。
|
||||
/// </summary>
|
||||
/// <param name="trajectory">规划阶段产出的连续轨迹信息。</param>
|
||||
/// <param name="servoPeriodSeconds">J519 物理发送周期,单位为秒。</param>
|
||||
/// <param name="speedRatio">速度倍率;只缩放轨迹采样时间,不改变物理发送周期。</param>
|
||||
/// <returns>按 J519 发送周期排列的角度制采样序列。</returns>
|
||||
public static IReadOnlyList<J519SendSample> SamplePlannedTrajectory(
|
||||
PlannedTrajectory trajectory,
|
||||
double servoPeriodSeconds,
|
||||
double speedRatio)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(trajectory);
|
||||
ValidatePlannedTrajectoryInputs(trajectory, servoPeriodSeconds, speedRatio);
|
||||
|
||||
var durationSeconds = trajectory.WaypointTimes[^1];
|
||||
var trajectoryStepSeconds = servoPeriodSeconds * speedRatio;
|
||||
var sampleCount = CalculateSampleCount(durationSeconds, trajectoryStepSeconds);
|
||||
var spline = RebuildSpline(trajectory);
|
||||
var samples = new List<J519SendSample>((int)Math.Min(sampleCount, int.MaxValue));
|
||||
|
||||
for (long sampleIndex = 0; sampleIndex < sampleCount; sampleIndex++)
|
||||
{
|
||||
// 直接在连续样条上按 trajectoryTime 取点,避免先离散再线性回采样引入局部跃度尖峰。
|
||||
var sendTime = sampleIndex * servoPeriodSeconds;
|
||||
var trajectoryTime = Math.Min(sendTime * speedRatio, durationSeconds);
|
||||
var joints = spline.Evaluate(trajectoryTime).Select(RadiansToDegrees).ToArray();
|
||||
samples.Add(new J519SendSample(sampleIndex, sendTime, trajectoryTime, speedRatio, joints));
|
||||
}
|
||||
|
||||
return samples;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据 J519 伺服周期和 speed_ratio 生成完整实发采样序列。
|
||||
/// </summary>
|
||||
@@ -83,7 +120,7 @@ public static class J519SendTrajectorySampler
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 构造实发时间映射文本行,格式为 sample_index + send_time + trajectory_time + speed_ratio。
|
||||
/// 构造实发时间映射文本行,格式为 sample_index + send_time + trajectory_time + final_speed_ratio。
|
||||
/// </summary>
|
||||
/// <param name="sample">待写出的 J519 实发采样点。</param>
|
||||
/// <returns>与 ActualSendTiming.txt 兼容的数值行。</returns>
|
||||
@@ -99,6 +136,40 @@ public static class J519SendTrajectorySampler
|
||||
];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 构造带倍率诊断信息的实发时间映射文本行。
|
||||
/// </summary>
|
||||
/// <param name="sample">待写出的 J519 实发采样点。</param>
|
||||
/// <param name="requestedSpeedRatio">用户请求的执行倍率。</param>
|
||||
/// <param name="stretchIterationCount">历史诊断字段;当前执行侧不自动改写 speed_ratio,因此正常为 0。</param>
|
||||
/// <returns>前 4 列兼容 ActualSendTiming.txt,后续列记录请求倍率和倍率改写次数。</returns>
|
||||
public static IReadOnlyList<double> BuildTimingRow(
|
||||
J519SendSample sample,
|
||||
double requestedSpeedRatio,
|
||||
int stretchIterationCount)
|
||||
{
|
||||
if (requestedSpeedRatio <= 0.0 || double.IsNaN(requestedSpeedRatio) || double.IsInfinity(requestedSpeedRatio))
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(requestedSpeedRatio), "请求 speed_ratio 必须是有限正数。");
|
||||
}
|
||||
|
||||
if (stretchIterationCount < 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(stretchIterationCount), "倍率改写次数不能为负数。");
|
||||
}
|
||||
|
||||
var legacyRow = BuildTimingRow(sample);
|
||||
return
|
||||
[
|
||||
legacyRow[0],
|
||||
legacyRow[1],
|
||||
legacyRow[2],
|
||||
legacyRow[3],
|
||||
Math.Round(requestedSpeedRatio, 6),
|
||||
stretchIterationCount
|
||||
];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 构造相邻发送点之间的角度制跃度统计行。
|
||||
/// </summary>
|
||||
@@ -232,6 +303,45 @@ public static class J519SendTrajectorySampler
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 校验连续规划轨迹采样入口的基础输入,避免异常时间轴进入最终发送队列。
|
||||
/// </summary>
|
||||
private static void ValidatePlannedTrajectoryInputs(
|
||||
PlannedTrajectory trajectory,
|
||||
double servoPeriodSeconds,
|
||||
double speedRatio)
|
||||
{
|
||||
if (trajectory.WaypointTimes.Count < 2)
|
||||
{
|
||||
throw new InvalidOperationException("规划轨迹至少需要两个路点才能重建连续样条。");
|
||||
}
|
||||
|
||||
if (trajectory.WaypointTimes[^1] < 0.0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(trajectory), "规划轨迹总时长不能为负数。");
|
||||
}
|
||||
|
||||
if (servoPeriodSeconds <= 0.0 || double.IsNaN(servoPeriodSeconds) || double.IsInfinity(servoPeriodSeconds))
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(servoPeriodSeconds), "J519 伺服周期必须是有限正数。");
|
||||
}
|
||||
|
||||
if (speedRatio <= 0.0 || double.IsNaN(speedRatio) || double.IsInfinity(speedRatio))
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(speedRatio), "speed_ratio 必须是有限正数。");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从规划轨迹重建连续三次样条,供飞拍最终发送队列直接采样。
|
||||
/// </summary>
|
||||
private static CubicSplineInterpolator RebuildSpline(PlannedTrajectory trajectory)
|
||||
{
|
||||
var times = trajectory.WaypointTimes.ToArray();
|
||||
var values = trajectory.PlannedWaypoints.Select(static waypoint => waypoint.Positions.ToArray()).ToArray();
|
||||
return new CubicSplineInterpolator(times, values);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 角度单位转换:rad -> deg。
|
||||
/// </summary>
|
||||
|
||||
@@ -19,16 +19,18 @@ public static class TrajectoryLimitValidator
|
||||
/// <param name="rows">稠密轨迹行,格式为 time + 关节弧度。</param>
|
||||
/// <param name="toleranceMultiplier">限值容差倍率,用于过滤浮点舍入误差。</param>
|
||||
/// <param name="trajectoryName">诊断用轨迹名称。</param>
|
||||
/// <param name="validateJerk">是否校验离散 jerk;飞拍链路可临时关闭,仅保留速度/加速度约束。</param>
|
||||
public static void ValidateDenseJointTrajectory(
|
||||
RobotProfile robot,
|
||||
IReadOnlyList<IReadOnlyList<double>> rows,
|
||||
double toleranceMultiplier = DefaultLimitTolerance,
|
||||
string? trajectoryName = null)
|
||||
string? trajectoryName = null,
|
||||
bool validateJerk = true)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(robot);
|
||||
ArgumentNullException.ThrowIfNull(rows);
|
||||
ValidateTolerance(toleranceMultiplier);
|
||||
ValidateRows(robot, rows, toleranceMultiplier, trajectoryName ?? "dense-joint-trajectory");
|
||||
ValidateRows(robot, rows, toleranceMultiplier, trajectoryName ?? "dense-joint-trajectory", validateJerk);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -38,11 +40,15 @@ public static class TrajectoryLimitValidator
|
||||
/// <param name="samples">J519 发送采样点,关节单位为角度。</param>
|
||||
/// <param name="toleranceMultiplier">限值容差倍率,用于过滤浮点舍入误差。</param>
|
||||
/// <param name="trajectoryName">诊断用轨迹名称。</param>
|
||||
/// <param name="validateJerk">是否校验离散 jerk;飞拍链路可临时关闭,仅保留速度/加速度约束。</param>
|
||||
/// <param name="strictJerkTolerance">是否按原始 jerk 限值做硬约束;false 时保留历史宽容口径。</param>
|
||||
public static void ValidateJ519SendSamples(
|
||||
RobotProfile robot,
|
||||
IReadOnlyList<J519SendSample> samples,
|
||||
double toleranceMultiplier = DefaultLimitTolerance,
|
||||
string? trajectoryName = null)
|
||||
string? trajectoryName = null,
|
||||
bool validateJerk = false,
|
||||
bool strictJerkTolerance = true)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(robot);
|
||||
ArgumentNullException.ThrowIfNull(samples);
|
||||
@@ -61,7 +67,13 @@ public static class TrajectoryLimitValidator
|
||||
rows.Add(row);
|
||||
}
|
||||
|
||||
ValidateRows(robot, rows, toleranceMultiplier, trajectoryName ?? "j519-send-trajectory");
|
||||
ValidateRows(
|
||||
robot,
|
||||
rows,
|
||||
toleranceMultiplier,
|
||||
trajectoryName ?? "j519-send-trajectory",
|
||||
validateJerk,
|
||||
strictJerkTolerance);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -82,7 +94,9 @@ public static class TrajectoryLimitValidator
|
||||
RobotProfile robot,
|
||||
IReadOnlyList<IReadOnlyList<double>> rows,
|
||||
double toleranceMultiplier,
|
||||
string trajectoryName)
|
||||
string trajectoryName,
|
||||
bool validateJerk,
|
||||
bool strictJerkTolerance = false)
|
||||
{
|
||||
double? previousTime = null;
|
||||
double[]? previousPositions = null;
|
||||
@@ -145,7 +159,7 @@ public static class TrajectoryLimitValidator
|
||||
jointLimit.AccelerationLimit,
|
||||
toleranceMultiplier);
|
||||
|
||||
if (previousAccelerations is not null)
|
||||
if (validateJerk && previousAccelerations is not null)
|
||||
{
|
||||
var jerk = (currentAccelerations[index] - previousAccelerations[index]) / dt;
|
||||
ThrowIfExceeded(
|
||||
@@ -157,7 +171,7 @@ public static class TrajectoryLimitValidator
|
||||
"Jerk",
|
||||
jerk,
|
||||
jointLimit.JerkLimit,
|
||||
toleranceMultiplier*4);
|
||||
strictJerkTolerance ? toleranceMultiplier : toleranceMultiplier * 4.0);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ namespace Flyshot.Core.Triggering;
|
||||
/// 本构建器只负责“时间轴映射”和“事件打包”,不负责轨迹规划与运行时发送。
|
||||
/// 其中:
|
||||
/// - ShotEvent 用于诊断、导出和日志观察;
|
||||
/// - TrajectoryDoEvent 用于运行时伺服链路注入 DO 脉冲。
|
||||
/// - TrajectoryDoEvent 用于运行时伺服链路注入 DO 或 RO 脉冲。
|
||||
/// </remarks>
|
||||
public sealed class ShotTimelineBuilder
|
||||
{
|
||||
@@ -43,7 +43,7 @@ public sealed class ShotTimelineBuilder
|
||||
/// <param name="trajectory">规划后的轨迹(含补中点信息和机器人配置)。</param>
|
||||
/// <param name="holdCycles">IO 保持周期数(对应原系统的 io_keep_cycles)。</param>
|
||||
/// <param name="samplePeriod">稠密采样周期,用于离散化 sample_index 和 sample_time。</param>
|
||||
/// <param name="useDo">是否生成可注入伺服流的 DO 事件。</param>
|
||||
/// <param name="useDo">输出类型选择:true 使用 DO,false 使用 RO;两者都生成触发事件。</param>
|
||||
/// <returns>包含 ShotEvent 和 TrajectoryDoEvent 的触发时间轴。</returns>
|
||||
/// <exception cref="ArgumentNullException">当 <paramref name="trajectory"/> 为 null 时抛出。</exception>
|
||||
/// <exception cref="ArgumentOutOfRangeException">
|
||||
@@ -101,17 +101,15 @@ public sealed class ShotTimelineBuilder
|
||||
sampleTime: sampleTime,
|
||||
addressGroup: addressGroup));
|
||||
|
||||
if (useDo)
|
||||
{
|
||||
// use_do=false 时保留 ShotEvent 诊断信息,但不向运行时下发 IO 脉冲。
|
||||
triggerTimeline.Add(new TrajectoryDoEvent(
|
||||
waypointIndex: i,
|
||||
triggerTime: triggerTime,
|
||||
offsetCycles: program.OffsetValues[i],
|
||||
holdCycles: holdCycles,
|
||||
addressGroup: addressGroup,
|
||||
referenceJointsDegrees: program.Waypoints[i].Positions.Select(static radians => radians * 180.0 / Math.PI)));
|
||||
}
|
||||
// use_do 只选择输出类型;RO 模式也必须保留拍照脉冲与保持周期。
|
||||
triggerTimeline.Add(new TrajectoryDoEvent(
|
||||
waypointIndex: i,
|
||||
triggerTime: triggerTime,
|
||||
offsetCycles: program.OffsetValues[i],
|
||||
holdCycles: holdCycles,
|
||||
addressGroup: addressGroup,
|
||||
referenceJointsDegrees: program.Waypoints[i].Positions.Select(static radians => radians * 180.0 / Math.PI),
|
||||
useDo: useDo));
|
||||
}
|
||||
|
||||
_logger?.LogInformation(
|
||||
|
||||
@@ -14,12 +14,6 @@ public interface IControllerRuntime
|
||||
/// <param name="robotName">兼容层传入的机器人名称。</param>
|
||||
void ResetRobot(RobotProfile robot, string robotName);
|
||||
|
||||
/// <summary>
|
||||
/// 选择当前活动控制器类型。
|
||||
/// </summary>
|
||||
/// <param name="sim">是否使用仿真控制器。</param>
|
||||
void SetActiveController(bool sim);
|
||||
|
||||
/// <summary>
|
||||
/// 建立到控制器 IP 的连接。
|
||||
/// </summary>
|
||||
@@ -113,4 +107,11 @@ public interface IControllerRuntime
|
||||
/// <param name="result">规划结果。</param>
|
||||
/// <param name="finalJointPositions">轨迹执行结束后的关节位置。</param>
|
||||
void ExecuteTrajectory(TrajectoryResult result, IReadOnlyList<double> finalJointPositions);
|
||||
|
||||
/// <summary>
|
||||
/// 执行一条已经完成规划的直角坐标轨迹,并更新最终 TCP 位姿。
|
||||
/// </summary>
|
||||
/// <param name="result">规划结果,必须包含稠密直角坐标轨迹。</param>
|
||||
/// <param name="finalPose">轨迹执行结束后的 [x,y,z,w,p,r] 位姿。</param>
|
||||
void ExecuteCartesianTrajectory(TrajectoryResult result, IReadOnlyList<double> finalPose);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ namespace Flyshot.Runtime.Fanuc;
|
||||
|
||||
/// <summary>
|
||||
/// FANUC 控制器运行时,将上层兼容层指令转换为三条真实 Socket 通道的交互。
|
||||
/// 仿真模式下仍保持内存桩行为,便于离线测试与回退。
|
||||
/// </summary>
|
||||
public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
|
||||
{
|
||||
@@ -25,7 +24,6 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
|
||||
|
||||
private RobotProfile? _robot;
|
||||
private string? _robotName;
|
||||
private bool? _activeControllerIsSimulation;
|
||||
private string? _connectedRobotIp;
|
||||
private bool _isEnabled;
|
||||
private bool _isInMotion;
|
||||
@@ -41,23 +39,49 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
|
||||
/// 初始化 FANUC 控制器运行时。
|
||||
/// </summary>
|
||||
/// <param name="logger">运行时日志记录器。</param>
|
||||
/// <param name="commandLogger">FANUC 10012 命令通道客户端日志记录器。</param>
|
||||
/// <param name="stateLogger">FANUC 10010 状态通道客户端日志记录器。</param>
|
||||
/// <param name="j519Logger">J519 客户端日志记录器。</param>
|
||||
public FanucControllerRuntime(ILogger<FanucControllerRuntime> logger, ILogger<FanucJ519Client> j519Logger)
|
||||
public FanucControllerRuntime(
|
||||
ILogger<FanucControllerRuntime> logger,
|
||||
ILogger<FanucCommandClient> commandLogger,
|
||||
ILogger<FanucStateClient> stateLogger,
|
||||
ILogger<FanucJ519Client> j519Logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
ArgumentNullException.ThrowIfNull(commandLogger);
|
||||
ArgumentNullException.ThrowIfNull(stateLogger);
|
||||
ArgumentNullException.ThrowIfNull(j519Logger);
|
||||
|
||||
_commandClient = new FanucCommandClient();
|
||||
_stateClient = new FanucStateClient();
|
||||
_commandClient = new FanucCommandClient(commandLogger);
|
||||
_stateClient = new FanucStateClient(new FanucStateClientOptions(), stateLogger);
|
||||
_j519Client = new FanucJ519Client(j519Logger);
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 初始化 FANUC 控制器运行时,并仅显式传入运行时与 J519 日志记录器。
|
||||
/// </summary>
|
||||
/// <param name="logger">运行时日志记录器。</param>
|
||||
/// <param name="j519Logger">J519 客户端日志记录器。</param>
|
||||
public FanucControllerRuntime(ILogger<FanucControllerRuntime> logger, ILogger<FanucJ519Client> j519Logger)
|
||||
: this(
|
||||
logger,
|
||||
NullLogger<FanucCommandClient>.Instance,
|
||||
NullLogger<FanucStateClient>.Instance,
|
||||
j519Logger)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 初始化一份无日志的 FANUC 控制器运行时,供离线测试或手工构造场景使用。
|
||||
/// </summary>
|
||||
public FanucControllerRuntime()
|
||||
: this(NullLogger<FanucControllerRuntime>.Instance, NullLogger<FanucJ519Client>.Instance)
|
||||
: this(
|
||||
NullLogger<FanucControllerRuntime>.Instance,
|
||||
NullLogger<FanucCommandClient>.Instance,
|
||||
NullLogger<FanucStateClient>.Instance,
|
||||
NullLogger<FanucJ519Client>.Instance)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -88,7 +112,6 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
|
||||
DisconnectClients();
|
||||
_robot = robot;
|
||||
_robotName = robotName;
|
||||
_activeControllerIsSimulation = null;
|
||||
_connectedRobotIp = null;
|
||||
_isEnabled = false;
|
||||
_isInMotion = false;
|
||||
@@ -101,21 +124,6 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void SetActiveController(bool sim)
|
||||
{
|
||||
_logger?.LogInformation("SetActiveController: sim={Sim}", sim);
|
||||
lock (_stateLock)
|
||||
{
|
||||
EnsureRobotSetup();
|
||||
DisconnectClients();
|
||||
_activeControllerIsSimulation = sim;
|
||||
_connectedRobotIp = null;
|
||||
_isEnabled = false;
|
||||
_isInMotion = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Connect(string robotIp)
|
||||
{
|
||||
@@ -124,21 +132,12 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
|
||||
throw new ArgumentException("控制器 IP 不能为空。", nameof(robotIp));
|
||||
}
|
||||
|
||||
_logger?.LogInformation("Connect 开始: robotIp={RobotIp}, 仿真={IsSim}", robotIp, _activeControllerIsSimulation);
|
||||
_logger?.LogInformation("Connect 开始: robotIp={RobotIp}", robotIp);
|
||||
|
||||
lock (_stateLock)
|
||||
{
|
||||
EnsureActiveControllerSelected();
|
||||
if (_activeControllerIsSimulation!.Value)
|
||||
{
|
||||
_connectedRobotIp = robotIp;
|
||||
_isEnabled = false;
|
||||
_isInMotion = false;
|
||||
_logger?.LogInformation("Connect 完成(仿真): robotIp={RobotIp}", robotIp);
|
||||
return;
|
||||
}
|
||||
|
||||
// 真机模式:顺序建立三条通道 — 状态 → 命令 → 运动。
|
||||
EnsureRobotSetup();
|
||||
// 顺序建立三条真机通道:状态、命令、运动。
|
||||
_stateClient.ConnectAsync(robotIp, 10010).GetAwaiter().GetResult();
|
||||
_commandClient.ConnectAsync(robotIp, 10012).GetAwaiter().GetResult();
|
||||
_j519Client.ConnectAsync(robotIp, 60015).GetAwaiter().GetResult();
|
||||
@@ -175,7 +174,7 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
|
||||
throw new ArgumentOutOfRangeException(nameof(bufferSize), "buffer_size 必须大于 0。");
|
||||
}
|
||||
|
||||
_logger?.LogInformation("EnableRobot 开始: bufferSize={BufferSize}, 仿真={IsSim}", bufferSize, _activeControllerIsSimulation);
|
||||
_logger?.LogInformation("EnableRobot 开始: bufferSize={BufferSize}", bufferSize);
|
||||
|
||||
lock (_stateLock)
|
||||
{
|
||||
@@ -183,14 +182,7 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
|
||||
_bufferSize = bufferSize;
|
||||
_j519Client.SetSequenceBufferSize(bufferSize);
|
||||
|
||||
if (IsSimulationMode)
|
||||
{
|
||||
_isEnabled = true;
|
||||
_logger?.LogInformation("EnableRobot 完成(仿真)");
|
||||
return;
|
||||
}
|
||||
|
||||
// 真机模式:按 all-reconnect.pcap 的重连序列启动 RVBUSTSM,暂不发送 StopProg。
|
||||
// 按 all-reconnect.pcap 的重连序列启动 RVBUSTSM。
|
||||
_commandClient.StopProgramAsync("RVBUSTSM").GetAwaiter().GetResult();
|
||||
_commandClient.ResetRobotAsync().GetAwaiter().GetResult();
|
||||
//_commandClient.GetProgramStatusAsync("RVBUSTSM").GetAwaiter().GetResult();
|
||||
@@ -211,11 +203,8 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
|
||||
{
|
||||
EnsureRobotSetup();
|
||||
CancelSendTaskLocked();
|
||||
if (!IsSimulationMode)
|
||||
{
|
||||
_j519Client.StopMotionAsync().GetAwaiter().GetResult();
|
||||
_commandClient.StopProgramAsync("RVBUSTSM").GetAwaiter().GetResult();
|
||||
}
|
||||
_j519Client.StopMotionAsync().GetAwaiter().GetResult();
|
||||
_commandClient.StopProgramAsync("RVBUSTSM").GetAwaiter().GetResult();
|
||||
|
||||
_isEnabled = false;
|
||||
_isInMotion = false;
|
||||
@@ -231,10 +220,7 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
|
||||
{
|
||||
EnsureRobotSetup();
|
||||
CancelSendTaskLocked();
|
||||
if (!IsSimulationMode)
|
||||
{
|
||||
_j519Client.StopMotionAsync().GetAwaiter().GetResult();
|
||||
}
|
||||
_j519Client.StopMotionAsync().GetAwaiter().GetResult();
|
||||
|
||||
_isInMotion = false;
|
||||
}
|
||||
@@ -247,11 +233,8 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
|
||||
lock (_stateLock)
|
||||
{
|
||||
EnsureConnected();
|
||||
if (!IsSimulationMode)
|
||||
{
|
||||
var response = _commandClient.GetSpeedRatioAsync().GetAwaiter().GetResult();
|
||||
_speedRatio = response.Ratio;
|
||||
}
|
||||
var response = _commandClient.GetSpeedRatioAsync().GetAwaiter().GetResult();
|
||||
_speedRatio = response.Ratio;
|
||||
|
||||
return _speedRatio;
|
||||
}
|
||||
@@ -271,11 +254,6 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
|
||||
{
|
||||
//EnsureConnected();
|
||||
var clampedRatio = Math.Clamp(ratio, 0.0, 1.0);
|
||||
if (!IsSimulationMode)
|
||||
{
|
||||
//_commandClient.SetSpeedRatioAsync(clampedRatio).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
_speedRatio = clampedRatio;
|
||||
}
|
||||
|
||||
@@ -288,7 +266,7 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
|
||||
lock (_stateLock)
|
||||
{
|
||||
EnsureRobotSetup();
|
||||
if (_activeControllerIsSimulation is false && !string.IsNullOrWhiteSpace(_connectedRobotIp))
|
||||
if (!string.IsNullOrWhiteSpace(_connectedRobotIp))
|
||||
{
|
||||
var response = _commandClient.GetTcpAsync(1).GetAwaiter().GetResult();
|
||||
_tcp = response.Pose.Take(3).ToArray();
|
||||
@@ -304,11 +282,8 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
|
||||
lock (_stateLock)
|
||||
{
|
||||
EnsureRobotSetup();
|
||||
if (_activeControllerIsSimulation is false)
|
||||
{
|
||||
EnsureConnected();
|
||||
_commandClient.SetTcpAsync(1, CreateTcpPose(x, y, z)).GetAwaiter().GetResult();
|
||||
}
|
||||
EnsureConnected();
|
||||
_commandClient.SetTcpAsync(1, CreateTcpPose(x, y, z)).GetAwaiter().GetResult();
|
||||
|
||||
_tcp = [x, y, z];
|
||||
}
|
||||
@@ -327,14 +302,9 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
|
||||
lock (_stateLock)
|
||||
{
|
||||
EnsureConnected();
|
||||
if (!IsSimulationMode)
|
||||
{
|
||||
var response = _commandClient.GetIoAsync(port, normalizedIoType).GetAwaiter().GetResult();
|
||||
_ioValues[(normalizedIoType, port)] = response.Value;
|
||||
return response.Value;
|
||||
}
|
||||
|
||||
return _ioValues.TryGetValue((normalizedIoType, port), out var value) && value;
|
||||
var response = _commandClient.GetIoAsync(port, normalizedIoType).GetAwaiter().GetResult();
|
||||
_ioValues[(normalizedIoType, port)] = response.Value;
|
||||
return response.Value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -351,10 +321,7 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
|
||||
lock (_stateLock)
|
||||
{
|
||||
EnsureEnabled();
|
||||
if (!IsSimulationMode)
|
||||
{
|
||||
_commandClient.SetIoAsync(port, value, normalizedIoType).GetAwaiter().GetResult();
|
||||
}
|
||||
_commandClient.SetIoAsync(port, value, normalizedIoType).GetAwaiter().GetResult();
|
||||
|
||||
_ioValues[(normalizedIoType, port)] = value;
|
||||
}
|
||||
@@ -366,7 +333,7 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
|
||||
lock (_stateLock)
|
||||
{
|
||||
EnsureRobotSetup();
|
||||
if (!IsSimulationMode)
|
||||
if (!string.IsNullOrWhiteSpace(_connectedRobotIp))
|
||||
{
|
||||
var frame = GetFreshStateFrame();
|
||||
if (frame?.JointRadians.Count >= _jointPositions.Length)
|
||||
@@ -385,7 +352,7 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
|
||||
lock (_stateLock)
|
||||
{
|
||||
EnsureRobotSetup();
|
||||
if (!IsSimulationMode)
|
||||
if (!string.IsNullOrWhiteSpace(_connectedRobotIp))
|
||||
{
|
||||
var frame = GetFreshStateFrame();
|
||||
if (frame?.CartesianPose.Count >= 6)
|
||||
@@ -414,7 +381,7 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
|
||||
bool? j519SystemReady = null;
|
||||
bool? j519RobotInMotion = null;
|
||||
|
||||
if (!IsSimulationMode)
|
||||
if (!string.IsNullOrWhiteSpace(_connectedRobotIp))
|
||||
{
|
||||
var frame = GetFreshStateFrame();
|
||||
if (frame is not null)
|
||||
@@ -485,16 +452,11 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
|
||||
EnsureJointCount(finalJointPositions.Count);
|
||||
CancelSendTaskLocked();
|
||||
|
||||
if (!IsSimulationMode && result.DenseJointTrajectory is not null)
|
||||
if (result.DenseJointTrajectory is not null)
|
||||
{
|
||||
if (_speedRatio <= 0.0)
|
||||
{
|
||||
throw new InvalidOperationException("Speed ratio must be greater than zero for dense J519 execution.");
|
||||
}
|
||||
|
||||
EnsureJ519ReadyForDenseExecution();
|
||||
|
||||
// 真机模式且存在稠密路点:准备可被 StopMove 取消的同步发送任务。
|
||||
// 存在稠密路点时,准备可被 StopMove 取消的同步发送任务。
|
||||
_isInMotion = true;
|
||||
_sendCts = new CancellationTokenSource();
|
||||
denseSendCancellationSource = _sendCts;
|
||||
@@ -505,14 +467,11 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!IsSimulationMode)
|
||||
{
|
||||
// 真机模式无稠密路点:回退到单点收敛。
|
||||
var command = new FanucJ519Command(
|
||||
sequence: 0,
|
||||
targetJoints: finalJointPositions.Select(j => (double)j).ToArray());
|
||||
_j519Client.UpdateCommand(command);
|
||||
}
|
||||
// 无稠密路点时回退到单点收敛。
|
||||
var command = new FanucJ519Command(
|
||||
sequence: 0,
|
||||
targetJoints: finalJointPositions.Select(j => (double)j).ToArray());
|
||||
_j519Client.UpdateCommand(command);
|
||||
|
||||
_isInMotion = true;
|
||||
_jointPositions = finalJointPositions.ToArray();
|
||||
@@ -543,6 +502,75 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void ExecuteCartesianTrajectory(TrajectoryResult result, IReadOnlyList<double> finalPose)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(result);
|
||||
ArgumentNullException.ThrowIfNull(finalPose);
|
||||
CancellationToken denseSendCancellationToken = default;
|
||||
CancellationTokenSource? denseSendCancellationSource = null;
|
||||
var shouldRunDenseTrajectory = false;
|
||||
|
||||
_logger?.LogInformation(
|
||||
"ExecuteCartesianTrajectory 开始: program={ProgramName}, 时长={Duration}s, 稠密采样={HasDense}, speedRatio={SpeedRatio}",
|
||||
result.ProgramName, result.Duration.TotalSeconds, result.DenseCartesianTrajectory is not null, _speedRatio);
|
||||
|
||||
lock (_stateLock)
|
||||
{
|
||||
EnsureEnabled();
|
||||
EnsureValidTrajectory(result);
|
||||
EnsurePoseCount(finalPose.Count);
|
||||
CancelSendTaskLocked();
|
||||
|
||||
if (result.DenseCartesianTrajectory is not null)
|
||||
{
|
||||
EnsureJ519ReadyForDenseExecution();
|
||||
|
||||
// 直角模式同样预装完整队列,由机器人状态包节拍驱动出队。
|
||||
_isInMotion = true;
|
||||
_sendCts = new CancellationTokenSource();
|
||||
denseSendCancellationSource = _sendCts;
|
||||
denseSendCancellationToken = _sendCts.Token;
|
||||
shouldRunDenseTrajectory = true;
|
||||
|
||||
_logger?.LogInformation("ExecuteCartesianTrajectory 开始同步直角稠密发送任务");
|
||||
}
|
||||
else
|
||||
{
|
||||
var command = new FanucJ519Command(
|
||||
sequence: 0,
|
||||
targetValues: BuildCartesianTargetValues(finalPose),
|
||||
dataStyle: 0);
|
||||
_j519Client.UpdateCommand(command);
|
||||
|
||||
_isInMotion = true;
|
||||
_pose = MergeFinalCartesianPose(finalPose);
|
||||
_isInMotion = false;
|
||||
_logger?.LogInformation("ExecuteCartesianTrajectory 完成(单点模式)");
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldRunDenseTrajectory)
|
||||
{
|
||||
try
|
||||
{
|
||||
SendDenseCartesianTrajectory(result, finalPose, denseSendCancellationToken);
|
||||
_logger?.LogInformation("ExecuteCartesianTrajectory 完成(稠密模式)");
|
||||
}
|
||||
finally
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (ReferenceEquals(_sendCts, denseSendCancellationSource))
|
||||
{
|
||||
denseSendCancellationSource?.Dispose();
|
||||
_sendCts = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 释放运行时持有的所有 Socket 客户端。
|
||||
/// </summary>
|
||||
@@ -562,13 +590,16 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 稠密轨迹发送任务:预生成完整 J519 命令队列,并等待机器人状态包按 speed_ratio 推进到执行完成。
|
||||
/// 稠密轨迹发送任务:预生成完整 J519 命令队列,并等待机器人状态包驱动队列执行完成。
|
||||
/// </summary>
|
||||
private void SendDenseTrajectory(TrajectoryResult result, IReadOnlyList<double> finalJointPositions, CancellationToken cancellationToken)
|
||||
{
|
||||
var triggers = result.TriggerTimeline;
|
||||
// 输出类型随触发事件固化,确保脉冲与清零帧都写入同一路 DO/RO。
|
||||
byte writeIoType = triggers.FirstOrDefault()?.UseDo == false
|
||||
? (byte)FanucIoTypes.RobotOutput
|
||||
: (byte)FanucIoTypes.DigitalOutput;
|
||||
var servoPeriodSeconds = _robot!.ServoPeriod.TotalSeconds;
|
||||
var speedRatio = _speedRatio;
|
||||
var preparedExecution = result.PreparedFlyshotExecution;
|
||||
var durationSeconds = preparedExecution?.FinalDurationSeconds ?? result.Duration.TotalSeconds;
|
||||
var samples = preparedExecution is null
|
||||
@@ -576,7 +607,7 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
|
||||
result.DenseJointTrajectory!,
|
||||
durationSeconds,
|
||||
servoPeriodSeconds,
|
||||
speedRatio)
|
||||
speedRatio: 1.0)
|
||||
: preparedExecution.Samples.Select(static sample => new J519SendSample(
|
||||
sample.SampleIndex,
|
||||
sample.SendTime,
|
||||
@@ -590,8 +621,8 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
|
||||
var sampleCount = samples.Count;
|
||||
|
||||
_logger?.LogInformation(
|
||||
"SendDenseTrajectory 开始: program={ProgramName}, 采样数={SampleCount}, 时长={Duration}s, speedRatio={SpeedRatio}, 周期={Period}ms, 触发事件数={TriggerCount}",
|
||||
result.ProgramName, sampleCount, durationSeconds, speedRatio, servoPeriodSeconds * 1000, triggers.Count);
|
||||
"SendDenseTrajectory 开始: program={ProgramName}, 采样数={SampleCount}, 时长={Duration}s, runtimeSpeedRatio={SpeedRatio}, 周期={Period}ms, 触发事件数={TriggerCount}",
|
||||
result.ProgramName, sampleCount, durationSeconds, _speedRatio, servoPeriodSeconds * 1000, triggers.Count);
|
||||
|
||||
var triggerBindings = preparedExecution is null
|
||||
? TriggerSampleBinder.Bind(
|
||||
@@ -627,6 +658,9 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
|
||||
double[]? previousVelocity = null;
|
||||
double[]? previousAcceleration = null;
|
||||
var preparedJerkRows = preparedExecution?.JerkRows;
|
||||
var diagnosticRequestedSpeedRatio = _speedRatio > 0.0 && !double.IsNaN(_speedRatio) && !double.IsInfinity(_speedRatio)
|
||||
? _speedRatio
|
||||
: 1.0;
|
||||
|
||||
try
|
||||
{
|
||||
@@ -680,14 +714,19 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
|
||||
var command = new FanucJ519Command(
|
||||
sequence: 0,
|
||||
targetJoints: sample.JointsDegrees,
|
||||
writeIoType: 2,
|
||||
writeIoType: writeIoType,
|
||||
writeIoIndex: 1,
|
||||
writeIoMask: ioMask,
|
||||
writeIoValue: ioValue);
|
||||
|
||||
commands.Add(command);
|
||||
sentJointRows.Add(BuildDenseSendJointRow(sample.SendTime, sample.JointsDegrees, ioMask, ioValue));
|
||||
sentTimingRows.Add(J519SendTrajectorySampler.BuildTimingRow(sample));
|
||||
sentTimingRows.Add(preparedExecution is null
|
||||
? J519SendTrajectorySampler.BuildTimingRow(sample, diagnosticRequestedSpeedRatio, stretchIterationCount: 0)
|
||||
: J519SendTrajectorySampler.BuildTimingRow(
|
||||
sample,
|
||||
preparedExecution.RequestSpeedRatio,
|
||||
preparedExecution.StretchIterationCount));
|
||||
|
||||
if (preparedJerkRows is not null)
|
||||
{
|
||||
@@ -728,6 +767,20 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
|
||||
sample.TrajectoryTime);
|
||||
}
|
||||
}
|
||||
//TODO 不进行结束,一致发送最后一帧
|
||||
|
||||
// if (_j519Client.EndCommandStreamAfterQueueDrained && commands.Count > 0)
|
||||
// {
|
||||
// // 正常结束命令流时追加独立结束帧,避免把最后一个真实轨迹采样点和结束语义混在一起。
|
||||
// commands.Add(new FanucJ519Command(
|
||||
// sequence: 0,
|
||||
// targetJoints: commands[^1].TargetJoints,
|
||||
// lastData: 1,
|
||||
// writeIoType: 2,
|
||||
// writeIoIndex: 1,
|
||||
// writeIoMask: 0,
|
||||
// writeIoValue: 0));
|
||||
// }
|
||||
TryWriteDenseSendArtifacts(result, outputDir, sentJointRows, sentTimingRows, sentJerkRows, triggerBindings);
|
||||
|
||||
// 上层只负责生成完整目标序列,真实出队节拍交给 J519 状态包驱动。
|
||||
@@ -757,6 +810,84 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 直角稠密轨迹发送任务:预生成 Data format=0 的 J519 命令队列,并等待状态包驱动执行完成。
|
||||
/// </summary>
|
||||
private void SendDenseCartesianTrajectory(TrajectoryResult result, IReadOnlyList<double> finalPose, CancellationToken cancellationToken)
|
||||
{
|
||||
var rows = result.DenseCartesianTrajectory ?? throw new InvalidOperationException("Cartesian trajectory requires dense Cartesian samples.");
|
||||
var commands = new List<FanucJ519Command>(rows.Count);
|
||||
var sentPoseRows = new List<IReadOnlyList<double>>(rows.Count);
|
||||
var outputDir = CreateDenseSendOutputDirectory(result.ProgramName);
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var row in rows)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (row.Count != 7)
|
||||
{
|
||||
throw new InvalidOperationException("Cartesian dense trajectory rows must contain time plus six pose values.");
|
||||
}
|
||||
|
||||
var pose = row.Skip(1).Take(6).ToArray();
|
||||
var command = new FanucJ519Command(
|
||||
sequence: 0,
|
||||
targetValues: BuildCartesianTargetValues(pose),
|
||||
dataStyle: 0,
|
||||
writeIoType: 2,
|
||||
writeIoIndex: 1,
|
||||
writeIoMask: 0,
|
||||
writeIoValue: 0);
|
||||
|
||||
commands.Add(command);
|
||||
sentPoseRows.Add(BuildDenseSendPoseRow(row[0], pose));
|
||||
}
|
||||
if (_j519Client.EndCommandStreamAfterQueueDrained && commands.Count > 0)
|
||||
{
|
||||
// 直角轨迹结束帧沿用最终目标位姿,并明确清空 IO 输出。
|
||||
commands.Add(new FanucJ519Command(
|
||||
sequence: 0,
|
||||
targetValues: commands[^1].TargetValues,
|
||||
lastData: 1,
|
||||
dataStyle: 0,
|
||||
writeIoType: 2,
|
||||
writeIoIndex: 1,
|
||||
writeIoMask: 0,
|
||||
writeIoValue: 0));
|
||||
}
|
||||
|
||||
TryWriteDenseCartesianSendArtifacts(outputDir, sentPoseRows);
|
||||
|
||||
_j519Client.LoadCommandQueue(commands);
|
||||
if (_j519Client.IsConnected)
|
||||
{
|
||||
_j519Client.WaitForCommandQueueDrainedAsync(cancellationToken).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
_logger?.LogInformation(
|
||||
"SendDenseCartesianTrajectory 正常完成: 采样数={SampleCount}, 队列装载耗时={ElapsedMs}ms",
|
||||
commands.Count,
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger?.LogWarning(
|
||||
"SendDenseCartesianTrajectory 被取消: 已生成 {Current}/{Total} 条命令",
|
||||
commands.Count,
|
||||
rows.Count);
|
||||
}
|
||||
finally
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
_isInMotion = false;
|
||||
_pose = MergeFinalCartesianPose(finalPose);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 若已有 J519 响应,则在启动稠密轨迹前检查伺服侧是否接受命令并处于系统就绪状态。
|
||||
/// </summary>
|
||||
@@ -778,7 +909,7 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
|
||||
/// <param name="getLatestResponse">读取最新 J519 状态的委托。</param>
|
||||
/// <param name="retryEnableRobot">状态未就绪时触发一次 EnableRobot 重试的委托。</param>
|
||||
/// <param name="waitAfterRetry">重试后等待状态刷新的委托。</param>
|
||||
internal static void EnsureJ519ReadyForDenseExecutionCore(
|
||||
internal void EnsureJ519ReadyForDenseExecutionCore(
|
||||
Func<FanucJ519Response?> getLatestResponse,
|
||||
Action retryEnableRobot,
|
||||
Action waitAfterRetry)
|
||||
@@ -788,11 +919,12 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
|
||||
ArgumentNullException.ThrowIfNull(waitAfterRetry);
|
||||
|
||||
var response = getLatestResponse();
|
||||
|
||||
if (response is null || IsJ519ReadyForDenseExecution(response))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_logger?.LogInformation("当前机器人不满足执行条件,重新启用机器人");
|
||||
try
|
||||
{
|
||||
retryEnableRobot();
|
||||
@@ -876,6 +1008,41 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
|
||||
return row;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 构造实际发送直角位姿文本行,格式为 send_time + X/Y/Z/W/P/R。
|
||||
/// </summary>
|
||||
private static IReadOnlyList<double> BuildDenseSendPoseRow(double sendTime, IReadOnlyList<double> pose)
|
||||
{
|
||||
var row = new double[pose.Count + 1];
|
||||
row[0] = Math.Round(sendTime, 6);
|
||||
for (var index = 0; index < pose.Count; index++)
|
||||
{
|
||||
row[index + 1] = Math.Round(pose[index], 6);
|
||||
}
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 构造 J519 直角命令的 9 个目标槽位,当前现场无扩展轴时 E1/E2/E3 补零。
|
||||
/// </summary>
|
||||
private static double[] BuildCartesianTargetValues(IReadOnlyList<double> pose)
|
||||
{
|
||||
EnsurePoseCount(pose.Count);
|
||||
return [pose[0], pose[1], pose[2], pose[3], pose[4], pose[5], 0.0, 0.0, 0.0];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将六维 WPR 直角目标合并回运行时缓存,保留 GetPose 的 7 维兼容外形。
|
||||
/// </summary>
|
||||
private double[] MergeFinalCartesianPose(IReadOnlyList<double> pose)
|
||||
{
|
||||
EnsurePoseCount(pose.Count);
|
||||
return _pose.Length >= 7
|
||||
? [pose[0], pose[1], pose[2], pose[3], pose[4], pose[5], _pose[6]]
|
||||
: pose.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 尝试把实际发送点位、时间映射和跃度统计写入纯文本文件;若落盘失败,只记录日志,不影响运动主流程。
|
||||
/// </summary>
|
||||
@@ -907,6 +1074,25 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 尝试把直角实际发送点位写入文本文件;若落盘失败,只记录日志,不影响运动主流程。
|
||||
/// </summary>
|
||||
private void TryWriteDenseCartesianSendArtifacts(string outputDir, IReadOnlyList<IReadOnlyList<double>> sentPoseRows)
|
||||
{
|
||||
try
|
||||
{
|
||||
WriteDenseRows(Path.Combine(outputDir, "ActualSendPoseTraj.txt"), sentPoseRows);
|
||||
_logger?.LogInformation(
|
||||
"SendDenseCartesianTrajectory 已写出实际发送记录: outputDir={OutputDir}, poseRows={PoseRows}",
|
||||
outputDir,
|
||||
sentPoseRows.Count);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger?.LogWarning(exception, "SendDenseCartesianTrajectory 写出实际发送记录失败: outputDir={OutputDir}", outputDir);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 以旧轨迹文本兼容的空格分隔格式写出数值行。
|
||||
/// </summary>
|
||||
@@ -960,22 +1146,6 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
|
||||
return value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 判断当前是否处于仿真模式;若尚未选择控制器则抛出异常。
|
||||
/// </summary>
|
||||
private bool IsSimulationMode
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_activeControllerIsSimulation is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return _activeControllerIsSimulation.Value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 断开所有真实 Socket 通道,不影响内存状态。
|
||||
/// </summary>
|
||||
@@ -1033,9 +1203,7 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
|
||||
return "Disconnected";
|
||||
}
|
||||
|
||||
return _activeControllerIsSimulation is false
|
||||
? ResolveRealConnectionState(_stateClient.GetStatus())
|
||||
: "Connected";
|
||||
return ResolveRealConnectionState(_stateClient.GetStatus());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -1086,6 +1254,17 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 校验直角位姿固定为 [x,y,z,w,p,r] 六维。
|
||||
/// </summary>
|
||||
private static void EnsurePoseCount(int poseCount)
|
||||
{
|
||||
if (poseCount != 6)
|
||||
{
|
||||
throw new InvalidOperationException($"Expected 6 Cartesian pose values but received {poseCount}.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 校验机器人已经完成初始化。
|
||||
/// </summary>
|
||||
@@ -1097,24 +1276,12 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 校验活动控制器已经被选择。
|
||||
/// </summary>
|
||||
private void EnsureActiveControllerSelected()
|
||||
{
|
||||
EnsureRobotSetup();
|
||||
if (_activeControllerIsSimulation is null)
|
||||
{
|
||||
throw new InvalidOperationException("Active controller has not been selected.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 校验控制器已经建立连接。
|
||||
/// </summary>
|
||||
private void EnsureConnected()
|
||||
{
|
||||
EnsureActiveControllerSelected();
|
||||
EnsureRobotSetup();
|
||||
if (string.IsNullOrWhiteSpace(_connectedRobotIp))
|
||||
{
|
||||
throw new InvalidOperationException("Controller has not been connected.");
|
||||
|
||||
@@ -12,6 +12,10 @@ public sealed class FanucJ519Client : IDisposable
|
||||
private readonly object _commandLock = new();
|
||||
private readonly object _responseLock = new();
|
||||
private readonly ILogger<FanucJ519Client>? _logger;
|
||||
/// <summary>
|
||||
/// 指示预装队列发出 LastData 结束帧后是否进入仅监控状态,而不是继续发送保姿命令。
|
||||
/// </summary>
|
||||
private readonly bool _endCommandStreamAfterQueueDrained;
|
||||
private UdpClient? _udpClient;
|
||||
private CancellationTokenSource? _cts;
|
||||
private Thread? _receiveThread;
|
||||
@@ -25,8 +29,14 @@ public sealed class FanucJ519Client : IDisposable
|
||||
private long _slowSendCount;
|
||||
private long _maxReceiveToSendTicks;
|
||||
private uint _sequenceBufferSize;
|
||||
// 标记 StartMotion 前是否刚装载过新目标,用于区分新命令和上次运动残留目标。
|
||||
/// <summary>
|
||||
/// 在 StartMotion 前是否有待发送的新命令;如果为 true 则 StartMotion 不会清空当前目标,而是继续发送新目标。
|
||||
/// </summary>
|
||||
private bool _hasPendingCommandForStart;
|
||||
/// <summary>
|
||||
/// 标记命令流已通过 LastData 正常结束;该状态下仅接收状态包,不继续回发 hold 命令。
|
||||
/// </summary>
|
||||
private bool _monitorOnlyAfterLastData;
|
||||
private bool _motionStarted;
|
||||
private bool _disposed;
|
||||
|
||||
@@ -35,13 +45,22 @@ public sealed class FanucJ519Client : IDisposable
|
||||
/// </summary>
|
||||
public bool IsConnected => _udpClient is not null;
|
||||
|
||||
/// <summary>
|
||||
/// 获取正常队列结束时是否通过 LastData 结束命令流并保留状态监控。
|
||||
/// </summary>
|
||||
internal bool EndCommandStreamAfterQueueDrained => _endCommandStreamAfterQueueDrained;
|
||||
|
||||
/// <summary>
|
||||
/// 初始化 FANUC J519 客户端。
|
||||
/// </summary>
|
||||
/// <param name="logger">日志记录器;允许 null,供无日志场景使用。</param>
|
||||
public FanucJ519Client(ILogger<FanucJ519Client>? logger = null)
|
||||
/// <param name="endCommandStreamAfterQueueDrained">队列结束帧发出后是否停止命令流并保留状态监控。</param>
|
||||
public FanucJ519Client(
|
||||
ILogger<FanucJ519Client>? logger = null,
|
||||
bool endCommandStreamAfterQueueDrained = true)
|
||||
{
|
||||
_logger = logger;
|
||||
_endCommandStreamAfterQueueDrained = endCommandStreamAfterQueueDrained;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -113,6 +132,7 @@ public sealed class FanucJ519Client : IDisposable
|
||||
}
|
||||
|
||||
_hasPendingCommandForStart = false;
|
||||
_monitorOnlyAfterLastData = false;
|
||||
_motionStarted = true;
|
||||
|
||||
}
|
||||
@@ -156,6 +176,7 @@ public sealed class FanucJ519Client : IDisposable
|
||||
{
|
||||
_motionStarted = false;
|
||||
_hasPendingCommandForStart = false;
|
||||
_monitorOnlyAfterLastData = false;
|
||||
CompleteCommandQueueLocked();
|
||||
}
|
||||
|
||||
@@ -178,6 +199,7 @@ public sealed class FanucJ519Client : IDisposable
|
||||
CompleteCommandQueueLocked();
|
||||
_currentCommand = command;
|
||||
_hasPendingCommandForStart = true;
|
||||
_monitorOnlyAfterLastData = false;
|
||||
_commandHistoryForTests?.Add(command);
|
||||
}
|
||||
|
||||
@@ -211,6 +233,8 @@ public sealed class FanucJ519Client : IDisposable
|
||||
// 队列耗尽后继续保持最后一帧目标,避免运动结束后回落到旧目标或空目标。
|
||||
_currentCommand = commands[^1];
|
||||
_hasPendingCommandForStart = true;
|
||||
_monitorOnlyAfterLastData = false;
|
||||
_motionStarted = true;
|
||||
_commandQueueDrainedCompletion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
_commandHistoryForTests?.AddRange(commands);
|
||||
}
|
||||
@@ -337,6 +361,7 @@ public sealed class FanucJ519Client : IDisposable
|
||||
CompleteCommandQueueLocked();
|
||||
_commandHistoryForTests = null;
|
||||
_hasPendingCommandForStart = false;
|
||||
_monitorOnlyAfterLastData = false;
|
||||
_motionStarted = false;
|
||||
}
|
||||
|
||||
@@ -375,6 +400,7 @@ public sealed class FanucJ519Client : IDisposable
|
||||
{
|
||||
CompleteCommandQueueLocked();
|
||||
_hasPendingCommandForStart = false;
|
||||
_monitorOnlyAfterLastData = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -526,9 +552,15 @@ public sealed class FanucJ519Client : IDisposable
|
||||
{
|
||||
FanucJ519Command? command;
|
||||
var willDrainQueue = false;
|
||||
var suppressHoldCommand = false;
|
||||
lock (_commandLock)
|
||||
{
|
||||
if (!_motionStarted)
|
||||
if (_monitorOnlyAfterLastData)
|
||||
{
|
||||
command = null;
|
||||
suppressHoldCommand = true;
|
||||
}
|
||||
else if (!_motionStarted)
|
||||
{
|
||||
command = null;
|
||||
}
|
||||
@@ -546,7 +578,7 @@ public sealed class FanucJ519Client : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
if (command is null)
|
||||
if (command is null && !suppressHoldCommand)
|
||||
{
|
||||
command = TryBuildHoldCommandFromLatestResponse(response);
|
||||
}
|
||||
@@ -574,7 +606,14 @@ public sealed class FanucJ519Client : IDisposable
|
||||
lock (_commandLock)
|
||||
{
|
||||
_lastSentCommand = command;
|
||||
if (willDrainQueue && _commandQueue is { Count: 0 })
|
||||
if (_endCommandStreamAfterQueueDrained && command.LastData == 1)
|
||||
{
|
||||
// LastData 是命令流的正常结束语义;结束后保留状态接收,但不继续回发保姿包。
|
||||
// _monitorOnlyAfterLastData = true;
|
||||
// _motionStarted = false;
|
||||
CompleteCommandQueueLocked();
|
||||
}
|
||||
else if (willDrainQueue && _commandQueue is { Count: 0 })
|
||||
{
|
||||
CompleteCommandQueueLocked();
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace Flyshot.Runtime.Fanuc.Protocol;
|
||||
/// </summary>
|
||||
public sealed class FanucJ519Command
|
||||
{
|
||||
private readonly double[] _targetJoints;
|
||||
private readonly double[] _targetValues;
|
||||
|
||||
/// <summary>
|
||||
/// 初始化 J519 命令数据。
|
||||
@@ -35,11 +35,53 @@ public sealed class FanucJ519Command
|
||||
ushort writeIoIndex = 1,
|
||||
ushort writeIoMask = 0,
|
||||
ushort writeIoValue = 0)
|
||||
: this(
|
||||
sequence,
|
||||
targetValues: targetJoints,
|
||||
lastData,
|
||||
readIoType,
|
||||
readIoIndex,
|
||||
readIoMask,
|
||||
dataStyle,
|
||||
writeIoType,
|
||||
writeIoIndex,
|
||||
writeIoMask,
|
||||
writeIoValue)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(targetJoints);
|
||||
if (targetJoints.Count is <= 0 or > 9)
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 初始化 J519 命令数据。
|
||||
/// </summary>
|
||||
/// <param name="sequence">命令序号。</param>
|
||||
/// <param name="targetValues">J519 目标槽位,直角模式为 X/Y/Z/W/P/R/E1/E2/E3,关节模式为 J1..J9。</param>
|
||||
/// <param name="lastData">是否为最后一帧数据。</param>
|
||||
/// <param name="readIoType">读取 IO 类型。</param>
|
||||
/// <param name="readIoIndex">读取 IO 起始索引。</param>
|
||||
/// <param name="readIoMask">读取 IO 掩码。</param>
|
||||
/// <param name="dataStyle">目标数据类型,0 为直角坐标,1 为关节坐标。</param>
|
||||
/// <param name="writeIoType">写入 IO 类型。</param>
|
||||
/// <param name="writeIoIndex">写入 IO 起始索引。</param>
|
||||
/// <param name="writeIoMask">写入 IO 掩码。</param>
|
||||
/// <param name="writeIoValue">写入 IO 数值。</param>
|
||||
public FanucJ519Command(
|
||||
uint sequence,
|
||||
IEnumerable<double> targetValues,
|
||||
byte lastData = 0,
|
||||
byte readIoType = 2,
|
||||
ushort readIoIndex = 1,
|
||||
ushort readIoMask = 255,
|
||||
byte dataStyle = 1,
|
||||
byte writeIoType = 2,
|
||||
ushort writeIoIndex = 1,
|
||||
ushort writeIoMask = 0,
|
||||
ushort writeIoValue = 0)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(targetValues);
|
||||
var copiedTargetValues = targetValues.ToArray();
|
||||
if (copiedTargetValues.Length is <= 0 or > 9)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(targetJoints), "J519 目标数据必须包含 1 到 9 个槽位。");
|
||||
throw new ArgumentOutOfRangeException(nameof(targetValues), "J519 目标数据必须包含 1 到 9 个槽位。");
|
||||
}
|
||||
|
||||
Sequence = sequence;
|
||||
@@ -52,7 +94,7 @@ public sealed class FanucJ519Command
|
||||
WriteIoIndex = writeIoIndex;
|
||||
WriteIoMask = writeIoMask;
|
||||
WriteIoValue = writeIoValue;
|
||||
_targetJoints = targetJoints.ToArray();
|
||||
_targetValues = copiedTargetValues;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -108,7 +150,12 @@ public sealed class FanucJ519Command
|
||||
/// <summary>
|
||||
/// 获取目标关节或扩展轴数据。
|
||||
/// </summary>
|
||||
public IReadOnlyList<double> TargetJoints => _targetJoints;
|
||||
public IReadOnlyList<double> TargetJoints => _targetValues;
|
||||
|
||||
/// <summary>
|
||||
/// 获取 J519 目标槽位,直角模式为 X/Y/Z/W/P/R/E1/E2/E3,关节模式为 J1..J9。
|
||||
/// </summary>
|
||||
public IReadOnlyList<double> TargetValues => _targetValues;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -25,59 +25,6 @@ public sealed class LegacyHttpApiController : ControllerBase
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 兼容旧根路径探活接口。
|
||||
/// </summary>
|
||||
/// <returns>旧 HTTP 服务约定的 Hello World 响应。</returns>
|
||||
[HttpGet("/")]
|
||||
public IActionResult Root()
|
||||
{
|
||||
return Ok(new { message = "Hello World" });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 兼容旧 `/connect_server/` 路由;在 replacement 宿主中仅记录调用方期望连接的地址。
|
||||
/// </summary>
|
||||
/// <param name="server_ip">旧客户端传入的服务端 IP。</param>
|
||||
/// <param name="port">旧客户端传入的服务端端口。</param>
|
||||
/// <returns>与旧 FastAPI 层一致的状态响应。</returns>
|
||||
[HttpPost("/connect_server/")]
|
||||
public IActionResult ConnectServer([FromQuery] string server_ip, [FromQuery] int port)
|
||||
{
|
||||
_logger.LogInformation("ConnectServer 调用: server_ip={ServerIp}, port={Port}", server_ip, port);
|
||||
try
|
||||
{
|
||||
_compatService.ConnectServer(server_ip, port);
|
||||
_logger.LogInformation("ConnectServer 成功: server_ip={ServerIp}, port={Port}", server_ip, port);
|
||||
return Ok(new { status = "connected" });
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogError(exception, "ConnectServer 失败: server_ip={ServerIp}, port={Port}", server_ip, port);
|
||||
return LegacyBadRequest("Connect Server failed");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 兼容旧 `GetServerVersion` 版本查询语义。
|
||||
/// </summary>
|
||||
/// <returns>服务端版本号。</returns>
|
||||
[HttpGet("/get_server_version/")]
|
||||
public IActionResult GetServerVersion()
|
||||
{
|
||||
return Ok(new { server_version = _compatService.GetServerVersion() });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 兼容旧 `GetClientVersion` 版本查询语义。
|
||||
/// </summary>
|
||||
/// <returns>客户端版本号。</returns>
|
||||
[HttpGet("/get_client_version/")]
|
||||
public IActionResult GetClientVersion()
|
||||
{
|
||||
return Ok(new { client_version = _compatService.GetClientVersion() });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 兼容旧 `/setup_robot/` 路由。
|
||||
/// </summary>
|
||||
@@ -100,59 +47,6 @@ public sealed class LegacyHttpApiController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 兼容旧 `SetUpRobotFromEnv(env_file)` 参数形状。
|
||||
/// </summary>
|
||||
/// <param name="env_file">环境文件路径。</param>
|
||||
/// <returns>旧 FastAPI 层风格的状态响应。</returns>
|
||||
[HttpPost("/setup_robot_from_env/")]
|
||||
public IActionResult SetupRobotFromEnv([FromQuery] string env_file)
|
||||
{
|
||||
try
|
||||
{
|
||||
_compatService.SetUpRobotFromEnv(env_file);
|
||||
return Ok(new { status = "robot setup" });
|
||||
}
|
||||
catch
|
||||
{
|
||||
return LegacyBadRequest("SetUpRobotFromEnv failed");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 兼容旧 `SetShowTCP(is_show, axis_length, axis_size)` 参数形状。
|
||||
/// </summary>
|
||||
/// <param name="is_show">是否显示 TCP。</param>
|
||||
/// <param name="axis_length">坐标轴长度。</param>
|
||||
/// <param name="axis_size">坐标轴线宽。</param>
|
||||
/// <returns>旧 FastAPI 层风格的状态响应。</returns>
|
||||
[HttpPost("/set_show_tcp/")]
|
||||
public IActionResult SetShowTcp(
|
||||
[FromQuery] bool is_show = true,
|
||||
[FromQuery] double axis_length = 0.1,
|
||||
[FromQuery] int axis_size = 2)
|
||||
{
|
||||
try
|
||||
{
|
||||
_compatService.SetShowTcp(is_show, axis_length, axis_size);
|
||||
return Ok(new { status = "show TCP set" });
|
||||
}
|
||||
catch
|
||||
{
|
||||
return LegacyBadRequest("SetShowTCP failed");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 兼容旧 `/is_setup/` 路由。
|
||||
/// </summary>
|
||||
/// <returns>当前机器人是否完成初始化。</returns>
|
||||
[HttpGet("/is_setup/")]
|
||||
public IActionResult IsSetup()
|
||||
{
|
||||
return Ok(new { is_setup = _compatService.IsSetUp });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 兼容旧 `EnableRobot(buffer_size=4)` 参数形状。
|
||||
/// </summary>
|
||||
@@ -217,28 +111,6 @@ public sealed class LegacyHttpApiController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 兼容旧 `/set_active_controller/` 路由。
|
||||
/// </summary>
|
||||
/// <param name="sim">是否切到仿真控制器。</param>
|
||||
/// <returns>旧 FastAPI 层风格的状态响应。</returns>
|
||||
[HttpPost("/set_active_controller/")]
|
||||
public IActionResult SetActiveController([FromQuery] bool sim)
|
||||
{
|
||||
_logger.LogInformation("SetActiveController 调用: sim={Sim}", sim);
|
||||
try
|
||||
{
|
||||
_compatService.SetActiveController(sim);
|
||||
_logger.LogInformation("SetActiveController 成功: sim={Sim}", sim);
|
||||
return Ok(new { status = "active controller set" });
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogError(exception, "SetActiveController 失败: sim={Sim}", sim);
|
||||
return LegacyBadRequest("SetActiveController failed");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 兼容旧 `/connect_robot/` 路由。
|
||||
/// </summary>
|
||||
@@ -431,6 +303,32 @@ public sealed class LegacyHttpApiController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 以直角坐标 `[x,y,z,w,p,r]` 执行点到点移动。
|
||||
/// </summary>
|
||||
/// <param name="pose_data">直角位姿请求体。</param>
|
||||
/// <returns>旧 FastAPI 层风格的状态响应。</returns>
|
||||
[HttpPost("/move_pose/")]
|
||||
public IActionResult MovePose([FromBody] JsonElement pose_data)
|
||||
{
|
||||
try
|
||||
{
|
||||
var poseRequest = LegacyCartesianPoseRequest.FromJson(pose_data);
|
||||
var pose = poseRequest.ToPoseArray();
|
||||
_logger.LogInformation("MovePose 调用: x={X}, y={Y}, z={Z}, w={W}, p={P}, r={R}",
|
||||
poseRequest.x, poseRequest.y, poseRequest.z, poseRequest.w, poseRequest.p, poseRequest.r);
|
||||
|
||||
_compatService.MovePose(pose);
|
||||
_logger.LogInformation("MovePose 成功");
|
||||
return Ok(new { status = "robot moved" });
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogError(exception, "MovePose 失败");
|
||||
return LegacyBadRequest("MovePose failed");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 兼容旧 `GetNearestIK(pose, seed, ik)` 参数形状。
|
||||
/// </summary>
|
||||
@@ -461,11 +359,6 @@ public sealed class LegacyHttpApiController : ControllerBase
|
||||
public IActionResult ListFlyshotTrajectories()
|
||||
{
|
||||
var names = _compatService.ListTrajectoryNames();
|
||||
if (names.Count == 0)
|
||||
{
|
||||
return LegacyBadRequest("ListFlyShotTraj failed");
|
||||
}
|
||||
|
||||
return Ok(new { flyshot_trajs = names });
|
||||
}
|
||||
|
||||
@@ -597,9 +490,9 @@ public sealed class LegacyHttpApiController : ControllerBase
|
||||
_logger.LogInformation("SaveTrajectoryInfo 调用: name={Name}, method={Method}", request.name, request.method);
|
||||
try
|
||||
{
|
||||
_compatService.SaveTrajectoryInfo(request.name, request.method);
|
||||
var result = _compatService.SaveTrajectoryInfo(request.name, request.method);
|
||||
_logger.LogInformation("SaveTrajectoryInfo 成功: name={Name}", request.name);
|
||||
return Ok(new { status = "trajectory info saved", success = true });
|
||||
return Ok(result);
|
||||
}
|
||||
catch (NotSupportedException exception)
|
||||
{
|
||||
@@ -697,36 +590,26 @@ public sealed class LegacyHttpApiController : ControllerBase
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 兼容旧 `/init_mpc_robt` 路由,保留历史拼写。
|
||||
/// 使用机器人名称和 IP 执行真机优先初始化。
|
||||
/// </summary>
|
||||
/// <param name="data">初始化请求体。</param>
|
||||
/// <returns>旧 FastAPI 层风格的初始化结果。</returns>
|
||||
[HttpPost("/init_mpc_robt")]
|
||||
public IActionResult InitMpcRobot([FromBody] LegacyInitMpcRobotRequest data)
|
||||
[HttpPost("/init_mpc_robot")]
|
||||
public IActionResult InitMpcRobot([FromBody] InitMpcRobotRequest data)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"InitMpcRobot 调用: robot_name={RobotName}, robot_ip={RobotIp}, sim={Sim}, server={ServerIp}:{Port}",
|
||||
data.robot_name, data.robot_ip, data.sim, data.server_ip, data.port);
|
||||
"InitMpcRobot 调用: robot_name={RobotName}, robot_ip={RobotIp}",
|
||||
data.robot_name, data.robot_ip);
|
||||
try
|
||||
{
|
||||
|
||||
_compatService.SetUpRobot(data.robot_name);
|
||||
if (!_compatService.IsSetUp)
|
||||
{
|
||||
_logger.LogWarning("InitMpcRobot 失败: Robot not setup");
|
||||
return LegacyBadRequest("Robot not setup");
|
||||
}
|
||||
|
||||
_compatService.SetActiveController(data.sim);
|
||||
_compatService.Connect(data.robot_ip);
|
||||
_compatService.EnableRobot(8);
|
||||
_compatService.InitializeRobot(data.robot_name, data.robot_ip, bufferSize: 8);
|
||||
_logger.LogInformation("InitMpcRobot 成功: robot_name={RobotName}", data.robot_name);
|
||||
return Ok(new { message = "init_Success", returnCode = 0 });
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogError(exception, "InitMpcRobot 失败");
|
||||
return LegacyBadRequest("Connect Server failed");
|
||||
return BadRequest(new { detail = exception.Message });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -874,6 +757,136 @@ public sealed class LegacyJointPositionRequest
|
||||
public List<double> joints { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 表示 `/move_pose/` 路由使用的直角位姿请求体。
|
||||
/// </summary>
|
||||
public sealed class LegacyCartesianPoseRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// MovePose 第一版入口硬限制:TCP X/Y 最大绝对值,单位 mm。
|
||||
/// </summary>
|
||||
private const double MaxHorizontalMillimeters = 1000.0;
|
||||
|
||||
/// <summary>
|
||||
/// MovePose 第一版入口硬限制:TCP Z 最小值,单位 mm。
|
||||
/// </summary>
|
||||
private const double MinZMillimeters = 0.0;
|
||||
|
||||
/// <summary>
|
||||
/// MovePose 第一版入口硬限制:TCP Z 最大值,单位 mm。
|
||||
/// </summary>
|
||||
private const double MaxZMillimeters = 1200.0;
|
||||
|
||||
/// <summary>
|
||||
/// MovePose 第一版入口硬限制:W/R 姿态角最大绝对值,单位 deg。
|
||||
/// </summary>
|
||||
private const double MaxRollYawDegrees = 180.0;
|
||||
|
||||
/// <summary>
|
||||
/// MovePose 第一版入口硬限制:P 姿态角最大绝对值,单位 deg。
|
||||
/// </summary>
|
||||
private const double MaxPitchDegrees = 90.0;
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置 TCP X,单位为 mm。
|
||||
/// </summary>
|
||||
public double x { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置 TCP Y,单位为 mm。
|
||||
/// </summary>
|
||||
public double y { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置 TCP Z,单位为 mm。
|
||||
/// </summary>
|
||||
public double z { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置姿态 W,单位为 deg。
|
||||
/// </summary>
|
||||
public double w { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置姿态 P,单位为 deg。
|
||||
/// </summary>
|
||||
public double p { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置姿态 R,单位为 deg。
|
||||
/// </summary>
|
||||
public double r { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 从原始 JSON 请求体解析直角位姿,并显式拒绝缺字段、null 和非有限数。
|
||||
/// </summary>
|
||||
/// <param name="json">原始 JSON 请求体。</param>
|
||||
/// <returns>解析后的直角位姿请求。</returns>
|
||||
public static LegacyCartesianPoseRequest FromJson(JsonElement json)
|
||||
{
|
||||
if (json.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
throw new ArgumentException("MovePose request body must be an object.");
|
||||
}
|
||||
|
||||
// 旧接口要求请求体必须完整提供 x/y/z/w/p/r,不能让模型绑定把缺字段静默补 0。
|
||||
return new LegacyCartesianPoseRequest
|
||||
{
|
||||
x = ReadRequiredFiniteDouble(json, "x", -MaxHorizontalMillimeters, MaxHorizontalMillimeters),
|
||||
y = ReadRequiredFiniteDouble(json, "y", -MaxHorizontalMillimeters, MaxHorizontalMillimeters),
|
||||
z = ReadRequiredFiniteDouble(json, "z", MinZMillimeters, MaxZMillimeters),
|
||||
w = ReadRequiredFiniteDouble(json, "w", -MaxRollYawDegrees, MaxRollYawDegrees),
|
||||
p = ReadRequiredFiniteDouble(json, "p", -MaxPitchDegrees, MaxPitchDegrees),
|
||||
r = ReadRequiredFiniteDouble(json, "r", -MaxRollYawDegrees, MaxRollYawDegrees)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 转换为兼容层使用的六维位姿数组。
|
||||
/// </summary>
|
||||
/// <returns>[x,y,z,w,p,r] 数组。</returns>
|
||||
public IReadOnlyList<double> ToPoseArray()
|
||||
{
|
||||
return [x, y, z, w, p, r];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 读取必填有限数值字段。
|
||||
/// </summary>
|
||||
/// <param name="json">请求体 JSON 对象。</param>
|
||||
/// <param name="propertyName">字段名。</param>
|
||||
/// <param name="minInclusive">允许的最小值。</param>
|
||||
/// <param name="maxInclusive">允许的最大值。</param>
|
||||
/// <returns>字段对应的有限 double 数值。</returns>
|
||||
private static double ReadRequiredFiniteDouble(
|
||||
JsonElement json,
|
||||
string propertyName,
|
||||
double minInclusive,
|
||||
double maxInclusive)
|
||||
{
|
||||
if (!json.TryGetProperty(propertyName, out var property) || property.ValueKind != JsonValueKind.Number)
|
||||
{
|
||||
throw new ArgumentException($"MovePose request field '{propertyName}' is required and must be a number.");
|
||||
}
|
||||
|
||||
var value = property.GetDouble();
|
||||
if (double.IsNaN(value) || double.IsInfinity(value))
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(propertyName, "MovePose request values must be finite.");
|
||||
}
|
||||
|
||||
if (value < minInclusive || value > maxInclusive)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
propertyName,
|
||||
value,
|
||||
$"MovePose request field '{propertyName}' must be between {minInclusive} and {maxInclusive}.");
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 表示旧 `/upload_flyshot/` 路由使用的飞拍上传请求体。
|
||||
/// </summary>
|
||||
@@ -1017,20 +1030,10 @@ public sealed class LegacySpeedRatioRequest
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 表示旧 `/init_mpc_robt` 路由使用的初始化请求体。
|
||||
/// 表示 `/init_mpc_robot` 路由使用的真机初始化请求体。
|
||||
/// </summary>
|
||||
public sealed class LegacyInitMpcRobotRequest
|
||||
public sealed class InitMpcRobotRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取或设置目标服务端 IP。
|
||||
/// </summary>
|
||||
public string server_ip { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置目标服务端端口。
|
||||
/// </summary>
|
||||
public int port { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置机器人名称。
|
||||
/// </summary>
|
||||
@@ -1041,8 +1044,4 @@ public sealed class LegacyInitMpcRobotRequest
|
||||
/// </summary>
|
||||
public string robot_ip { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置是否使用仿真控制器;默认 false 连接真机。
|
||||
/// </summary>
|
||||
public bool sim { get; init; }
|
||||
}
|
||||
|
||||
@@ -49,23 +49,27 @@ public sealed class StatusController : ControllerBase
|
||||
public IActionResult GetSnapshot()
|
||||
{
|
||||
var snapshot = _compatService.GetControllerSnapshot();
|
||||
var isSetup = _compatService.IsSetUp;
|
||||
|
||||
// 状态页需要在机器人未初始化时仍能打开,因此只有初始化后才读取机器人元数据。
|
||||
var robotName = isSetup ? _compatService.GetRobotName() : null;
|
||||
var degreesOfFreedom = isSetup ? _compatService.GetDegreesOfFreedom() : 0;
|
||||
var uploadedTrajectories = isSetup ? _compatService.ListTrajectoryNames() : Array.Empty<string>();
|
||||
var metadata = _compatService.GetStatusSnapshotMetadata();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Status = "ok",
|
||||
Service = "flyshot-server-host",
|
||||
ServerVersion = _compatService.GetServerVersion(),
|
||||
ClientVersion = _compatService.GetClientVersion(),
|
||||
IsSetup = isSetup,
|
||||
RobotName = robotName,
|
||||
DegreesOfFreedom = degreesOfFreedom,
|
||||
UploadedTrajectories = uploadedTrajectories,
|
||||
ServerVersion = metadata.ServerVersion,
|
||||
ClientVersion = metadata.ClientVersion,
|
||||
IsSetup = metadata.IsSetup,
|
||||
RobotName = metadata.RobotName,
|
||||
DegreesOfFreedom = metadata.DegreesOfFreedom,
|
||||
UploadedTrajectories = metadata.UploadedTrajectories,
|
||||
OperatingMode = metadata.OperatingMode,
|
||||
InitializationStage = metadata.InitializationStage,
|
||||
ModelLoaded = metadata.IsSetup,
|
||||
RobotIp = metadata.RobotIp,
|
||||
LastConnectionError = metadata.LastConnectionError,
|
||||
LastConnectionErrorAt = metadata.LastConnectionErrorAt,
|
||||
LastInitializationAttemptAt = metadata.LastInitializationAttemptAt,
|
||||
CanPlanOffline = metadata.CanPlanOffline,
|
||||
CanExecuteRealMotion = metadata.CanExecuteRealMotion,
|
||||
Snapshot = snapshot
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<!-- 生成 XML 文档以便 Swashbuckle 把控制器/DTO 上的 /// summary 注释注入 OpenAPI 文档,
|
||||
调试页和 Swagger UI 的端点标题都依赖这一份文档。1591 抑制掉 “缺失 XML 注释” 的噪音。 -->
|
||||
<!-- 生成 XML 文档以便 Swashbuckle 把控制器/DTO 上的 /// summary 注释注入 OpenAPI 文档;
|
||||
调试页说明文本已经静态内嵌在 debug.html 中,这份 XML 现在只服务 Swagger UI。1591 抑制掉 “缺失 XML 注释” 的噪音。 -->
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);1591</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Flyshot.ControllerClientCompat;
|
||||
using Flyshot.Server.Host;
|
||||
using Flyshot.Server.Host.Middleware;
|
||||
using Flyshot.Server.Host.Services;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using NLog.Web;
|
||||
@@ -23,6 +24,10 @@ try
|
||||
builder.Services.Configure<HostSwaggerOptions>(builder.Configuration.GetSection("Swagger"));
|
||||
builder.Services.Configure<HostCorsOptions>(builder.Configuration.GetSection("Cors"));
|
||||
builder.Services.AddControllerClientCompat(builder.Configuration);
|
||||
|
||||
// 注册服务启动时自动初始化机器人的托管服务;配置缺失时自动跳过。
|
||||
builder.Services.AddHostedService<AutoInitRobotHostedService>();
|
||||
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
|
||||
@@ -35,7 +40,8 @@ try
|
||||
Version = swaggerOptions.Version
|
||||
});
|
||||
|
||||
// 把控制器与 DTO 上的 /// summary 注释纳入 OpenAPI 文档;调试页据此渲染端点标题。
|
||||
// 把控制器与 DTO 上的 /// summary 注释纳入 OpenAPI 文档;当前主要服务 Swagger UI,
|
||||
// 调试页的人类可读说明已经静态内嵌在 debug.html 中,不再依赖发布目录中的 XML 文档。
|
||||
var xmlDocumentationPath = Path.Combine(AppContext.BaseDirectory, $"{typeof(Program).Assembly.GetName().Name}.xml");
|
||||
if (File.Exists(xmlDocumentationPath))
|
||||
{
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
using Flyshot.ControllerClientCompat;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Flyshot.Server.Host.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 服务启动时自动初始化机器人的托管服务。
|
||||
/// 从配置文件读取 robot_name 和 robot_ip,执行统一的真机优先初始化流程。
|
||||
/// 初始化失败不会阻止服务启动,仅记录警告日志。
|
||||
/// </summary>
|
||||
public sealed class AutoInitRobotHostedService : IHostedService
|
||||
{
|
||||
private readonly IControllerClientCompatService _compatService;
|
||||
private readonly ControllerClientCompatOptions _options;
|
||||
private readonly ILogger<AutoInitRobotHostedService> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// 初始化自动初始化机器人托管服务的实例。
|
||||
/// </summary>
|
||||
/// <param name="compatService">控制器兼容服务,用于执行机器人初始化操作。</param>
|
||||
/// <param name="options">控制器兼容层配置,包含自动初始化参数。</param>
|
||||
/// <param name="logger">日志记录器。</param>
|
||||
public AutoInitRobotHostedService(
|
||||
IControllerClientCompatService compatService,
|
||||
IOptions<ControllerClientCompatOptions> options,
|
||||
ILogger<AutoInitRobotHostedService> logger)
|
||||
{
|
||||
_compatService = compatService;
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 服务启动时执行自动初始化。若配置中未指定 AutoInit 或参数为空,则跳过。
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">取消令牌。</param>
|
||||
/// <returns>异步任务。</returns>
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var autoInit = _options.AutoInit;
|
||||
|
||||
// 未配置 AutoInit 或参数为空时,跳过自动初始化
|
||||
if (autoInit == null
|
||||
|| string.IsNullOrWhiteSpace(autoInit.RobotName)
|
||||
|| string.IsNullOrWhiteSpace(autoInit.RobotIp))
|
||||
{
|
||||
_logger.LogInformation("未配置 AutoInit,跳过服务启动自动初始化。");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"开始自动初始化机器人: robot_name={RobotName}, robot_ip={RobotIp}",
|
||||
autoInit.RobotName, autoInit.RobotIp);
|
||||
|
||||
try
|
||||
{
|
||||
// 启动与人工初始化复用同一编排,确保状态阶段和失败降级语义一致。
|
||||
_compatService.InitializeRobot(autoInit.RobotName, autoInit.RobotIp, bufferSize: 8);
|
||||
|
||||
_logger.LogInformation("自动初始化成功: robot_name={RobotName}", autoInit.RobotName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 初始化失败不阻止服务启动,仅记录警告
|
||||
_logger.LogWarning(ex, "自动初始化机器人失败,服务将继续启动。robot_name={RobotName}", autoInit.RobotName);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 服务停止时的清理操作。当前无需额外清理。
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">取消令牌。</param>
|
||||
/// <returns>异步任务。</returns>
|
||||
public Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,11 @@
|
||||
}
|
||||
},
|
||||
"ControllerClientCompat": {
|
||||
"ServerVersion": "flyshot-replacement-controller-client-compat/0.1.0"
|
||||
"ServerVersion": "flyshot-replacement-controller-client-compat/0.1.0",
|
||||
"AutoInit": {
|
||||
"RobotName": "FANUC_LR_Mate_200iD_7L",
|
||||
"RobotIp": "192.168.10.11"
|
||||
}
|
||||
},
|
||||
"Swagger": {
|
||||
"Enabled": true,
|
||||
|
||||
@@ -422,3 +422,64 @@ details > summary {
|
||||
max-height: none;
|
||||
}
|
||||
}
|
||||
.offline-panel {
|
||||
margin: 16px 0 24px;
|
||||
padding: 18px;
|
||||
border: 1px solid rgba(56, 189, 248, 0.35);
|
||||
border-radius: 12px;
|
||||
background: rgba(15, 23, 42, 0.72);
|
||||
}
|
||||
|
||||
.offline-warning {
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.offline-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 1fr) auto minmax(220px, 1fr) auto;
|
||||
gap: 10px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.offline-grid label {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.offline-grid input,
|
||||
.offline-grid select {
|
||||
min-height: 38px;
|
||||
}
|
||||
|
||||
.offline-table-wrap {
|
||||
max-height: 520px;
|
||||
overflow: auto;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
#offline-send-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
#offline-send-table th,
|
||||
#offline-send-table td {
|
||||
padding: 7px;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.18);
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.offline-summary {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
gap: 6px 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.offline-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,18 +2,45 @@
|
||||
const DEBUG_CONFIG_URL = "/api/debug/config";
|
||||
const STORAGE_PREFIX = "flyshot.debug.";
|
||||
const HISTORY_LIMIT = 10;
|
||||
const STATIC_DOCS_ELEMENT_ID = "debug-static-docs";
|
||||
|
||||
const groupTitleByPrefix = [
|
||||
// 基础与状态分组:探活和状态快照两个固定 API 路径
|
||||
{ match: function (op) { return op.path === "/healthz" || op.path === "/api/status/snapshot"; }, title: "基础与状态" },
|
||||
// 默认兜底:剩余全部走 ControllerClient 兼容分组
|
||||
{ match: function () { return true; }, title: "ControllerClient 兼容" }
|
||||
{ match: function (op) { return ["/healthz", "/status", "/debug", "/api/status/snapshot"].indexOf(op.path) >= 0; }, title: "基础状态" },
|
||||
{ match: function (op) { return ["/setup_robot/", "/init_mpc_robot", "/connect_robot/", "/enable_robot/", "/disable_robot/", "/disconnect_robot/", "/stop_move/"].indexOf(op.path) >= 0; }, title: "初始化与维护" },
|
||||
{ match: function (op) { return ["/move_joint/", "/move_pose/", "/set_tcp/", "/get_tcp/", "/set_io/", "/get_io/", "/get_joint_position/", "/get_pose", "/robot_info/"].indexOf(op.path) >= 0; }, title: "机器人操作" },
|
||||
{ match: function () { return true; }, title: "轨迹与飞拍" }
|
||||
];
|
||||
|
||||
const state = {
|
||||
spec: null,
|
||||
operations: [],
|
||||
history: []
|
||||
history: [],
|
||||
staticDocsByKey: loadStaticDocs()
|
||||
};
|
||||
|
||||
const requestBodySamples = {
|
||||
"POST /init_mpc_robot": {
|
||||
robot_name: "FANUC_LR_Mate_200iD",
|
||||
robot_ip: "192.168.10.101"
|
||||
},
|
||||
"POST /move_pose/": {
|
||||
x: 100.0,
|
||||
y: 200.0,
|
||||
z: 300.0,
|
||||
w: 0.0,
|
||||
p: 45.0,
|
||||
r: 0.0
|
||||
},
|
||||
"POST /save_traj_info/": {
|
||||
name: "demo-flyshot",
|
||||
method: "icsp"
|
||||
}
|
||||
};
|
||||
|
||||
const offlinePlanningState = {
|
||||
result: null,
|
||||
page: 1,
|
||||
pageSize: 50
|
||||
};
|
||||
|
||||
/** 简单的 escape:把任意字符串安全嵌入 textContent 之外的位置时使用。 */
|
||||
@@ -23,6 +50,29 @@ function escapeHtml(value) {
|
||||
});
|
||||
}
|
||||
|
||||
/** 为 METHOD PATH 文档块构造统一索引键。 */
|
||||
function buildOperationKey(method, path) {
|
||||
return String(method || "").toUpperCase() + " " + String(path || "");
|
||||
}
|
||||
|
||||
/** 读取 debug.html 中内嵌的静态说明 JSON,并建立 METHOD PATH 索引。 */
|
||||
function loadStaticDocs() {
|
||||
const docNode = document.getElementById(STATIC_DOCS_ELEMENT_ID);
|
||||
if (!docNode) return {};
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(docNode.textContent || "{}");
|
||||
const indexed = {};
|
||||
Object.keys(parsed).forEach(function (key) {
|
||||
indexed[key] = parsed[key];
|
||||
});
|
||||
return indexed;
|
||||
} catch (error) {
|
||||
console.warn("读取静态调试说明失败:", error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** 解析 OpenAPI 中的 $ref 引用,仅支持本地 components.schemas 形式。 */
|
||||
function resolveRef(ref) {
|
||||
if (!ref || !state.spec) return null;
|
||||
@@ -38,34 +88,49 @@ function resolveRef(ref) {
|
||||
return cursor;
|
||||
}
|
||||
|
||||
/** 展开 schema,统一处理 $ref / oneOf / anyOf / allOf。 */
|
||||
function resolveSchema(schema, depth) {
|
||||
depth = depth || 0;
|
||||
if (!schema || depth > 4) return null;
|
||||
|
||||
if (schema.$ref) {
|
||||
return resolveSchema(resolveRef(schema.$ref), depth + 1);
|
||||
}
|
||||
|
||||
if (Array.isArray(schema.oneOf) && schema.oneOf.length > 0) return resolveSchema(schema.oneOf[0], depth + 1);
|
||||
if (Array.isArray(schema.anyOf) && schema.anyOf.length > 0) return resolveSchema(schema.anyOf[0], depth + 1);
|
||||
if (Array.isArray(schema.allOf) && schema.allOf.length > 0) {
|
||||
const merged = { type: "object", properties: {}, required: [] };
|
||||
schema.allOf.forEach(function (subSchema) {
|
||||
const resolved = resolveSchema(subSchema, depth + 1);
|
||||
if (!resolved) return;
|
||||
if (resolved.properties) Object.assign(merged.properties, resolved.properties);
|
||||
if (Array.isArray(resolved.required)) {
|
||||
resolved.required.forEach(function (name) {
|
||||
if (merged.required.indexOf(name) < 0) merged.required.push(name);
|
||||
});
|
||||
}
|
||||
});
|
||||
return merged;
|
||||
}
|
||||
|
||||
return schema;
|
||||
}
|
||||
|
||||
/** 根据 schema 生成默认 JSON 模板,用于自动填充请求体编辑器。 */
|
||||
function buildSampleFromSchema(schema, depth) {
|
||||
depth = depth || 0;
|
||||
// 防御递归:复杂自引用 schema 在 4 层后停下,避免栈爆。
|
||||
if (!schema || depth > 4) return null;
|
||||
|
||||
if (schema.$ref) {
|
||||
const resolved = resolveRef(schema.$ref);
|
||||
return resolved ? buildSampleFromSchema(resolved, depth + 1) : null;
|
||||
}
|
||||
const resolvedSchema = resolveSchema(schema, depth);
|
||||
if (!resolvedSchema) return null;
|
||||
|
||||
// 部分 schema 只标 oneOf/anyOf/allOf,挑第一个分支即可,调试场景够用。
|
||||
if (Array.isArray(schema.oneOf) && schema.oneOf.length > 0) return buildSampleFromSchema(schema.oneOf[0], depth + 1);
|
||||
if (Array.isArray(schema.anyOf) && schema.anyOf.length > 0) return buildSampleFromSchema(schema.anyOf[0], depth + 1);
|
||||
if (Array.isArray(schema.allOf) && schema.allOf.length > 0) {
|
||||
const merged = {};
|
||||
schema.allOf.forEach(function (sub) {
|
||||
const value = buildSampleFromSchema(sub, depth + 1);
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) Object.assign(merged, value);
|
||||
});
|
||||
return merged;
|
||||
}
|
||||
|
||||
const type = schema.type || (schema.properties ? "object" : "string");
|
||||
const type = resolvedSchema.type || (resolvedSchema.properties ? "object" : "string");
|
||||
switch (type) {
|
||||
case "object": {
|
||||
const result = {};
|
||||
const props = schema.properties || {};
|
||||
const props = resolvedSchema.properties || {};
|
||||
Object.keys(props).forEach(function (key) {
|
||||
result[key] = buildSampleFromSchema(props[key], depth + 1);
|
||||
});
|
||||
@@ -80,7 +145,7 @@ function buildSampleFromSchema(schema, depth) {
|
||||
return false;
|
||||
case "string":
|
||||
default:
|
||||
if (schema.enum && schema.enum.length > 0) return schema.enum[0];
|
||||
if (resolvedSchema.enum && resolvedSchema.enum.length > 0) return resolvedSchema.enum[0];
|
||||
return "";
|
||||
}
|
||||
}
|
||||
@@ -88,13 +153,65 @@ function buildSampleFromSchema(schema, depth) {
|
||||
/** 把 schema.type 翻译成 input type 与展示文本。 */
|
||||
function inputKindForType(schema) {
|
||||
if (!schema) return { kind: "text", label: "string" };
|
||||
const type = schema.type || "string";
|
||||
const resolvedSchema = resolveSchema(schema, 0) || schema;
|
||||
const type = resolvedSchema.type || "string";
|
||||
if (type === "boolean") return { kind: "checkbox", label: "boolean" };
|
||||
if (type === "integer" || type === "number") return { kind: "number", label: type };
|
||||
return { kind: "text", label: type };
|
||||
}
|
||||
|
||||
/** 把 OpenAPI 的 paths 节点展开成扁平的 operation 列表。 */
|
||||
/** 把 schema 转成适合字段表展示的简短类型文本。 */
|
||||
function describeSchemaType(schema) {
|
||||
const resolvedSchema = resolveSchema(schema, 0);
|
||||
if (!resolvedSchema) return "unknown";
|
||||
|
||||
if (resolvedSchema.type === "array") {
|
||||
const itemType = describeSchemaType(resolvedSchema.items);
|
||||
return itemType === "unknown" ? "array" : "array<" + itemType + ">";
|
||||
}
|
||||
|
||||
if (resolvedSchema.type) return resolvedSchema.type;
|
||||
if (resolvedSchema.properties) return "object";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
/** 当静态说明缺失时,从普通 object schema 推导字段表。 */
|
||||
function buildFieldDocsFromSchema(schema) {
|
||||
const resolvedSchema = resolveSchema(schema, 0);
|
||||
if (!resolvedSchema) return [];
|
||||
|
||||
const schemaType = resolvedSchema.type || (resolvedSchema.properties ? "object" : "");
|
||||
if (schemaType !== "object" || !resolvedSchema.properties) return [];
|
||||
|
||||
const requiredSet = new Set(resolvedSchema.required || []);
|
||||
return Object.keys(resolvedSchema.properties).map(function (name) {
|
||||
const propertySchema = resolvedSchema.properties[name];
|
||||
return {
|
||||
name: name,
|
||||
type: describeSchemaType(propertySchema),
|
||||
required: requiredSet.has(name),
|
||||
description: propertySchema && propertySchema.description ? propertySchema.description : "暂无说明"
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 规范化静态字段说明,避免缺字段时前端渲染异常。 */
|
||||
function normalizeFieldDocs(fieldDocs) {
|
||||
if (!Array.isArray(fieldDocs)) return [];
|
||||
|
||||
return fieldDocs.map(function (fieldDoc) {
|
||||
return {
|
||||
name: fieldDoc && fieldDoc.name ? String(fieldDoc.name) : "",
|
||||
type: fieldDoc && fieldDoc.type ? String(fieldDoc.type) : "unknown",
|
||||
required: Boolean(fieldDoc && fieldDoc.required),
|
||||
description: fieldDoc && fieldDoc.description ? String(fieldDoc.description) : "暂无说明"
|
||||
};
|
||||
}).filter(function (fieldDoc) {
|
||||
return fieldDoc.name !== "";
|
||||
});
|
||||
}
|
||||
|
||||
/** 把 OpenAPI 的 paths 节点展开成扁平的 operation 列表,并合并静态说明。 */
|
||||
function extractOperations(spec) {
|
||||
const operations = [];
|
||||
const paths = spec.paths || {};
|
||||
@@ -109,6 +226,11 @@ function extractOperations(spec) {
|
||||
const json = op.requestBody.content["application/json"];
|
||||
if (json && json.schema) bodySchema = json.schema;
|
||||
}
|
||||
|
||||
const operationKey = buildOperationKey(method, path);
|
||||
const staticDoc = state.staticDocsByKey[operationKey] || null;
|
||||
const staticFieldDocs = staticDoc ? normalizeFieldDocs(staticDoc.requestBodyFields) : [];
|
||||
const schemaFieldDocs = staticFieldDocs.length === 0 ? buildFieldDocsFromSchema(bodySchema) : [];
|
||||
operations.push({
|
||||
method: method.toUpperCase(),
|
||||
path: path,
|
||||
@@ -116,7 +238,11 @@ function extractOperations(spec) {
|
||||
description: op.description || "",
|
||||
tags: op.tags || [],
|
||||
parameters: parameters,
|
||||
bodySchema: bodySchema
|
||||
bodySchema: bodySchema,
|
||||
staticDoc: staticDoc,
|
||||
mergedSummary: staticDoc && staticDoc.summary ? staticDoc.summary : "暂无说明",
|
||||
mergedDescription: staticDoc && staticDoc.description ? staticDoc.description : "暂无说明",
|
||||
requestBodyFieldDocs: staticFieldDocs.length > 0 ? staticFieldDocs : schemaFieldDocs
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -137,6 +263,12 @@ function storageKey(op) {
|
||||
return STORAGE_PREFIX + op.method + ":" + op.path;
|
||||
}
|
||||
|
||||
/** 返回指定端点的手工调试样例,处理 JsonElement 等 OpenAPI 无法自动推断字段的接口。 */
|
||||
function getRequestBodySample(op) {
|
||||
const operationKey = op.method + " " + op.path;
|
||||
return requestBodySamples[operationKey] || null;
|
||||
}
|
||||
|
||||
/** 读取本端点最近一次输入;解析失败则当作空。 */
|
||||
function loadInputs(op) {
|
||||
try {
|
||||
@@ -252,10 +384,80 @@ function renderParameterRows(container, op, savedValues) {
|
||||
};
|
||||
}
|
||||
|
||||
/** 渲染请求体字段说明表;静态说明缺失时退回 schema 推导。 */
|
||||
function renderRequestBodyFieldDocs(container, op) {
|
||||
if (!op.bodySchema && op.requestBodyFieldDocs.length === 0) return;
|
||||
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "body-block";
|
||||
|
||||
const title = document.createElement("div");
|
||||
title.className = "body-label";
|
||||
const left = document.createElement("div");
|
||||
left.className = "left";
|
||||
left.textContent = "请求体字段说明";
|
||||
title.appendChild(left);
|
||||
wrapper.appendChild(title);
|
||||
|
||||
if (op.requestBodyFieldDocs.length === 0) {
|
||||
const empty = document.createElement("div");
|
||||
empty.style.color = "var(--muted)";
|
||||
empty.style.fontSize = "13px";
|
||||
empty.textContent = "暂无说明";
|
||||
wrapper.appendChild(empty);
|
||||
container.appendChild(wrapper);
|
||||
return;
|
||||
}
|
||||
|
||||
const table = document.createElement("table");
|
||||
table.style.width = "100%";
|
||||
table.style.borderCollapse = "collapse";
|
||||
table.style.fontSize = "13px";
|
||||
|
||||
const headerRow = document.createElement("tr");
|
||||
["字段名", "类型", "必填", "说明"].forEach(function (text) {
|
||||
const th = document.createElement("th");
|
||||
th.textContent = text;
|
||||
th.style.textAlign = "left";
|
||||
th.style.padding = "6px 8px";
|
||||
th.style.borderBottom = "1px solid rgba(148, 163, 184, 0.25)";
|
||||
th.style.color = "var(--muted)";
|
||||
headerRow.appendChild(th);
|
||||
});
|
||||
table.appendChild(headerRow);
|
||||
|
||||
op.requestBodyFieldDocs.forEach(function (fieldDoc) {
|
||||
const row = document.createElement("tr");
|
||||
[
|
||||
fieldDoc.name,
|
||||
fieldDoc.type,
|
||||
fieldDoc.required ? "是" : "否",
|
||||
fieldDoc.description || "暂无说明"
|
||||
].forEach(function (value, index) {
|
||||
const cell = document.createElement("td");
|
||||
cell.style.padding = "8px";
|
||||
cell.style.verticalAlign = "top";
|
||||
cell.style.borderBottom = "1px solid rgba(148, 163, 184, 0.12)";
|
||||
if (index === 0 || index === 1) {
|
||||
cell.style.fontFamily = "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace";
|
||||
}
|
||||
cell.textContent = value;
|
||||
row.appendChild(cell);
|
||||
});
|
||||
table.appendChild(row);
|
||||
});
|
||||
|
||||
wrapper.appendChild(table);
|
||||
container.appendChild(wrapper);
|
||||
}
|
||||
|
||||
/** 渲染请求体编辑器,返回收集函数。 */
|
||||
function renderBodyEditor(container, op, savedBody) {
|
||||
if (!op.bodySchema) return function () { return null; };
|
||||
|
||||
// 字段说明先于 JSON 编辑器出现,便于现场先看约定再改样例。
|
||||
renderRequestBodyFieldDocs(container, op);
|
||||
|
||||
const block = document.createElement("div");
|
||||
block.className = "body-block";
|
||||
|
||||
@@ -281,7 +483,7 @@ function renderBodyEditor(container, op, savedBody) {
|
||||
if (savedBody !== undefined && savedBody !== null) {
|
||||
initialText = typeof savedBody === "string" ? savedBody : JSON.stringify(savedBody, null, 2);
|
||||
} else {
|
||||
const sample = buildSampleFromSchema(op.bodySchema, 0);
|
||||
const sample = getRequestBodySample(op) || buildSampleFromSchema(op.bodySchema, 0);
|
||||
initialText = sample === null ? "" : JSON.stringify(sample, null, 2);
|
||||
}
|
||||
textarea.value = initialText;
|
||||
@@ -326,6 +528,7 @@ function pushHistory(entry) {
|
||||
renderHistory();
|
||||
}
|
||||
|
||||
/** 渲染请求历史列表。 */
|
||||
function renderHistory() {
|
||||
const list = document.getElementById("history-list");
|
||||
list.innerHTML = "";
|
||||
@@ -360,7 +563,7 @@ function renderHistory() {
|
||||
/** 渲染单个端点的卡片。 */
|
||||
function renderOperationCard(op) {
|
||||
const card = document.createElement("section");
|
||||
card.className = "card collapsed";
|
||||
card.className = "card";
|
||||
|
||||
const head = document.createElement("div");
|
||||
head.className = "card-head";
|
||||
@@ -377,13 +580,13 @@ function renderOperationCard(op) {
|
||||
|
||||
const summary = document.createElement("span");
|
||||
summary.className = "card-summary";
|
||||
summary.textContent = op.summary;
|
||||
summary.title = op.summary;
|
||||
summary.textContent = op.mergedSummary;
|
||||
summary.title = op.mergedSummary;
|
||||
head.appendChild(summary);
|
||||
|
||||
const toggle = document.createElement("span");
|
||||
toggle.className = "card-toggle";
|
||||
toggle.textContent = "展开 ▾";
|
||||
toggle.textContent = "收起 ▴";
|
||||
head.appendChild(toggle);
|
||||
|
||||
head.addEventListener("click", function () {
|
||||
@@ -395,15 +598,12 @@ function renderOperationCard(op) {
|
||||
const body = document.createElement("div");
|
||||
body.className = "card-body";
|
||||
|
||||
// 描述(来自 XML summary)独立成一段
|
||||
if (op.summary) {
|
||||
const desc = document.createElement("div");
|
||||
desc.style.color = "var(--muted)";
|
||||
desc.style.marginBottom = "10px";
|
||||
desc.style.fontSize = "13px";
|
||||
desc.textContent = op.summary;
|
||||
body.appendChild(desc);
|
||||
}
|
||||
const desc = document.createElement("div");
|
||||
desc.style.color = "var(--muted)";
|
||||
desc.style.marginBottom = "10px";
|
||||
desc.style.fontSize = "13px";
|
||||
desc.textContent = op.mergedDescription;
|
||||
body.appendChild(desc);
|
||||
|
||||
const saved = loadInputs(op) || {};
|
||||
|
||||
@@ -448,6 +648,7 @@ function renderOperationCard(op) {
|
||||
responseBlock.style.display = "none";
|
||||
body.appendChild(responseBlock);
|
||||
|
||||
/** 在卡片内渲染 HTTP 响应摘要、头和正文。 */
|
||||
function renderResponse(payload) {
|
||||
responseBlock.style.display = "block";
|
||||
responseBlock.innerHTML = "";
|
||||
@@ -594,9 +795,9 @@ function renderGroups(operations) {
|
||||
grouped.get(groupTitle).push(op);
|
||||
});
|
||||
|
||||
// 固定输出顺序:基础与状态在前,ControllerClient 兼容在后,其余按字典序。
|
||||
// 固定输出顺序与现场操作顺序一致。
|
||||
const orderedTitles = [];
|
||||
["基础与状态", "ControllerClient 兼容"].forEach(function (title) {
|
||||
["基础状态", "初始化与维护", "机器人操作", "轨迹与飞拍"].forEach(function (title) {
|
||||
if (grouped.has(title)) orderedTitles.push(title);
|
||||
});
|
||||
Array.from(grouped.keys()).sort().forEach(function (title) {
|
||||
@@ -620,6 +821,144 @@ function renderGroups(operations) {
|
||||
});
|
||||
}
|
||||
|
||||
/** 调用 JSON API,并在非 2xx 时把服务端 detail 作为异常抛出。 */
|
||||
async function requestJson(url, init) {
|
||||
const response = await fetch(url, init || {});
|
||||
const text = await response.text();
|
||||
let payload = null;
|
||||
if (text) {
|
||||
try { payload = JSON.parse(text); } catch (error) { payload = { detail: text }; }
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(payload && payload.detail ? payload.detail : "HTTP " + response.status);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
/** 刷新固定 RobotConfig.json 中的飞拍轨迹下拉列表。 */
|
||||
async function refreshOfflineTrajectoryList() {
|
||||
const select = document.getElementById("offline-trajectory-select");
|
||||
const payload = await requestJson("/list_flyShotTraj/");
|
||||
const names = payload.flyshot_trajs || [];
|
||||
select.innerHTML = "";
|
||||
names.forEach(function (name) {
|
||||
const option = document.createElement("option");
|
||||
option.value = name;
|
||||
option.textContent = name;
|
||||
select.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
/** 按页渲染最终发送点,避免大点列一次性创建过多 DOM。 */
|
||||
function renderOfflineSendPage() {
|
||||
const result = offlinePlanningState.result;
|
||||
const body = document.querySelector("#offline-send-table tbody");
|
||||
body.innerHTML = "";
|
||||
if (!result) {
|
||||
document.getElementById("offline-page-info").textContent = "0 / 0";
|
||||
return;
|
||||
}
|
||||
|
||||
const points = result.finalSendPoints || [];
|
||||
const pageCount = Math.max(1, Math.ceil(points.length / offlinePlanningState.pageSize));
|
||||
offlinePlanningState.page = Math.min(Math.max(1, offlinePlanningState.page), pageCount);
|
||||
const start = (offlinePlanningState.page - 1) * offlinePlanningState.pageSize;
|
||||
points.slice(start, start + offlinePlanningState.pageSize).forEach(function (point) {
|
||||
const row = document.createElement("tr");
|
||||
[
|
||||
point.sampleIndex,
|
||||
Number(point.sendTime).toFixed(6),
|
||||
Number(point.trajectoryTime).toFixed(6),
|
||||
(point.jointsDegrees || []).map(function (value) { return Number(value).toFixed(4); }).join(", "),
|
||||
point.ioMask,
|
||||
point.ioValue
|
||||
].forEach(function (value) {
|
||||
const cell = document.createElement("td");
|
||||
cell.textContent = value;
|
||||
row.appendChild(cell);
|
||||
});
|
||||
body.appendChild(row);
|
||||
});
|
||||
document.getElementById("offline-page-info").textContent = offlinePlanningState.page + " / " + pageCount;
|
||||
}
|
||||
|
||||
/** 展示离线规划摘要、触发绑定、最终发送点和导出路径。 */
|
||||
function renderOfflinePlanningResult(result) {
|
||||
offlinePlanningState.result = result;
|
||||
offlinePlanningState.page = 1;
|
||||
document.getElementById("offline-planning-summary").innerHTML =
|
||||
"<dl class=\"offline-summary\">" +
|
||||
"<dt>运行结果</dt><dd>" + escapeHtml(result.isValid ? "有效" : "无效") + "</dd>" +
|
||||
"<dt>规划时长</dt><dd>" + Number(result.planningDurationSeconds).toFixed(6) + " s</dd>" +
|
||||
"<dt>最终发送时长</dt><dd>" + Number(result.finalSendDurationSeconds).toFixed(6) + " s</dd>" +
|
||||
"<dt>关键/稠密/发送点</dt><dd>" + result.plannedWaypointCount + " / " + result.densePointCount + " / " + result.finalSendPointCount + "</dd>" +
|
||||
"<dt>速度倍率</dt><dd>" + result.speedRatio + "</dd>" +
|
||||
"</dl>";
|
||||
document.getElementById("offline-shot-events").textContent =
|
||||
"ShotEvents / TriggerBindings\n" + JSON.stringify({
|
||||
shotEvents: result.shotEvents,
|
||||
triggerBindings: result.triggerBindings
|
||||
}, null, 2);
|
||||
document.getElementById("offline-artifact-paths").textContent =
|
||||
"导出文件\n" + JSON.stringify(result.artifactPaths, null, 2);
|
||||
renderOfflineSendPage();
|
||||
}
|
||||
|
||||
/** 初始化离线规划操作面板。 */
|
||||
function initializeOfflinePlanningPanel() {
|
||||
const status = document.getElementById("offline-planning-status");
|
||||
document.getElementById("offline-load-model").addEventListener("click", async function () {
|
||||
try {
|
||||
const robotName = document.getElementById("offline-robot-name").value.trim();
|
||||
await requestJson("/setup_robot/?robot_name=" + encodeURIComponent(robotName), { method: "POST" });
|
||||
status.textContent = "模型已加载,当前可离线规划。";
|
||||
} catch (error) {
|
||||
status.textContent = "模型加载失败: " + error.message;
|
||||
}
|
||||
});
|
||||
document.getElementById("offline-refresh-trajectories").addEventListener("click", async function () {
|
||||
try { await refreshOfflineTrajectoryList(); status.textContent = "轨迹列表已刷新。"; }
|
||||
catch (error) { status.textContent = "刷新失败: " + error.message; }
|
||||
});
|
||||
document.getElementById("offline-upload").addEventListener("click", async function () {
|
||||
try {
|
||||
const body = document.getElementById("offline-upload-json").value;
|
||||
await requestJson("/upload_flyshot/", { method: "POST", headers: { "Content-Type": "application/json" }, body: body });
|
||||
await refreshOfflineTrajectoryList();
|
||||
status.textContent = "轨迹已上传到固定 RobotConfig.json。";
|
||||
} catch (error) {
|
||||
status.textContent = "上传失败: " + error.message;
|
||||
}
|
||||
});
|
||||
document.getElementById("offline-generate").addEventListener("click", async function () {
|
||||
try {
|
||||
const name = document.getElementById("offline-trajectory-select").value;
|
||||
const method = document.getElementById("offline-planning-method").value;
|
||||
status.textContent = "正在生成最终发送轨迹...";
|
||||
const result = await requestJson("/save_traj_info/", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: name, method: method })
|
||||
});
|
||||
renderOfflinePlanningResult(result);
|
||||
status.textContent = "离线规划完成,未向机器人发送命令。";
|
||||
} catch (error) {
|
||||
status.textContent = "离线规划失败: " + error.message;
|
||||
}
|
||||
});
|
||||
document.getElementById("offline-prev-page").addEventListener("click", function () {
|
||||
offlinePlanningState.page--;
|
||||
renderOfflineSendPage();
|
||||
});
|
||||
document.getElementById("offline-next-page").addEventListener("click", function () {
|
||||
offlinePlanningState.page++;
|
||||
renderOfflineSendPage();
|
||||
});
|
||||
refreshOfflineTrajectoryList().catch(function (error) {
|
||||
status.textContent = "轨迹列表加载失败: " + error.message;
|
||||
});
|
||||
}
|
||||
|
||||
/** 加载 OpenAPI 文档并渲染。 */
|
||||
async function loadSpecAndRender() {
|
||||
const metaSpec = document.getElementById("meta-spec-url");
|
||||
@@ -667,4 +1006,5 @@ document.getElementById("history-clear").addEventListener("click", function () {
|
||||
});
|
||||
|
||||
renderHistory();
|
||||
initializeOfflinePlanningPanel();
|
||||
loadSpecAndRender();
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
--text: #172033;
|
||||
--muted: #5b667a;
|
||||
--accent: #007c89;
|
||||
--accent-dark: #005d67;
|
||||
--steel: #2f3b4f;
|
||||
--steel-soft: #e7ebf1;
|
||||
--good: #12805c;
|
||||
--warn: #b7791f;
|
||||
--bad: #b42318;
|
||||
@@ -188,6 +191,289 @@ dd {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.jog-panel {
|
||||
grid-column: 1 / -1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.jog-toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(180px, 1fr)) minmax(220px, 1fr);
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: #fbfcfe;
|
||||
}
|
||||
|
||||
.step-field {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.step-field input {
|
||||
min-height: 38px;
|
||||
width: 100%;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: #ffffff;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.jog-body {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 18px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.stick-card {
|
||||
min-width: 0;
|
||||
border: 1px solid #ccd4df;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f3f6fa 100%);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.stick-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 16px 0;
|
||||
}
|
||||
|
||||
.stick-head h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.stick-head span {
|
||||
color: var(--muted);
|
||||
font-family: Consolas, "Cascadia Mono", monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.stick-console {
|
||||
display: grid;
|
||||
grid-template-columns: 72px 1fr 72px;
|
||||
grid-template-rows: 72px 72px 72px;
|
||||
gap: 8px;
|
||||
width: min(100%, 344px);
|
||||
margin: 18px auto 16px;
|
||||
padding: 14px;
|
||||
border: 1px solid #c2cad7;
|
||||
border-radius: 999px;
|
||||
background:
|
||||
radial-gradient(circle at 50% 50%, #ffffff 0 26%, #e6ebf2 27% 48%, #cdd5e0 49% 50%, #edf1f6 51% 100%);
|
||||
}
|
||||
|
||||
.jog-button {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
color: var(--steel);
|
||||
font-weight: 650;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.axis-button,
|
||||
.mini-button {
|
||||
border: 1px solid #99a8ba;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #e5ebf2 100%);
|
||||
box-shadow: 0 2px 0 #b7c1ce;
|
||||
}
|
||||
|
||||
.axis-button {
|
||||
min-width: 0;
|
||||
min-height: 64px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.mini-button {
|
||||
padding: 0 10px;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.axis-button:hover,
|
||||
.axis-button.active {
|
||||
border-color: var(--accent);
|
||||
background: linear-gradient(180deg, #10a4b3 0%, var(--accent) 100%);
|
||||
color: #ffffff;
|
||||
box-shadow: 0 2px 0 var(--accent-dark);
|
||||
}
|
||||
|
||||
.mini-button:hover,
|
||||
.mini-button.active {
|
||||
border-color: var(--warn);
|
||||
background: var(--warn);
|
||||
color: #ffffff;
|
||||
box-shadow: 0 2px 0 #8a5a13;
|
||||
}
|
||||
|
||||
.jog-button:focus-visible {
|
||||
outline: 3px solid rgba(0, 124, 137, 0.3);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.jog-button.active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.axis-button.up {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
.axis-button.left {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
}
|
||||
|
||||
.axis-button.right {
|
||||
grid-column: 3;
|
||||
grid-row: 2;
|
||||
}
|
||||
|
||||
.axis-button.down {
|
||||
grid-column: 2;
|
||||
grid-row: 3;
|
||||
}
|
||||
|
||||
.center-stack {
|
||||
display: grid;
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 6px;
|
||||
place-items: center;
|
||||
padding: 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(47, 59, 79, 0.08);
|
||||
}
|
||||
|
||||
.hint-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
padding: 0 16px 16px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hint-row span {
|
||||
min-height: 28px;
|
||||
padding: 5px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--steel-soft);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.jog-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
min-height: 38px;
|
||||
color: var(--muted);
|
||||
font-family: Consolas, "Cascadia Mono", monospace;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.jog-status.error {
|
||||
color: var(--bad);
|
||||
}
|
||||
|
||||
.log-panel {
|
||||
margin: 0 16px 16px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #fbfcfe;
|
||||
}
|
||||
|
||||
.log-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.log-head h3 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.clear-button {
|
||||
min-height: 34px;
|
||||
padding: 0 12px;
|
||||
border-color: var(--line);
|
||||
background: #ffffff;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.clear-button:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.request-log {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
max-height: 300px;
|
||||
min-height: 54px;
|
||||
margin: 0;
|
||||
padding: 12px 14px 14px;
|
||||
overflow: auto;
|
||||
font-family: Consolas, "Cascadia Mono", monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
display: grid;
|
||||
grid-template-columns: 88px 96px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
padding: 9px 10px;
|
||||
border: 1px solid #e1e7ef;
|
||||
border-radius: 6px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.log-entry:first-child {
|
||||
border-color: rgba(0, 124, 137, 0.35);
|
||||
background: rgba(0, 124, 137, 0.04);
|
||||
}
|
||||
|
||||
.log-entry.error {
|
||||
border-color: rgba(180, 35, 24, 0.35);
|
||||
background: rgba(180, 35, 24, 0.06);
|
||||
}
|
||||
|
||||
.log-time,
|
||||
.log-axis {
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.log-payload {
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.log-empty {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.topbar {
|
||||
align-items: flex-start;
|
||||
@@ -199,6 +485,16 @@ dd {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.jog-toolbar,
|
||||
.jog-body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.jog-status {
|
||||
justify-content: flex-start;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
dl {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@@ -212,3 +508,19 @@ dd {
|
||||
padding-top: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.stick-console {
|
||||
grid-template-columns: 60px 1fr 60px;
|
||||
grid-template-rows: 60px 60px 60px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.axis-button {
|
||||
min-height: 56px;
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,9 +15,37 @@ const fields = {
|
||||
joints: document.getElementById("joints"),
|
||||
pose: document.getElementById("pose"),
|
||||
trajectories: document.getElementById("trajectories"),
|
||||
refresh: document.getElementById("refresh")
|
||||
refresh: document.getElementById("refresh"),
|
||||
linearStep: document.getElementById("linear-step"),
|
||||
angularStep: document.getElementById("angular-step"),
|
||||
jogStatus: document.getElementById("jog-status"),
|
||||
requestLog: document.getElementById("request-log"),
|
||||
clearLog: document.getElementById("clear-log"),
|
||||
jogButtons: Array.from(document.querySelectorAll(".jog-button"))
|
||||
};
|
||||
|
||||
const axisIndexes = {
|
||||
x: 0,
|
||||
y: 1,
|
||||
z: 2,
|
||||
w: 3,
|
||||
p: 4,
|
||||
r: 5
|
||||
};
|
||||
|
||||
// 点动状态集中保存,确保按住按钮时不会并发发送多条 MovePose 请求。
|
||||
const jogState = {
|
||||
timer: null,
|
||||
activeButton: null,
|
||||
inFlight: false,
|
||||
lastSnapshot: null,
|
||||
sequence: 0,
|
||||
logs: []
|
||||
};
|
||||
|
||||
const maxJogLogs = 80;
|
||||
const jogIntervalMs = 250;
|
||||
|
||||
function formatArray(values) {
|
||||
if (!Array.isArray(values) || values.length === 0) {
|
||||
return "--";
|
||||
@@ -56,12 +84,202 @@ function setDot(connectionState) {
|
||||
}
|
||||
}
|
||||
|
||||
function clampStep(input, min, max) {
|
||||
const value = Number(input.value);
|
||||
if (!Number.isFinite(value)) {
|
||||
input.value = String(min);
|
||||
return min;
|
||||
}
|
||||
|
||||
const clamped = Math.min(Math.max(value, min), max);
|
||||
input.value = String(clamped);
|
||||
return clamped;
|
||||
}
|
||||
|
||||
function getStepForAxis(axis) {
|
||||
// 平移和姿态使用不同单位,但共享 0.1 到 20 的现场可调范围。
|
||||
return axis === "x" || axis === "y" || axis === "z"
|
||||
? clampStep(fields.linearStep, 0.1, 20)
|
||||
: clampStep(fields.angularStep, 0.1, 20);
|
||||
}
|
||||
|
||||
function setJogStatus(message, isError) {
|
||||
fields.jogStatus.textContent = message;
|
||||
fields.jogStatus.classList.toggle("error", Boolean(isError));
|
||||
}
|
||||
|
||||
function formatDirection(axis, direction) {
|
||||
return `${axis.toUpperCase()}${direction > 0 ? "+" : "-"}`;
|
||||
}
|
||||
|
||||
function renderJogLogs() {
|
||||
fields.requestLog.replaceChildren();
|
||||
|
||||
if (jogState.logs.length === 0) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "log-empty";
|
||||
empty.textContent = "暂无请求记录";
|
||||
fields.requestLog.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
jogState.logs.forEach(function (entry) {
|
||||
const row = document.createElement("div");
|
||||
row.className = `log-entry${entry.ok ? "" : " error"}`;
|
||||
|
||||
const time = document.createElement("span");
|
||||
time.className = "log-time";
|
||||
time.textContent = entry.time;
|
||||
|
||||
const axis = document.createElement("span");
|
||||
axis.className = "log-axis";
|
||||
axis.textContent = `#${entry.sequence} ${entry.direction}`;
|
||||
|
||||
const payload = document.createElement("span");
|
||||
payload.className = "log-payload";
|
||||
payload.textContent = `step=${entry.step} ${entry.unit}; body=${entry.body}; ${entry.result}`;
|
||||
|
||||
row.append(time, axis, payload);
|
||||
fields.requestLog.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function addJogLog(entry) {
|
||||
jogState.logs.unshift(entry);
|
||||
if (jogState.logs.length > maxJogLogs) {
|
||||
jogState.logs.length = maxJogLogs;
|
||||
}
|
||||
|
||||
renderJogLogs();
|
||||
}
|
||||
|
||||
async function loadSnapshotForJog() {
|
||||
const response = await fetch("/api/status/snapshot", { cache: "no-store" });
|
||||
if (!response.ok) {
|
||||
throw new Error(`状态快照读取失败: HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
jogState.lastSnapshot = payload.snapshot;
|
||||
return payload.snapshot;
|
||||
}
|
||||
|
||||
function buildJogPose(snapshot, axis, direction) {
|
||||
// 每次点动都从最新 TCP 位姿出发,只修改一个轴,避免连续按压时累积本地误差。
|
||||
const sourcePose = Array.isArray(snapshot.cartesianPose) ? snapshot.cartesianPose : [];
|
||||
if (sourcePose.length < 6) {
|
||||
throw new Error("当前 TCP 位姿不足 6 维,无法点动。");
|
||||
}
|
||||
|
||||
const pose = sourcePose.slice(0, 6).map(Number);
|
||||
if (pose.some(value => !Number.isFinite(value))) {
|
||||
throw new Error("当前 TCP 位姿包含非数值,无法点动。");
|
||||
}
|
||||
|
||||
const axisIndex = axisIndexes[axis];
|
||||
pose[axisIndex] = Number((pose[axisIndex] + direction * getStepForAxis(axis)).toFixed(6));
|
||||
return {
|
||||
x: pose[0],
|
||||
y: pose[1],
|
||||
z: pose[2],
|
||||
w: pose[3],
|
||||
p: pose[4],
|
||||
r: pose[5]
|
||||
};
|
||||
}
|
||||
|
||||
async function sendJog(button) {
|
||||
// MovePose 底层会生成完整直角轨迹;前端这里只负责构造增量目标位姿。
|
||||
if (jogState.inFlight) {
|
||||
return;
|
||||
}
|
||||
|
||||
const axis = button.dataset.axis;
|
||||
const direction = Number(button.dataset.direction);
|
||||
if (!Object.prototype.hasOwnProperty.call(axisIndexes, axis) || !Number.isFinite(direction)) {
|
||||
return;
|
||||
}
|
||||
|
||||
jogState.inFlight = true;
|
||||
const step = getStepForAxis(axis);
|
||||
const directionLabel = formatDirection(axis, direction);
|
||||
const sequence = ++jogState.sequence;
|
||||
let bodyText = "{}";
|
||||
let resultText = "";
|
||||
let ok = false;
|
||||
|
||||
try {
|
||||
const snapshot = await loadSnapshotForJog();
|
||||
const pose = buildJogPose(snapshot, axis, direction);
|
||||
bodyText = JSON.stringify(pose);
|
||||
const response = await fetch("/move_pose/", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: bodyText
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`MovePose 调用失败: HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
ok = true;
|
||||
resultText = `HTTP ${response.status}`;
|
||||
setJogStatus(`${directionLabel} 已发送`, false);
|
||||
await refreshStatus();
|
||||
} catch (error) {
|
||||
resultText = error instanceof Error ? error.message : "点动失败";
|
||||
setJogStatus(resultText, true);
|
||||
stopJog();
|
||||
} finally {
|
||||
addJogLog({
|
||||
time: new Date().toLocaleTimeString(),
|
||||
sequence,
|
||||
direction: directionLabel,
|
||||
step,
|
||||
unit: axis === "x" || axis === "y" || axis === "z" ? "mm" : "deg",
|
||||
body: bodyText,
|
||||
result: resultText,
|
||||
ok
|
||||
});
|
||||
jogState.inFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startJog(event) {
|
||||
const button = event.currentTarget;
|
||||
if (!button || button.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
stopJog();
|
||||
jogState.activeButton = button;
|
||||
button.classList.add("active");
|
||||
sendJog(button);
|
||||
jogState.timer = window.setInterval(function () {
|
||||
sendJog(button);
|
||||
}, jogIntervalMs);
|
||||
}
|
||||
|
||||
function stopJog() {
|
||||
if (jogState.timer !== null) {
|
||||
window.clearInterval(jogState.timer);
|
||||
jogState.timer = null;
|
||||
}
|
||||
|
||||
if (jogState.activeButton) {
|
||||
jogState.activeButton.classList.remove("active");
|
||||
jogState.activeButton = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshStatus() {
|
||||
fields.refresh.disabled = true;
|
||||
try {
|
||||
const response = await fetch("/api/status/snapshot", { cache: "no-store" });
|
||||
const payload = await response.json();
|
||||
const snapshot = payload.snapshot;
|
||||
jogState.lastSnapshot = snapshot;
|
||||
|
||||
fields.connectionState.textContent = snapshot.connectionState;
|
||||
fields.robotName.textContent = payload.robotName || "--";
|
||||
@@ -88,5 +306,28 @@ async function refreshStatus() {
|
||||
}
|
||||
|
||||
fields.refresh.addEventListener("click", refreshStatus);
|
||||
fields.linearStep.addEventListener("change", function () {
|
||||
clampStep(fields.linearStep, 0.1, 20);
|
||||
});
|
||||
fields.angularStep.addEventListener("change", function () {
|
||||
clampStep(fields.angularStep, 0.1, 20);
|
||||
});
|
||||
fields.clearLog.addEventListener("click", function () {
|
||||
jogState.logs = [];
|
||||
renderJogLogs();
|
||||
});
|
||||
fields.jogButtons.forEach(function (button) {
|
||||
button.addEventListener("pointerdown", startJog);
|
||||
button.addEventListener("pointerup", stopJog);
|
||||
button.addEventListener("pointercancel", stopJog);
|
||||
button.addEventListener("pointerleave", stopJog);
|
||||
});
|
||||
window.addEventListener("blur", stopJog);
|
||||
window.addEventListener("keyup", function (event) {
|
||||
if (event.key === "Escape") {
|
||||
stopJog();
|
||||
}
|
||||
});
|
||||
renderJogLogs();
|
||||
refreshStatus();
|
||||
window.setInterval(refreshStatus, 2000);
|
||||
|
||||
@@ -28,6 +28,58 @@
|
||||
<dd id="meta-status">初始化中...</dd>
|
||||
</dl>
|
||||
</section>
|
||||
<section class="offline-panel" id="offline-planning-panel">
|
||||
<h2>离线飞拍规划</h2>
|
||||
<p class="offline-warning">生成轨迹不会向机器人发送命令;只有 RealReady 状态才能执行真实运动。</p>
|
||||
<div class="offline-grid">
|
||||
<label>
|
||||
机器人模型
|
||||
<input id="offline-robot-name" value="FANUC_LR_Mate_200iD">
|
||||
</label>
|
||||
<button id="offline-load-model" type="button">加载模型</button>
|
||||
<label>
|
||||
已上传轨迹
|
||||
<select id="offline-trajectory-select"></select>
|
||||
</label>
|
||||
<button id="offline-refresh-trajectories" type="button" class="secondary">刷新列表</button>
|
||||
<label>
|
||||
规划方法
|
||||
<select id="offline-planning-method">
|
||||
<option value="icsp">icsp</option>
|
||||
<option value="self-adapt-icsp">self-adapt-icsp</option>
|
||||
</select>
|
||||
</label>
|
||||
<button id="offline-generate" type="button">生成最终发送轨迹</button>
|
||||
</div>
|
||||
<details>
|
||||
<summary>上传飞拍轨迹 JSON</summary>
|
||||
<textarea id="offline-upload-json" class="body-editor">{
|
||||
"name": "demo-flyshot",
|
||||
"waypoints": [[0,0,0,0,0,0],[0.001,0,0,0,0,0],[0.002,0,0,0,0,0],[0.003,0,0,0,0,0]],
|
||||
"shot_flags": [false,true,false,false],
|
||||
"offset_values": [0,1,0,0],
|
||||
"addrs": [[],[7,8],[],[]]
|
||||
}</textarea>
|
||||
<button id="offline-upload" type="button">上传并刷新列表</button>
|
||||
</details>
|
||||
<div id="offline-planning-status" class="empty-hint">等待离线规划操作。</div>
|
||||
<div id="offline-planning-summary"></div>
|
||||
<div class="offline-table-wrap">
|
||||
<table id="offline-send-table">
|
||||
<thead>
|
||||
<tr><th>序号</th><th>发送时间</th><th>轨迹时间</th><th>关节角(deg)</th><th>IO Mask</th><th>IO Value</th></tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<button id="offline-prev-page" type="button" class="secondary">上一页</button>
|
||||
<span id="offline-page-info">0 / 0</span>
|
||||
<button id="offline-next-page" type="button" class="secondary">下一页</button>
|
||||
</div>
|
||||
<pre id="offline-shot-events" class="response-body"></pre>
|
||||
<pre id="offline-artifact-paths" class="response-body"></pre>
|
||||
</section>
|
||||
<div id="debug-console-app">
|
||||
<div class="empty-hint">正在加载接口列表...</div>
|
||||
</div>
|
||||
@@ -39,6 +91,204 @@
|
||||
</h3>
|
||||
<ul id="history-list"></ul>
|
||||
</aside>
|
||||
<script id="debug-static-docs" type="application/json">
|
||||
{
|
||||
"GET /healthz": {
|
||||
"summary": "宿主健康检查",
|
||||
"description": "返回 replacement 宿主的基础健康状态,适合探活和部署验收。",
|
||||
"requestBodyFields": []
|
||||
},
|
||||
"GET /status": {
|
||||
"summary": "跳转状态页",
|
||||
"description": "短路由入口,浏览器访问后会跳转到静态状态监控页 `/status.html`。",
|
||||
"requestBodyFields": []
|
||||
},
|
||||
"GET /debug": {
|
||||
"summary": "跳转调试页",
|
||||
"description": "短路由入口,浏览器访问后会跳转到静态调试页 `/debug.html`。",
|
||||
"requestBodyFields": []
|
||||
},
|
||||
"GET /api/status/snapshot": {
|
||||
"summary": "读取状态快照",
|
||||
"description": "返回当前 ControllerClient 兼容层和 FANUC 运行时的聚合状态快照,供状态页和外部诊断读取。",
|
||||
"requestBodyFields": []
|
||||
},
|
||||
"POST /setup_robot/": {
|
||||
"summary": "按名称加载机器人",
|
||||
"description": "只加载机器人模型和 RobotConfig.json,不连接、不使能;成功后可进行离线规划。",
|
||||
"requestBodyFields": []
|
||||
},
|
||||
"GET /enable_robot/": {
|
||||
"summary": "使能机器人",
|
||||
"description": "调用兼容层使能机器人,并可通过查询参数传入控制器执行缓冲区大小。",
|
||||
"requestBodyFields": []
|
||||
},
|
||||
"GET /disable_robot/": {
|
||||
"summary": "去使能机器人",
|
||||
"description": "调用兼容层关闭机器人使能,通常用于结束调试或执行链路回收。",
|
||||
"requestBodyFields": []
|
||||
},
|
||||
"GET /stop_move/": {
|
||||
"summary": "停止当前运动",
|
||||
"description": "触发兼容层的 `StopMove` 语义,用于终止当前点动或轨迹执行。",
|
||||
"requestBodyFields": []
|
||||
},
|
||||
"POST /connect_robot/": {
|
||||
"summary": "连接机器人控制器",
|
||||
"description": "根据查询参数 `ip` 连接机器人控制器,建立后续命令与状态链路。",
|
||||
"requestBodyFields": []
|
||||
},
|
||||
"POST /disconnect_robot/": {
|
||||
"summary": "断开机器人连接",
|
||||
"description": "主动断开当前机器人控制器连接,释放兼容层运行时资源。",
|
||||
"requestBodyFields": []
|
||||
},
|
||||
"GET /robot_info/": {
|
||||
"summary": "读取机器人信息",
|
||||
"description": "返回机器人名称、自由度、速度倍率和服务端版本等聚合元信息。",
|
||||
"requestBodyFields": []
|
||||
},
|
||||
"POST /set_tcp/": {
|
||||
"summary": "设置 TCP 偏移",
|
||||
"description": "设置当前工具中心点的三维偏移,单位按旧接口约定使用毫米。",
|
||||
"requestBodyFields": [
|
||||
{ "name": "x", "type": "number", "required": true, "description": "TCP 的 X 偏移,单位 mm。" },
|
||||
{ "name": "y", "type": "number", "required": true, "description": "TCP 的 Y 偏移,单位 mm。" },
|
||||
{ "name": "z", "type": "number", "required": true, "description": "TCP 的 Z 偏移,单位 mm。" }
|
||||
]
|
||||
},
|
||||
"GET /get_tcp/": {
|
||||
"summary": "读取 TCP 偏移",
|
||||
"description": "返回当前生效的 TCP 三维偏移值。",
|
||||
"requestBodyFields": []
|
||||
},
|
||||
"POST /set_io/": {
|
||||
"summary": "设置数字 IO",
|
||||
"description": "通过查询参数设置指定端口的 IO 值和 IO 类型,常用于现场脉冲或夹具联动调试。",
|
||||
"requestBodyFields": []
|
||||
},
|
||||
"GET /get_io/": {
|
||||
"summary": "读取数字 IO",
|
||||
"description": "读取指定端口和类型的当前 IO 值,便于确认控制柜当前输入输出状态。",
|
||||
"requestBodyFields": []
|
||||
},
|
||||
"GET /get_joint_position/": {
|
||||
"summary": "读取当前关节位置",
|
||||
"description": "返回旧 HTTP 层兼容的关节数组外形,便于现场确认当前姿态。",
|
||||
"requestBodyFields": []
|
||||
},
|
||||
"POST /move_joint/": {
|
||||
"summary": "执行关节点到点运动",
|
||||
"description": "根据目标关节数组执行一次关节空间点到点运动;实际执行链路会进入兼容层与 J519 运行时。",
|
||||
"requestBodyFields": [
|
||||
{ "name": "joints", "type": "array<number>", "required": true, "description": "目标关节数组,按关节顺序给出数值。" }
|
||||
]
|
||||
},
|
||||
"POST /move_pose/": {
|
||||
"summary": "执行笛卡尔点到点运动",
|
||||
"description": "以 `[x,y,z,w,p,r]` 直角位姿执行点到点移动。六个字段必须完整提供,`x/y/z` 使用 mm,`w/p/r` 使用 deg,并受当前入口限幅约束。",
|
||||
"requestBodyFields": [
|
||||
{ "name": "x", "type": "number", "required": true, "description": "TCP X,单位 mm,当前入口限制在 -1000 到 1000 之间。" },
|
||||
{ "name": "y", "type": "number", "required": true, "description": "TCP Y,单位 mm,当前入口限制在 -1000 到 1000 之间。" },
|
||||
{ "name": "z", "type": "number", "required": true, "description": "TCP Z,单位 mm,当前入口限制在 0 到 1200 之间。" },
|
||||
{ "name": "w", "type": "number", "required": true, "description": "姿态 W,单位 deg,当前入口限制在 -180 到 180 之间。" },
|
||||
{ "name": "p", "type": "number", "required": true, "description": "姿态 P,单位 deg,当前入口限制在 -90 到 90 之间。" },
|
||||
{ "name": "r", "type": "number", "required": true, "description": "姿态 R,单位 deg,当前入口限制在 -180 到 180 之间。" }
|
||||
]
|
||||
},
|
||||
"POST /get_nearest_ik/": {
|
||||
"summary": "求最近 IK",
|
||||
"description": "根据目标位姿和 seed 关节数组求最近一组 IK 解;当前若底层未实现,会返回 501。",
|
||||
"requestBodyFields": [
|
||||
{ "name": "pose", "type": "array<number>", "required": true, "description": "目标位姿数组,约定为 `[x,y,z,qx,qy,qz,qw]`。" },
|
||||
{ "name": "seed", "type": "array<number>", "required": true, "description": "IK seed 关节数组,用作就近求解参考。" }
|
||||
]
|
||||
},
|
||||
"GET /list_flyShotTraj/": {
|
||||
"summary": "列出已上传飞拍轨迹",
|
||||
"description": "返回当前运行目录中已经保存的飞拍轨迹名称列表。",
|
||||
"requestBodyFields": []
|
||||
},
|
||||
"POST /execute_trajectory/": {
|
||||
"summary": "执行普通轨迹",
|
||||
"description": "执行普通关节轨迹。该接口兼容两种历史请求体形态:一是对象形态 `{ waypoints, method, save_traj }`,二是直接传纯数组路点;查询参数里的 `method` 和 `save_traj` 仍可覆盖请求体值。",
|
||||
"requestBodyFields": [
|
||||
{ "name": "waypoints", "type": "array<array<number>> | array<object>", "required": true, "description": "历史兼容对象形态中的轨迹路点集合;每个路点既可直接是关节数组,也可写成 `{ joints: [...] }`。" },
|
||||
{ "name": "method", "type": "string", "required": false, "description": "对象形态中的轨迹规划方法,默认 `icsp`。" },
|
||||
{ "name": "save_traj", "type": "boolean", "required": false, "description": "对象形态中的是否保存轨迹标记,默认 `false`。" },
|
||||
{ "name": "根数组", "type": "array<array<number>> | array<object>", "required": true, "description": "纯数组形态时,请直接提交路点数组本身,而不是对象包裹层。" },
|
||||
{ "name": "根数组[].joints", "type": "array<number>", "required": false, "description": "当路点元素是对象形态时,兼容层会读取其中的 `joints` 数组。" }
|
||||
]
|
||||
},
|
||||
"POST /upload_flyshot/": {
|
||||
"summary": "上传飞拍轨迹",
|
||||
"description": "上传一条带拍照触发信息的飞拍轨迹。`waypoints`、`shot_flags`、`offset_values`、`addrs` 四组数据长度必须一致。",
|
||||
"requestBodyFields": [
|
||||
{ "name": "name", "type": "string", "required": true, "description": "飞拍轨迹名称,后续执行、校验和删除都通过它定位。" },
|
||||
{ "name": "waypoints", "type": "array<array<number>>", "required": true, "description": "飞拍轨迹的关节路点集合。" },
|
||||
{ "name": "shot_flags", "type": "array<boolean>", "required": true, "description": "与每个路点对齐的拍照触发标志数组。" },
|
||||
{ "name": "offset_values", "type": "array<number>", "required": true, "description": "与每个路点对齐的触发偏移周期集合。" },
|
||||
{ "name": "addrs", "type": "array<array<integer>>", "required": true, "description": "与每个路点对齐的 IO 地址组集合。" }
|
||||
]
|
||||
},
|
||||
"POST /execute_flyshot/": {
|
||||
"summary": "执行已上传飞拍轨迹",
|
||||
"description": "按名称执行一条已上传的飞拍轨迹,可控制是否先移动到起点、是否复用缓存、是否保存轨迹以及是否等待执行完成。",
|
||||
"requestBodyFields": [
|
||||
{ "name": "name", "type": "string", "required": true, "description": "要执行的飞拍轨迹名称。" },
|
||||
{ "name": "move_to_start", "type": "boolean", "required": false, "description": "是否先用临时 PTP 轨迹移动到飞拍起点,默认 `true`。" },
|
||||
{ "name": "method", "type": "string", "required": false, "description": "飞拍轨迹生成方法,默认 `icsp`。" },
|
||||
{ "name": "save_traj", "type": "boolean", "required": false, "description": "是否保存轨迹信息,默认 `true`。" },
|
||||
{ "name": "use_cache", "type": "boolean", "required": false, "description": "是否优先复用已缓存的轨迹结果,默认 `true`。" },
|
||||
{ "name": "wait", "type": "boolean", "required": false, "description": "是否等待整条飞拍轨迹执行完成后再返回,默认 `true`。" }
|
||||
]
|
||||
},
|
||||
"POST /save_traj_info/": {
|
||||
"summary": "离线生成最终发送轨迹",
|
||||
"description": "仅要求机器人模型已加载;完成规划、触发绑定、最终 8ms 序列生成和文件导出,并返回完整规划结果。",
|
||||
"requestBodyFields": [
|
||||
{ "name": "name", "type": "string", "required": true, "description": "目标飞拍轨迹名称。" },
|
||||
{ "name": "method", "type": "string", "required": false, "description": "轨迹规划方法,默认 `icsp`。" }
|
||||
]
|
||||
},
|
||||
"POST /is_flyShotTrajValid/": {
|
||||
"summary": "校验飞拍轨迹有效性",
|
||||
"description": "检查指定飞拍轨迹是否可生成有效执行结果,并返回轨迹时长;底层未实现时会返回 501。",
|
||||
"requestBodyFields": [
|
||||
{ "name": "name", "type": "string", "required": true, "description": "要校验的飞拍轨迹名称。" },
|
||||
{ "name": "method", "type": "string", "required": false, "description": "轨迹规划方法,默认 `icsp`。" },
|
||||
{ "name": "save_traj", "type": "boolean", "required": false, "description": "是否同时保存轨迹信息,默认 `true`。" }
|
||||
]
|
||||
},
|
||||
"POST /set_speedRatio/": {
|
||||
"summary": "设置运行速度倍率",
|
||||
"description": "设置机器人运行速度倍率。当前接口要求 `speed` 在 `0.01` 到 `1.0` 之间,否则会直接返回 400。",
|
||||
"requestBodyFields": [
|
||||
{ "name": "speed", "type": "number", "required": true, "description": "目标速度倍率,允许范围为 0.01 到 1.0。" }
|
||||
]
|
||||
},
|
||||
"POST /delete_flyshot/": {
|
||||
"summary": "删除飞拍轨迹",
|
||||
"description": "按名称删除一条已上传的飞拍轨迹及其持久化记录。",
|
||||
"requestBodyFields": [
|
||||
{ "name": "name", "type": "string", "required": true, "description": "要删除的飞拍轨迹名称。" }
|
||||
]
|
||||
},
|
||||
"POST /init_mpc_robot": {
|
||||
"summary": "真机优先初始化",
|
||||
"description": "执行 `加载模型 -> 连接真机 -> EnableRobot(8)`;连接失败时模型仍保持 OfflineReady。",
|
||||
"requestBodyFields": [
|
||||
{ "name": "robot_name", "type": "string", "required": true, "description": "机器人名称,用于加载模型与配置。" },
|
||||
{ "name": "robot_ip", "type": "string", "required": true, "description": "机器人控制器 IP。" }
|
||||
]
|
||||
},
|
||||
"GET /get_pose": {
|
||||
"summary": "读取当前末端位姿",
|
||||
"description": "返回当前末端位姿数组,便于确认机器人 TCP 的当前空间位置。",
|
||||
"requestBodyFields": []
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script src="/assets/debug.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -57,6 +57,73 @@
|
||||
<dt>已上传轨迹</dt><dd id="trajectories" class="empty">--</dd>
|
||||
</dl>
|
||||
</section>
|
||||
<section class="jog-panel">
|
||||
<h2>直角坐标点动</h2>
|
||||
<div class="jog-toolbar">
|
||||
<label class="step-field" for="linear-step">
|
||||
<span>平移步长 mm</span>
|
||||
<input id="linear-step" type="number" min="0.1" max="20" step="0.1" value="1">
|
||||
</label>
|
||||
<label class="step-field" for="angular-step">
|
||||
<span>姿态步长 deg</span>
|
||||
<input id="angular-step" type="number" min="0.1" max="20" step="0.1" value="1">
|
||||
</label>
|
||||
<div id="jog-status" class="jog-status">等待点动输入</div>
|
||||
</div>
|
||||
<div class="jog-body">
|
||||
<article class="stick-card">
|
||||
<div class="stick-head">
|
||||
<h3>XYZ 平移摇杆</h3>
|
||||
<span>mm</span>
|
||||
</div>
|
||||
<div class="stick-console" aria-label="XYZ 平移摇杆">
|
||||
<button class="jog-button axis-button up" type="button" data-axis="y" data-direction="1">Y+</button>
|
||||
<button class="jog-button axis-button left" type="button" data-axis="x" data-direction="-1">X-</button>
|
||||
<div class="center-stack">
|
||||
<button class="jog-button mini-button" type="button" data-axis="z" data-direction="1">Z+</button>
|
||||
<button class="jog-button mini-button" type="button" data-axis="z" data-direction="-1">Z-</button>
|
||||
</div>
|
||||
<button class="jog-button axis-button right" type="button" data-axis="x" data-direction="1">X+</button>
|
||||
<button class="jog-button axis-button down" type="button" data-axis="y" data-direction="-1">Y-</button>
|
||||
</div>
|
||||
<div class="hint-row">
|
||||
<span>X 左右</span>
|
||||
<span>Y 上下</span>
|
||||
<span>Z 中心</span>
|
||||
</div>
|
||||
</article>
|
||||
<article class="stick-card">
|
||||
<div class="stick-head">
|
||||
<h3>WPR 姿态摇杆</h3>
|
||||
<span>deg</span>
|
||||
</div>
|
||||
<div class="stick-console" aria-label="WPR 姿态摇杆">
|
||||
<button class="jog-button axis-button up" type="button" data-axis="p" data-direction="1">P+</button>
|
||||
<button class="jog-button axis-button left" type="button" data-axis="w" data-direction="-1">W-</button>
|
||||
<div class="center-stack">
|
||||
<button class="jog-button mini-button" type="button" data-axis="r" data-direction="-1">R-</button>
|
||||
<button class="jog-button mini-button" type="button" data-axis="r" data-direction="1">R+</button>
|
||||
</div>
|
||||
<button class="jog-button axis-button right" type="button" data-axis="w" data-direction="1">W+</button>
|
||||
<button class="jog-button axis-button down" type="button" data-axis="p" data-direction="-1">P-</button>
|
||||
</div>
|
||||
<div class="hint-row">
|
||||
<span>W 左右</span>
|
||||
<span>P 上下</span>
|
||||
<span>R 中心</span>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<div class="log-panel">
|
||||
<div class="log-head">
|
||||
<h3>请求日志</h3>
|
||||
<button id="clear-log" class="clear-button" type="button">清空</button>
|
||||
</div>
|
||||
<div id="request-log" class="request-log" aria-live="polite">
|
||||
<div class="log-empty">暂无请求记录</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
<script src="/assets/status.js" defer></script>
|
||||
|
||||
@@ -8,13 +8,13 @@ namespace Flyshot.Core.Tests;
|
||||
public sealed class ConfigCompatibilityTests
|
||||
{
|
||||
/// <summary>
|
||||
/// 验证现有 RobotConfig.json 能被加载,并保持关键机器人参数与飞拍程序内容不变。
|
||||
/// 验证现有旧样本配置能被加载,并保持关键机器人参数与飞拍程序内容不变。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RobotConfigLoader_LoadsLegacyRobotConfig_AndPreservesPrograms()
|
||||
{
|
||||
var workspaceRoot = GetWorkspaceRoot();
|
||||
var configPath = Path.Combine(workspaceRoot, "Rvbust", "EOL10_EAU_0", "RobotConfig.json");
|
||||
var configPath = Path.Combine(workspaceRoot, "Rvbust", "EOL10_EAU_0", "EOL10_EAU_0.json");
|
||||
|
||||
var loaded = new RobotConfigLoader().Load(configPath);
|
||||
|
||||
@@ -75,6 +75,7 @@ public sealed class ConfigCompatibilityTests
|
||||
Assert.Equal(0.5, loaded.Robot.AccLimitScale);
|
||||
Assert.Equal(0.25, loaded.Robot.JerkLimitScale);
|
||||
Assert.Equal(1.0, loaded.Robot.PlanningSpeedScale);
|
||||
Assert.Equal(1.0, loaded.Robot.SpeedRatio);
|
||||
Assert.True(loaded.Robot.SmoothStartStopTiming);
|
||||
Assert.Equal([0, 0, 0], program.OffsetValues);
|
||||
Assert.All(program.AddressGroups, group => Assert.Empty(group.Addresses));
|
||||
@@ -85,6 +86,46 @@ public sealed class ConfigCompatibilityTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证 RobotConfig.json 可以显式配置运行时默认速度倍率,供 MoveJoint 和后续执行链路初始化使用。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RobotConfigLoader_LoadsRuntimeSpeedRatio()
|
||||
{
|
||||
var tempRoot = CreateTempDirectory();
|
||||
try
|
||||
{
|
||||
var configPath = Path.Combine(tempRoot, "legacy.json");
|
||||
File.WriteAllText(
|
||||
configPath,
|
||||
"""
|
||||
{
|
||||
"robot": {
|
||||
"use_do": true,
|
||||
"io_keep_cycles": 2,
|
||||
"acc_limit": 1.0,
|
||||
"jerk_limit": 1.0,
|
||||
"speed_ratio": 0.65
|
||||
},
|
||||
"flying_shots": {
|
||||
"demo": {
|
||||
"traj_waypoints": [[0, 1], [2, 3], [4, 5], [6, 7]],
|
||||
"shot_flags": [false, false, false, false]
|
||||
}
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
var loaded = new RobotConfigLoader().Load(configPath);
|
||||
|
||||
Assert.Equal(0.65, loaded.Robot.SpeedRatio, precision: 6);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证 RobotConfig.json 可以显式配置规划限速倍率,且该倍率独立于运行时 J519 速度倍率。
|
||||
/// </summary>
|
||||
@@ -212,7 +253,7 @@ public sealed class ConfigCompatibilityTests
|
||||
public void RobotModelLoader_LoadsRobotProfile_WithJointLimitsAndCoupling()
|
||||
{
|
||||
var replacementRoot = GetReplacementRoot();
|
||||
var modelPath = Path.Combine(replacementRoot, "Config", "LR_Mate_200iD_7L.json");
|
||||
var modelPath = Path.Combine(replacementRoot, "Config", "Models", "LR_Mate_200iD_7L.json");
|
||||
|
||||
var profile = new RobotModelLoader().LoadProfile(modelPath);
|
||||
|
||||
@@ -236,7 +277,7 @@ public sealed class ConfigCompatibilityTests
|
||||
public void RobotModelLoader_AppliesAccelerationAndJerkScales()
|
||||
{
|
||||
var replacementRoot = GetReplacementRoot();
|
||||
var modelPath = Path.Combine(replacementRoot, "Config", "LR_Mate_200iD_7L.json");
|
||||
var modelPath = Path.Combine(replacementRoot, "Config", "Models", "LR_Mate_200iD_7L.json");
|
||||
|
||||
var profile = new RobotModelLoader().LoadProfile(modelPath, accLimitScale: 0.5, jerkLimitScale: 0.25);
|
||||
|
||||
@@ -251,7 +292,7 @@ public sealed class ConfigCompatibilityTests
|
||||
public void RobotModelLoader_LoadsProfileAndKinematics_FromSingleParse()
|
||||
{
|
||||
var replacementRoot = GetReplacementRoot();
|
||||
var modelPath = Path.Combine(replacementRoot, "Config", "LR_Mate_200iD_7L.json");
|
||||
var modelPath = Path.Combine(replacementRoot, "Config", "Models", "LR_Mate_200iD_7L.json");
|
||||
|
||||
var loaded = new RobotModelLoader().LoadProfileAndKinematics(modelPath, accLimitScale: 0.5, jerkLimitScale: 0.25);
|
||||
|
||||
|
||||
@@ -74,7 +74,8 @@ public sealed class ControllerClientCompatConfigRootTests
|
||||
triggerSampleIndexOffsetCycles: 7,
|
||||
accLimitScale: 1.0,
|
||||
jerkLimitScale: 1.0,
|
||||
adaptIcspTryNum: 5);
|
||||
adaptIcspTryNum: 5,
|
||||
speedRatio: 0.65);
|
||||
var trajectory = TestRobotFactory.CreateUploadedTrajectoryWithSingleShot();
|
||||
|
||||
store.Save("FANUC_LR_Mate_200iD", settings, trajectory);
|
||||
@@ -85,6 +86,7 @@ public sealed class ControllerClientCompatConfigRootTests
|
||||
var loaded = store.LoadAll("FANUC_LR_Mate_200iD", out var loadedSettings);
|
||||
Assert.NotNull(loadedSettings);
|
||||
Assert.Equal(7, loadedSettings.TriggerSampleIndexOffsetCycles);
|
||||
Assert.Equal(0.65, loadedSettings.SpeedRatio, precision: 6);
|
||||
Assert.Contains(trajectory.Name, loaded);
|
||||
|
||||
store.Delete("FANUC_LR_Mate_200iD", trajectory.Name);
|
||||
|
||||
@@ -16,15 +16,79 @@ namespace Flyshot.Core.Tests;
|
||||
/// </summary>
|
||||
public sealed class FanucControllerRuntimeDenseTests
|
||||
{
|
||||
/// <summary>
|
||||
/// 验证飞拍输出选择贯通到 J519 命令,DO/RO 模式都输出两周期脉冲并在同一路清零。
|
||||
/// </summary>
|
||||
/// <param name="useDo">true 选择 DO,false 选择 RO。</param>
|
||||
/// <param name="expectedIoType">预期 J519 写入类型,DO 为 2,RO 为 9。</param>
|
||||
/// <param name="usePreparedExecution">是否使用上游已完成触发绑定的最终发送队列。</param>
|
||||
[Theory]
|
||||
[InlineData(true, 2, false)]
|
||||
[InlineData(false, 9, false)]
|
||||
[InlineData(true, 2, true)]
|
||||
[InlineData(false, 9, true)]
|
||||
public void ExecuteTrajectory_FlyshotOutputSelection_PreservesPulseAndClear(bool useDo, int expectedIoType, bool usePreparedExecution)
|
||||
{
|
||||
using var commandClient = new FanucCommandClient();
|
||||
using var stateClient = new FanucStateClient();
|
||||
using var j519Client = new FanucJ519Client();
|
||||
using var runtime = new FanucControllerRuntime(commandClient, stateClient, j519Client);
|
||||
runtime.ResetRobot(TestRobotFactory.CreateRobotProfile(), "FANUC_LR_Mate_200iD");
|
||||
j519Client.EnableCommandHistoryForTests();
|
||||
ForceRealModeEnabled(runtime, speedRatio: 1.0);
|
||||
|
||||
var trigger = new TrajectoryDoEvent(waypointIndex: 1, triggerTime: 0.008,
|
||||
offsetCycles: 0, holdCycles: 2, addressGroup: new IoAddressGroup([1]), useDo: useDo);
|
||||
var samples = Enumerable.Range(0, 4)
|
||||
.Select(index => new FlyshotPreparedSample(index, index * 0.008, index * 0.008,
|
||||
1.0, [0.0, 0.0, 0.0, 0.0, 0.0, 0.0])).ToArray();
|
||||
var preparedExecution = usePreparedExecution
|
||||
? new FlyshotPreparedExecution(samples,
|
||||
[new FlyshotPreparedTriggerBinding(trigger, samples[1], 1, true)],
|
||||
Array.Empty<IReadOnlyList<double>>(), Array.Empty<IReadOnlyList<double>>(),
|
||||
requestSpeedRatio: 1.0, finalSpeedRatio: 1.0, finalDurationSeconds: 0.024, stretchIterationCount: 0)
|
||||
: null;
|
||||
|
||||
// 静止轨迹隔离运动算法影响,仅验证触发、保持和清零对应的协议字段。
|
||||
var result = new TrajectoryResult(
|
||||
programName: "output-selection",
|
||||
method: PlanningMethod.Icsp,
|
||||
isValid: true,
|
||||
duration: TimeSpan.FromSeconds(0.024),
|
||||
shotEvents: Array.Empty<ShotEvent>(),
|
||||
triggerTimeline: [trigger],
|
||||
artifacts: Array.Empty<TrajectoryArtifact>(),
|
||||
failureReason: null,
|
||||
usedCache: false,
|
||||
originalWaypointCount: 2,
|
||||
plannedWaypointCount: 2,
|
||||
denseJointTrajectory:
|
||||
[
|
||||
new double[] { 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 },
|
||||
new double[] { 0.024, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 }
|
||||
],
|
||||
preparedFlyshotExecution: preparedExecution);
|
||||
|
||||
runtime.ExecuteTrajectory(result, [0.0, 0.0, 0.0, 0.0, 0.0, 0.0]);
|
||||
WaitUntilIdle(runtime);
|
||||
|
||||
var commands = j519Client.GetCommandHistoryForTests();
|
||||
Assert.True(commands.Count >= 4);
|
||||
Assert.All(commands, command => Assert.Equal((byte)expectedIoType, command.WriteIoType));
|
||||
Assert.All(commands, command => Assert.Equal((ushort)1, command.WriteIoIndex));
|
||||
Assert.Equal(new ushort[] { 0, 1, 1, 1 }, commands.Take(4).Select(command => command.WriteIoMask));
|
||||
Assert.Equal(new ushort[] { 0, 1, 1, 0 }, commands.Take(4).Select(command => command.WriteIoValue));
|
||||
}
|
||||
|
||||
private const double SmoothPtpVelocityShapeCoefficient = 2.1875;
|
||||
private const double SmoothPtpAccelerationShapeCoefficient = 7.513188404399293;
|
||||
private const double SmoothPtpJerkShapeCoefficient = 52.5;
|
||||
|
||||
/// <summary>
|
||||
/// 验证真机 J519 会预生成按 8ms 轨迹映射的命令队列,并输出角度制目标。
|
||||
/// 验证真机 J519 会直接消费已经按 8ms 生成好的稠密轨迹,不再按运行时 speedRatio 临场重采样。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ExecuteTrajectory_WithDenseWaypoints_RealMode_ResamplesBySpeedRatioAndConvertsRadiansToDegrees()
|
||||
public void ExecuteTrajectory_WithDenseWaypoints_RealMode_UsesPlannedDenseRowsAndConvertsRadiansToDegrees()
|
||||
{
|
||||
using var commandClient = new FanucCommandClient();
|
||||
using var stateClient = new FanucStateClient();
|
||||
@@ -38,8 +102,8 @@ public sealed class FanucControllerRuntimeDenseTests
|
||||
var denseTrajectory = new[]
|
||||
{
|
||||
new[] { 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 },
|
||||
new[] { 0.008, Math.PI / 2.0, 0.0, 0.0, 0.0, 0.0, 0.0 },
|
||||
new[] { 0.016, Math.PI, 0.0, 0.0, 0.0, 0.0, 0.0 }
|
||||
new[] { 0.008, DegreesToRadians(0.08), 0.0, 0.0, 0.0, 0.0, 0.0 },
|
||||
new[] { 0.016, DegreesToRadians(0.16), 0.0, 0.0, 0.0, 0.0, 0.0 }
|
||||
};
|
||||
|
||||
var result = new TrajectoryResult(
|
||||
@@ -56,13 +120,24 @@ public sealed class FanucControllerRuntimeDenseTests
|
||||
plannedWaypointCount: 4,
|
||||
denseJointTrajectory: denseTrajectory);
|
||||
|
||||
runtime.ExecuteTrajectory(result, [Math.PI, 0.0, 0.0, 0.0, 0.0, 0.0]);
|
||||
runtime.ExecuteTrajectory(result, [DegreesToRadians(0.16), 0.0, 0.0, 0.0, 0.0, 0.0]);
|
||||
WaitUntilIdle(runtime);
|
||||
|
||||
var commands = j519Client.GetCommandHistoryForTests();
|
||||
Assert.Equal(5, commands.Count);
|
||||
Assert.Equal(4, commands.Count);
|
||||
Assert.All(commands, static command => Assert.Equal(0u, command.Sequence));
|
||||
Assert.Equal([0.0, 45.0, 90.0, 135.0, 180.0], commands.Select(static command => command.TargetJoints[0]));
|
||||
var expectedJ1Targets = new[] { 0.0, 0.08, 0.16 };
|
||||
var actualJ1Targets = commands.Take(3).Select(static command => command.TargetJoints[0]).ToArray();
|
||||
Assert.Equal(expectedJ1Targets.Length, actualJ1Targets.Length);
|
||||
for (var index = 0; index < expectedJ1Targets.Length; index++)
|
||||
{
|
||||
Assert.Equal(expectedJ1Targets[index], actualJ1Targets[index], precision: 6);
|
||||
}
|
||||
Assert.All(commands.Take(3), static command => Assert.Equal(0, command.LastData));
|
||||
Assert.Equal(1, commands[^1].LastData);
|
||||
Assert.Equal(0, commands[^1].WriteIoMask);
|
||||
Assert.Equal(0, commands[^1].WriteIoValue);
|
||||
Assert.Equal(0.16, commands[^1].TargetJoints[0], precision: 6);
|
||||
Assert.False(j519Client.IsCommandQueueDrainedForTests());
|
||||
}
|
||||
|
||||
@@ -90,8 +165,8 @@ public sealed class FanucControllerRuntimeDenseTests
|
||||
var denseTrajectory = new[]
|
||||
{
|
||||
new[] { 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 },
|
||||
new[] { 0.008, Math.PI / 2.0, 0.0, 0.0, 0.0, 0.0, 0.0 },
|
||||
new[] { 0.016, Math.PI, 0.0, 0.0, 0.0, 0.0, 0.0 }
|
||||
new[] { 0.008, DegreesToRadians(0.08), 0.0, 0.0, 0.0, 0.0, 0.0 },
|
||||
new[] { 0.016, DegreesToRadians(0.16), 0.0, 0.0, 0.0, 0.0, 0.0 }
|
||||
};
|
||||
var result = new TrajectoryResult(
|
||||
programName: "wait-drain",
|
||||
@@ -107,13 +182,13 @@ public sealed class FanucControllerRuntimeDenseTests
|
||||
plannedWaypointCount: 4,
|
||||
denseJointTrajectory: denseTrajectory);
|
||||
|
||||
var executeTask = Task.Run(() => runtime.ExecuteTrajectory(result, [Math.PI, 0.0, 0.0, 0.0, 0.0, 0.0]), cts.Token);
|
||||
await WaitUntilAsync(() => j519Client.GetCommandHistoryForTests().Count == 3, cts.Token);
|
||||
var executeTask = Task.Run(() => runtime.ExecuteTrajectory(result, [DegreesToRadians(0.16), 0.0, 0.0, 0.0, 0.0, 0.0]), cts.Token);
|
||||
await WaitUntilAsync(() => j519Client.GetCommandHistoryForTests().Count == 4, cts.Token);
|
||||
|
||||
// 只有机器人状态包把队列全部取出后,ExecuteTrajectory 才能向上层返回。
|
||||
Assert.False(executeTask.IsCompleted);
|
||||
|
||||
for (uint sequence = 100; sequence < 103; sequence++)
|
||||
for (uint sequence = 100; sequence < 104; sequence++)
|
||||
{
|
||||
await SendStatusPacketAsync(server, initResult.RemoteEndPoint, sequence, cts.Token);
|
||||
_ = await server.ReceiveAsync(cts.Token);
|
||||
@@ -184,9 +259,9 @@ public sealed class FanucControllerRuntimeDenseTests
|
||||
var timingLines = File.ReadAllLines(timingPath);
|
||||
var jerkLines = File.ReadAllLines(jerkPath);
|
||||
var shotEventsJson = File.ReadAllText(shotEventsPath);
|
||||
Assert.Equal(commands.Count, pointLines.Length);
|
||||
Assert.Equal(commands.Count, timingLines.Length);
|
||||
Assert.Equal(Math.Max(0, commands.Count - 1), jerkLines.Length);
|
||||
Assert.Equal(denseTrajectory.Length, pointLines.Length);
|
||||
Assert.Equal(denseTrajectory.Length, timingLines.Length);
|
||||
Assert.Equal(Math.Max(0, denseTrajectory.Length - 1), jerkLines.Length);
|
||||
|
||||
var firstColumns = ParseColumns(pointLines[0]);
|
||||
var secondColumns = ParseColumns(pointLines[1]);
|
||||
@@ -200,15 +275,17 @@ public sealed class FanucControllerRuntimeDenseTests
|
||||
var firstTimingColumns = ParseColumns(timingLines[0]);
|
||||
var secondTimingColumns = ParseColumns(timingLines[1]);
|
||||
var lastTimingColumns = ParseColumns(timingLines[^1]);
|
||||
Assert.Equal(4, firstTimingColumns.Length);
|
||||
Assert.Equal(6, firstTimingColumns.Length);
|
||||
Assert.Equal(0.0, firstTimingColumns[0], precision: 6);
|
||||
Assert.Equal(0.0, firstTimingColumns[1], precision: 6);
|
||||
Assert.Equal(0.0, firstTimingColumns[2], precision: 6);
|
||||
Assert.Equal(0.5, firstTimingColumns[3], precision: 6);
|
||||
Assert.Equal(1.0, firstTimingColumns[3], precision: 6);
|
||||
Assert.Equal(0.5, firstTimingColumns[4], precision: 6);
|
||||
Assert.Equal(0.0, firstTimingColumns[5], precision: 6);
|
||||
Assert.Equal(1.0, secondTimingColumns[0], precision: 6);
|
||||
Assert.Equal(0.008, secondTimingColumns[1], precision: 6);
|
||||
Assert.Equal(0.004, secondTimingColumns[2], precision: 6);
|
||||
Assert.Equal(commands.Count - 1, lastTimingColumns[0], precision: 6);
|
||||
Assert.Equal(0.008, secondTimingColumns[2], precision: 6);
|
||||
Assert.Equal(denseTrajectory.Length - 1, lastTimingColumns[0], precision: 6);
|
||||
Assert.Equal(0.016, lastTimingColumns[2], precision: 6);
|
||||
|
||||
var firstJerkColumns = ParseColumns(jerkLines[0]);
|
||||
@@ -355,6 +432,7 @@ public sealed class FanucControllerRuntimeDenseTests
|
||||
planningSpeedScale: fullSpeedSettings.PlanningSpeedScale);
|
||||
var preparedExecution = FlyshotExecutionSendSequenceBuilder.Build(
|
||||
fixture.Robot,
|
||||
bundle.ExecutionTrajectory,
|
||||
bundle.Result,
|
||||
fixture.Robot.ServoPeriod.TotalSeconds,
|
||||
speedRatio);
|
||||
@@ -386,8 +464,11 @@ public sealed class FanucControllerRuntimeDenseTests
|
||||
|
||||
var commands = j519Client.GetCommandHistoryForTests();
|
||||
Assert.NotEmpty(commands);
|
||||
Assert.Equal(preparedExecution.Samples.Count, commands.Count);
|
||||
AssertJointDegreesEqual(result.DenseJointTrajectory[0].Skip(1).ToArray(), commands[0].TargetJoints);
|
||||
Assert.Equal(preparedExecution.Samples.Count + 1, commands.Count);
|
||||
Assert.Equal(speedRatio, preparedExecution.RequestSpeedRatio, precision: 6);
|
||||
Assert.Equal(0, preparedExecution.StretchIterationCount);
|
||||
Assert.Equal(preparedExecution.Samples[0].JointsDegrees, commands[0].TargetJoints);
|
||||
Assert.Equal(1, commands[^1].LastData);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -458,18 +539,19 @@ public sealed class FanucControllerRuntimeDenseTests
|
||||
WaitUntilIdle(runtime);
|
||||
|
||||
var commands = j519Client.GetCommandHistoryForTests();
|
||||
Assert.Equal(preparedSamples.Length, commands.Count);
|
||||
Assert.Equal([1.0, 2.0, 3.0], commands.Select(static command => command.TargetJoints[0]));
|
||||
Assert.Equal(preparedSamples.Length + 1, commands.Count);
|
||||
Assert.Equal([1.0, 2.0, 3.0], commands.Take(preparedSamples.Length).Select(static command => command.TargetJoints[0]));
|
||||
Assert.Equal(1, commands[^1].LastData);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证 MoveJoint 会按抓包确认的点到点临时轨迹生成稠密 J519 目标,并继续叠加 speed_ratio 重采样。
|
||||
/// 验证 MoveJoint 会在生成阶段折算 speed_ratio,运行时只发送已经规划好的 8ms 稠密点列。
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(1.0)]
|
||||
[InlineData(0.7)]
|
||||
[InlineData(0.5)]
|
||||
public void MoveJoint_RealMode_GeneratesTemporaryPtpTrajectoryAndResamplesBySpeedRatio(double speedRatio)
|
||||
public void MoveJoint_RealMode_GeneratesTemporaryPtpTrajectoryWithSpeedRatioAlreadyPlanned(double speedRatio)
|
||||
{
|
||||
using var commandClient = new FanucCommandClient();
|
||||
using var stateClient = new FanucStateClient();
|
||||
@@ -512,19 +594,59 @@ public sealed class FanucControllerRuntimeDenseTests
|
||||
WaitUntilIdle(runtime);
|
||||
|
||||
var commands = j519Client.GetCommandHistoryForTests();
|
||||
Assert.Equal(expectedResult.DenseJointTrajectory!.Count, commands.Count);
|
||||
Assert.Equal(expectedResult.DenseJointTrajectory!.Count + 1, commands.Count);
|
||||
AssertJointDegreesEqual(startJoints, commands[0].TargetJoints);
|
||||
AssertJointDegreesEqual(targetJoints, commands[^1].TargetJoints);
|
||||
Assert.Equal(1, commands[^1].LastData);
|
||||
Assert.All(commands.Take(commands.Count - 1), static command => Assert.Equal(0, command.LastData));
|
||||
|
||||
var middleAlpha = ComputeLineAlpha(commands[commands.Count / 2].TargetJoints, startJoints, targetJoints);
|
||||
var middleAlpha = ComputeLineAlpha(commands[(commands.Count - 1) / 2].TargetJoints, startJoints, targetJoints);
|
||||
Assert.InRange(middleAlpha, 0.45, 0.55);
|
||||
|
||||
var earlyAlpha = ComputeLineAlpha(commands[Math.Min(5, commands.Count - 1)].TargetJoints, startJoints, targetJoints);
|
||||
Assert.InRange(earlyAlpha, 0.0, 0.02);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证关闭命令流结束开关时,MoveJoint 保持旧队列长度和最终目标保持语义。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MoveJointTrajectoryGenerator_LowerSpeedUsesMoreSamplesWithoutFixedCountContract()
|
||||
public void MoveJoint_RealMode_WithEndCommandStreamDisabled_KeepsLegacyQueueShape()
|
||||
{
|
||||
using var commandClient = new FanucCommandClient();
|
||||
using var stateClient = new FanucStateClient();
|
||||
using var j519Client = new FanucJ519Client(endCommandStreamAfterQueueDrained: false);
|
||||
using var runtime = new FanucControllerRuntime(commandClient, stateClient, j519Client);
|
||||
var service = CreateCompatService(runtime);
|
||||
var startJoints = new[] { 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 };
|
||||
var targetJoints = new[] { 0.2, -0.1, 0.05, 0.0, 0.0, 0.1 };
|
||||
|
||||
service.SetUpRobot("FANUC_LR_Mate_200iD");
|
||||
j519Client.EnableCommandHistoryForTests();
|
||||
ForceRealModeEnabled(runtime, speedRatio: 1.0);
|
||||
SetPrivateField(runtime, "_jointPositions", startJoints);
|
||||
|
||||
var options = new ControllerClientCompatOptions
|
||||
{
|
||||
ConfigRoot = TestRobotFactory.GetConfigRoot()
|
||||
};
|
||||
var robot = new ControllerClientCompatRobotCatalog(options, new RobotModelLoader())
|
||||
.LoadProfile("FANUC_LR_Mate_200iD", accLimitScale: 1.0, jerkLimitScale: 1.0);
|
||||
var expectedResult = MoveJointTrajectoryGenerator.CreateResult(robot, startJoints, targetJoints, speedRatio: 1.0);
|
||||
|
||||
service.MoveJoint(targetJoints);
|
||||
WaitUntilIdle(runtime);
|
||||
|
||||
var commands = j519Client.GetCommandHistoryForTests();
|
||||
Assert.Equal(expectedResult.DenseJointTrajectory!.Count, commands.Count);
|
||||
Assert.All(commands, static command => Assert.Equal(0, command.LastData));
|
||||
var currentCommand = j519Client.GetCurrentCommand();
|
||||
Assert.NotNull(currentCommand);
|
||||
AssertJointDegreesEqual(targetJoints, currentCommand.TargetJoints);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MoveJointTrajectoryGenerator_LowerSpeedUsesFixedServoPeriodAndLongerPlannedDuration()
|
||||
{
|
||||
var robot = CreateMoveJointReferenceRobotProfile();
|
||||
var startJoints = new[] { 1.056731, 0.011664811, -0.017892333, -0.01516874, 0.021492079, 0.009567846 };
|
||||
@@ -536,9 +658,36 @@ public sealed class FanucControllerRuntimeDenseTests
|
||||
|
||||
Assert.True(speed07.DenseJointTrajectory!.Count > fullSpeed.DenseJointTrajectory!.Count);
|
||||
Assert.True(speed05.DenseJointTrajectory!.Count > speed07.DenseJointTrajectory!.Count);
|
||||
Assert.True(fullSpeed.Duration.TotalSeconds >= ExpectedSmoothPtpDuration(robot, startJoints, targetJoints));
|
||||
Assert.True(speed07.Duration.TotalSeconds >= ExpectedSmoothPtpDuration(robot, startJoints, targetJoints));
|
||||
Assert.True(speed05.Duration.TotalSeconds >= ExpectedSmoothPtpDuration(robot, startJoints, targetJoints));
|
||||
AssertDenseRowsUseServoPeriod(fullSpeed.DenseJointTrajectory, robot.ServoPeriod.TotalSeconds);
|
||||
AssertDenseRowsUseServoPeriod(speed07.DenseJointTrajectory, robot.ServoPeriod.TotalSeconds);
|
||||
AssertDenseRowsUseServoPeriod(speed05.DenseJointTrajectory, robot.ServoPeriod.TotalSeconds);
|
||||
Assert.True(fullSpeed.Duration.TotalSeconds >= ExpectedSmoothPtpDuration(robot, startJoints, targetJoints, speedRatio: 1.0));
|
||||
Assert.True(speed07.Duration.TotalSeconds >= ExpectedSmoothPtpDuration(robot, startJoints, targetJoints, speedRatio: 0.7));
|
||||
Assert.True(speed05.Duration.TotalSeconds >= ExpectedSmoothPtpDuration(robot, startJoints, targetJoints, speedRatio: 0.5));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证 MovePose 低速倍率仍保持固定伺服周期,并通过拉长时长降低直角运动速度。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MovePoseTrajectoryGenerator_LowerSpeedUsesFixedServoPeriodAndLongerPlannedDuration()
|
||||
{
|
||||
var servoPeriod = TimeSpan.FromMilliseconds(8);
|
||||
var startPose = new[] { 100.0, 200.0, 300.0, 1.0, 2.0, 3.0 };
|
||||
var targetPose = new[] { 140.0, 260.0, 330.0, 8.0, 10.0, 12.0 };
|
||||
|
||||
var fullSpeed = MovePoseTrajectoryGenerator.CreateResult(startPose, targetPose, servoPeriod, speedRatio: 1.0);
|
||||
var speed07 = MovePoseTrajectoryGenerator.CreateResult(startPose, targetPose, servoPeriod, speedRatio: 0.7);
|
||||
var speed05 = MovePoseTrajectoryGenerator.CreateResult(startPose, targetPose, servoPeriod, speedRatio: 0.5);
|
||||
|
||||
Assert.True(speed07.DenseCartesianTrajectory!.Count > fullSpeed.DenseCartesianTrajectory!.Count);
|
||||
Assert.True(speed05.DenseCartesianTrajectory!.Count > speed07.DenseCartesianTrajectory!.Count);
|
||||
AssertDenseRowsUseServoPeriod(fullSpeed.DenseCartesianTrajectory, servoPeriod.TotalSeconds);
|
||||
AssertDenseRowsUseServoPeriod(speed07.DenseCartesianTrajectory, servoPeriod.TotalSeconds);
|
||||
AssertDenseRowsUseServoPeriod(speed05.DenseCartesianTrajectory, servoPeriod.TotalSeconds);
|
||||
AssertPoseEqual(startPose, fullSpeed.DenseCartesianTrajectory[0].Skip(1).ToArray());
|
||||
AssertPoseEqual(targetPose, fullSpeed.DenseCartesianTrajectory[^1].Skip(1).ToArray());
|
||||
Assert.True(MovePoseTrajectoryGenerator.SatisfiesDefaultCartesianLimits(speed05.DenseCartesianTrajectory, speedRatio: 0.5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -565,10 +714,49 @@ public sealed class FanucControllerRuntimeDenseTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证 speed_ratio=0 时不会启动无法推进轨迹时间的后台发送任务。
|
||||
/// 验证 MovePose 会生成直角坐标 J519 队列,并使用 Data format=0 下发 X/Y/Z/W/P/R。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ExecuteTrajectory_WithDenseWaypoints_RealMode_RejectsZeroSpeedRatio()
|
||||
public void MovePose_RealMode_GeneratesCartesianJ519Queue()
|
||||
{
|
||||
using var commandClient = new FanucCommandClient();
|
||||
using var stateClient = new FanucStateClient();
|
||||
using var j519Client = new FanucJ519Client();
|
||||
using var runtime = new FanucControllerRuntime(commandClient, stateClient, j519Client);
|
||||
var service = CreateCompatService(runtime);
|
||||
var startPose = new[] { 100.0, 200.0, 300.0, 1.0, 2.0, 3.0 };
|
||||
var targetPose = new[] { 110.0, 220.0, 315.0, 4.0, 5.0, 6.0 };
|
||||
|
||||
service.SetUpRobot("FANUC_LR_Mate_200iD");
|
||||
j519Client.EnableCommandHistoryForTests();
|
||||
ForceRealModeEnabled(runtime, speedRatio: 1.0);
|
||||
SetPrivateField(runtime, "_pose", startPose);
|
||||
|
||||
service.MovePose(targetPose);
|
||||
WaitUntilIdle(runtime);
|
||||
|
||||
var commands = j519Client.GetCommandHistoryForTests();
|
||||
Assert.NotEmpty(commands);
|
||||
Assert.All(commands, static command => Assert.Equal(0, command.DataStyle));
|
||||
AssertPoseEqual(startPose, commands[0].TargetValues.Take(6).ToArray());
|
||||
AssertPoseEqual(targetPose, commands[^1].TargetValues.Take(6).ToArray());
|
||||
Assert.All(commands.Take(commands.Count - 1), static command => Assert.Equal(0, command.LastData));
|
||||
Assert.Equal(1, commands[^1].LastData);
|
||||
Assert.Equal(0, commands[^1].WriteIoMask);
|
||||
Assert.Equal(0, commands[^1].WriteIoValue);
|
||||
Assert.All(commands, static command =>
|
||||
{
|
||||
Assert.Equal(0.0, command.TargetValues[6], precision: 6);
|
||||
Assert.Equal(0.0, command.TargetValues[7], precision: 6);
|
||||
Assert.Equal(0.0, command.TargetValues[8], precision: 6);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证运行时稠密发送不再依赖当前 speed_ratio;倍率合法性应在上游规划/生成阶段处理。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ExecuteTrajectory_WithDenseWaypoints_RealMode_IgnoresRuntimeSpeedRatioForDenseFallback()
|
||||
{
|
||||
using var commandClient = new FanucCommandClient();
|
||||
using var stateClient = new FanucStateClient();
|
||||
@@ -576,12 +764,14 @@ public sealed class FanucControllerRuntimeDenseTests
|
||||
using var runtime = new FanucControllerRuntime(commandClient, stateClient, j519Client);
|
||||
var robot = TestRobotFactory.CreateRobotProfile();
|
||||
runtime.ResetRobot(robot, "FANUC_LR_Mate_200iD");
|
||||
j519Client.EnableCommandHistoryForTests();
|
||||
ForceRealModeEnabled(runtime, speedRatio: 0.0);
|
||||
|
||||
var denseTrajectory = new[]
|
||||
{
|
||||
new[] { 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 },
|
||||
new[] { 0.016, Math.PI, 0.0, 0.0, 0.0, 0.0, 0.0 }
|
||||
new[] { 0.008, DegreesToRadians(0.08), 0.0, 0.0, 0.0, 0.0, 0.0 },
|
||||
new[] { 0.016, DegreesToRadians(0.16), 0.0, 0.0, 0.0, 0.0, 0.0 }
|
||||
};
|
||||
|
||||
var result = new TrajectoryResult(
|
||||
@@ -598,9 +788,13 @@ public sealed class FanucControllerRuntimeDenseTests
|
||||
plannedWaypointCount: 4,
|
||||
denseJointTrajectory: denseTrajectory);
|
||||
|
||||
var exception = Assert.Throws<InvalidOperationException>(
|
||||
() => runtime.ExecuteTrajectory(result, [Math.PI, 0.0, 0.0, 0.0, 0.0, 0.0]));
|
||||
Assert.Contains("Speed ratio", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
runtime.ExecuteTrajectory(result, [DegreesToRadians(0.16), 0.0, 0.0, 0.0, 0.0, 0.0]);
|
||||
WaitUntilIdle(runtime);
|
||||
|
||||
var commands = j519Client.GetCommandHistoryForTests();
|
||||
Assert.Equal(4, commands.Count);
|
||||
Assert.Equal(0.16, commands[^1].TargetJoints[0], precision: 6);
|
||||
Assert.Equal(1, commands[^1].LastData);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -704,9 +898,12 @@ public sealed class FanucControllerRuntimeDenseTests
|
||||
WaitUntilIdle(runtime);
|
||||
|
||||
var commands = j519Client.GetCommandHistoryForTests();
|
||||
Assert.Equal(4, commands.Count);
|
||||
Assert.Equal([(ushort)0, (ushort)5, (ushort)5, (ushort)5], commands.Select(static command => command.WriteIoMask));
|
||||
Assert.Equal([(ushort)0, (ushort)5, (ushort)5, (ushort)0], commands.Select(static command => command.WriteIoValue));
|
||||
Assert.Equal(5, commands.Count);
|
||||
Assert.Equal([(ushort)0, (ushort)5, (ushort)5, (ushort)5], commands.Take(4).Select(static command => command.WriteIoMask));
|
||||
Assert.Equal([(ushort)0, (ushort)5, (ushort)5, (ushort)0], commands.Take(4).Select(static command => command.WriteIoValue));
|
||||
Assert.Equal(1, commands[^1].LastData);
|
||||
Assert.Equal(0, commands[^1].WriteIoMask);
|
||||
Assert.Equal(0, commands[^1].WriteIoValue);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -759,50 +956,12 @@ public sealed class FanucControllerRuntimeDenseTests
|
||||
WaitUntilIdle(runtime);
|
||||
|
||||
var commands = j519Client.GetCommandHistoryForTests();
|
||||
Assert.Equal(4, commands.Count);
|
||||
Assert.Equal([(ushort)0, (ushort)0, (ushort)1, (ushort)1], commands.Select(static command => command.WriteIoMask));
|
||||
Assert.Equal([(ushort)0, (ushort)0, (ushort)1, (ushort)0], commands.Select(static command => command.WriteIoValue));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证仿真模式下即使传入稠密路点,也会回退到单点同步更新。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ExecuteTrajectory_WithDenseWaypoints_SimulationMode_FallsBackToSinglePoint()
|
||||
{
|
||||
var runtime = new FanucControllerRuntime();
|
||||
var robot = TestRobotFactory.CreateRobotProfile();
|
||||
runtime.ResetRobot(robot, "FANUC_LR_Mate_200iD");
|
||||
runtime.SetActiveController(sim: true);
|
||||
runtime.Connect("192.168.10.101");
|
||||
runtime.EnableRobot(bufferSize: 2);
|
||||
|
||||
var denseTrajectory = new[]
|
||||
{
|
||||
new[] { 0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6 },
|
||||
new[] { 0.008, 0.11, 0.21, 0.31, 0.41, 0.51, 0.61 },
|
||||
new[] { 0.016, 0.12, 0.22, 0.32, 0.42, 0.52, 0.62 }
|
||||
};
|
||||
|
||||
var result = new TrajectoryResult(
|
||||
programName: "demo",
|
||||
method: PlanningMethod.Icsp,
|
||||
isValid: true,
|
||||
duration: TimeSpan.FromSeconds(0.016),
|
||||
shotEvents: Array.Empty<ShotEvent>(),
|
||||
triggerTimeline: Array.Empty<TrajectoryDoEvent>(),
|
||||
artifacts: Array.Empty<TrajectoryArtifact>(),
|
||||
failureReason: null,
|
||||
usedCache: false,
|
||||
originalWaypointCount: 4,
|
||||
plannedWaypointCount: 4,
|
||||
denseJointTrajectory: denseTrajectory);
|
||||
|
||||
runtime.ExecuteTrajectory(result, [0.12, 0.22, 0.32, 0.42, 0.52, 0.62]);
|
||||
|
||||
var snapshot = runtime.GetSnapshot();
|
||||
Assert.False(snapshot.IsInMotion);
|
||||
Assert.Equal([0.12, 0.22, 0.32, 0.42, 0.52, 0.62], snapshot.JointPositions);
|
||||
Assert.Equal(5, commands.Count);
|
||||
Assert.Equal([(ushort)0, (ushort)0, (ushort)1, (ushort)1], commands.Take(4).Select(static command => command.WriteIoMask));
|
||||
Assert.Equal([(ushort)0, (ushort)0, (ushort)1, (ushort)0], commands.Take(4).Select(static command => command.WriteIoValue));
|
||||
Assert.Equal(1, commands[^1].LastData);
|
||||
Assert.Equal(0, commands[^1].WriteIoMask);
|
||||
Assert.Equal(0, commands[^1].WriteIoValue);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -819,8 +978,10 @@ public sealed class FanucControllerRuntimeDenseTests
|
||||
var enableRobotRetryCount = 0;
|
||||
var waitCount = 0;
|
||||
|
||||
using var runtime = new FanucControllerRuntime();
|
||||
|
||||
var exception = Record.Exception(
|
||||
() => FanucControllerRuntime.EnsureJ519ReadyForDenseExecutionCore(
|
||||
() => runtime.EnsureJ519ReadyForDenseExecutionCore(
|
||||
() => responses.Dequeue(),
|
||||
() => enableRobotRetryCount++,
|
||||
() => waitCount++));
|
||||
@@ -844,8 +1005,10 @@ public sealed class FanucControllerRuntimeDenseTests
|
||||
var enableRobotRetryCount = 0;
|
||||
var waitCount = 0;
|
||||
|
||||
using var runtime = new FanucControllerRuntime();
|
||||
|
||||
var exception = Assert.Throws<InvalidOperationException>(
|
||||
() => FanucControllerRuntime.EnsureJ519ReadyForDenseExecutionCore(
|
||||
() => runtime.EnsureJ519ReadyForDenseExecutionCore(
|
||||
() => responses.Dequeue(),
|
||||
() => enableRobotRetryCount++,
|
||||
() => waitCount++));
|
||||
@@ -857,24 +1020,6 @@ public sealed class FanucControllerRuntimeDenseTests
|
||||
Assert.Contains("seq=22", exception.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证 StopMove 在没有任何后台发送任务运行时不会抛出异常。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void StopMove_DoesNotThrowWhenNoSendTaskRunning()
|
||||
{
|
||||
var runtime = new FanucControllerRuntime();
|
||||
var robot = TestRobotFactory.CreateRobotProfile();
|
||||
runtime.ResetRobot(robot, "FANUC_LR_Mate_200iD");
|
||||
runtime.SetActiveController(sim: true);
|
||||
runtime.Connect("192.168.10.101");
|
||||
runtime.EnableRobot(bufferSize: 2);
|
||||
|
||||
var exception = Record.Exception(() => runtime.StopMove());
|
||||
Assert.Null(exception);
|
||||
Assert.False(runtime.GetSnapshot().IsInMotion);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证 IO 地址组中的地址号按 writeIoIndex=1 的手册语义映射为 writeIoValue 位掩码。
|
||||
/// </summary>
|
||||
@@ -980,8 +1125,9 @@ public sealed class FanucControllerRuntimeDenseTests
|
||||
var rows = result.DenseJointTrajectory!;
|
||||
|
||||
Assert.True(
|
||||
result.Duration.TotalSeconds >= ExpectedSmoothPtpDuration(robot, startJoints, targetJoints),
|
||||
result.Duration.TotalSeconds >= ExpectedSmoothPtpDuration(robot, startJoints, targetJoints, speedRatio: 0.7),
|
||||
$"Duration was shortened to {result.Duration.TotalSeconds:F6}s.");
|
||||
AssertDenseRowsUseServoPeriod(rows, robot.ServoPeriod.TotalSeconds);
|
||||
AssertJointDegreesEqual(startJoints, rows[0].Skip(1).Select(RadiansToDegrees).ToArray());
|
||||
AssertJointDegreesEqual(targetJoints, rows[^1].Skip(1).Select(RadiansToDegrees).ToArray());
|
||||
}
|
||||
@@ -1030,7 +1176,6 @@ public sealed class FanucControllerRuntimeDenseTests
|
||||
|
||||
private static void ForceRealModeEnabled(FanucControllerRuntime runtime, double speedRatio)
|
||||
{
|
||||
SetPrivateField(runtime, "_activeControllerIsSimulation", false);
|
||||
SetPrivateField(runtime, "_connectedRobotIp", "127.0.0.1");
|
||||
SetPrivateField(runtime, "_isEnabled", true);
|
||||
SetPrivateField(runtime, "_bufferSize", 2);
|
||||
@@ -1079,6 +1224,15 @@ public sealed class FanucControllerRuntimeDenseTests
|
||||
}
|
||||
}
|
||||
|
||||
private static void AssertPoseEqual(IReadOnlyList<double> expected, IReadOnlyList<double> actual)
|
||||
{
|
||||
Assert.Equal(expected.Count, actual.Count);
|
||||
for (var index = 0; index < expected.Count; index++)
|
||||
{
|
||||
Assert.Equal(expected[index], actual[index], precision: 6);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建用于就绪状态测试的最小 J519 响应。
|
||||
/// </summary>
|
||||
@@ -1207,8 +1361,14 @@ public sealed class FanucControllerRuntimeDenseTests
|
||||
private static double ExpectedSmoothPtpDuration(
|
||||
RobotProfile robot,
|
||||
IReadOnlyList<double> startJoints,
|
||||
IReadOnlyList<double> targetJoints)
|
||||
IReadOnlyList<double> targetJoints,
|
||||
double speedRatio = 1.0)
|
||||
{
|
||||
if (speedRatio <= 0.0 || double.IsNaN(speedRatio) || double.IsInfinity(speedRatio))
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(speedRatio), "speed_ratio 必须是有限正数。");
|
||||
}
|
||||
|
||||
var duration = 0.0;
|
||||
for (var index = 0; index < robot.DegreesOfFreedom; index++)
|
||||
{
|
||||
@@ -1219,15 +1379,27 @@ public sealed class FanucControllerRuntimeDenseTests
|
||||
}
|
||||
|
||||
var limit = robot.JointLimits[index];
|
||||
var velocityDuration = distance * SmoothPtpVelocityShapeCoefficient / limit.VelocityLimit;
|
||||
var accelerationDuration = Math.Sqrt(distance * SmoothPtpAccelerationShapeCoefficient / limit.AccelerationLimit);
|
||||
var jerkDuration = Math.Cbrt(distance * SmoothPtpJerkShapeCoefficient / limit.JerkLimit);
|
||||
var velocityDuration = distance * SmoothPtpVelocityShapeCoefficient / (limit.VelocityLimit * speedRatio);
|
||||
var accelerationDuration = Math.Sqrt(distance * SmoothPtpAccelerationShapeCoefficient / (limit.AccelerationLimit * speedRatio * speedRatio));
|
||||
var jerkDuration = Math.Cbrt(distance * SmoothPtpJerkShapeCoefficient / (limit.JerkLimit * speedRatio * speedRatio * speedRatio));
|
||||
duration = Math.Max(duration, Math.Max(velocityDuration, Math.Max(accelerationDuration, jerkDuration)));
|
||||
}
|
||||
|
||||
return duration;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证 MoveJoint 稠密轨迹已经是物理伺服周期,不把 speed_ratio 编码到采样间隔里。
|
||||
/// </summary>
|
||||
private static void AssertDenseRowsUseServoPeriod(IReadOnlyList<IReadOnlyList<double>> rows, double servoPeriodSeconds)
|
||||
{
|
||||
Assert.True(rows.Count > 1);
|
||||
for (var index = 1; index < rows.Count; index++)
|
||||
{
|
||||
Assert.Equal(servoPeriodSeconds, rows[index][0] - rows[index - 1][0], precision: 6);
|
||||
}
|
||||
}
|
||||
|
||||
private static double DegreesToRadians(double degrees)
|
||||
{
|
||||
return degrees * Math.PI / 180.0;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Flyshot.Runtime.Common;
|
||||
using Flyshot.Runtime.Fanuc;
|
||||
using Flyshot.Runtime.Fanuc.Protocol;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using System.Reflection;
|
||||
|
||||
@@ -14,7 +15,7 @@ public sealed class FanucControllerRuntimeLoggingRegistrationTests
|
||||
/// 验证宿主按 IControllerRuntime 解析 FANUC 运行时时,不会因为可选日志参数而退回到无日志实例。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ServiceProvider_Resolves_FanucControllerRuntime_WithLogger()
|
||||
public void ServiceProvider_Resolves_FanucControllerRuntime_WithAllClientLoggers()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
@@ -28,5 +29,27 @@ public sealed class FanucControllerRuntimeLoggingRegistrationTests
|
||||
|
||||
Assert.NotNull(loggerField);
|
||||
Assert.NotNull(loggerField!.GetValue(concreteRuntime));
|
||||
|
||||
AssertClientLoggerIsInjected<FanucCommandClient>(concreteRuntime, "_commandClient");
|
||||
AssertClientLoggerIsInjected<FanucStateClient>(concreteRuntime, "_stateClient");
|
||||
AssertClientLoggerIsInjected<FanucJ519Client>(concreteRuntime, "_j519Client");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 断言运行时持有的底层协议客户端已注入对应类型的日志记录器。
|
||||
/// </summary>
|
||||
/// <typeparam name="TClient">待检查的协议客户端类型。</typeparam>
|
||||
/// <param name="runtime">已经通过依赖注入解析出的 FANUC 运行时。</param>
|
||||
/// <param name="clientFieldName">运行时内部保存该客户端实例的字段名。</param>
|
||||
private static void AssertClientLoggerIsInjected<TClient>(FanucControllerRuntime runtime, string clientFieldName)
|
||||
{
|
||||
var clientField = typeof(FanucControllerRuntime).GetField(clientFieldName, BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
|
||||
Assert.NotNull(clientField);
|
||||
var client = Assert.IsType<TClient>(clientField!.GetValue(runtime));
|
||||
var loggerField = typeof(TClient).GetField("_logger", BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
|
||||
Assert.NotNull(loggerField);
|
||||
Assert.NotNull(loggerField!.GetValue(client));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,6 +163,28 @@ public sealed class FanucJ519ClientTests : IDisposable
|
||||
await client.StopMotionAsync(_cts.Token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证直角坐标命令会把 Data format 写为 0,并按通用目标槽位写入 X/Y/Z/W/P/R。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void PackCommandPacket_WritesCartesianDataFormatAndTargetValues()
|
||||
{
|
||||
var command = new FanucJ519Command(
|
||||
sequence: 7,
|
||||
targetValues: [100.0, 200.0, 300.0, 1.0, 2.0, 3.0, 0.0, 0.0, 0.0],
|
||||
dataStyle: 0);
|
||||
|
||||
var packet = FanucJ519Protocol.PackCommandPacket(command);
|
||||
|
||||
Assert.Equal(0, packet[0x12]);
|
||||
Assert.Equal(100.0f, BinaryPrimitives.ReadSingleBigEndian(packet.AsSpan(0x1c, 4)));
|
||||
Assert.Equal(200.0f, BinaryPrimitives.ReadSingleBigEndian(packet.AsSpan(0x20, 4)));
|
||||
Assert.Equal(300.0f, BinaryPrimitives.ReadSingleBigEndian(packet.AsSpan(0x24, 4)));
|
||||
Assert.Equal(1.0f, BinaryPrimitives.ReadSingleBigEndian(packet.AsSpan(0x28, 4)));
|
||||
Assert.Equal(2.0f, BinaryPrimitives.ReadSingleBigEndian(packet.AsSpan(0x2c, 4)));
|
||||
Assert.Equal(3.0f, BinaryPrimitives.ReadSingleBigEndian(packet.AsSpan(0x30, 4)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证配置 J519 buffer size 后,实际回发命令序号会在状态包序号基础上增加该缓冲深度。
|
||||
/// </summary>
|
||||
@@ -257,6 +279,147 @@ public sealed class FanucJ519ClientTests : IDisposable
|
||||
Assert.True(client.IsCommandQueueDrainedForTests());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证机器人持续不可接收命令时,后续装载的命令队列会被完成,避免执行请求永久等待。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task LoadCommandQueue_WhenLatestStatusDoesNotAcceptCommands_CompletesQueueWait()
|
||||
{
|
||||
using var client = new FanucJ519Client();
|
||||
await client.ConnectAsync("127.0.0.1", Port, _cts.Token);
|
||||
var initResult = await _server.ReceiveAsync(_cts.Token);
|
||||
|
||||
client.StartMotion();
|
||||
await SendStatusPacketAsync(initResult.RemoteEndPoint, sequence: 900, status: 0x04);
|
||||
await Task.Delay(100, _cts.Token);
|
||||
|
||||
client.LoadCommandQueue(
|
||||
[
|
||||
new FanucJ519Command(sequence: 0, targetJoints: [1.0, 0.0, 0.0, 0.0, 0.0, 0.0])
|
||||
]);
|
||||
await SendStatusPacketAsync(initResult.RemoteEndPoint, sequence: 901, status: 0x04);
|
||||
|
||||
await client.WaitForCommandQueueDrainedAsync(_cts.Token).WaitAsync(TimeSpan.FromSeconds(1), _cts.Token);
|
||||
await Assert.ThrowsAsync<TimeoutException>(
|
||||
() => _server.ReceiveAsync(_cts.Token).AsTask().WaitAsync(TimeSpan.FromMilliseconds(120)));
|
||||
|
||||
Assert.True(client.IsCommandQueueDrainedForTests());
|
||||
await client.StopMotionAsync(_cts.Token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证启用命令流结束开关后,LastData 结束帧发送完成会进入仅监控状态,不再继续回发保姿命令。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task LoadCommandQueue_WithEndCommandStreamEnabled_StopsSendingAfterLastDataAndKeepsMonitoring()
|
||||
{
|
||||
using var client = new FanucJ519Client();
|
||||
await client.ConnectAsync("127.0.0.1", Port, _cts.Token);
|
||||
var initResult = await _server.ReceiveAsync(_cts.Token);
|
||||
|
||||
client.StartMotion();
|
||||
client.LoadCommandQueue(
|
||||
[
|
||||
new FanucJ519Command(sequence: 0, targetJoints: [1.0, 0.0, 0.0, 0.0, 0.0, 0.0]),
|
||||
new FanucJ519Command(sequence: 0, targetJoints: [2.0, 0.0, 0.0, 0.0, 0.0, 0.0], lastData: 1)
|
||||
]);
|
||||
|
||||
await SendStatusPacketAsync(initResult.RemoteEndPoint, sequence: 800);
|
||||
var first = await _server.ReceiveAsync(_cts.Token);
|
||||
await SendStatusPacketAsync(initResult.RemoteEndPoint, sequence: 801);
|
||||
var end = await _server.ReceiveAsync(_cts.Token);
|
||||
await SendStatusPacketAsync(
|
||||
initResult.RemoteEndPoint,
|
||||
sequence: 802,
|
||||
jointDegrees: [7.0, 8.0, 9.0, 10.0, 11.0, 12.0]);
|
||||
|
||||
await Assert.ThrowsAsync<TimeoutException>(
|
||||
() => _server.ReceiveAsync(_cts.Token).AsTask().WaitAsync(TimeSpan.FromMilliseconds(120)));
|
||||
|
||||
var latest = client.GetLatestResponse();
|
||||
Assert.Equal(FanucJ519Protocol.CommandPacketLength, first.Buffer.Length);
|
||||
Assert.Equal(FanucJ519Protocol.CommandPacketLength, end.Buffer.Length);
|
||||
Assert.Equal(0, first.Buffer[0x0c]);
|
||||
Assert.Equal(1, end.Buffer[0x0c]);
|
||||
Assert.NotNull(latest);
|
||||
Assert.Equal(802u, latest.Sequence);
|
||||
Assert.Equal(7.0, latest.JointDegrees[0], precision: 6);
|
||||
|
||||
await client.StopMotionAsync(_cts.Token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证禁用命令流结束开关后,即使队列里出现 LastData 也继续沿用旧的最后目标保持行为。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task LoadCommandQueue_WithEndCommandStreamDisabled_HoldsLastCommandAfterLastData()
|
||||
{
|
||||
using var client = new FanucJ519Client(endCommandStreamAfterQueueDrained: false);
|
||||
await client.ConnectAsync("127.0.0.1", Port, _cts.Token);
|
||||
var initResult = await _server.ReceiveAsync(_cts.Token);
|
||||
|
||||
client.StartMotion();
|
||||
client.LoadCommandQueue(
|
||||
[
|
||||
new FanucJ519Command(sequence: 0, targetJoints: [1.0, 0.0, 0.0, 0.0, 0.0, 0.0]),
|
||||
new FanucJ519Command(sequence: 0, targetJoints: [2.0, 0.0, 0.0, 0.0, 0.0, 0.0], lastData: 1)
|
||||
]);
|
||||
|
||||
var packets = new List<byte[]>();
|
||||
for (uint sequence = 810; sequence < 813; sequence++)
|
||||
{
|
||||
await SendStatusPacketAsync(initResult.RemoteEndPoint, sequence);
|
||||
var result = await _server.ReceiveAsync(_cts.Token);
|
||||
packets.Add(result.Buffer);
|
||||
}
|
||||
|
||||
Assert.Equal([810u, 811u, 812u], packets
|
||||
.Select(packet => BinaryPrimitives.ReadUInt32BigEndian(packet.AsSpan(0x08, 4)))
|
||||
.ToArray());
|
||||
Assert.Equal([1.0f, 2.0f, 2.0f], packets
|
||||
.Select(packet => BinaryPrimitives.ReadSingleBigEndian(packet.AsSpan(0x1c, 4)))
|
||||
.ToArray());
|
||||
|
||||
await client.StopMotionAsync(_cts.Token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证 LastData 后的仅监控状态不会阻止下一轮队列重新开始发送。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task LoadCommandQueue_AfterLastDataMonitoring_CanStreamNewQueue()
|
||||
{
|
||||
using var client = new FanucJ519Client();
|
||||
await client.ConnectAsync("127.0.0.1", Port, _cts.Token);
|
||||
var initResult = await _server.ReceiveAsync(_cts.Token);
|
||||
|
||||
client.StartMotion();
|
||||
client.LoadCommandQueue(
|
||||
[
|
||||
new FanucJ519Command(sequence: 0, targetJoints: [1.0, 0.0, 0.0, 0.0, 0.0, 0.0]),
|
||||
new FanucJ519Command(sequence: 0, targetJoints: [2.0, 0.0, 0.0, 0.0, 0.0, 0.0], lastData: 1)
|
||||
]);
|
||||
await SendStatusPacketAsync(initResult.RemoteEndPoint, sequence: 820);
|
||||
_ = await _server.ReceiveAsync(_cts.Token);
|
||||
await SendStatusPacketAsync(initResult.RemoteEndPoint, sequence: 821);
|
||||
_ = await _server.ReceiveAsync(_cts.Token);
|
||||
|
||||
client.LoadCommandQueue(
|
||||
[
|
||||
new FanucJ519Command(sequence: 0, targetJoints: [3.0, 0.0, 0.0, 0.0, 0.0, 0.0]),
|
||||
new FanucJ519Command(sequence: 0, targetJoints: [4.0, 0.0, 0.0, 0.0, 0.0, 0.0], lastData: 1)
|
||||
]);
|
||||
await SendStatusPacketAsync(initResult.RemoteEndPoint, sequence: 822);
|
||||
var restarted = await _server.ReceiveAsync(_cts.Token);
|
||||
|
||||
Assert.Equal(FanucJ519Protocol.CommandPacketLength, restarted.Buffer.Length);
|
||||
Assert.Equal(822u, BinaryPrimitives.ReadUInt32BigEndian(restarted.Buffer.AsSpan(0x08, 4)));
|
||||
Assert.Equal(0, restarted.Buffer[0x0c]);
|
||||
Assert.Equal(3.0f, BinaryPrimitives.ReadSingleBigEndian(restarted.Buffer.AsSpan(0x1c, 4)), precision: 6);
|
||||
|
||||
await client.StopMotionAsync(_cts.Token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证停止运动后可在同一连接内重启发送,命令序号仍由新的状态包决定。
|
||||
/// </summary>
|
||||
@@ -437,13 +600,14 @@ public sealed class FanucJ519ClientTests : IDisposable
|
||||
private async Task SendStatusPacketAsync(
|
||||
IPEndPoint clientEndpoint,
|
||||
uint sequence,
|
||||
IReadOnlyList<double>? jointDegrees = null)
|
||||
IReadOnlyList<double>? jointDegrees = null,
|
||||
byte status = 15)
|
||||
{
|
||||
var responsePacket = new byte[FanucJ519Protocol.ResponsePacketLength];
|
||||
BinaryPrimitives.WriteUInt32BigEndian(responsePacket.AsSpan(0x00, 4), 0);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(responsePacket.AsSpan(0x04, 4), 1);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(responsePacket.AsSpan(0x08, 4), sequence);
|
||||
responsePacket[0x0c] = 15;
|
||||
responsePacket[0x0c] = status;
|
||||
|
||||
if (jointDegrees is not null)
|
||||
{
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using Flyshot.Core.Planning.Sampling;
|
||||
using Flyshot.Core.Domain;
|
||||
using Flyshot.Core.Planning;
|
||||
|
||||
namespace Flyshot.Core.Tests;
|
||||
|
||||
@@ -43,6 +45,58 @@ public sealed class J519SendTrajectorySamplerTests
|
||||
Assert.Equal(180.0, samples[^1].JointsDegrees[0], precision: 6);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证飞拍 speed_ratio 安全队列从连续规划样条直接采样,而不是先把规划轨迹离散成稠密点后再线性插值。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void SamplePlannedTrajectory_DirectlyEvaluatesContinuousSplineForSpeedRatio()
|
||||
{
|
||||
var trajectory = CreateSingleJointTrajectory();
|
||||
|
||||
var samples = J519SendTrajectorySampler.SamplePlannedTrajectory(
|
||||
trajectory,
|
||||
servoPeriodSeconds: 0.008,
|
||||
speedRatio: 0.5);
|
||||
|
||||
var sample = Assert.Single(samples.Where(static item => Math.Abs(item.TrajectoryTime - 0.004) < 1e-9));
|
||||
Assert.Equal(0.008, sample.SendTime, precision: 6);
|
||||
Assert.Equal(0.004, sample.TrajectoryTime, precision: 6);
|
||||
Assert.NotEqual(45.0, sample.JointsDegrees[0], precision: 3);
|
||||
|
||||
var expected = new CubicSplineInterpolator(
|
||||
trajectory.WaypointTimes.ToArray(),
|
||||
trajectory.PlannedWaypoints.Select(static waypoint => waypoint.Positions.ToArray()).ToArray())
|
||||
.Evaluate(0.004)[0] * 180.0 / Math.PI;
|
||||
Assert.Equal(expected, sample.JointsDegrees[0], precision: 6);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证 speed_ratio 等价于执行侧时间轴拉长:原轨迹 0.8 倍执行应与时间整体拉长 1/0.8 的轨迹 1 倍执行一致。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void SamplePlannedTrajectory_SpeedRatioMatchesUniformPlanningTimeStretch()
|
||||
{
|
||||
var trajectory = CreateSingleJointTrajectory();
|
||||
var stretchedTrajectory = StretchTrajectoryTime(trajectory, stretchFactor: 1.0 / 0.8);
|
||||
|
||||
var ratioSamples = J519SendTrajectorySampler.SamplePlannedTrajectory(
|
||||
trajectory,
|
||||
servoPeriodSeconds: 0.008,
|
||||
speedRatio: 0.8);
|
||||
var stretchedSamples = J519SendTrajectorySampler.SamplePlannedTrajectory(
|
||||
stretchedTrajectory,
|
||||
servoPeriodSeconds: 0.008,
|
||||
speedRatio: 1.0);
|
||||
|
||||
Assert.Equal(stretchedSamples.Count, ratioSamples.Count);
|
||||
for (var index = 0; index < ratioSamples.Count; index++)
|
||||
{
|
||||
Assert.Equal(stretchedSamples[index].SendTime, ratioSamples[index].SendTime, precision: 6);
|
||||
Assert.Equal(stretchedSamples[index].TrajectoryTime * 0.8, ratioSamples[index].TrajectoryTime, precision: 6);
|
||||
Assert.Equal(stretchedSamples[index].JointsDegrees[0], ratioSamples[index].JointsDegrees[0], precision: 6);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证空稠密轨迹会直接暴露为调用错误,避免生成无意义下发点。
|
||||
/// </summary>
|
||||
@@ -96,4 +150,84 @@ public sealed class J519SendTrajectorySamplerTests
|
||||
|
||||
Assert.Equal([2.0, 0.016, 0.008, 0.5], row);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证倍率诊断行在保留旧 4 列的同时追加请求倍率和历史改写次数。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void BuildTimingRow_WithRatioDiagnostics_AppendsRequestedRatioAndIterationCount()
|
||||
{
|
||||
var sample = new J519SendSample(
|
||||
sampleIndex: 2,
|
||||
sendTime: 0.016,
|
||||
trajectoryTime: 0.0076,
|
||||
speedRatio: 0.475,
|
||||
jointsDegrees: [90.0, 0.0, 0.0, 0.0, 0.0, 0.0]);
|
||||
|
||||
var row = J519SendTrajectorySampler.BuildTimingRow(
|
||||
sample,
|
||||
requestedSpeedRatio: 0.5,
|
||||
stretchIterationCount: 1);
|
||||
|
||||
Assert.Equal([2.0, 0.016, 0.0076, 0.475, 0.5, 1.0], row);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 构造一个最小单关节规划轨迹,便于验证 speed_ratio 直接样条采样语义。
|
||||
/// </summary>
|
||||
private static PlannedTrajectory CreateSingleJointTrajectory()
|
||||
{
|
||||
var robot = new RobotProfile(
|
||||
name: "TestRobot",
|
||||
modelPath: "Models/Test.robot",
|
||||
degreesOfFreedom: 1,
|
||||
jointLimits: [new JointLimit("J1", 100.0, 1000.0, 10000.0)],
|
||||
jointCouplings: Array.Empty<JointCoupling>(),
|
||||
servoPeriod: TimeSpan.FromMilliseconds(8),
|
||||
triggerPeriod: TimeSpan.FromMilliseconds(8));
|
||||
var program = new FlyshotProgram(
|
||||
name: "spline-sample",
|
||||
waypoints:
|
||||
[
|
||||
new JointWaypoint([0.0]),
|
||||
new JointWaypoint([Math.PI / 2.0]),
|
||||
new JointWaypoint([Math.PI])
|
||||
],
|
||||
shotFlags: [false, false, false],
|
||||
offsetValues: [0, 0, 0],
|
||||
addressGroups:
|
||||
[
|
||||
new IoAddressGroup(Array.Empty<int>()),
|
||||
new IoAddressGroup(Array.Empty<int>()),
|
||||
new IoAddressGroup(Array.Empty<int>())
|
||||
]);
|
||||
|
||||
return new PlannedTrajectory(
|
||||
robot: robot,
|
||||
originalProgram: program,
|
||||
plannedWaypoints: program.Waypoints,
|
||||
waypointTimes: [0.0, 0.008, 0.016],
|
||||
segmentDurations: [0.008, 0.008],
|
||||
segmentScales: [1.0, 1.0],
|
||||
method: PlanningMethod.Icsp,
|
||||
iterations: 1,
|
||||
threshold: 0.0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 构造统一拉长时间轴后的轨迹,模拟更低 planning_speed_scale 生成的等几何时间结果。
|
||||
/// </summary>
|
||||
private static PlannedTrajectory StretchTrajectoryTime(PlannedTrajectory trajectory, double stretchFactor)
|
||||
{
|
||||
return new PlannedTrajectory(
|
||||
robot: trajectory.Robot,
|
||||
originalProgram: trajectory.OriginalProgram,
|
||||
plannedWaypoints: trajectory.PlannedWaypoints,
|
||||
waypointTimes: trajectory.WaypointTimes.Select(time => time * stretchFactor).ToArray(),
|
||||
segmentDurations: trajectory.SegmentDurations.Select(duration => duration * stretchFactor).ToArray(),
|
||||
segmentScales: trajectory.SegmentScales.Select(scale => scale / stretchFactor).ToArray(),
|
||||
method: trajectory.Method,
|
||||
iterations: trajectory.Iterations,
|
||||
threshold: trajectory.Threshold);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ public sealed class OfflinePlanTests
|
||||
// 1. 加载配置和模型。
|
||||
var loadedConfig = new RobotConfigLoader().Load(resolvedConfigPath, repoRoot: workspaceRoot);
|
||||
var program = loadedConfig.Programs[trajName];
|
||||
var resolvedRobotModelPath = Path.Combine(workspaceRoot, "flyshot-replacement", "Config", "LR_Mate_200iD_7L.json");
|
||||
var resolvedRobotModelPath = Path.Combine(workspaceRoot, "flyshot-replacement", "Config", "Models", "LR_Mate_200iD_7L.json");
|
||||
var loadedModel = new RobotModelLoader().LoadProfileAndKinematics(
|
||||
resolvedRobotModelPath,
|
||||
loadedConfig.Robot.AccLimitScale,
|
||||
|
||||
@@ -67,7 +67,7 @@ public sealed class PlanningCompatibilityTests
|
||||
{
|
||||
var workspaceRoot = GetWorkspaceRoot();
|
||||
var configPath = Path.Combine(workspaceRoot, "Rvbust", "EOL9 EAU 90", "eol9_eau_90.json");
|
||||
var modelPath = Path.Combine(workspaceRoot, "flyshot-replacement", "Config", "LR_Mate_200iD_7L.json");
|
||||
var modelPath = Path.Combine(workspaceRoot, "flyshot-replacement", "Config", "Models", "LR_Mate_200iD_7L.json");
|
||||
|
||||
var config = new RobotConfigLoader().Load(configPath);
|
||||
var baseProfile = new RobotModelLoader().LoadProfile(modelPath, config.Robot.AccLimitScale, config.Robot.JerkLimitScale);
|
||||
@@ -239,6 +239,174 @@ public sealed class PlanningCompatibilityTests
|
||||
Assert.Contains("dense-acceleration-check", exception.Message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证飞拍链路临时关闭 jerk 复核时,仍保留速度/加速度校验,但不会再被 jerk 单独阻断。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TrajectoryLimitValidator_CanSkipJerkValidation_ForFlyshotPath()
|
||||
{
|
||||
var robot = CreateRobotProfile([100.0], [1000.0], [10.0]);
|
||||
IReadOnlyList<IReadOnlyList<double>> rows =
|
||||
[
|
||||
[0.0, 0.0],
|
||||
[0.008, 0.0],
|
||||
[0.016, 0.01],
|
||||
[0.024, 0.02]
|
||||
];
|
||||
|
||||
var exception = Assert.Throws<InvalidOperationException>(() =>
|
||||
TrajectoryLimitValidator.ValidateDenseJointTrajectory(
|
||||
robot,
|
||||
rows,
|
||||
trajectoryName: "dense-jerk-check"));
|
||||
|
||||
Assert.Contains("Jerk", exception.Message);
|
||||
|
||||
TrajectoryLimitValidator.ValidateDenseJointTrajectory(
|
||||
robot,
|
||||
rows,
|
||||
trajectoryName: "dense-jerk-check-skip",
|
||||
validateJerk: false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证飞拍最终 J519 队列的 jerk 硬约束使用严格限值,不再沿用历史上放大 4 倍的宽容口径。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TrajectoryLimitValidator_ThrowsForJ519Jerk_WhenStrictFinalQueueValidationIsEnabled()
|
||||
{
|
||||
var robot = CreateRobotProfile([100.0], [1000.0], [100.0]);
|
||||
var samples = new[]
|
||||
{
|
||||
new J519SendSample(0, 0.000, 0.000, 1.0, [0.0]),
|
||||
new J519SendSample(1, 0.008, 0.008, 1.0, [0.0]),
|
||||
new J519SendSample(2, 0.016, 0.016, 1.0, [0.003754]),
|
||||
new J519SendSample(3, 0.024, 0.024, 1.0, [0.007508])
|
||||
};
|
||||
|
||||
var exception = Assert.Throws<InvalidOperationException>(() =>
|
||||
TrajectoryLimitValidator.ValidateJ519SendSamples(
|
||||
robot,
|
||||
samples,
|
||||
trajectoryName: "strict-final-j519",
|
||||
validateJerk: true));
|
||||
|
||||
Assert.Contains("Jerk", exception.Message);
|
||||
Assert.Contains("strict-final-j519", exception.Message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证飞拍最终发送队列在请求倍率下严格 jerk 超限时直接拒绝执行,而不是自动改写 speed_ratio。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FlyshotExecutionSendSequenceBuilder_ThrowsInsteadOfRewritingRequestedSpeedRatio()
|
||||
{
|
||||
var robot = CreateRobotProfile([100.0], [10000.0], [800.0]);
|
||||
var program = CreateProgram(
|
||||
[
|
||||
[0.0],
|
||||
[0.001]
|
||||
]);
|
||||
var trajectory = new PlannedTrajectory(
|
||||
robot: robot,
|
||||
originalProgram: program,
|
||||
plannedWaypoints: program.Waypoints,
|
||||
waypointTimes: [0.0, 0.024],
|
||||
segmentDurations: [0.024],
|
||||
segmentScales: [1.0],
|
||||
method: PlanningMethod.Icsp,
|
||||
iterations: 1,
|
||||
threshold: 0.0);
|
||||
var result = new TrajectoryResult(
|
||||
programName: "strict-builder",
|
||||
method: PlanningMethod.Icsp,
|
||||
isValid: true,
|
||||
duration: TimeSpan.FromSeconds(0.024),
|
||||
shotEvents: Array.Empty<ShotEvent>(),
|
||||
triggerTimeline: Array.Empty<TrajectoryDoEvent>(),
|
||||
artifacts: Array.Empty<TrajectoryArtifact>(),
|
||||
failureReason: null,
|
||||
usedCache: false,
|
||||
originalWaypointCount: 2,
|
||||
plannedWaypointCount: 2);
|
||||
var initialSamples = J519SendTrajectorySampler.SamplePlannedTrajectory(
|
||||
trajectory,
|
||||
servoPeriodSeconds: 0.008,
|
||||
speedRatio: 1.0);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
TrajectoryLimitValidator.ValidateJ519SendSamples(
|
||||
robot,
|
||||
initialSamples,
|
||||
trajectoryName: "strict-builder-initial",
|
||||
validateJerk: true));
|
||||
|
||||
var exception = Assert.Throws<InvalidOperationException>(() =>
|
||||
FlyshotExecutionSendSequenceBuilder.Build(
|
||||
robot,
|
||||
trajectory,
|
||||
result,
|
||||
servoPeriodSeconds: 0.008,
|
||||
requestedSpeedRatio: 1.0));
|
||||
|
||||
Assert.Contains("speed_ratio=1.000000", exception.Message);
|
||||
Assert.Contains("planning_speed_scale", exception.Message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证飞拍最终发送队列通过严格校验时,请求 speed_ratio 会原样成为最终倍率。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FlyshotExecutionSendSequenceBuilder_KeepsRequestedSpeedRatioWhenQueuePasses()
|
||||
{
|
||||
var robot = CreateRobotProfile([100.0], [10000.0], [10000.0]);
|
||||
var program = CreateProgram(
|
||||
[
|
||||
[0.0],
|
||||
[0.001]
|
||||
]);
|
||||
var trajectory = new PlannedTrajectory(
|
||||
robot: robot,
|
||||
originalProgram: program,
|
||||
plannedWaypoints: program.Waypoints,
|
||||
waypointTimes: [0.0, 0.024],
|
||||
segmentDurations: [0.024],
|
||||
segmentScales: [1.0],
|
||||
method: PlanningMethod.Icsp,
|
||||
iterations: 1,
|
||||
threshold: 0.0);
|
||||
var result = new TrajectoryResult(
|
||||
programName: "rewrite-speedratio-builder",
|
||||
method: PlanningMethod.Icsp,
|
||||
isValid: true,
|
||||
duration: TimeSpan.FromSeconds(0.024),
|
||||
shotEvents: Array.Empty<ShotEvent>(),
|
||||
triggerTimeline: Array.Empty<TrajectoryDoEvent>(),
|
||||
artifacts: Array.Empty<TrajectoryArtifact>(),
|
||||
failureReason: null,
|
||||
usedCache: false,
|
||||
originalWaypointCount: 2,
|
||||
plannedWaypointCount: 2);
|
||||
|
||||
var prepared = FlyshotExecutionSendSequenceBuilder.Build(
|
||||
robot,
|
||||
trajectory,
|
||||
result,
|
||||
servoPeriodSeconds: 0.008,
|
||||
requestedSpeedRatio: 0.8);
|
||||
|
||||
Assert.Equal(0.8, prepared.RequestSpeedRatio, precision: 6);
|
||||
Assert.Equal(0.8, prepared.FinalSpeedRatio, precision: 6);
|
||||
Assert.Equal(0, prepared.StretchIterationCount);
|
||||
Assert.All(prepared.Samples, sample => Assert.Equal(0.8, sample.SpeedRatio, precision: 6));
|
||||
Assert.All(prepared.TimingRows, row =>
|
||||
{
|
||||
Assert.Equal(0.8, row[3], precision: 6);
|
||||
Assert.Equal(0.8, row[4], precision: 6);
|
||||
Assert.Equal(0.0, row[5], precision: 6);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 构造一个最小 RobotProfile,便于规划层单元测试聚焦在时间轴逻辑上。
|
||||
/// </summary>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -33,6 +33,17 @@ public sealed class DebugConsoleEndpointTests(FlyshotServerFactory factory) : IC
|
||||
|
||||
Assert.Contains("Flyshot Replacement 接口调试", html, StringComparison.Ordinal);
|
||||
Assert.Contains("id=\"debug-console-app\"", html, StringComparison.Ordinal);
|
||||
Assert.Contains("id=\"debug-static-docs\"", html, StringComparison.Ordinal);
|
||||
Assert.Contains("POST /move_pose/", html, StringComparison.Ordinal);
|
||||
Assert.Contains("POST /upload_flyshot/", html, StringComparison.Ordinal);
|
||||
Assert.Contains("POST /init_mpc_robot", html, StringComparison.Ordinal);
|
||||
Assert.Contains("id=\"offline-planning-panel\"", html, StringComparison.Ordinal);
|
||||
Assert.Contains("id=\"offline-trajectory-select\"", html, StringComparison.Ordinal);
|
||||
Assert.Contains("生成轨迹不会向机器人发送命令", html, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("/init_mpc_robt", html, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("/set_active_controller/", html, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("\"sim\"", html, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("\"server_ip\"", html, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("__SWAGGER_JSON_URL__", html, StringComparison.Ordinal);
|
||||
Assert.Contains("/assets/debug.css", html, StringComparison.Ordinal);
|
||||
Assert.Contains("/assets/debug.js", html, StringComparison.Ordinal);
|
||||
@@ -42,6 +53,70 @@ public sealed class DebugConsoleEndpointTests(FlyshotServerFactory factory) : IC
|
||||
|
||||
var script = await scriptResponse.Content.ReadAsStringAsync();
|
||||
Assert.Contains("/api/debug/config", script, StringComparison.Ordinal);
|
||||
Assert.Contains("/list_flyShotTraj/", script, StringComparison.Ordinal);
|
||||
Assert.Contains("/save_traj_info/", script, StringComparison.Ordinal);
|
||||
Assert.Contains("renderOfflinePlanningResult", script, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 调试页应当为 MovePose 提供可直接发送的六字段请求体模板。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetDebugScript_ContainsMovePoseRequestSample()
|
||||
{
|
||||
using var configuredFactory = CreateFactoryWithSwaggerEnabled(true);
|
||||
using var client = configuredFactory.CreateClient();
|
||||
|
||||
using var scriptResponse = await client.GetAsync("/assets/debug.js");
|
||||
Assert.Equal(HttpStatusCode.OK, scriptResponse.StatusCode);
|
||||
|
||||
var script = await scriptResponse.Content.ReadAsStringAsync();
|
||||
Assert.Contains("/move_pose/", script, StringComparison.Ordinal);
|
||||
Assert.Contains("x: 100.0", script, StringComparison.Ordinal);
|
||||
Assert.Contains("y: 200.0", script, StringComparison.Ordinal);
|
||||
Assert.Contains("z: 300.0", script, StringComparison.Ordinal);
|
||||
Assert.Contains("w: 0.0", script, StringComparison.Ordinal);
|
||||
Assert.Contains("p: 45.0", script, StringComparison.Ordinal);
|
||||
Assert.Contains("r: 0.0", script, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 调试脚本应当优先读取静态说明并在缺失时回退到 OpenAPI / “暂无说明”。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetDebugScript_ContainsStaticDocumentationMergeLogic()
|
||||
{
|
||||
using var configuredFactory = CreateFactoryWithSwaggerEnabled(true);
|
||||
using var client = configuredFactory.CreateClient();
|
||||
|
||||
using var scriptResponse = await client.GetAsync("/assets/debug.js");
|
||||
Assert.Equal(HttpStatusCode.OK, scriptResponse.StatusCode);
|
||||
|
||||
var script = await scriptResponse.Content.ReadAsStringAsync();
|
||||
Assert.Contains("debug-static-docs", script, StringComparison.Ordinal);
|
||||
Assert.Contains("op.method + \" \" + op.path", script, StringComparison.Ordinal);
|
||||
Assert.Contains("mergedSummary", script, StringComparison.Ordinal);
|
||||
Assert.Contains("mergedDescription", script, StringComparison.Ordinal);
|
||||
Assert.Contains("requestBodyFieldDocs", script, StringComparison.Ordinal);
|
||||
Assert.Contains("暂无说明", script, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 调试卡片默认应直接展开,确保静态说明和字段表在初始页面就可见。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetDebugScript_DefaultsCardsToExpandedForVisibleDocumentation()
|
||||
{
|
||||
using var configuredFactory = CreateFactoryWithSwaggerEnabled(true);
|
||||
using var client = configuredFactory.CreateClient();
|
||||
|
||||
using var scriptResponse = await client.GetAsync("/assets/debug.js");
|
||||
Assert.Equal(HttpStatusCode.OK, scriptResponse.StatusCode);
|
||||
|
||||
var script = await scriptResponse.Content.ReadAsStringAsync();
|
||||
Assert.DoesNotContain("card.className = \"card collapsed\"", script, StringComparison.Ordinal);
|
||||
Assert.Contains("card.className = \"card\"", script, StringComparison.Ordinal);
|
||||
Assert.Contains("toggle.textContent = \"收起 ▴\"", script, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -93,11 +168,14 @@ public sealed class DebugConsoleEndpointTests(FlyshotServerFactory factory) : IC
|
||||
using var document = await System.Text.Json.JsonDocument.ParseAsync(responseStream);
|
||||
var paths = document.RootElement.GetProperty("paths");
|
||||
|
||||
// 这些路径分别覆盖:基础探活、状态快照、版本查询、上传飞拍轨迹四种典型形态。
|
||||
// 这些路径分别覆盖:基础探活、状态快照、真机初始化、上传和离线规划。
|
||||
AssertPathExists(paths, "/healthz");
|
||||
AssertPathExists(paths, "/api/status/snapshot");
|
||||
AssertPathExists(paths, "/get_server_version");
|
||||
AssertPathExists(paths, "/init_mpc_robot");
|
||||
AssertPathExists(paths, "/upload_flyshot");
|
||||
AssertPathExists(paths, "/save_traj_info");
|
||||
Assert.False(paths.TryGetProperty("/init_mpc_robt", out _));
|
||||
Assert.False(paths.TryGetProperty("/set_active_controller/", out _));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,10 +1,147 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using Flyshot.Core.Domain;
|
||||
using Flyshot.Runtime.Common;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
|
||||
namespace Flyshot.Server.IntegrationTests;
|
||||
|
||||
public sealed class FlyshotServerFactory : WebApplicationFactory<Program>;
|
||||
/// <summary>
|
||||
/// 使用可控 fake runtime 启动测试宿主,避免集成测试访问真实 FANUC 控制器。
|
||||
/// </summary>
|
||||
public sealed class FlyshotServerFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.RemoveAll<IControllerRuntime>();
|
||||
services.AddSingleton<IControllerRuntime, IntegrationFakeControllerRuntime>();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 为 HTTP 集成测试提供不访问硬件的控制器运行时。
|
||||
/// </summary>
|
||||
internal sealed class IntegrationFakeControllerRuntime : IControllerRuntime
|
||||
{
|
||||
private RobotProfile? _robot;
|
||||
private bool _connected;
|
||||
private bool _enabled;
|
||||
private double _speedRatio = 1.0;
|
||||
private double[] _tcp = [0.0, 0.0, 0.0];
|
||||
private double[] _joints = Array.Empty<double>();
|
||||
private double[] _pose = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0];
|
||||
|
||||
/// <inheritdoc />
|
||||
public void ResetRobot(RobotProfile robot, string robotName)
|
||||
{
|
||||
_robot = robot;
|
||||
_connected = false;
|
||||
_enabled = false;
|
||||
_joints = new double[robot.DegreesOfFreedom];
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Connect(string robotIp)
|
||||
{
|
||||
_ = _robot ?? throw new InvalidOperationException("Robot has not been setup.");
|
||||
_connected = true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Disconnect()
|
||||
{
|
||||
_connected = false;
|
||||
_enabled = false;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void EnableRobot(int bufferSize)
|
||||
{
|
||||
if (!_connected)
|
||||
{
|
||||
throw new InvalidOperationException("Controller has not been connected.");
|
||||
}
|
||||
|
||||
_enabled = true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void DisableRobot() => _enabled = false;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void StopMove()
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public double GetSpeedRatio() => _speedRatio;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void SetSpeedRatio(double ratio) => _speedRatio = ratio;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<double> GetTcp() => _tcp.ToArray();
|
||||
|
||||
/// <inheritdoc />
|
||||
public void SetTcp(double x, double y, double z) => _tcp = [x, y, z];
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool GetIo(int port, string ioType) => false;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void SetIo(int port, bool value, string ioType)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<double> GetJointPositions() => _joints.ToArray();
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<double> GetPose() => _pose.ToArray();
|
||||
|
||||
/// <inheritdoc />
|
||||
public ControllerStateSnapshot GetSnapshot()
|
||||
{
|
||||
return new ControllerStateSnapshot(
|
||||
DateTimeOffset.UtcNow,
|
||||
_connected ? "Connected" : "Disconnected",
|
||||
_enabled,
|
||||
false,
|
||||
_speedRatio,
|
||||
_joints,
|
||||
_pose,
|
||||
Array.Empty<RuntimeAlarm>());
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void ExecuteTrajectory(TrajectoryResult result, IReadOnlyList<double> finalJointPositions)
|
||||
{
|
||||
if (!_enabled)
|
||||
{
|
||||
throw new InvalidOperationException("Robot has not been enabled.");
|
||||
}
|
||||
|
||||
_joints = finalJointPositions.ToArray();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void ExecuteCartesianTrajectory(TrajectoryResult result, IReadOnlyList<double> finalPose)
|
||||
{
|
||||
if (!_enabled)
|
||||
{
|
||||
throw new InvalidOperationException("Robot has not been enabled.");
|
||||
}
|
||||
|
||||
_pose = [.. finalPose, 1.0];
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class HealthEndpointTests(FlyshotServerFactory factory) : IClassFixture<FlyshotServerFactory>
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Flyshot.Server.IntegrationTests;
|
||||
@@ -10,18 +11,39 @@ namespace Flyshot.Server.IntegrationTests;
|
||||
public sealed class LegacyHttpApiCompatibilityTests(FlyshotServerFactory factory) : IClassFixture<FlyshotServerFactory>
|
||||
{
|
||||
/// <summary>
|
||||
/// 验证根路径会返回旧 HTTP 服务使用的 Hello World JSON,而不是跳转到健康检查页。
|
||||
/// 验证计划中明确移除的旧路由不会再出现在宿主中。
|
||||
/// </summary>
|
||||
/// <param name="method">请求方法。</param>
|
||||
/// <param name="path">已移除路由。</param>
|
||||
[Theory]
|
||||
[InlineData("GET", "/")]
|
||||
[InlineData("POST", "/connect_server/?server_ip=127.0.0.1&port=50001")]
|
||||
[InlineData("GET", "/get_server_version/")]
|
||||
[InlineData("GET", "/get_client_version/")]
|
||||
[InlineData("POST", "/setup_robot_from_env/?env_file=test")]
|
||||
[InlineData("POST", "/set_show_tcp/")]
|
||||
[InlineData("GET", "/is_setup/")]
|
||||
[InlineData("POST", "/set_active_controller/?sim=true")]
|
||||
[InlineData("POST", "/init_mpc_robt")]
|
||||
public async Task RemovedRoutes_ReturnNotFound(string method, string path)
|
||||
{
|
||||
using var client = factory.CreateClient();
|
||||
using var request = new HttpRequestMessage(new HttpMethod(method), path);
|
||||
using var response = await client.SendAsync(request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证已移除的旧根路径返回 404。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Root_ReturnsLegacyHelloWorldPayload()
|
||||
public async Task Root_ReturnsNotFound()
|
||||
{
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
using var response = await client.GetAsync("/");
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
using var json = await ReadJsonAsync(response);
|
||||
Assert.Equal("Hello World", json.RootElement.GetProperty("message").GetString());
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -32,47 +54,7 @@ public sealed class LegacyHttpApiCompatibilityTests(FlyshotServerFactory factory
|
||||
{
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
using (var connectServerResponse = await client.PostAsync("/connect_server/?server_ip=127.0.0.1&port=50001", content: null))
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.OK, connectServerResponse.StatusCode);
|
||||
using var connectServerJson = await ReadJsonAsync(connectServerResponse);
|
||||
Assert.Equal("connected", connectServerJson.RootElement.GetProperty("status").GetString());
|
||||
}
|
||||
|
||||
using (var setupResponse = await client.PostAsync("/setup_robot/?robot_name=FANUC_LR_Mate_200iD", content: null))
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.OK, setupResponse.StatusCode);
|
||||
using var setupJson = await ReadJsonAsync(setupResponse);
|
||||
Assert.Equal("robot setup", setupJson.RootElement.GetProperty("status").GetString());
|
||||
}
|
||||
|
||||
using (var isSetupResponse = await client.GetAsync("/is_setup/"))
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.OK, isSetupResponse.StatusCode);
|
||||
using var isSetupJson = await ReadJsonAsync(isSetupResponse);
|
||||
Assert.True(isSetupJson.RootElement.GetProperty("is_setup").GetBoolean());
|
||||
}
|
||||
|
||||
using (var activeControllerResponse = await client.PostAsync("/set_active_controller/?sim=true", content: null))
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.OK, activeControllerResponse.StatusCode);
|
||||
using var activeControllerJson = await ReadJsonAsync(activeControllerResponse);
|
||||
Assert.Equal("active controller set", activeControllerJson.RootElement.GetProperty("status").GetString());
|
||||
}
|
||||
|
||||
using (var connectRobotResponse = await client.PostAsync("/connect_robot/?ip=192.168.10.101", content: null))
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.OK, connectRobotResponse.StatusCode);
|
||||
using var connectRobotJson = await ReadJsonAsync(connectRobotResponse);
|
||||
Assert.Equal("robot connected", connectRobotJson.RootElement.GetProperty("status").GetString());
|
||||
}
|
||||
|
||||
using (var enableRobotResponse = await client.GetAsync("/enable_robot/"))
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.OK, enableRobotResponse.StatusCode);
|
||||
using var enableRobotJson = await ReadJsonAsync(enableRobotResponse);
|
||||
Assert.True(enableRobotJson.RootElement.GetProperty("enable_robot").GetBoolean());
|
||||
}
|
||||
await InitializeRobotAsync(client);
|
||||
|
||||
using (var robotInfoResponse = await client.GetAsync("/robot_info/"))
|
||||
{
|
||||
@@ -131,6 +113,13 @@ public sealed class LegacyHttpApiCompatibilityTests(FlyshotServerFactory factory
|
||||
Assert.Equal("robot moved", moveJointJson.RootElement.GetProperty("status").GetString());
|
||||
}
|
||||
|
||||
using (var movePoseResponse = await client.PostAsJsonAsync("/move_pose/", new { x = 100.0, y = 200.0, z = 300.0, w = 1.0, p = 2.0, r = 3.0 }))
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.OK, movePoseResponse.StatusCode);
|
||||
using var movePoseJson = await ReadJsonAsync(movePoseResponse);
|
||||
Assert.Equal("robot moved", movePoseJson.RootElement.GetProperty("status").GetString());
|
||||
}
|
||||
|
||||
using (var getJointPositionResponse = await client.GetAsync("/get_joint_position/"))
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.OK, getJointPositionResponse.StatusCode);
|
||||
@@ -165,6 +154,32 @@ public sealed class LegacyHttpApiCompatibilityTests(FlyshotServerFactory factory
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证 MovePose 请求必须显式提供六个有限直角坐标字段,避免缺字段被模型绑定静默补 0。
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("""{"x":100.0,"y":200.0,"z":300.0,"w":1.0,"p":2.0}""")]
|
||||
[InlineData("null")]
|
||||
[InlineData("""{"x":1e999,"y":200.0,"z":300.0,"w":1.0,"p":2.0,"r":3.0}""")]
|
||||
[InlineData("""{"x":1000.1,"y":200.0,"z":300.0,"w":1.0,"p":2.0,"r":3.0}""")]
|
||||
[InlineData("""{"x":100.0,"y":-1000.1,"z":300.0,"w":1.0,"p":2.0,"r":3.0}""")]
|
||||
[InlineData("""{"x":100.0,"y":200.0,"z":-0.1,"w":1.0,"p":2.0,"r":3.0}""")]
|
||||
[InlineData("""{"x":100.0,"y":200.0,"z":300.0,"w":-180.1,"p":2.0,"r":3.0}""")]
|
||||
[InlineData("""{"x":100.0,"y":200.0,"z":300.0,"w":1.0,"p":90.1,"r":3.0}""")]
|
||||
[InlineData("""{"x":100.0,"y":200.0,"z":300.0,"w":1.0,"p":2.0,"r":180.1}""")]
|
||||
public async Task MovePose_InvalidPayload_ReturnsLegacyBadRequest(string payload)
|
||||
{
|
||||
using var client = factory.CreateClient();
|
||||
await InitializeRobotAsync(client);
|
||||
|
||||
using var content = new StringContent(payload, Encoding.UTF8, "application/json");
|
||||
using var response = await client.PostAsync("/move_pose/", content);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
using var json = await ReadJsonAsync(response);
|
||||
Assert.Equal("MovePose failed", json.RootElement.GetProperty("detail").GetString());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证飞拍 HTTP 接口可以按旧 API 层的路径和字段完成上传、列出、执行与删除。
|
||||
/// </summary>
|
||||
@@ -173,6 +188,10 @@ public sealed class LegacyHttpApiCompatibilityTests(FlyshotServerFactory factory
|
||||
{
|
||||
using var client = factory.CreateClient();
|
||||
await InitializeRobotAsync(client);
|
||||
using (var speedResponse = await client.PostAsJsonAsync("/set_speedRatio/", new { speed = 0.5 }))
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.OK, speedResponse.StatusCode);
|
||||
}
|
||||
|
||||
var uploadPayload = new
|
||||
{
|
||||
@@ -188,10 +207,10 @@ public sealed class LegacyHttpApiCompatibilityTests(FlyshotServerFactory factory
|
||||
shot_flags = new[] { false, true, false, false },
|
||||
waypoints = new[]
|
||||
{
|
||||
new[] { 0.1, 0.2, 0.3, 0.4, 0.5, 0.6 },
|
||||
new[] { 0.2, 0.2, 0.3, 0.4, 0.5, 0.6 },
|
||||
new[] { 0.3, 0.2, 0.3, 0.4, 0.5, 0.6 },
|
||||
new[] { 0.4, 0.2, 0.3, 0.4, 0.5, 0.6 }
|
||||
new[] { 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 },
|
||||
new[] { 0.001, 0.0, 0.0, 0.0, 0.0, 0.0 },
|
||||
new[] { 0.002, 0.0, 0.0, 0.0, 0.0, 0.0 },
|
||||
new[] { 0.003, 0.0, 0.0, 0.0, 0.0, 0.0 }
|
||||
}
|
||||
};
|
||||
|
||||
@@ -248,7 +267,8 @@ public sealed class LegacyHttpApiCompatibilityTests(FlyshotServerFactory factory
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.OK, saveInfoResponse.StatusCode);
|
||||
using var saveInfoJson = await ReadJsonAsync(saveInfoResponse);
|
||||
Assert.True(saveInfoJson.RootElement.GetProperty("success").GetBoolean());
|
||||
Assert.True(saveInfoJson.RootElement.GetProperty("isValid").GetBoolean());
|
||||
Assert.True(saveInfoJson.RootElement.GetProperty("finalSendPointCount").GetInt32() > 0);
|
||||
}
|
||||
|
||||
using (var deleteResponse = await client.PostAsJsonAsync("/delete_flyshot/", new { name = "demo-http-flyshot" }))
|
||||
@@ -264,13 +284,10 @@ public sealed class LegacyHttpApiCompatibilityTests(FlyshotServerFactory factory
|
||||
/// </summary>
|
||||
private static async Task InitializeRobotAsync(HttpClient client)
|
||||
{
|
||||
using var initResponse = await client.PostAsJsonAsync("/init_mpc_robt", new
|
||||
using var initResponse = await client.PostAsJsonAsync("/init_mpc_robot", new
|
||||
{
|
||||
server_ip = "127.0.0.1",
|
||||
port = 50001,
|
||||
robot_name = "FANUC_LR_Mate_200iD",
|
||||
robot_ip = "192.168.10.101",
|
||||
sim = true
|
||||
robot_ip = "192.168.10.101"
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, initResponse.StatusCode);
|
||||
|
||||
@@ -34,6 +34,60 @@ public sealed class StatusEndpointTests(FlyshotServerFactory factory) : IClassFi
|
||||
Assert.Contains("/api/status/snapshot", script, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 状态页应当提供双摇杆直角坐标点动面板,并复用现有 MovePose HTTP 接口。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetStatusPageAssets_ExposeCartesianJogControls()
|
||||
{
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
using var htmlResponse = await client.GetAsync("/status.html");
|
||||
Assert.Equal(HttpStatusCode.OK, htmlResponse.StatusCode);
|
||||
|
||||
var html = await htmlResponse.Content.ReadAsStringAsync();
|
||||
Assert.Contains("直角坐标点动", html, StringComparison.Ordinal);
|
||||
Assert.Contains("XYZ 平移摇杆", html, StringComparison.Ordinal);
|
||||
Assert.Contains("WPR 姿态摇杆", html, StringComparison.Ordinal);
|
||||
Assert.Contains("id=\"linear-step\"", html, StringComparison.Ordinal);
|
||||
Assert.Contains("id=\"angular-step\"", html, StringComparison.Ordinal);
|
||||
Assert.Contains("max=\"20\"", html, StringComparison.Ordinal);
|
||||
Assert.Contains("请求日志", html, StringComparison.Ordinal);
|
||||
Assert.Contains("id=\"request-log\"", html, StringComparison.Ordinal);
|
||||
Assert.Contains("id=\"clear-log\"", html, StringComparison.Ordinal);
|
||||
|
||||
foreach (var (axis, negative, positive) in new[]
|
||||
{
|
||||
("x", "X-", "X+"),
|
||||
("y", "Y-", "Y+"),
|
||||
("z", "Z-", "Z+"),
|
||||
("w", "W-", "W+"),
|
||||
("p", "P-", "P+"),
|
||||
("r", "R-", "R+")
|
||||
})
|
||||
{
|
||||
Assert.Contains($"data-axis=\"{axis}\"", html, StringComparison.Ordinal);
|
||||
Assert.Contains($"data-axis=\"{axis}\" data-direction=\"1\"", html, StringComparison.Ordinal);
|
||||
Assert.Contains($"data-axis=\"{axis}\" data-direction=\"-1\"", html, StringComparison.Ordinal);
|
||||
Assert.Contains(negative, html, StringComparison.Ordinal);
|
||||
Assert.Contains(positive, html, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
using var scriptResponse = await client.GetAsync("/assets/status.js");
|
||||
Assert.Equal(HttpStatusCode.OK, scriptResponse.StatusCode);
|
||||
|
||||
var script = await scriptResponse.Content.ReadAsStringAsync();
|
||||
Assert.Contains("/api/status/snapshot", script, StringComparison.Ordinal);
|
||||
Assert.Contains("/move_pose/", script, StringComparison.Ordinal);
|
||||
Assert.Contains("cartesianPose", script, StringComparison.Ordinal);
|
||||
Assert.Contains("pointerdown", script, StringComparison.Ordinal);
|
||||
Assert.Contains("pointerup", script, StringComparison.Ordinal);
|
||||
Assert.Contains("pointerleave", script, StringComparison.Ordinal);
|
||||
Assert.Contains("blur", script, StringComparison.Ordinal);
|
||||
Assert.Contains("request-log", script, StringComparison.Ordinal);
|
||||
Assert.Contains("clear-log", script, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证状态快照 API 会返回运行时连接、使能、速度和机器人元数据。
|
||||
/// </summary>
|
||||
@@ -59,13 +113,15 @@ public sealed class StatusEndpointTests(FlyshotServerFactory factory) : IClassFi
|
||||
|
||||
Assert.Equal("ok", root.GetProperty("status").GetString());
|
||||
Assert.True(root.GetProperty("isSetup").GetBoolean());
|
||||
Assert.Equal("RealReady", root.GetProperty("operatingMode").GetString());
|
||||
Assert.True(root.GetProperty("canPlanOffline").GetBoolean());
|
||||
Assert.True(root.GetProperty("canExecuteRealMotion").GetBoolean());
|
||||
Assert.Equal("FANUC_LR_Mate_200iD", root.GetProperty("robotName").GetString());
|
||||
Assert.Equal(6, root.GetProperty("degreesOfFreedom").GetInt32());
|
||||
var uploadedTrajectories = root.GetProperty("uploadedTrajectories")
|
||||
.EnumerateArray()
|
||||
.Select(static value => value.GetString())
|
||||
.ToArray();
|
||||
Assert.Contains("20251015", uploadedTrajectories);
|
||||
Assert.Contains("UTTC_MS11", uploadedTrajectories);
|
||||
Assert.Equal("Connected", snapshot.GetProperty("connectionState").GetString());
|
||||
Assert.True(snapshot.GetProperty("isEnabled").GetBoolean());
|
||||
@@ -85,11 +141,6 @@ public sealed class StatusEndpointTests(FlyshotServerFactory factory) : IClassFi
|
||||
Assert.Equal(HttpStatusCode.OK, setupResponse.StatusCode);
|
||||
}
|
||||
|
||||
using (var activeControllerResponse = await client.PostAsync("/set_active_controller/?sim=true", content: null))
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.OK, activeControllerResponse.StatusCode);
|
||||
}
|
||||
|
||||
using (var connectRobotResponse = await client.PostAsync("/connect_robot/?ip=192.168.10.101", content: null))
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.OK, connectRobotResponse.StatusCode);
|
||||
|
||||
252
tests/Publishing/Publish-Obfuscated.Tests.ps1
Normal file
252
tests/Publishing/Publish-Obfuscated.Tests.ps1
Normal file
@@ -0,0 +1,252 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
验证混淆发布脚本生成的 Obfuscar 配置满足仓库发布契约。
|
||||
|
||||
.DESCRIPTION
|
||||
该测试使用临时空文件模拟发布目录,仅验证模块筛选、绝对路径和失败语义,
|
||||
不会执行 dotnet publish,也不会要求测试环境安装 Obfuscar。
|
||||
#>
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$repositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
|
||||
$publishScriptPath = Join-Path $repositoryRoot 'scripts\Publish-Obfuscated.ps1'
|
||||
$publishProfilePath = Join-Path $repositoryRoot 'src\Flyshot.Server.Host\Properties\PublishProfiles\FolderProfile.pubxml'
|
||||
$temporaryRoot = Join-Path ([System.IO.Path]::GetTempPath()) "flyshot-obfuscation-test-$([guid]::NewGuid().ToString('N'))"
|
||||
$inputDirectory = Join-Path $temporaryRoot 'input'
|
||||
$outputDirectory = Join-Path $temporaryRoot 'obfuscated'
|
||||
$configPath = Join-Path $temporaryRoot 'obfuscar.xml'
|
||||
|
||||
$expectedAssemblies = @(
|
||||
'Flyshot.Server.Host.dll'
|
||||
'Flyshot.ControllerClientCompat.dll'
|
||||
'Flyshot.Core.Config.dll'
|
||||
'Flyshot.Core.Domain.dll'
|
||||
'Flyshot.Core.Planning.dll'
|
||||
'Flyshot.Core.Triggering.dll'
|
||||
'Flyshot.Runtime.Fanuc.dll'
|
||||
'Flyshot.Runtime.Common.dll'
|
||||
)
|
||||
$futureAssemblyName = 'Flyshot.Future.Module.dll'
|
||||
|
||||
try
|
||||
{
|
||||
$publishScriptContent = Get-Content -LiteralPath $publishScriptPath -Raw
|
||||
$publishScriptTokens = $null
|
||||
$publishScriptErrors = $null
|
||||
$publishScriptAst = [System.Management.Automation.Language.Parser]::ParseInput(
|
||||
$publishScriptContent,
|
||||
[ref]$publishScriptTokens,
|
||||
[ref]$publishScriptErrors)
|
||||
|
||||
if ($publishScriptErrors.Count -ne 0)
|
||||
{
|
||||
throw "Publish-Obfuscated.ps1 存在 PowerShell 语法错误:$($publishScriptErrors[0].Message)"
|
||||
}
|
||||
|
||||
$scriptParameters = @($publishScriptAst.ParamBlock.Parameters | ForEach-Object {
|
||||
$_.Name.VariablePath.UserPath
|
||||
})
|
||||
if ('RuntimeIdentifiers' -in $scriptParameters -or 'ExistingRuntimeIdentifier' -in $scriptParameters)
|
||||
{
|
||||
throw '发布脚本不应再暴露运行平台或 RID 参数。'
|
||||
}
|
||||
|
||||
if ($publishScriptContent -match "'-r'|--self-contained'\s*,?\s*'true'|win-x64|linux-x64")
|
||||
{
|
||||
throw '发布脚本默认发布流程不得再指定 RID 或生成自包含平台包。'
|
||||
}
|
||||
|
||||
if ($publishScriptContent -notmatch '-p:UseAppHost=false')
|
||||
{
|
||||
throw '框架依赖发布必须关闭平台相关 apphost,只保留跨平台 DLL 入口。'
|
||||
}
|
||||
|
||||
[xml]$publishProfile = Get-Content -LiteralPath $publishProfilePath -Raw
|
||||
$publishNamespace = [System.Xml.XmlNamespaceManager]::new($publishProfile.NameTable)
|
||||
$publishNamespace.AddNamespace('msb', $publishProfile.Project.NamespaceURI)
|
||||
|
||||
$useAppHost = $publishProfile.SelectSingleNode(
|
||||
'/msb:Project/msb:PropertyGroup/msb:UseAppHost',
|
||||
$publishNamespace)
|
||||
if ($null -eq $useAppHost -or $useAppHost.InnerText -ne 'false')
|
||||
{
|
||||
throw 'FolderProfile.pubxml 必须关闭平台相关 apphost。'
|
||||
}
|
||||
|
||||
$afterPublishTarget = $publishProfile.SelectSingleNode(
|
||||
'/msb:Project/msb:Target[@Name="ObfuscatePublishedFlyshotAssemblies"]',
|
||||
$publishNamespace)
|
||||
if ($null -eq $afterPublishTarget -or $afterPublishTarget.AfterTargets -ne 'Publish')
|
||||
{
|
||||
throw 'FolderProfile.pubxml 必须注册 Publish 完成后的混淆 Target。'
|
||||
}
|
||||
|
||||
$publishExec = $afterPublishTarget.SelectSingleNode('msb:Exec', $publishNamespace)
|
||||
if (
|
||||
$null -eq $publishExec -or
|
||||
$publishExec.Command -notmatch '-ObfuscateExistingPublish' -or
|
||||
$publishExec.Command -notmatch '\$\(PublishDir\)\.' -or
|
||||
$publishExec.Command -match 'RuntimeIdentifier'
|
||||
)
|
||||
{
|
||||
throw 'Visual Studio 发布 Target 必须把 PublishDir 以安全的非反斜杠结尾形式交给混淆模式。'
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path $inputDirectory -Force | Out-Null
|
||||
|
||||
foreach ($assemblyName in $expectedAssemblies)
|
||||
{
|
||||
New-Item -ItemType File -Path (Join-Path $inputDirectory $assemblyName) -Force | Out-Null
|
||||
}
|
||||
|
||||
# 模拟未来新增模块,证明脚本会自动保护所有 Flyshot.* 程序集,而不是只处理当前固定清单。
|
||||
New-Item -ItemType File -Path (Join-Path $inputDirectory $futureAssemblyName) -Force | Out-Null
|
||||
|
||||
# 第三方程序集用于证明配置生成器只选择 Flyshot 自研模块。
|
||||
New-Item -ItemType File -Path (Join-Path $inputDirectory 'NLog.dll') -Force | Out-Null
|
||||
|
||||
& pwsh -NoProfile -File $publishScriptPath `
|
||||
-GenerateConfigOnly `
|
||||
-InputDirectory $inputDirectory `
|
||||
-ObfuscatedDirectory $outputDirectory `
|
||||
-ConfigPath $configPath
|
||||
|
||||
if ($LASTEXITCODE -ne 0)
|
||||
{
|
||||
throw "配置生成命令退出码不是 0:$LASTEXITCODE"
|
||||
}
|
||||
|
||||
[xml]$configuration = Get-Content -LiteralPath $configPath -Raw
|
||||
$modulePaths = @($configuration.Obfuscator.Module | ForEach-Object { [string]$_.file })
|
||||
|
||||
$allExpectedAssemblies = @($expectedAssemblies) + $futureAssemblyName
|
||||
if ($modulePaths.Count -ne $allExpectedAssemblies.Count)
|
||||
{
|
||||
throw "预期生成 $($allExpectedAssemblies.Count) 个模块,实际为 $($modulePaths.Count) 个。"
|
||||
}
|
||||
|
||||
foreach ($assemblyName in $allExpectedAssemblies)
|
||||
{
|
||||
$expectedPath = [System.IO.Path]::GetFullPath((Join-Path $inputDirectory $assemblyName))
|
||||
if ($expectedPath -notin $modulePaths)
|
||||
{
|
||||
throw "Obfuscar 配置缺少自研程序集:$assemblyName"
|
||||
}
|
||||
}
|
||||
|
||||
if ($modulePaths | Where-Object { [System.IO.Path]::GetFileName($_) -eq 'NLog.dll' })
|
||||
{
|
||||
throw '第三方程序集 NLog.dll 不应进入 Obfuscar 模块列表。'
|
||||
}
|
||||
|
||||
foreach ($path in $modulePaths)
|
||||
{
|
||||
if (-not [System.IO.Path]::IsPathFullyQualified($path))
|
||||
{
|
||||
throw "Obfuscar 模块路径不是绝对路径:$path"
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($module in @($configuration.Obfuscator.Module))
|
||||
{
|
||||
$anonymousTypeRule = @($module.SkipType | Where-Object {
|
||||
[string]$_.name -eq '^<>f__AnonymousType.*'
|
||||
})
|
||||
|
||||
if ($anonymousTypeRule.Count -ne 1)
|
||||
{
|
||||
throw "模块 $($module.file) 必须显式保留匿名类型元数据。"
|
||||
}
|
||||
|
||||
if (
|
||||
[string]$anonymousTypeRule[0].skipMethods -ne 'true' -or
|
||||
[string]$anonymousTypeRule[0].skipFields -ne 'true' -or
|
||||
[string]$anonymousTypeRule[0].skipProperties -ne 'true' -or
|
||||
[string]$anonymousTypeRule[0].skipEvents -ne 'true'
|
||||
)
|
||||
{
|
||||
throw "模块 $($module.file) 的匿名类型排除规则不完整。"
|
||||
}
|
||||
}
|
||||
|
||||
$assemblySearchPaths = @($configuration.Obfuscator.AssemblySearchPath | ForEach-Object {
|
||||
[string]$_.path
|
||||
})
|
||||
if ($assemblySearchPaths.Count -lt 2)
|
||||
{
|
||||
throw 'Obfuscar 配置必须包含 .NETCore.App 与 ASP.NET Core 共享框架搜索目录。'
|
||||
}
|
||||
|
||||
foreach ($assemblySearchPath in $assemblySearchPaths)
|
||||
{
|
||||
if (-not [System.IO.Path]::IsPathFullyQualified($assemblySearchPath))
|
||||
{
|
||||
throw "共享框架搜索目录不是绝对路径:$assemblySearchPath"
|
||||
}
|
||||
}
|
||||
|
||||
if (-not ($assemblySearchPaths | Where-Object { $_ -match 'Microsoft\.NETCore\.App' }))
|
||||
{
|
||||
throw 'Obfuscar 配置缺少 Microsoft.NETCore.App 搜索目录。'
|
||||
}
|
||||
|
||||
if (-not ($assemblySearchPaths | Where-Object { $_ -match 'Microsoft\.AspNetCore\.App' }))
|
||||
{
|
||||
throw 'Obfuscar 配置缺少 Microsoft.AspNetCore.App 搜索目录。'
|
||||
}
|
||||
|
||||
$variables = @{}
|
||||
foreach ($variable in $configuration.Obfuscator.Var)
|
||||
{
|
||||
$variables[[string]$variable.name] = [string]$variable.value
|
||||
}
|
||||
|
||||
if ($variables['KeepPublicApi'] -ne 'true')
|
||||
{
|
||||
throw 'KeepPublicApi 必须为 true,以保护 ASP.NET Core 和公共契约。'
|
||||
}
|
||||
|
||||
if ($variables['HidePrivateApi'] -ne 'true')
|
||||
{
|
||||
throw 'HidePrivateApi 必须为 true,以混淆内部实现。'
|
||||
}
|
||||
|
||||
if ($variables['RenameProperties'] -ne 'false' -or $variables['RenameEvents'] -ne 'false')
|
||||
{
|
||||
throw '属性和事件重命名必须关闭,以保护配置绑定及序列化契约。'
|
||||
}
|
||||
|
||||
if ($variables['SkipGenerated'] -ne 'true')
|
||||
{
|
||||
throw '编译器生成类型必须跳过混淆,以保护匿名 JSON 响应和异步状态机。'
|
||||
}
|
||||
|
||||
if ($variables['SkipSpecialName'] -ne 'true')
|
||||
{
|
||||
throw 'SpecialName 成员必须跳过混淆,以保护属性、事件和框架反射约定。'
|
||||
}
|
||||
|
||||
Remove-Item -LiteralPath (Join-Path $inputDirectory 'Flyshot.Runtime.Common.dll') -Force
|
||||
|
||||
$missingAssemblyConfigPath = Join-Path $temporaryRoot 'missing-assembly.xml'
|
||||
& pwsh -NoProfile -File $publishScriptPath `
|
||||
-GenerateConfigOnly `
|
||||
-InputDirectory $inputDirectory `
|
||||
-ObfuscatedDirectory $outputDirectory `
|
||||
-ConfigPath $missingAssemblyConfigPath *> $null
|
||||
|
||||
if ($LASTEXITCODE -eq 0)
|
||||
{
|
||||
throw '缺少预期的 Flyshot 程序集时,配置生成必须失败。'
|
||||
}
|
||||
|
||||
Write-Host 'Publish-Obfuscated.Tests.ps1: PASS'
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Test-Path -LiteralPath $temporaryRoot)
|
||||
{
|
||||
Remove-Item -LiteralPath $temporaryRoot -Recurse -Force
|
||||
}
|
||||
}
|
||||
35
tests/Publishing/Test-PublishedArtifactPrivacy.ps1
Normal file
35
tests/Publishing/Test-PublishedArtifactPrivacy.ps1
Normal file
@@ -0,0 +1,35 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
验证混淆发布包不会泄露 Flyshot 源码文档或调试符号。
|
||||
|
||||
.PARAMETER PublishDirectory
|
||||
需要检查的框架依赖混淆发布目录。
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$PublishDirectory = (Join-Path $PSScriptRoot '..\..\outputs\obfuscated\framework-dependent')
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$absolutePublishDirectory = [System.IO.Path]::GetFullPath($PublishDirectory)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $absolutePublishDirectory -PathType Container))
|
||||
{
|
||||
throw "混淆发布目录不存在:$absolutePublishDirectory"
|
||||
}
|
||||
|
||||
$sensitiveFiles = @(
|
||||
Get-ChildItem -LiteralPath $absolutePublishDirectory -File |
|
||||
Where-Object {
|
||||
$_.Name -like 'Flyshot*.xml' -or
|
||||
$_.Name -like 'Flyshot*.pdb'
|
||||
}
|
||||
)
|
||||
|
||||
if ($sensitiveFiles.Count -gt 0)
|
||||
{
|
||||
throw "混淆发布包仍包含源码文档或调试符号:$($sensitiveFiles.Name -join ', ')"
|
||||
}
|
||||
|
||||
Write-Host 'Test-PublishedArtifactPrivacy.ps1: PASS'
|
||||
Reference in New Issue
Block a user