Merge branch 'dev'

This commit is contained in:
2026-10-30 13:53:22 +08:00
48 changed files with 4721 additions and 1133 deletions

View File

@@ -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
View File

@@ -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
View File

@@ -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": []
}
]
}

View File

@@ -1,305 +1,454 @@
{
"robot": {
"use_do": true,
"io_addr": [
7,
8
],
"io_keep_cycles": 2,
"trigger_sample_index_offset_cycles": 6,
"acc_limit": 1,
"jerk_limit": 1,
"adapt_icsp_try_num": 5,
"speed_ratio": 1,
"planning_speed_scale": 0.74227,
"smooth_start_stop_timing": true
},
"flying_shots": {
"UTTC_MS11": {
"traj_waypoints": [
[
1.056731,
0.011664811,
-0.017892333,
-0.01516874,
0.021492079,
0.009567846
],
[
0.8532358,
0.03837953,
-0.19235304,
0.0071595116,
0.109054826,
0.040055145
],
[
0.96600056,
0.20607172,
-0.12233179,
-1.2394339,
0.10493033,
1.2958988
],
[
0.9618476,
0.15288207,
-0.14867093,
-0.7176314,
0.1764264,
0.73228663
],
[
0.76189893,
-0.028442925,
-0.30919823,
0.10463613,
0.5615024,
-0.39399016
],
[
1.1271763,
0.074403025,
-0.27347943,
-0.5227772,
0.52098846,
0.79633313
],
[
1.0555661,
0.4026262,
-0.08746306,
0.6301835,
0.09644133,
-0.5463328
],
[
1.2300354,
0.28612664,
-0.23486805,
-0.4868128,
0.25369516,
0.55347764
],
[
1.2144431,
-0.29855102,
-0.15202847,
-1.0205934,
0.13317892,
1.1246506
],
[
1.2840607,
-0.11222197,
-0.16805042,
-2.248135,
0.2560587,
2.4434967
],
[
1.3189346,
-0.25620222,
-0.12730704,
-2.285038,
0.30872014,
2.4765089
],
[
1.502615,
-0.25304365,
-0.23878741,
-1.2194318,
0.46674785,
1.5533328
],
[
1.07723,
-0.07387611,
-0.1707704,
-1.8916591,
0.38677844,
2.061968
],
[
1.3920237,
0.08098731,
-0.2672306,
-0.9780007,
0.4561093,
0.9102286
],
[
1.9016331,
0.023924276,
-0.58633333,
-0.8441697,
0.76730615,
1.4842151
],
[
1.9300697,
-0.06738541,
-0.56542397,
-0.892083,
0.77194446,
1.5293273
],
[
2.0611632,
-0.30327517,
-0.54225636,
-1.0395275,
0.8505439,
1.6429617
],
[
1.0921186,
-0.40034482,
-0.1803499,
1.3524796,
0.6210477,
-1.2159473
],
[
1.0521278,
-0.40034503,
-0.1803492,
1.3524843,
0.6210471,
-1.2159531
],
[
1.056731,
0.011664811,
-0.017892333,
-0.01516874,
0.021492079,
0.009567846
]
],
"shot_flags": [
false,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
false,
true,
false
],
"offset_values": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"addr": [
[],
[
2,
4
],
[
3,
4,
2
],
[
3,
4,
2
],
[
4,
2
],
[
4,
2
],
[
3,
4
],
[
3,
4
],
[
4,
2
],
[
4,
2
],
[
4,
2
],
[
4,
2
],
[
4,
2
],
[
4,
3
],
[
4,
2
],
[
4,
2
],
[
4,
2
],
[
4,
2
],
[
4,
3
],
[]
]
}
}
}
{
"robot": {
"use_do": true,
"io_addr": [
7,
8
],
"io_keep_cycles": 2,
"trigger_sample_index_offset_cycles": 6,
"acc_limit": 1,
"jerk_limit": 1,
"adapt_icsp_try_num": 5,
"planning_speed_scale": 0.74227,
"speed_ratio": 1,
"smooth_start_stop_timing": true
},
"flying_shots": {
"UTTC_MS11": {
"traj_waypoints": [
[
1.056731,
0.011664811,
-0.017892333,
-0.01516874,
0.021492079,
0.009567846
],
[
0.8532358,
0.03837953,
-0.19235304,
0.0071595116,
0.109054826,
0.040055145
],
[
0.96600056,
0.20607172,
-0.12233179,
-1.2394339,
0.10493033,
1.2958988
],
[
0.9618476,
0.15288207,
-0.14867093,
-0.7176314,
0.1764264,
0.73228663
],
[
0.76189893,
-0.028442925,
-0.30919823,
0.10463613,
0.5615024,
-0.39399016
],
[
1.1271763,
0.074403025,
-0.27347943,
-0.5227772,
0.52098846,
0.79633313
],
[
1.0555661,
0.4026262,
-0.08746306,
0.6301835,
0.09644133,
-0.5463328
],
[
1.2300354,
0.28612664,
-0.23486805,
-0.4868128,
0.25369516,
0.55347764
],
[
1.2144431,
-0.29855102,
-0.15202847,
-1.0205934,
0.13317892,
1.1246506
],
[
1.2840607,
-0.11222197,
-0.16805042,
-2.248135,
0.2560587,
2.4434967
],
[
1.3189346,
-0.25620222,
-0.12730704,
-2.285038,
0.30872014,
2.4765089
],
[
1.502615,
-0.25304365,
-0.23878741,
-1.2194318,
0.46674785,
1.5533328
],
[
1.07723,
-0.07387611,
-0.1707704,
-1.8916591,
0.38677844,
2.061968
],
[
1.3920237,
0.08098731,
-0.2672306,
-0.9780007,
0.4561093,
0.9102286
],
[
1.9016331,
0.023924276,
-0.58633333,
-0.8441697,
0.76730615,
1.4842151
],
[
1.9300697,
-0.06738541,
-0.56542397,
-0.892083,
0.77194446,
1.5293273
],
[
2.0611632,
-0.30327517,
-0.54225636,
-1.0395275,
0.8505439,
1.6429617
],
[
1.0921186,
-0.40034482,
-0.1803499,
1.3524796,
0.6210477,
-1.2159473
],
[
1.0521278,
-0.40034503,
-0.1803492,
1.3524843,
0.6210471,
-1.2159531
],
[
1.056731,
0.011664811,
-0.017892333,
-0.01516874,
0.021492079,
0.009567846
]
],
"shot_flags": [
false,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
false,
true,
false
],
"offset_values": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"addr": [
[],
[
2,
4
],
[
3,
4,
2
],
[
3,
4,
2
],
[
4,
2
],
[
4,
2
],
[
3,
4
],
[
3,
4
],
[
4,
2
],
[
4,
2
],
[
4,
2
],
[
4,
2
],
[
4,
2
],
[
4,
3
],
[
4,
2
],
[
4,
2
],
[
4,
2
],
[
4,
2
],
[
4,
3
],
[]
]
},
"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
],
[],
[]
]
}
}
}

View File

@@ -24,6 +24,9 @@
- 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`。
单位约定总览:
@@ -66,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`。
@@ -110,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. 真机联调与运行安全
@@ -120,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。

View 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}'
```

View File

@@ -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` 与抓包一致。

View File

@@ -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 手册算法靠近。

View 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. 常见问题排查
```

View File

@@ -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`,确认没有第三方程序集或映射文件进入部署包。

View File

@@ -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 离线规划页面。
- 最终执行核心测试、集成测试和解决方案构建。

View File

@@ -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 稳定运行所需的安全边界;若未来需要更强的控制流混淆、字符串加密或防篡改,应评估商业混淆器。

View 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 脉冲与清零。软件测试不代表末端接线和真实相机触发已完成验收。

View File

@@ -1,6 +1,6 @@
{
"sdk": {
"version": "8.0.420",
"rollForward": "disable"
"version": "8.0.422",
"rollForward": "latestPatch"
}
}

View 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
}

View File

@@ -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;
}

View File

@@ -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);

View File

@@ -25,11 +25,12 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
private RobotProfile? _activeRobotProfile;
private string? _configuredRobotName;
private CompatibilityRobotSettings? _robotSettings;
private string? _connectedServerIp;
private int _connectedServerPort;
private bool _showTcp = true;
private double _showTcpAxisLength = 0.1;
private int _showTcpAxisSize = 2;
private string _operatingMode = "ModelLoadFailed";
private string _initializationStage = "NotStarted";
private string? _robotIp;
private string? _lastConnectionError;
private DateTimeOffset? _lastConnectionErrorAt;
private DateTimeOffset? _lastInitializationAttemptAt;
/// <summary>
/// 初始化一份 HTTP-only 的 ControllerClient 兼容服务。
@@ -60,6 +61,15 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
_artifactWriter = artifactWriter ?? new FlyshotTrajectoryArtifactWriter(_options, new RobotModelLoader());
_trajectoryStore = trajectoryStore ?? new JsonFlyshotTrajectoryStore(_options, _configLoader);
_logger = logger;
// 飞拍轨迹是固定 RobotConfig.json 中的独立资产,服务构造后立即加载,不依赖机器人模型。
var savedTrajectories = _trajectoryStore.LoadAll(out var savedSettings);
foreach (var saved in savedTrajectories)
{
_uploadedTrajectories[saved.Key] = saved.Value;
}
_robotSettings = savedSettings;
}
/// <inheritdoc />
@@ -82,125 +92,111 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
/// </summary>
public bool IsInMotion => _runtime.GetSnapshot().IsInMotion;
/// <inheritdoc />
public void ConnectServer(string serverIp, int port)
{
if (string.IsNullOrWhiteSpace(serverIp))
{
throw new ArgumentException("服务端 IP 不能为空。", nameof(serverIp));
}
if (port <= 0)
{
throw new ArgumentOutOfRangeException(nameof(port), "端口必须大于 0。");
}
lock (_stateLock)
{
// HTTP-only 阶段仍记录旧客户端期望的 50001 地址,便于后续 TCP 入口恢复时复用状态。
_connectedServerIp = serverIp;
_connectedServerPort = port;
}
_logger?.LogInformation("ConnectServer 完成: {ServerIp}:{Port}", serverIp, port);
}
/// <inheritdoc />
public string GetServerVersion()
{
return ServerVersion;
}
/// <inheritdoc />
public string GetClientVersion()
{
return "flyshot-replacement-controller-client-compat/0.1.0";
}
/// <inheritdoc />
public void SetUpRobot(string robotName)
{
_logger?.LogInformation("SetUpRobot 开始: robotName={RobotName}", robotName);
var robotSettings = TryLoadRobotSettings() ?? CreateDefaultRobotSettings();
var robotProfile = _robotCatalog.LoadProfile(
robotName,
robotSettings.AccLimitScale,
robotSettings.JerkLimitScale);
lock (_stateLock)
{
// 机器人重新初始化时同步重置运行时和上传轨迹目录,保持旧服务初始化语义。
_configuredRobotName = robotName;
_activeRobotProfile = robotProfile;
_uploadedTrajectories.Clear();
_runtime.ResetRobot(robotProfile, robotName);
// RobotConfig.json 的 speed_ratio 是启动默认值;在线 set_speedRatio 仍可覆盖后续执行。
_runtime.SetSpeedRatio(robotSettings.SpeedRatio);
_robotSettings = robotSettings;
_initializationStage = "LoadingModel";
}
// 从持久化存储恢复该机器人名下之前已上传的轨迹。
var savedTrajectories = _trajectoryStore.LoadAll(robotName, out _);
foreach (var saved in savedTrajectories)
try
{
var robotSettings = TryLoadRobotSettings() ?? _robotSettings ?? CreateDefaultRobotSettings();
var robotProfile = _robotCatalog.LoadProfile(
robotName,
robotSettings.AccLimitScale,
robotSettings.JerkLimitScale);
lock (_stateLock)
{
_uploadedTrajectories[saved.Key] = saved.Value;
// 重新加载模型只重置 Runtime;飞拍资产继续保留在固定 RobotConfig.json 中。
_configuredRobotName = robotName;
_activeRobotProfile = robotProfile;
_runtime.ResetRobot(robotProfile, robotName);
_runtime.SetSpeedRatio(robotSettings.SpeedRatio);
_robotSettings = robotSettings;
_operatingMode = "OfflineReady";
_initializationStage = "ModelLoaded";
}
_logger?.LogInformation(
"SetUpRobot 完成: robotName={RobotName}, dof={Dof}, accLimit={AccLimit}, jerkLimit={JerkLimit}, speedRatio={SpeedRatio}, 保留轨迹数={TrajCount}",
robotName,
robotProfile.DegreesOfFreedom,
robotSettings.AccLimitScale,
robotSettings.JerkLimitScale,
robotSettings.SpeedRatio,
_uploadedTrajectories.Count);
}
catch
{
try
{
_runtime.Disconnect();
}
catch
{
// 模型切换失败时优先清理上层状态;底层若尚未初始化,Disconnect 失败可忽略。
}
lock (_stateLock)
{
_activeRobotProfile = null;
_configuredRobotName = null;
_operatingMode = "ModelLoadFailed";
_initializationStage = "ModelLoadFailed";
}
throw;
}
}
/// <inheritdoc />
public void InitializeRobot(string robotName, string robotIp, int bufferSize = 8)
{
lock (_stateLock)
{
_lastInitializationAttemptAt = DateTimeOffset.UtcNow;
_robotIp = robotIp;
_lastConnectionError = null;
_lastConnectionErrorAt = null;
}
SetUpRobot(robotName);
try
{
lock (_stateLock)
{
_initializationStage = "Connecting";
}
// 真机优先初始化固定执行连接和使能,不再选择生产仿真控制器。
Connect(robotIp);
EnableRobot(bufferSize);
lock (_stateLock)
{
_operatingMode = "RealReady";
_initializationStage = "RealReady";
}
}
_logger?.LogInformation(
"SetUpRobot 完成: robotName={RobotName}, dof={Dof}, accLimit={AccLimit}, jerkLimit={JerkLimit}, speedRatio={SpeedRatio}, 恢复轨迹数={TrajCount}",
robotName,
robotProfile.DegreesOfFreedom,
robotSettings.AccLimitScale,
robotSettings.JerkLimitScale,
robotSettings.SpeedRatio,
_uploadedTrajectories.Count);
}
/// <inheritdoc />
public void SetUpRobotFromEnv(string envFile)
{
if (string.IsNullOrWhiteSpace(envFile))
catch (Exception exception)
{
throw new ArgumentException("环境文件路径不能为空。", nameof(envFile));
lock (_stateLock)
{
// 模型已加载时,真机失败必须保留离线规划能力。
_operatingMode = _activeRobotProfile is null ? "ModelLoadFailed" : "OfflineReady";
_initializationStage = "OfflineReady";
_lastConnectionError = exception.Message;
_lastConnectionErrorAt = DateTimeOffset.UtcNow;
}
throw;
}
throw new NotSupportedException("SetUpRobotFromEnv 尚未接入环境文件解析。");
}
/// <inheritdoc />
public void SetShowTcp(bool isShow, double axisLength, int axisSize)
{
if (axisLength <= 0.0)
{
throw new ArgumentOutOfRangeException(nameof(axisLength), "TCP 坐标轴长度必须大于 0。");
}
if (axisSize <= 0)
{
throw new ArgumentOutOfRangeException(nameof(axisSize), "TCP 坐标轴线宽必须大于 0。");
}
lock (_stateLock)
{
EnsureRobotSetup();
// 当前无 GUI 渲染层,先保存显示参数,保证旧 SDK 参数不会在 HTTP 边界丢失。
_showTcp = isShow;
_showTcpAxisLength = axisLength;
_showTcpAxisSize = axisSize;
}
}
/// <inheritdoc />
public void SetActiveController(bool sim)
{
lock (_stateLock)
{
EnsureRobotSetup();
}
_runtime.SetActiveController(sim);
}
/// <inheritdoc />
@@ -216,6 +212,8 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
lock (_stateLock)
{
EnsureRobotSetup();
_robotIp = robotIp;
_initializationStage = "Connecting";
}
_runtime.Connect(robotIp);
@@ -232,6 +230,11 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
}
_runtime.Disconnect();
lock (_stateLock)
{
_operatingMode = _activeRobotProfile is null ? "ModelLoadFailed" : "OfflineReady";
_initializationStage = _activeRobotProfile is null ? "ModelLoadFailed" : "OfflineReady";
}
}
/// <inheritdoc />
@@ -243,6 +246,13 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
EnsureRobotSetup();
}
_runtime.EnableRobot(bufferSize);
lock (_stateLock)
{
_operatingMode = "RealReady";
_initializationStage = "RealReady";
_lastConnectionError = null;
_lastConnectionErrorAt = null;
}
_logger?.LogInformation("EnableRobot 完成");
}
@@ -257,6 +267,11 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
// DisableRobot 是中断/控制命令,不能被长时间运动串行锁挡住。
_runtime.DisableRobot();
lock (_stateLock)
{
_operatingMode = _activeRobotProfile is null ? "ModelLoadFailed" : "OfflineReady";
_initializationStage = _activeRobotProfile is null ? "ModelLoadFailed" : "OfflineReady";
}
_logger?.LogInformation("DisableRobot 完成");
}
@@ -290,9 +305,18 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
isSetup,
isSetup ? _configuredRobotName : null,
isSetup ? _activeRobotProfile!.DegreesOfFreedom : 0,
isSetup ? _uploadedTrajectories.Keys.ToArray() : Array.Empty<string>(),
GetServerVersion(),
GetClientVersion());
_uploadedTrajectories.Keys.ToArray(),
ServerVersion,
"flyshot-replacement-controller-client-compat/0.1.0",
_operatingMode,
_initializationStage,
_robotIp,
_lastConnectionError,
_lastConnectionErrorAt,
_lastInitializationAttemptAt,
canPlanOffline: isSetup,
canExecuteRealMotion: string.Equals(_operatingMode, "RealReady", StringComparison.Ordinal)
&& _runtime.GetSnapshot().IsEnabled);
}
}
@@ -509,19 +533,16 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
trajectory.Waypoints.Count,
trajectory.ShotFlags.Count(static f => f));
string robotName;
CompatibilityRobotSettings settings;
lock (_stateLock)
{
EnsureRuntimeEnabled();
ValidateUploadedTrajectoryAsset(trajectory);
_uploadedTrajectories[trajectory.Name] = trajectory;
robotName = _configuredRobotName ?? throw new InvalidOperationException("Robot has not been setup.");
settings = _robotSettings ?? CreateDefaultRobotSettings();
}
// RobotConfig.json 持久化是文件 I/O,放在状态锁外,避免状态页轮询被磁盘写入拖住。
_trajectoryStore.Save(robotName, settings, trajectory);
_trajectoryStore.Save(settings, trajectory);
_logger?.LogInformation("UploadTrajectory 完成: name={Name}", trajectory.Name);
}
@@ -630,6 +651,7 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
/// <summary>
/// 从当前关节位置生成临时 PTP 稠密轨迹并阻塞等待运行时完成,避免后续 J519 目标发生突变。
/// 规划倍率与当前运行速度倍率相乘后用于生成轨迹,使直接移动和飞拍预移动采用相同限速规则。
/// </summary>
/// <param name="robot">当前机器人模型。</param>
/// <param name="targetJointPositions">目标关节位置,单位为弧度。</param>
@@ -641,11 +663,21 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
EnsureJointVector(targetJointPositions, robot.DegreesOfFreedom, nameof(targetJointPositions));
var speedRatio = _runtime.GetSnapshot().SpeedRatio;
var moveResult = MoveJointTrajectoryGenerator.CreateResult(robot, currentJointPositions, targetJointPositions, speedRatio, _logger);
double planningSpeedScale;
lock (_stateLock)
{
planningSpeedScale = RequireRobotSettings().PlanningSpeedScale;
}
// 在生成阶段统一缩放速度、加速度和跃度限制,发送阶段仍保持固定伺服周期。
var effectivePlanningSpeedScale = planningSpeedScale * speedRatio;
var moveResult = MoveJointTrajectoryGenerator.CreateResult(robot, currentJointPositions, targetJointPositions, effectivePlanningSpeedScale, _logger);
_logger?.LogInformation(
"{OperationName} PTP规划完成: 当前速度倍率={SpeedRatio}, 规划时长={Duration}s, 采样点数={SampleCount}",
"{OperationName} PTP规划完成: 当前速度倍率={SpeedRatio}, 规划倍率={PlanningSpeedScale}, 有效规划倍率={EffectivePlanningSpeedScale}, 规划时长={Duration}s, 采样点数={SampleCount}",
operationName,
speedRatio,
planningSpeedScale,
effectivePlanningSpeedScale,
moveResult.Duration.TotalSeconds,
moveResult.DenseJointTrajectory?.Count ?? 0);
@@ -743,7 +775,7 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
}
/// <inheritdoc />
public void SaveTrajectoryInfo(string name, string method = "icsp")
public OfflineFlyshotPlanningResult SaveTrajectoryInfo(string name, string method = "icsp")
{
if (string.IsNullOrWhiteSpace(name))
{
@@ -792,6 +824,7 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
speedRatio);
_logger?.LogInformation("SaveTrajectoryInfo 完成: name={Name}", name);
return CreateOfflinePlanningResult(name, method, robot, bundle, speedRatio);
}
/// <inheritdoc />
@@ -858,7 +891,6 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
_logger?.LogInformation("DeleteTrajectory 开始: name={Name}", name);
string robotName;
lock (_stateLock)
{
if (!_uploadedTrajectories.Remove(name))
@@ -867,11 +899,10 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
throw new InvalidOperationException("DeleteFlyShotTraj failed");
}
robotName = _configuredRobotName ?? throw new InvalidOperationException("Robot has not been setup.");
}
// 删除持久化文件不占用状态锁,状态页只需要看到内存字典的即时快照。
_trajectoryStore.Delete(robotName, name);
_trajectoryStore.Delete(name);
_logger?.LogInformation("DeleteTrajectory 完成: name={Name}", name);
}
@@ -1066,8 +1097,7 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
bundle.ExecutionTrajectory,
bundle.Result,
robot.ServoPeriod.TotalSeconds,
requestedSpeedRatio: speedRatio,
samplingSpeedRatio: 1.0);
requestedSpeedRatio: speedRatio);
var preparedResult = new TrajectoryResult(
programName: bundle.Result.ProgramName,
method: bundle.Result.Method,
@@ -1146,4 +1176,248 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
adaptIcspTryNum: 5);
}
/// <summary>
/// 校验上传资产自身结构,不在上传阶段绑定机器人自由度或关节限制。
/// </summary>
/// <param name="trajectory">待保存的飞拍资产。</param>
private static void ValidateUploadedTrajectoryAsset(ControllerClientCompatUploadedTrajectory trajectory)
{
if (trajectory.Waypoints.Count == 0)
{
throw new ArgumentException("飞拍轨迹至少需要一个路点。", nameof(trajectory));
}
if (trajectory.ShotFlags.Count != trajectory.Waypoints.Count
|| trajectory.OffsetValues.Count != trajectory.Waypoints.Count
|| trajectory.AddressGroups.Count != trajectory.Waypoints.Count)
{
throw new ArgumentException("飞拍轨迹的辅助数组长度必须与路点数量一致。", nameof(trajectory));
}
for (var waypointIndex = 0; waypointIndex < trajectory.Waypoints.Count; waypointIndex++)
{
if (trajectory.Waypoints[waypointIndex].Length == 0)
{
throw new ArgumentException($"第 {waypointIndex} 个路点不能为空。", nameof(trajectory));
}
for (var jointIndex = 0; jointIndex < trajectory.Waypoints[waypointIndex].Length; jointIndex++)
{
var value = trajectory.Waypoints[waypointIndex][jointIndex];
if (double.IsNaN(value) || double.IsInfinity(value))
{
throw new ArgumentOutOfRangeException(
nameof(trajectory),
$"第 {waypointIndex} 个路点的第 {jointIndex} 个值必须是有限数值。");
}
}
}
}
/// <summary>
/// 把内部规划结果转换成供 HTTP 和 Debug 页面直接消费的完整离线结果。
/// </summary>
/// <param name="name">轨迹名称。</param>
/// <param name="method">规划方法。</param>
/// <param name="robot">当前机器人模型。</param>
/// <param name="bundle">已经完成最终发送队列构建的结果包。</param>
/// <param name="speedRatio">本次速度倍率。</param>
/// <returns>完整离线规划结果。</returns>
private OfflineFlyshotPlanningResult CreateOfflinePlanningResult(
string name,
string method,
RobotProfile robot,
PlannedExecutionBundle bundle,
double speedRatio)
{
var prepared = bundle.PreparedExecution
?? throw new InvalidOperationException("离线规划结果缺少最终发送队列。");
var ioBySampleIndex = BuildIoTimeline(prepared);
var finalSendPoints = prepared.Samples.Select((sample, index) =>
{
ioBySampleIndex.TryGetValue(index, out var io);
return new OfflineFlyshotSendPoint(
sample.SampleIndex,
sample.SendTime,
sample.TrajectoryTime,
sample.JointsDegrees,
io.Mask,
io.Value);
}).ToArray();
var triggerBindings = prepared.TriggerBindings.Select(binding =>
{
var ioValue = ComputeIoValue(binding.Trigger.AddressGroup);
return new OfflineFlyshotTriggerBinding(
binding.Trigger.WaypointIndex,
binding.Trigger.TriggerTime,
binding.SampleIndex,
binding.Sample.SampleIndex,
binding.FoundInWindow,
ioValue,
ioValue);
}).ToArray();
var plannedRows = bundle.ExecutionTrajectory.PlannedWaypoints
.Select((waypoint, index) =>
(IReadOnlyList<double>)[bundle.ExecutionTrajectory.WaypointTimes[index], .. waypoint.Positions])
.ToArray();
var artifactDirectory = Path.Combine(_options.ResolveConfigRoot(), "Data", SanitizeDirectoryName(name));
var artifactPaths = new[]
{
"JointTraj.txt",
"JointDetialTraj.txt",
"CartTraj.txt",
"CartDetialTraj.txt",
"ActualSendJointTraj.txt",
"ActualSendTiming.txt",
"ActualSendJerkStats.txt",
"ShotEvents.json"
}.ToDictionary(
static fileName => fileName,
fileName => Path.GetFullPath(Path.Combine(artifactDirectory, fileName)),
StringComparer.Ordinal);
var cartesianTrajectory = ReadNumericRows(artifactPaths["CartDetialTraj.txt"]);
var diagnostics = CalculateDiagnostics(prepared.Samples);
return new OfflineFlyshotPlanningResult(
name,
method,
bundle.Result.IsValid,
bundle.Result.Duration.TotalSeconds,
prepared.FinalDurationSeconds,
bundle.Result.OriginalWaypointCount,
bundle.Result.PlannedWaypointCount,
bundle.Result.DenseJointTrajectory?.Count ?? 0,
speedRatio,
robot.ServoPeriod.TotalSeconds,
plannedRows,
bundle.Result.DenseJointTrajectory ?? Array.Empty<IReadOnlyList<double>>(),
cartesianTrajectory,
finalSendPoints,
bundle.Result.ShotEvents,
triggerBindings,
artifactPaths,
diagnostics);
}
/// <summary>
/// 根据触发绑定和保持周期构造每个最终发送点的 IO 掩码和值。
/// </summary>
/// <param name="prepared">最终发送结果。</param>
/// <returns>按发送点索引保存的 IO 数据。</returns>
private static Dictionary<int, (ushort Mask, ushort Value)> BuildIoTimeline(FlyshotPreparedExecution prepared)
{
var result = new Dictionary<int, (ushort Mask, ushort Value)>();
foreach (var binding in prepared.TriggerBindings)
{
var ioValue = ComputeIoValue(binding.Trigger.AddressGroup);
var holdCount = Math.Max(1, binding.Trigger.HoldCycles);
for (var offset = 0; offset < holdCount && binding.SampleIndex + offset < prepared.Samples.Count; offset++)
{
result[binding.SampleIndex + offset] = (ioValue, ioValue);
}
}
return result;
}
/// <summary>
/// 把 IO 地址组转换为 16 位写出掩码。
/// </summary>
/// <param name="group">IO 地址组。</param>
/// <returns>写出掩码和值。</returns>
private static ushort ComputeIoValue(IoAddressGroup group)
{
ushort value = 0;
foreach (var address in group.Addresses)
{
if (address is >= 1 and <= 16)
{
value |= (ushort)(1 << (address - 1));
}
}
return value;
}
/// <summary>
/// 清理轨迹名称中的非法路径字符。
/// </summary>
/// <param name="name">原始轨迹名称。</param>
/// <returns>可安全用于目录名的字符串。</returns>
private static string SanitizeDirectoryName(string name)
{
var invalidChars = Path.GetInvalidFileNameChars();
return new string(name.Select(character => invalidChars.Contains(character) ? '_' : character).ToArray());
}
/// <summary>
/// 读取空格分隔的数值轨迹文件。
/// </summary>
/// <param name="path">待读取文件绝对路径。</param>
/// <returns>数值行集合;文件不存在时返回空集合。</returns>
private static IReadOnlyList<IReadOnlyList<double>> ReadNumericRows(string path)
{
if (!File.Exists(path))
{
return Array.Empty<IReadOnlyList<double>>();
}
return File.ReadLines(path)
.Where(static line => !string.IsNullOrWhiteSpace(line))
.Select(line => (IReadOnlyList<double>)line
.Split(' ', StringSplitOptions.RemoveEmptyEntries)
.Select(value => double.Parse(value, System.Globalization.CultureInfo.InvariantCulture))
.ToArray())
.ToArray();
}
/// <summary>
/// 从最终发送点列计算最大绝对速度、加速度和跃度。
/// </summary>
/// <param name="samples">最终发送采样点。</param>
/// <returns>离散运动学诊断统计。</returns>
private static OfflineFlyshotDiagnostics CalculateDiagnostics(IReadOnlyList<FlyshotPreparedSample> samples)
{
var maxVelocity = 0.0;
var maxAcceleration = 0.0;
var maxJerk = 0.0;
double[]? previousVelocity = null;
double[]? previousAcceleration = null;
for (var sampleIndex = 1; sampleIndex < samples.Count; sampleIndex++)
{
var dt = samples[sampleIndex].SendTime - samples[sampleIndex - 1].SendTime;
if (dt <= 0.0)
{
continue;
}
var velocity = new double[samples[sampleIndex].JointsDegrees.Count];
var acceleration = new double[velocity.Length];
for (var jointIndex = 0; jointIndex < velocity.Length; jointIndex++)
{
velocity[jointIndex] =
(samples[sampleIndex].JointsDegrees[jointIndex] - samples[sampleIndex - 1].JointsDegrees[jointIndex]) / dt;
maxVelocity = Math.Max(maxVelocity, Math.Abs(velocity[jointIndex]));
if (previousVelocity is not null)
{
acceleration[jointIndex] = (velocity[jointIndex] - previousVelocity[jointIndex]) / dt;
maxAcceleration = Math.Max(maxAcceleration, Math.Abs(acceleration[jointIndex]));
}
if (previousAcceleration is not null)
{
var jerk = (acceleration[jointIndex] - previousAcceleration[jointIndex]) / dt;
maxJerk = Math.Max(maxJerk, Math.Abs(jerk));
}
}
previousVelocity = velocity;
previousAcceleration = acceleration;
}
return new OfflineFlyshotDiagnostics(maxVelocity, maxAcceleration, maxJerk);
}
}

View File

@@ -20,7 +20,15 @@ public sealed record ControllerClientStatusSnapshotMetadata
int degreesOfFreedom,
IReadOnlyList<string> uploadedTrajectories,
string serverVersion,
string clientVersion)
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;
@@ -28,6 +36,14 @@ public sealed record ControllerClientStatusSnapshotMetadata
UploadedTrajectories = uploadedTrajectories.ToArray();
ServerVersion = serverVersion;
ClientVersion = clientVersion;
OperatingMode = operatingMode;
InitializationStage = initializationStage;
RobotIp = robotIp;
LastConnectionError = lastConnectionError;
LastConnectionErrorAt = lastConnectionErrorAt;
LastInitializationAttemptAt = lastInitializationAttemptAt;
CanPlanOffline = canPlanOffline;
CanExecuteRealMotion = canExecuteRealMotion;
}
/// <summary>
@@ -59,4 +75,28 @@ public sealed record ControllerClientStatusSnapshotMetadata
/// 获取兼容客户端版本。
/// </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; }
}

View File

@@ -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>

View File

@@ -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>
/// 记录当前控制器已经建立连接。
@@ -206,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>
/// 检查指定飞拍轨迹是否可执行。

View File

@@ -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);

View File

@@ -81,7 +81,7 @@ public sealed class CompatibilityRobotSettings
}
/// <summary>
/// 获取是否启用伺服同步 IO。
/// 获取飞拍同步输出类型选择:true 使用 DO,false 使用 RO;两者都启用触发。
/// </summary>
public bool UseDo { get; }

View File

@@ -174,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)
{
@@ -210,6 +218,7 @@ public sealed class TrajectoryDoEvent
HoldCycles = holdCycles;
AddressGroup = addressGroup ?? throw new ArgumentNullException(nameof(addressGroup));
ReferenceJointsDegrees = referenceJointsDegrees?.ToArray();
UseDo = useDo;
}
/// <summary>
@@ -248,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>

View File

@@ -39,7 +39,8 @@ public static class FlyshotExecutionSendSequenceBuilder
throw new ArgumentOutOfRangeException(nameof(servoPeriodSeconds), "伺服周期必须是有限正数。");
}
var finalSpeedRatio = samplingSpeedRatio ?? 1.0;
// 未显式指定独立采样倍率时,最终发送队列必须原样使用请求倍率。
var finalSpeedRatio = samplingSpeedRatio ?? requestedSpeedRatio;
if (finalSpeedRatio <= 0.0 || double.IsNaN(finalSpeedRatio) || double.IsInfinity(finalSpeedRatio))
{
throw new ArgumentOutOfRangeException(nameof(samplingSpeedRatio), "最终队列采样倍率必须是有限正数。");
@@ -56,7 +57,7 @@ public static class FlyshotExecutionSendSequenceBuilder
robot,
samples,
trajectoryName: result.ProgramName,
validateJerk: false,
validateJerk: true,
strictJerkTolerance: true);
}
catch (InvalidOperationException exception)

View File

@@ -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(

View File

@@ -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>

View File

@@ -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,11 +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)
{
EnsureJ519ReadyForDenseExecution();
// 真机模式且存在稠密路点:准备可被 StopMove 取消的同步发送任务。
// 存在稠密路点时,准备可被 StopMove 取消的同步发送任务。
_isInMotion = true;
_sendCts = new CancellationTokenSource();
denseSendCancellationSource = _sendCts;
@@ -500,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();
@@ -558,11 +522,11 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
EnsurePoseCount(finalPose.Count);
CancelSendTaskLocked();
if (!IsSimulationMode && result.DenseCartesianTrajectory is not null)
if (result.DenseCartesianTrajectory is not null)
{
EnsureJ519ReadyForDenseExecution();
// 真机直角模式同样预装完整队列,由机器人状态包节拍驱动出队。
// 直角模式同样预装完整队列,由机器人状态包节拍驱动出队。
_isInMotion = true;
_sendCts = new CancellationTokenSource();
denseSendCancellationSource = _sendCts;
@@ -573,14 +537,11 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
}
else
{
if (!IsSimulationMode)
{
var command = new FanucJ519Command(
sequence: 0,
targetValues: BuildCartesianTargetValues(finalPose),
dataStyle: 0);
_j519Client.UpdateCommand(command);
}
var command = new FanucJ519Command(
sequence: 0,
targetValues: BuildCartesianTargetValues(finalPose),
dataStyle: 0);
_j519Client.UpdateCommand(command);
_isInMotion = true;
_pose = MergeFinalCartesianPose(finalPose);
@@ -634,6 +595,10 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
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 preparedExecution = result.PreparedFlyshotExecution;
var durationSeconds = preparedExecution?.FinalDurationSeconds ?? result.Duration.TotalSeconds;
@@ -749,7 +714,7 @@ 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);
@@ -802,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 状态包驱动。
@@ -865,6 +844,19 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
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);
@@ -1041,7 +1033,7 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
}
/// <summary>
/// 将六维 WPR 直角目标合并回运行时缓存,保留旧 GetPose 仿真路径的 7 维外形。
/// 将六维 WPR 直角目标合并回运行时缓存,保留 GetPose 的 7 维兼容外形。
/// </summary>
private double[] MergeFinalCartesianPose(IReadOnlyList<double> pose)
{
@@ -1154,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>
@@ -1227,9 +1203,7 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
return "Disconnected";
}
return _activeControllerIsSimulation is false
? ResolveRealConnectionState(_stateClient.GetStatus())
: "Connected";
return ResolveRealConnectionState(_stateClient.GetStatus());
}
/// <summary>
@@ -1302,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.");

View File

@@ -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();
}

View File

@@ -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>
@@ -487,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 });
}
@@ -623,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)
{
@@ -723,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 });
}
}
@@ -1173,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>
@@ -1197,8 +1044,4 @@ public sealed class LegacyInitMpcRobotRequest
/// </summary>
public string robot_ip { get; init; } = string.Empty;
/// <summary>
/// 获取或设置是否使用仿真控制器;默认 false 连接真机。
/// </summary>
public bool sim { get; init; }
}

View File

@@ -61,6 +61,15 @@ public sealed class StatusController : ControllerBase
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
});
}

View File

@@ -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>

View File

@@ -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))
{

View File

@@ -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;
}
}

View File

@@ -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,

View File

@@ -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;
}
}

View File

@@ -2,21 +2,27 @@
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,
@@ -24,9 +30,19 @@ const requestBodySamples = {
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 之外的位置时使用。 */
function escapeHtml(value) {
return String(value).replace(/[&<>"']/g, function (ch) {
@@ -34,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;
@@ -49,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);
});
@@ -91,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 "";
}
}
@@ -99,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 || {};
@@ -120,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,
@@ -127,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
});
});
});
@@ -150,7 +265,8 @@ function storageKey(op) {
/** 返回指定端点的手工调试样例,处理 JsonElement 等 OpenAPI 无法自动推断字段的接口。 */
function getRequestBodySample(op) {
return requestBodySamples[op.method + " " + op.path] || null;
const operationKey = op.method + " " + op.path;
return requestBodySamples[operationKey] || null;
}
/** 读取本端点最近一次输入;解析失败则当作空。 */
@@ -268,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";
@@ -342,6 +528,7 @@ function pushHistory(entry) {
renderHistory();
}
/** 渲染请求历史列表。 */
function renderHistory() {
const list = document.getElementById("history-list");
list.innerHTML = "";
@@ -376,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";
@@ -393,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 () {
@@ -411,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) || {};
@@ -464,6 +648,7 @@ function renderOperationCard(op) {
responseBlock.style.display = "none";
body.appendChild(responseBlock);
/** 在卡片内渲染 HTTP 响应摘要、头和正文。 */
function renderResponse(payload) {
responseBlock.style.display = "block";
responseBlock.innerHTML = "";
@@ -610,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) {
@@ -636,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");
@@ -683,4 +1006,5 @@ document.getElementById("history-clear").addEventListener("click", function () {
});
renderHistory();
initializeOfflinePlanningPanel();
loadSpecAndRender();

View File

@@ -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>

View File

@@ -16,6 +16,70 @@ 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;
@@ -60,15 +124,20 @@ public sealed class FanucControllerRuntimeDenseTests
WaitUntilIdle(runtime);
var commands = j519Client.GetCommandHistoryForTests();
Assert.Equal(3, commands.Count);
Assert.Equal(4, commands.Count);
Assert.All(commands, static command => Assert.Equal(0u, command.Sequence));
var expectedJ1Targets = new[] { 0.0, 0.08, 0.16 };
var actualJ1Targets = commands.Select(static command => command.TargetJoints[0]).ToArray();
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());
}
@@ -114,12 +183,12 @@ public sealed class FanucControllerRuntimeDenseTests
denseJointTrajectory: denseTrajectory);
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 == 3, 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);
@@ -190,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]);
@@ -216,7 +285,7 @@ public sealed class FanucControllerRuntimeDenseTests
Assert.Equal(1.0, secondTimingColumns[0], precision: 6);
Assert.Equal(0.008, secondTimingColumns[1], precision: 6);
Assert.Equal(0.008, secondTimingColumns[2], precision: 6);
Assert.Equal(commands.Count - 1, lastTimingColumns[0], precision: 6);
Assert.Equal(denseTrajectory.Length - 1, lastTimingColumns[0], precision: 6);
Assert.Equal(0.016, lastTimingColumns[2], precision: 6);
var firstJerkColumns = ParseColumns(jerkLines[0]);
@@ -395,10 +464,11 @@ public sealed class FanucControllerRuntimeDenseTests
var commands = j519Client.GetCommandHistoryForTests();
Assert.NotEmpty(commands);
Assert.Equal(preparedExecution.Samples.Count, commands.Count);
Assert.Equal(speedRatio, preparedExecution.FinalSpeedRatio, precision: 6);
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
{
@@ -469,8 +539,9 @@ 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>
@@ -523,17 +594,57 @@ 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 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()
{
@@ -629,6 +740,10 @@ public sealed class FanucControllerRuntimeDenseTests
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);
@@ -677,8 +792,9 @@ public sealed class FanucControllerRuntimeDenseTests
WaitUntilIdle(runtime);
var commands = j519Client.GetCommandHistoryForTests();
Assert.Equal(3, commands.Count);
Assert.Equal(4, commands.Count);
Assert.Equal(0.16, commands[^1].TargetJoints[0], precision: 6);
Assert.Equal(1, commands[^1].LastData);
}
/// <summary>
@@ -782,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>
@@ -837,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>
@@ -939,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>
@@ -1113,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);

View File

@@ -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));
}
}

View File

@@ -279,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>
@@ -459,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)
{

View File

@@ -16,40 +16,6 @@ namespace Flyshot.Core.Tests;
/// </summary>
public sealed class RuntimeOrchestrationTests
{
/// <summary>
/// 验证 FANUC 最小运行时执行轨迹后会更新状态快照与最终关节位置。
/// </summary>
[Fact]
public void FanucControllerRuntime_ExecuteTrajectory_UpdatesSnapshotAndFinalJointPositions()
{
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 result = new TrajectoryResult(
programName: "demo",
method: PlanningMethod.Icsp,
isValid: true,
duration: TimeSpan.FromSeconds(1.2),
shotEvents: Array.Empty<ShotEvent>(),
triggerTimeline: Array.Empty<TrajectoryDoEvent>(),
artifacts: Array.Empty<TrajectoryArtifact>(),
failureReason: null,
usedCache: false,
originalWaypointCount: 4,
plannedWaypointCount: 4);
runtime.ExecuteTrajectory(result, [1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
var snapshot = runtime.GetSnapshot();
Assert.Equal("Connected", snapshot.ConnectionState);
Assert.False(snapshot.IsInMotion);
Assert.Equal([1.0, 2.0, 3.0, 4.0, 5.0, 6.0], snapshot.JointPositions);
}
/// <summary>
/// 验证真机运行时会把 TCP 10010 状态通道健康度映射为可诊断连接状态。
/// </summary>
@@ -275,13 +241,14 @@ public sealed class RuntimeOrchestrationTests
var doEvent = Assert.Single(bundle.Result.TriggerTimeline);
Assert.Equal(4, doEvent.HoldCycles);
Assert.True(doEvent.UseDo);
}
/// <summary>
/// 验证 RobotConfig.json 关闭 use_do 时仍保留 ShotEvent 诊断信息,但不生成伺服 DO 事件。
/// 验证 use_do=false 时仍生成飞拍触发事件,保持周期不变。
/// </summary>
[Fact]
public void ControllerClientTrajectoryOrchestrator_PlanUploadedFlyshot_SuppressesDoTimeline_WhenUseDoIsFalse()
public void ControllerClientTrajectoryOrchestrator_PlanUploadedFlyshot_PreservesTriggerTimeline_WhenUseDoIsFalse()
{
var orchestrator = new ControllerClientTrajectoryOrchestrator();
var robot = TestRobotFactory.CreateRobotProfile();
@@ -298,7 +265,9 @@ public sealed class RuntimeOrchestrationTests
var bundle = orchestrator.PlanUploadedFlyshot(robot, uploaded, settings: settings);
Assert.Single(bundle.Result.ShotEvents);
Assert.Empty(bundle.Result.TriggerTimeline);
var trigger = Assert.Single(bundle.Result.TriggerTimeline);
Assert.Equal(4, trigger.HoldCycles);
Assert.False(trigger.UseDo);
}
/// <summary>
@@ -761,7 +730,6 @@ public sealed class RuntimeOrchestrationTests
{
var service = TestRobotFactory.CreateCompatService();
service.SetUpRobot("FANUC_LR_Mate_200iD");
service.SetActiveController(sim: true);
service.Connect("192.168.10.101");
service.EnableRobot(2);
@@ -803,7 +771,6 @@ public sealed class RuntimeOrchestrationTests
};
service.SetUpRobot("FANUC_LR_Mate_200iD");
service.SetActiveController(sim: false);
service.Connect("192.168.10.101");
service.EnableRobot(2);
runtime.SetSpeedRatio(0.5);
@@ -824,6 +791,66 @@ public sealed class RuntimeOrchestrationTests
}
}
/// <summary>
/// 验证直接关节移动和飞拍预移动均按规划倍率与当前运行倍率的乘积生成点对点轨迹。
/// </summary>
/// <param name="moveToStart">是否通过飞拍预移动入口执行点对点运动。</param>
/// <param name="planningSpeedScale">配置中的规划速度倍率。</param>
/// <param name="speedRatio">机器人初始化后设置的当前运行速度倍率。</param>
[Theory]
[InlineData(false, 0.74227, 1.0)]
[InlineData(false, 0.74227, 0.5)]
[InlineData(false, 1.0, 0.7)]
[InlineData(true, 0.74227, 0.5)]
public void ControllerClientCompatService_MoveJoint_AppliesCombinedPlanningSpeedScale(
bool moveToStart, double planningSpeedScale, double speedRatio)
{
var configRoot = CreateTempConfigRoot();
try
{
WriteRobotConfigWithDemoTrajectory(configRoot, planningSpeedScale: planningSpeedScale, speedRatio: 1.0);
var options = new ControllerClientCompatOptions { ConfigRoot = configRoot };
double[] startJoints = [0.4, 0.0, 0.0, 0.0, 0.0, 0.0];
double[] targetJoints = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
var runtime = new DelayedCompletionControllerRuntime(startJoints, TimeSpan.Zero);
var service = new ControllerClientCompatService(
options,
new ControllerClientCompatRobotCatalog(options, new RobotModelLoader()),
runtime,
new ControllerClientTrajectoryOrchestrator(),
new RobotConfigLoader());
service.SetUpRobot("FANUC_LR_Mate_200iD");
service.Connect("192.168.10.101");
service.EnableRobot(2);
service.SetSpeedRatio(speedRatio);
// 两个公开入口必须共享相同的点对点限速,且采用当前倍率而非配置默认倍率。
if (moveToStart)
{
service.UploadTrajectory(TestRobotFactory.CreateUploadedTrajectoryWithSingleShot());
service.ExecuteTrajectoryByName("demo-flyshot", new FlyshotExecutionOptions(moveToStart: true, useCache: false));
}
else
{
service.MoveJoint(targetJoints);
}
var robot = new ControllerClientCompatRobotCatalog(options, new RobotModelLoader())
.LoadProfile("FANUC_LR_Mate_200iD", accLimitScale: 1.0, jerkLimitScale: 1.0);
var expected = MoveJointTrajectoryGenerator.CreateResult(robot, startJoints, targetJoints, planningSpeedScale * speedRatio);
var actual = runtime.ExecuteCalls[0].Result;
Assert.Equal(expected.Duration, actual.Duration);
Assert.Equal(expected.DenseJointTrajectory!.Count, actual.DenseJointTrajectory!.Count);
Assert.Equal(targetJoints, runtime.ExecuteCalls[0].FinalJointPositions);
Assert.Equal(speedRatio, runtime.GetSnapshot().SpeedRatio);
Assert.False(runtime.SecondTrajectoryStartedBeforeFirstMotionCompleted);
}
finally
{
Directory.Delete(configRoot, recursive: true);
}
}
/// <summary>
/// 验证 ExecuteFlyShotTraj(move_to_start=true) 会先执行稠密 PTP 到起点,并等待该段运动完成后再启动飞拍轨迹。
/// </summary>
@@ -849,7 +876,6 @@ public sealed class RuntimeOrchestrationTests
new RobotConfigLoader());
service.SetUpRobot("FANUC_LR_Mate_200iD");
service.SetActiveController(sim: false);
service.Connect("192.168.10.101");
service.EnableRobot(2);
service.UploadTrajectory(TestRobotFactory.CreateUploadedTrajectoryWithSingleShot());
@@ -894,7 +920,6 @@ public sealed class RuntimeOrchestrationTests
new RobotConfigLoader());
service.SetUpRobot("FANUC_LR_Mate_200iD");
service.SetActiveController(sim: false);
service.Connect("192.168.10.101");
service.EnableRobot(2);
service.UploadTrajectory(TestRobotFactory.CreateUploadedTrajectoryWithSingleShot());
@@ -938,7 +963,6 @@ public sealed class RuntimeOrchestrationTests
new RobotConfigLoader());
service.SetUpRobot("FANUC_LR_Mate_200iD");
service.SetActiveController(sim: false);
service.Connect("192.168.10.101");
service.EnableRobot(2);
service.UploadTrajectory(TestRobotFactory.CreateUploadedTrajectoryWithSingleShot());
@@ -979,7 +1003,6 @@ public sealed class RuntimeOrchestrationTests
new RobotConfigLoader());
service.SetUpRobot("FANUC_LR_Mate_200iD");
service.SetActiveController(sim: false);
service.Connect("192.168.10.101");
service.EnableRobot(2);
service.UploadTrajectory(TestRobotFactory.CreateUploadedTrajectoryWithSingleShot());
@@ -1030,7 +1053,6 @@ public sealed class RuntimeOrchestrationTests
new RobotConfigLoader());
service.SetUpRobot("FANUC_LR_Mate_200iD");
service.SetActiveController(sim: false);
service.Connect("192.168.10.101");
service.EnableRobot(2);
service.UploadTrajectory(TestRobotFactory.CreateUploadedTrajectoryWithSingleShot());
@@ -1081,7 +1103,6 @@ public sealed class RuntimeOrchestrationTests
new RobotConfigLoader());
service.SetUpRobot("FANUC_LR_Mate_200iD");
service.SetActiveController(sim: false);
service.Connect("192.168.10.101");
service.EnableRobot(2);
service.UploadTrajectory(TestRobotFactory.CreateUploadedTrajectoryWithSingleShot());
@@ -1240,8 +1261,8 @@ public sealed class RuntimeOrchestrationTests
$"ActualSend 应按当前 speedRatio 的执行视图导出,不能再用旧 JointDetialTraj 规划时长约束点数。actual={pointRows.Length}, planningRows={planningExportRowsAtServoPeriod}");
Assert.Equal(0.0, pointRows[0][0], precision: 6);
Assert.Equal(0.008, pointRows[1][0], precision: 6);
Assert.Equal(0.008, timingRows[1][2], precision: 6);
Assert.Equal(1.0, timingRows[1][3], precision: 6);
Assert.Equal(0.004, timingRows[1][2], precision: 6);
Assert.Equal(0.5, timingRows[1][3], precision: 6);
Assert.Equal(0.5, timingRows[1][4], precision: 6);
Assert.Equal(0.0, timingRows[1][5], precision: 6);
Assert.Contains("\"trigger_window_seconds\": 0.1", shotEventsJson);
@@ -1371,6 +1392,142 @@ public sealed class RuntimeOrchestrationTests
return configRoot;
}
/// <summary>
/// 验证未加载机器人模型时仍可上传、列出和删除飞拍资产。
/// </summary>
[Fact]
public void ControllerClientCompatService_ManagesFlyshotAssetsWithoutRobotSetup()
{
var configRoot = CreateTempConfigRoot();
try
{
var options = new ControllerClientCompatOptions { ConfigRoot = configRoot };
var service = new ControllerClientCompatService(
options,
new ControllerClientCompatRobotCatalog(options, new RobotModelLoader()),
new RecordingControllerRuntime(),
new ControllerClientTrajectoryOrchestrator(),
new RobotConfigLoader());
service.UploadTrajectory(TestRobotFactory.CreateUploadedTrajectoryWithSingleShot());
Assert.Contains("demo-flyshot", service.ListTrajectoryNames());
Assert.Contains(
"\"demo-flyshot\"",
File.ReadAllText(Path.Combine(configRoot, "RobotConfig.json")),
StringComparison.Ordinal);
service.DeleteTrajectory("demo-flyshot");
Assert.DoesNotContain("demo-flyshot", service.ListTrajectoryNames());
}
finally
{
Directory.Delete(configRoot, recursive: true);
}
}
/// <summary>
/// 验证重新加载机器人模型不会清空已经上传的飞拍资产。
/// </summary>
[Fact]
public void ControllerClientCompatService_SetUpRobot_PreservesUploadedFlyshotAssets()
{
var configRoot = CreateTempConfigRoot();
try
{
var options = new ControllerClientCompatOptions { ConfigRoot = configRoot };
var service = new ControllerClientCompatService(
options,
new ControllerClientCompatRobotCatalog(options, new RobotModelLoader()),
new RecordingControllerRuntime(),
new ControllerClientTrajectoryOrchestrator(),
new RobotConfigLoader());
service.UploadTrajectory(TestRobotFactory.CreateUploadedTrajectoryWithSingleShot());
service.SetUpRobot("FANUC_LR_Mate_200iD");
Assert.Contains("demo-flyshot", service.ListTrajectoryNames());
Assert.Equal("OfflineReady", service.GetStatusSnapshotMetadata().OperatingMode);
Assert.True(service.GetStatusSnapshotMetadata().CanPlanOffline);
Assert.False(service.GetStatusSnapshotMetadata().CanExecuteRealMotion);
}
finally
{
Directory.Delete(configRoot, recursive: true);
}
}
/// <summary>
/// 验证离线状态可以生成完整最终发送点列,并返回导出文件绝对路径。
/// </summary>
[Fact]
public void ControllerClientCompatService_SaveTrajectoryInfo_ReturnsOfflinePlanningResult()
{
var configRoot = CreateTempConfigRoot();
WriteRobotConfigWithDemoTrajectory(configRoot, planningSpeedScale: 0.45, speedRatio: 1.0);
try
{
var options = new ControllerClientCompatOptions { ConfigRoot = configRoot };
var service = new ControllerClientCompatService(
options,
new ControllerClientCompatRobotCatalog(options, new RobotModelLoader()),
new RecordingControllerRuntime(),
new ControllerClientTrajectoryOrchestrator(),
new RobotConfigLoader());
service.SetUpRobot("FANUC_LR_Mate_200iD");
var result = service.SaveTrajectoryInfo("demo-flyshot");
Assert.True(result.IsValid);
Assert.Equal("demo-flyshot", result.TrajectoryName);
Assert.Equal(1.0, result.SpeedRatio, precision: 6);
Assert.NotEmpty(result.FinalSendPoints);
Assert.NotEmpty(result.ShotEvents);
Assert.NotEmpty(result.CartesianTrajectory);
Assert.True(result.Diagnostics.MaxAbsVelocityDegreesPerSecond > 0.0);
Assert.All(result.ArtifactPaths.Values, path => Assert.True(Path.IsPathFullyQualified(path)));
}
finally
{
Directory.Delete(configRoot, recursive: true);
}
}
/// <summary>
/// 验证模型加载成功但真机连接失败时,状态降级为 OfflineReady 并保留离线规划能力。
/// </summary>
[Fact]
public void ControllerClientCompatService_InitializeRobot_ConnectionFailureKeepsOfflineReady()
{
var configRoot = CreateTempConfigRoot();
try
{
var options = new ControllerClientCompatOptions { ConfigRoot = configRoot };
var runtime = new RecordingControllerRuntime { ThrowOnConnect = true };
var service = new ControllerClientCompatService(
options,
new ControllerClientCompatRobotCatalog(options, new RobotModelLoader()),
runtime,
new ControllerClientTrajectoryOrchestrator(),
new RobotConfigLoader());
Assert.Throws<InvalidOperationException>(
() => service.InitializeRobot("FANUC_LR_Mate_200iD", "192.168.10.101"));
var metadata = service.GetStatusSnapshotMetadata();
Assert.Equal("OfflineReady", metadata.OperatingMode);
Assert.True(metadata.CanPlanOffline);
Assert.False(metadata.CanExecuteRealMotion);
Assert.NotNull(metadata.LastConnectionError);
Assert.NotNull(metadata.LastConnectionErrorAt);
}
finally
{
Directory.Delete(configRoot, recursive: true);
}
}
/// <summary>
/// 写入包含一条飞拍轨迹的最小 RobotConfig.json,供兼容服务从统一配置恢复轨迹。
/// </summary>
@@ -1952,7 +2109,7 @@ internal static class TestRobotFactory
return new ControllerClientCompatService(
options,
new ControllerClientCompatRobotCatalog(options, new RobotModelLoader()),
new FanucControllerRuntime(),
new RecordingControllerRuntime(),
new ControllerClientTrajectoryOrchestrator(),
new RobotConfigLoader());
}
@@ -2024,6 +2181,11 @@ internal sealed class RecordingControllerRuntime : IControllerRuntime
/// </summary>
public TrajectoryResult? LastExecutedResult { get; private set; }
/// <summary>
/// 获取或设置连接时是否抛出测试异常。
/// </summary>
public bool ThrowOnConnect { get; set; }
/// <inheritdoc />
public void ResetRobot(RobotProfile robot, string robotName)
{
@@ -2038,6 +2200,10 @@ internal sealed class RecordingControllerRuntime : IControllerRuntime
/// <inheritdoc />
public void Connect(string robotIp)
{
if (ThrowOnConnect)
{
throw new InvalidOperationException("测试连接失败。");
}
}
/// <inheritdoc />
@@ -2123,6 +2289,10 @@ internal sealed class RecordingControllerRuntime : IControllerRuntime
/// </summary>
internal sealed class DelayedCompletionControllerRuntime : IControllerRuntime
{
/// <summary>
/// 当前测试运行速度倍率,用于验证初始化与运行中调速对预移动规划的影响。
/// </summary>
private double _speedRatio = 1.0;
private readonly object _lock = new();
private readonly TimeSpan _firstMotionCompletionDelay;
private double[] _jointPositions;
@@ -2195,11 +2365,12 @@ internal sealed class DelayedCompletionControllerRuntime : IControllerRuntime
}
/// <inheritdoc />
public double GetSpeedRatio() => 1.0;
public double GetSpeedRatio() => _speedRatio;
/// <inheritdoc />
public void SetSpeedRatio(double ratio)
{
_speedRatio = ratio;
}
/// <inheritdoc />
@@ -2240,7 +2411,7 @@ internal sealed class DelayedCompletionControllerRuntime : IControllerRuntime
connectionState: "Connected",
isEnabled: _isEnabled,
isInMotion: _isInMotion,
speedRatio: 1.0,
speedRatio: _speedRatio,
jointPositions: _jointPositions.ToArray(),
cartesianPose: Array.Empty<double>(),
activeAlarms: Array.Empty<RuntimeAlarm>());

View File

@@ -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,9 @@ 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>
@@ -66,6 +80,45 @@ public sealed class DebugConsoleEndpointTests(FlyshotServerFactory factory) : IC
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>
/// 当 Swagger 启用时,调试配置 API 应当返回实际 Swagger JSON 地址。
/// </summary>
@@ -115,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>

View File

@@ -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>
{

View File

@@ -11,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>
@@ -33,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/"))
{
@@ -207,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
{
@@ -222,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 }
}
};
@@ -282,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" }))
@@ -298,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);

View File

@@ -113,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());
@@ -139,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);

View 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
}
}

View 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'