12 Commits

Author SHA1 Message Date
bcbf8017ad 📝 docs(planning): 统一 Rust 规划库命名
将 Rust 原生规划库设计口径统一为 flyshot_planning。

同步更新 Windows 和 Linux 产物名以及复制命令示例。
2026-06-27 15:10:06 +08:00
537c6b5320 💥 feat(planning)!: 拆分 C# ICSP 并默认 Rust
* 新增可选 Flyshot.CSharp.Planning 项目

* 将 icsp 映射到 Rust 后端并拒绝旧 icsp_rust

* 补充后端注册、文档、调试页和回归测试

BREAKING CHANGE: method=icsp 现在默认调用 Rust 后端;C# ICSP 改为 method=icsp-csharp,旧 method=icsp_rust 不再作为正式入口。
2026-06-27 15:03:05 +08:00
32cc740df5 ✨ feat(native-planning): 更新 Rust ICSP 原生库及构建脚本
* 将 Rust ICSP 原生库名称更改为 `flyshot_planning`,更新相关引用。
* 修改构建脚本以支持新的库名称,并确保库文件复制到正确的目录。
* 添加对 Rust ICSP 原生库的依赖检查,确保发布时包含必要的库文件。
* 更新测试项目以加载新的原生库,确保兼容性和功能完整性。
2026-06-26 17:47:04 +08:00
9c56ee825e ✨ feat(native-planning): 添加 Rust ICSP 原生规划核心及 C# 互操作集成
* 新增 Rust 原生 ICSP 规划库 (Cargo 项目, CubicSpline 算法, FFI 接口)
* 新增 Flyshot.Native.Planning C# 互操作项目 (P/Invoke 封装)
* 更新 ControllerClientCompat 适配新的规划接口
* 添加构建脚本 (Copy-NativePlanningArtifacts.ps1)
* 新增互操作测试及一致性对比测试
* 更新解决方案文件和设计文档
2026-06-26 15:41:01 +08:00
bb21e0923d docs(planning): 添加 Rust ICSP 原生核心设计 2026-06-26 15:41:01 +08:00
9a01ce851d Merge branch 'feat/offline-flyshot-planning' into dev 2026-06-24 11:43:44 +08:00
5193f537b0 ✨ feat(planning): 添加离线飞拍规划能力
* 固化离线飞拍轨迹加载、保存和规划结果。

* 真机初始化失败时保留模型加载后的离线规划能力。

* 更新状态接口、调试页和回归测试覆盖。
2026-06-24 11:40:07 +08:00
070d2ba5c0 ✨ feat(server): 增加混淆发布链路与调试页静态说明
- 新增 Obfuscar 混淆发布脚本、发布测试与产物隐私检查
- 调整 VS Code 发布任务和宿主发布配置,统一走混淆流程
- 为 debug 页面内嵌静态接口说明,并补充对应集成测试
- 更新 README 与设计计划文档,记录跨平台发布约束
2026-06-22 15:55:34 +08:00
ac41d04a54 ✨ feat(server): 添加服务启动自动初始化机器人功能
* 在 ControllerClientCompatOptions 中新增 AutoInitOptions 配置模型
* 创建 AutoInitRobotHostedService 实现启动时自动初始化链路
* 在 appsettings.json 的 ControllerClientCompat.AutoInit 节配置
  RobotName 和 RobotIp
* 启动时依次执行 SetUpRobot → SetActiveController → Connect →
  EnableRobot;配置缺失或连接失败不阻止服务启动
* 更新 README.md 和现场工作流文档
2026-06-22 13:29:04 +08:00
5c5eaf850d ✨ feat(docs): 添加飞拍控制系统宣传文稿与飞拍轨迹规划逻辑图 2026-06-10 19:52:22 +08:00
4aae6219cc Merge branch 'legacy-gateway-compat' 2026-05-28 11:02:01 +08:00
35bdb08d11 Merge commit '0952958af7685646cdbf2a4907c6e2ae59d4d352' 2026-05-28 10:44:03 +08:00
83 changed files with 7001 additions and 796 deletions

8
.gitignore vendored
View File

@@ -400,3 +400,11 @@ Config/Data/*
.dotnet-home/*
codex-dotnet-home/*
.dotnet-sdk8/*
# Flyshot 混淆发布产物、映射文件和临时工作目录
outputs/obfuscated/
outputs/obfuscation-maps/
.tmp/obfuscation/
# Rust 原生规划库构建产物
**/target/

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,4 +1,3 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
@@ -27,6 +26,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Flyshot.ControllerClientCom
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Flyshot.Runtime.Fanuc", "src\Flyshot.Runtime.Fanuc\Flyshot.Runtime.Fanuc.csproj", "{B705FA6C-19CA-44A8-882C-6CE26A5379C9}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Flyshot.Native.Planning", "src\Flyshot.Native.Planning\Flyshot.Native.Planning.csproj", "{928CF396-C0E1-488E-BCC1-CF0279CF2DD0}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Flyshot.CSharp.Planning", "src\Flyshot.CSharp.Planning\Flyshot.CSharp.Planning.csproj", "{4717558C-0A09-4D4D-BB1A-C0FD9241070E}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -157,6 +160,30 @@ Global
{B705FA6C-19CA-44A8-882C-6CE26A5379C9}.Release|x64.Build.0 = Release|Any CPU
{B705FA6C-19CA-44A8-882C-6CE26A5379C9}.Release|x86.ActiveCfg = Release|Any CPU
{B705FA6C-19CA-44A8-882C-6CE26A5379C9}.Release|x86.Build.0 = Release|Any CPU
{928CF396-C0E1-488E-BCC1-CF0279CF2DD0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{928CF396-C0E1-488E-BCC1-CF0279CF2DD0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{928CF396-C0E1-488E-BCC1-CF0279CF2DD0}.Debug|x64.ActiveCfg = Debug|Any CPU
{928CF396-C0E1-488E-BCC1-CF0279CF2DD0}.Debug|x64.Build.0 = Debug|Any CPU
{928CF396-C0E1-488E-BCC1-CF0279CF2DD0}.Debug|x86.ActiveCfg = Debug|Any CPU
{928CF396-C0E1-488E-BCC1-CF0279CF2DD0}.Debug|x86.Build.0 = Debug|Any CPU
{928CF396-C0E1-488E-BCC1-CF0279CF2DD0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{928CF396-C0E1-488E-BCC1-CF0279CF2DD0}.Release|Any CPU.Build.0 = Release|Any CPU
{928CF396-C0E1-488E-BCC1-CF0279CF2DD0}.Release|x64.ActiveCfg = Release|Any CPU
{928CF396-C0E1-488E-BCC1-CF0279CF2DD0}.Release|x64.Build.0 = Release|Any CPU
{928CF396-C0E1-488E-BCC1-CF0279CF2DD0}.Release|x86.ActiveCfg = Release|Any CPU
{928CF396-C0E1-488E-BCC1-CF0279CF2DD0}.Release|x86.Build.0 = Release|Any CPU
{4717558C-0A09-4D4D-BB1A-C0FD9241070E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4717558C-0A09-4D4D-BB1A-C0FD9241070E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4717558C-0A09-4D4D-BB1A-C0FD9241070E}.Debug|x64.ActiveCfg = Debug|Any CPU
{4717558C-0A09-4D4D-BB1A-C0FD9241070E}.Debug|x64.Build.0 = Debug|Any CPU
{4717558C-0A09-4D4D-BB1A-C0FD9241070E}.Debug|x86.ActiveCfg = Debug|Any CPU
{4717558C-0A09-4D4D-BB1A-C0FD9241070E}.Debug|x86.Build.0 = Debug|Any CPU
{4717558C-0A09-4D4D-BB1A-C0FD9241070E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4717558C-0A09-4D4D-BB1A-C0FD9241070E}.Release|Any CPU.Build.0 = Release|Any CPU
{4717558C-0A09-4D4D-BB1A-C0FD9241070E}.Release|x64.ActiveCfg = Release|Any CPU
{4717558C-0A09-4D4D-BB1A-C0FD9241070E}.Release|x64.Build.0 = Release|Any CPU
{4717558C-0A09-4D4D-BB1A-C0FD9241070E}.Release|x86.ActiveCfg = Release|Any CPU
{4717558C-0A09-4D4D-BB1A-C0FD9241070E}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -172,5 +199,7 @@ Global
{E4DDC34C-9AB6-4050-A927-3DF69804708A} = {64EABE09-B1E0-4476-A213-32C93E46E7C3}
{5B45CC23-3551-4D0F-B3CC-22659C2A8BA3} = {64EABE09-B1E0-4476-A213-32C93E46E7C3}
{B705FA6C-19CA-44A8-882C-6CE26A5379C9} = {64EABE09-B1E0-4476-A213-32C93E46E7C3}
{928CF396-C0E1-488E-BCC1-CF0279CF2DD0} = {64EABE09-B1E0-4476-A213-32C93E46E7C3}
{4717558C-0A09-4D4D-BB1A-C0FD9241070E} = {64EABE09-B1E0-4476-A213-32C93E46E7C3}
EndGlobalSection
EndGlobal

View File

@@ -16,7 +16,8 @@
- 当前仓库不再恢复旧 `50001/TCP+JSON` 监听入口;旧 `ControllerClient` 逆向资料只作为接口语义参考,不作为运行时目标。
- 宿主只保留 ASP.NET Core HTTP 控制器层,以及其后端 `Flyshot.ControllerClientCompat` 兼容服务。
- `ExecuteTrajectory` 与 `ExecuteFlyShotTraj` 已经接入 `Planning + Triggering + Runtime` 链路;Web 状态页已通过 `/status` 和 `/api/status/snapshot` 暴露当前兼容层与运行时状态。
- `Flyshot.Core.Planning` 的 ICSP / self-adapt-icsp 轨迹已经完成旧系统导出轨迹对齐;`doubles` 仍未实现。
- `Flyshot.Core.Planning` 保留规划公共模型、样条、采样、导出和运动学能力;C# ICSP / self-adapt-icsp 已拆分到可选项目 `Flyshot.CSharp.Planning`。
- `Flyshot.Native.Planning` 已提供默认 Rust ICSP 规划入口,通过 C ABI 调用 `flyshot_planning`;普通轨迹和飞拍轨迹的默认 `method="icsp"` 使用 Rust 后端,`method="icsp-csharp"` 和 `method="self-adapt-icsp"` 只有显式注册 C# 后端时可用。
- `Flyshot.Runtime.Fanuc` 已固化 `10010 / 10012 / 60015` 基础协议帧编解码。`10010` 状态通道以 `j519 协议.pcap` 和 `Rvbust/uttc-20260428/20260428.pcap` 真机抓包确认为 90B 固定帧。
- 2026-04-28 UTTC 抓包确认:UDP 60015 命令 `target[0..5]` 为关节角度制 `deg`,`JointDetialTraj.txt` 为弧度制 `rad`,`speed_ratio=0.7` 体现为 UDP 下发时间轴约 `1.427730x` 拉伸;2026-04-30 实体机确认 `speed_ratio` 不影响生成的 `JointTraj.txt` 规划时长,当前实际生成约 `7.4s` 轨迹。
- 2026-04-30 本机 `50001/TCP+JSON` 抓包确认:`ExecuteFlyShotTraj(save_traj=true,use_cache=false)` 请求只显式携带规划方法、保存、缓存和等待参数,不携带 `JointLimits / acc_limit / jerk_limit / velocity / acceleration / jerk`。因此旧系统不可见的有效规划限制不再继续假设来自公开链路,新系统按 replacement-only 内部参数限制规划加速度。
@@ -24,6 +25,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`。
单位约定总览:
@@ -61,16 +65,48 @@
- [x] 打通最小宿主与 `/healthz`
- [x] 建立领域模型与模块边界
- [x] 落地配置兼容与机器人模型解析
- [x] 落地 ICSP / self-adapt-icsp 轨迹规划与飞拍触发时间轴
- [x] 落地 Rust ICSP、可选 C# ICSP / self-adapt-icsp 轨迹规划与飞拍触发时间轴
- [x] 完成 ICSP 轨迹导出结果与旧系统对齐
- [x] 增加 Rust `cdylib` ICSP 核心、C# P/Invoke 适配和 C#/Rust 自动对拍
- [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`
### 规划后端与 Rust ICSP 原生库构建
普通轨迹或飞拍轨迹的默认参数 `method="icsp"` 会调用 Rust ICSP 后端。旧 `method="icsp_rust"` 已不再作为正式入口,调用时会提示改用 `method="icsp"`;Rust 原生库缺失时会明确报错,不会静默回退 C#。
C# `ICspPlanner` 与 `SelfAdaptIcspPlanner` 已拆分到 `Flyshot.CSharp.Planning.dll`。默认服务项目不引用该 DLL;只有在部署方显式引用并调用 `AddCSharpPlanningBackends()` 后,`method="icsp-csharp"` 与 `method="self-adapt-icsp"` 才可用。
Windows x64 需要安装:
- `rustup` 的 `stable-x86_64-pc-windows-msvc` 工具链。
- Visual Studio 或 Visual Studio Build Tools 的 C++ x64 编译工具和 Windows SDK。
在仓库根目录执行:
```powershell
.\build\Copy-NativePlanningArtifacts.ps1 -Configuration Release
```
脚本会执行 `cargo build --release`,构建
`native/flyshot-native-planning/target/release/flyshot_planning.dll`,
并默认复制到 `src/Libs/flyshot_planning.dll` 与
`tests/Flyshot.Core.Tests/bin/Release/net8.0/`。发布服务时可通过
`-DestinationDirectories` 把原生库复制到实际发布目录。
Linux x64 在已安装 `stable-x86_64-unknown-linux-gnu` 工具链的 Linux 环境执行同一脚本,
产物为 `libflyshot_planning.so`,默认写入 `src/Libs/libflyshot_planning.so`。
Windows 第一阶段不要求交叉编译 Linux 原生库。
剩余 Todo:
@@ -120,6 +156,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,76 @@
<#
.SYNOPSIS
构建 Rust ICSP 原生规划库并复制到 src/Libs 与可选目标目录。
.DESCRIPTION
在 Windows x64 上通过 cargo release 构建 flyshot_planning,默认写入
src/Libs/flyshot_planning.dll;Linux x64 写入 src/Libs/libflyshot_planning.so。
可通过 -DestinationDirectories 额外复制到测试或发布输出目录。
.PARAMETER Configuration
传递给 dotnet 相关路径的构建配置名,默认 Release。
.PARAMETER DestinationDirectories
除 src/Libs 外需要覆盖复制的额外目标目录列表。
#>
[CmdletBinding()]
param(
[ValidateSet('Debug', 'Release')]
[string]$Configuration = 'Release',
[string[]]$DestinationDirectories = @()
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
$repositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
$cargoManifest = Join-Path $repositoryRoot 'native\flyshot-native-planning\Cargo.toml'
$libsDirectory = Join-Path $repositoryRoot 'src\Libs'
$cargoProfile = if ($Configuration -eq 'Debug') { 'debug' } else { 'release' }
$targetDirectory = Join-Path $repositoryRoot "native\flyshot-native-planning\target\$cargoProfile"
if ($IsLinux -or (-not $IsWindows -and -not $IsMacOS))
{
$artifactFileName = 'libflyshot_planning.so'
}
else
{
$artifactFileName = 'flyshot_planning.dll'
}
$builtArtifactPath = Join-Path $targetDirectory $artifactFileName
$libsArtifactPath = Join-Path $libsDirectory $artifactFileName
Write-Host "正在构建 Rust 原生规划库 ($Configuration) ..."
& cargo build --manifest-path $cargoManifest --$Configuration
if ($LASTEXITCODE -ne 0)
{
throw "cargo build 失败,退出码:$LASTEXITCODE"
}
if (-not (Test-Path -LiteralPath $builtArtifactPath -PathType Leaf))
{
throw "未找到构建产物:$builtArtifactPath"
}
New-Item -ItemType Directory -Path $libsDirectory -Force | Out-Null
Copy-Item -LiteralPath $builtArtifactPath -Destination $libsArtifactPath -Force
Write-Host "已复制到:$libsArtifactPath"
$defaultTestOutput = Join-Path $repositoryRoot "tests\Flyshot.Core.Tests\bin\$Configuration\net8.0"
$allDestinations = @($DestinationDirectories) + @($defaultTestOutput)
foreach ($destination in $allDestinations | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique)
{
$absoluteDestination = [System.IO.Path]::GetFullPath($destination)
if (-not (Test-Path -LiteralPath $absoluteDestination -PathType Container))
{
Write-Host "跳过不存在的目标目录:$absoluteDestination"
continue
}
Copy-Item -LiteralPath $builtArtifactPath -Destination (Join-Path $absoluteDestination $artifactFileName) -Force
Write-Host "已复制到:$absoluteDestination\$artifactFileName"
}

View File

@@ -130,11 +130,11 @@
以下内容是当前新实现的状态,不反推为旧系统事实:
- `Flyshot.ControllerClientCompat` 继续作为 HTTP 控制器后端兼容服务,不启动 `50001/TCP+JSON` 监听。
- `ExecuteTrajectory` 会先通过 `ICspPlanner` 规划普通轨迹,再把 `TrajectoryResult` 和最终关节位置交给 `IControllerRuntime`。
- `ExecuteFlyShotTraj` 会从上传轨迹目录取出轨迹,通过 `SelfAdaptIcspPlanner` 规划并用 `ShotTimelineBuilder` 生成 `ShotEvent` / `TrajectoryDoEvent`。
- `ExecuteTrajectory` 会先通过默认 Rust `method="icsp"` 后端规划普通轨迹,再把 `TrajectoryResult` 和最终关节位置交给 `IControllerRuntime`。
- `ExecuteFlyShotTraj` 会从上传轨迹目录取出轨迹,通过已注册的规划后端生成轨迹,并用 `ShotTimelineBuilder` 生成 `ShotEvent` / `TrajectoryDoEvent`。
- HTTP 控制器已经按公开文档和抓包补齐 `ExecuteTrajectory(method, save_traj)` 与 `ExecuteFlyShotTraj(move_to_start, method, save_traj, use_cache, wait)` 参数,并继续兼容旧的裸 waypoint 数组和只传 `name` 的请求体。
- 规划阶段会继续消费旧配置中的 `acc_limit / jerk_limit`。如果现场需要复现旧服务端不可见的保守约束,replacement 设计上使用内部 `planning_acceleration_scale` 限制规划加速度;该字段不属于旧 `ControllerClient` 公开 API,也不会通过 `50001` JSON 下发。
- `method="icsp"` 与 `method="self-adapt-icsp"` 已接入当前规划器;`method="doubles"` 会被识别但返回显式未实现,不会静默降级成 ICSP。
- `method="icsp"` 已接入默认 Rust 后端;`method="icsp-csharp"` 与 `method="self-adapt-icsp"` 只有显式注册 `Flyshot.CSharp.Planning` 后端时可用;`method="doubles"` 会被识别但返回显式未实现,不会静默降级成 ICSP。
- `Flyshot.Runtime.Fanuc.Protocol` 已经固化 `10010` 状态帧、`10012` 命令帧和 `60015` J519 数据包的基础编解码,并使用逆向抓包样本覆盖最小测试;`10010` 当前现场确认固定 90B。
- `Flyshot.Runtime.Fanuc` 已具备基础 Socket 客户端、程序启停、速度倍率/TCP/IO 参数命令和 J519 状态包驱动发送链路;稠密轨迹下发已按 `speed_ratio` 推进轨迹时间,并在收到机器人 UDP status 后按该 status sequence 回发命令。真实 R30iB 全流程现场联调仍需执行。
- 2026-04-28 `UTTC_MS11` 抓包确认 J519 命令目标为 `deg`、导出 `JointDetialTraj.txt` 为 `rad`,`speed_ratio=0.5/0.7/1.0` 分别形成 `1851/1322/926` 个主运行 J519 包;实际执行不发送 464 行导出点,而是在 8ms 实发周期上按 `t_traj = t_send * speed_ratio` 采样,包数为 `floor(duration / (0.008 * speed_ratio)) + 1`。

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,138 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="900" viewBox="0 0 1600 900" role="img" aria-labelledby="title desc">
<title id="title">飞拍轨迹规划逻辑</title>
<desc id="desc">用于 PPT 展示的飞拍轨迹规划逻辑图,展示输入、三次样条、约束检查、逐段缩放迭代、输出时间轴与采样层关系。</desc>
<defs>
<marker id="arrow-blue" markerWidth="14" markerHeight="14" refX="11" refY="7" orient="auto" markerUnits="strokeWidth">
<path d="M2,2 L12,7 L2,12 Z" fill="#2563eb" />
</marker>
<marker id="arrow-green" markerWidth="14" markerHeight="14" refX="11" refY="7" orient="auto" markerUnits="strokeWidth">
<path d="M2,2 L12,7 L2,12 Z" fill="#059669" />
</marker>
<marker id="arrow-amber" markerWidth="14" markerHeight="14" refX="11" refY="7" orient="auto" markerUnits="strokeWidth">
<path d="M2,2 L12,7 L2,12 Z" fill="#d97706" />
</marker>
<linearGradient id="title-grad" x1="0" x2="1" y1="0" y2="0">
<stop offset="0" stop-color="#1d4ed8" />
<stop offset="0.55" stop-color="#0f766e" />
<stop offset="1" stop-color="#7c3aed" />
</linearGradient>
<filter id="soft-shadow" x="-10%" y="-10%" width="120%" height="130%">
<feDropShadow dx="0" dy="8" stdDeviation="8" flood-color="#0f172a" flood-opacity="0.12" />
</filter>
<style>
.bg { fill: #f8fafc; }
.title { font: 700 42px "Microsoft YaHei", "Segoe UI", Arial, sans-serif; fill: #0f172a; letter-spacing: 0; }
.subtitle { font: 400 23px "Microsoft YaHei", "Segoe UI", Arial, sans-serif; fill: #475569; letter-spacing: 0; }
.section-title { font: 700 25px "Microsoft YaHei", "Segoe UI", Arial, sans-serif; fill: #0f172a; letter-spacing: 0; }
.label { font: 600 21px "Microsoft YaHei", "Segoe UI", Arial, sans-serif; fill: #0f172a; letter-spacing: 0; }
.small { font: 400 18px "Microsoft YaHei", "Segoe UI", Arial, sans-serif; fill: #334155; letter-spacing: 0; }
.tiny { font: 400 15px "Microsoft YaHei", "Segoe UI", Arial, sans-serif; fill: #475569; letter-spacing: 0; }
.formula { font: 500 20px "Consolas", "Microsoft YaHei", monospace; fill: #0f172a; letter-spacing: 0; }
.math { font: 500 25px "Cambria Math", "Times New Roman", "Microsoft YaHei", serif; fill: #0f172a; letter-spacing: 0; }
.math-small { font: 500 21px "Cambria Math", "Times New Roman", "Microsoft YaHei", serif; fill: #0f172a; letter-spacing: 0; }
.box { fill: #ffffff; stroke: #cbd5e1; stroke-width: 2; rx: 18; filter: url(#soft-shadow); }
.box-blue { fill: #eff6ff; stroke: #93c5fd; stroke-width: 2; rx: 18; }
.box-green { fill: #ecfdf5; stroke: #86efac; stroke-width: 2; rx: 18; }
.box-amber { fill: #fffbeb; stroke: #fcd34d; stroke-width: 2; rx: 18; }
.box-purple { fill: #f5f3ff; stroke: #c4b5fd; stroke-width: 2; rx: 18; }
.pill { rx: 22; }
.line { fill: none; stroke: #2563eb; stroke-width: 4; marker-end: url(#arrow-blue); }
.line-green { fill: none; stroke: #059669; stroke-width: 4; marker-end: url(#arrow-green); }
.line-amber { fill: none; stroke: #d97706; stroke-width: 4; marker-end: url(#arrow-amber); }
.loop { fill: none; stroke: #d97706; stroke-width: 4; stroke-dasharray: 10 8; marker-end: url(#arrow-amber); }
.axis { stroke: #64748b; stroke-width: 2; }
.grid { stroke: #e2e8f0; stroke-width: 1; }
.curve { fill: none; stroke: #2563eb; stroke-width: 5; stroke-linecap: round; }
.curve2 { fill: none; stroke: #0f766e; stroke-width: 4; stroke-linecap: round; }
.dot { fill: #1d4ed8; stroke: #ffffff; stroke-width: 4; }
.dot-green { fill: #059669; stroke: #ffffff; stroke-width: 4; }
.accent-bar { fill: url(#title-grad); rx: 4; }
.divider { stroke: #cbd5e1; stroke-width: 2; stroke-dasharray: 8 8; }
</style>
</defs>
<rect class="bg" width="1600" height="900" />
<rect class="accent-bar" x="80" y="58" width="540" height="8" />
<text class="title" x="80" y="120">飞拍轨迹规划逻辑</text>
<text class="subtitle" x="80" y="158">用三次样条连接示教点,通过反复拉长超限段,让整条轨迹满足关节约束。</text>
<!-- 主流程卡片 -->
<rect class="box-blue" x="80" y="215" width="255" height="180" />
<text class="section-title" x="112" y="258">输入</text>
<text class="small" x="112" y="312">示教点</text>
<text class="small" x="112" y="346">关节约束</text>
<path class="line" d="M335 305 H420" />
<rect class="box" x="420" y="215" width="285" height="180" />
<text class="section-title" x="452" y="258">初始时间轴</text>
<text class="small" x="452" y="318">按相邻点距离</text>
<text class="small" x="452" y="352">估算每段时长</text>
<path class="line" d="M705 305 H790" />
<rect class="box-green" x="790" y="215" width="305" height="180" />
<text class="section-title" x="822" y="258">三次样条</text>
<text class="small" x="822" y="318">连接所有示教点</text>
<text class="small" x="822" y="352">形成连续轨迹</text>
<path class="line" d="M1095 305 H1180" />
<rect class="box-amber" x="1180" y="215" width="340" height="180" />
<text class="section-title" x="1212" y="258">约束检查</text>
<text class="small" x="1212" y="312">速度 / 加速度 / jerk</text>
<text class="small" x="1212" y="346">找出超限段</text>
<!-- 曲线示意 -->
<rect class="box" x="105" y="455" width="585" height="285" />
<text class="section-title" x="135" y="497">轨迹形状:经过原始示教点</text>
<line class="grid" x1="150" y1="680" x2="650" y2="680" />
<line class="grid" x1="150" y1="620" x2="650" y2="620" />
<line class="grid" x1="150" y1="560" x2="650" y2="560" />
<line class="axis" x1="150" y1="690" x2="650" y2="690" />
<line class="axis" x1="150" y1="690" x2="150" y2="535" />
<path class="curve" d="M165 665 C205 618, 240 565, 285 585 C340 610, 375 695, 425 645 C475 595, 500 515, 558 548 C595 570, 620 625, 640 600" />
<circle class="dot" cx="165" cy="665" r="9" />
<circle class="dot" cx="285" cy="585" r="9" />
<circle class="dot" cx="425" cy="645" r="9" />
<circle class="dot" cx="558" cy="548" r="9" />
<circle class="dot" cx="640" cy="600" r="9" />
<text class="tiny" x="160" y="720">q0</text>
<text class="tiny" x="277" y="720">q1</text>
<text class="tiny" x="417" y="720">q2</text>
<text class="tiny" x="550" y="720">q3</text>
<text class="tiny" x="630" y="720">qn</text>
<text class="small" x="135" y="777">点位不变,主要调整每段用时。</text>
<!-- 核心公式卡片 -->
<rect class="box-purple" x="735" y="455" width="785" height="285" />
<text class="section-title" x="765" y="497">迭代逻辑</text>
<text class="label" x="780" y="560">1. 计算每段是否超限</text>
<text class="label" x="780" y="620">2. 超限段按比例拉长时间</text>
<text class="label" x="780" y="680">3. 重新生成样条,再检查一次</text>
<text class="math-small" x="1195" y="590">
<tspan>scale</tspan><tspan baseline-shift="sub" font-size="14">i</tspan>
<tspan> = max(s</tspan><tspan baseline-shift="sub" font-size="14">v</tspan><tspan>, s</tspan><tspan baseline-shift="sub" font-size="14">a</tspan><tspan>, s</tspan><tspan baseline-shift="sub" font-size="14">j</tspan><tspan>)</tspan>
</text>
<text class="math-small" x="1195" y="650">
<tspan>T</tspan><tspan baseline-shift="sub" font-size="14">i</tspan>
<tspan> &#8592; T</tspan><tspan baseline-shift="sub" font-size="14">i</tspan>
<tspan> * scale</tspan><tspan baseline-shift="sub" font-size="14">i</tspan>
</text>
<!-- 迭代回路 -->
<path class="loop" d="M1350 395 C1350 430, 1270 435, 1190 425 C1075 410, 988 410, 945 397" />
<text class="small" x="1015" y="435" fill="#92400e">未收敛就继续拉长、重算</text>
<!-- 输出层 -->
<rect class="box-green" x="80" y="805" width="330" height="58" />
<text class="label" x="110" y="842">输出时间轴</text>
<path class="line-green" d="M410 834 H515" />
<rect class="box" x="515" y="790" width="430" height="88" />
<text class="label" x="545" y="826">按周期采样</text>
<text class="tiny" x="545" y="856">后续模块生成稠密轨迹点</text>
<path class="line-green" d="M945 834 H1050" />
<rect class="box-amber" x="1050" y="790" width="470" height="88" />
<text class="label" x="1080" y="826">运行时下发</text>
<text class="tiny" x="1080" y="856">进入飞拍执行链路</text>
</svg>

After

Width:  |  Height:  |  Size: 8.3 KiB

View File

@@ -0,0 +1,257 @@
# 智能视觉质检系统宣传文稿
## 一句话定位
智能视觉质检系统是一套覆盖“边缘取图、AI 检测、人工复核、第三方对接、结果闭环”的工业视觉质量管理平台,帮助产线把图像采集、缺陷识别和复检工作流整合为一条连续、可追踪、可协同的质检链路。
## 宣传主文案
在工业视觉检测现场,真正影响效率的不只是模型能否识别缺陷,还包括图片如何稳定采集、AI 结果如何进入业务系统、人工如何快速复核、第三方系统如何查询结果,以及异常任务如何被追踪和处理。
智能视觉质检系统围绕这一完整链路设计:前端由边缘飞拍系统负责在机器人运动过程中完成连续取图;中间由 AI 检测服务对图片进行模型推理,输出 OK/NG、缺陷区域和结构化检测信息;上层由复检工作流平台承接图片、AI 结果和人工判断,完成任务管理、复核流转、结果查询和第三方接口对接。
这不是单个检测工具,而是一整套面向产线落地的视觉质检系统。它把“取图、识别、复核、上传、查询、归档”串成统一流程,让现场从分散的图片文件、模型结果和人工沟通,升级为可管理、可复核、可追溯的质量工作流。
## 系统组成
### 1. 边缘飞拍取图层
飞拍系统负责在机器人运动过程中完成边缘段取图。它根据检测路径和拍照节拍,在连续运动中触发相机采集图片,减少停顿拍照对产线节拍的影响。
核心作用:
- 支持机器人运动中连续取图。
- 支持按路径、节拍或检测点触发拍照。
- 适合边缘、焊缝、孔位、外观等路径型检测场景。
- 为后续 AI 检测提供稳定、连续、带位置语义的图片输入。
### 2. AI 检测服务层
AI 检测服务负责接收图片并执行模型推理。系统支持通过接口提交图片,可按业务需要调用单模型或多模型检测,并返回结构化结果。
核心作用:
- 接收产线图片进行 AI 检测。
- 输出 OK/NG、缺陷区域、模型结果和处理信息。
- 支持多模型组合检测,适配不同检测面、不同缺陷类型。
- 通过标准接口与上层复检平台、现场系统集成。
### 3. 人工复核与工作流层
复检工作流平台负责承接 AI 检测结果和图片数据,形成可操作的人工复核流程。它提供任务管理、图片浏览、AI 结果展示、人工判定、历史查询、第三方接口对接和结果归档能力。
核心作用:
- 管理待复核、已完成、历史任务。
- 支持原图、AI 标注图和检测元数据统一展示。
- 支持人工 OK/NG 判断与批量提交。
- 支持第三方系统上传任务、查询最新复检结果。
- 形成从图片到判定结果的完整追溯链。
## 端到端业务闭环
```text
机器人/相机飞拍取图
↓
图片进入 AI 检测服务
↓
AI 输出 OK/NG 与缺陷信息
↓
复检平台生成审核任务
↓
人工查看原图与 AI 标注图
↓
人工确认最终结果
↓
第三方系统查询或接收复检结果
↓
质检数据归档与追溯
```
这条链路把现场设备、AI 推理、人工经验和业务系统连接在一起,使视觉检测不再停留在单点识别,而是进入可运行的质量管理流程。
## 核心能力
### 1. 连续取图能力
系统支持机器人边运动边拍照,适合路径型工件和多点位检测。通过拍照节拍配置,可根据产线速度、相机曝光和检测需求调整采集频率。
### 2. AI 自动检测能力
系统支持图片接口化检测,能够将模型推理结果结构化输出。AI 检测结果可直接进入复检平台,作为人工判断的重要依据。
### 3. 人工复核能力
复检平台提供面向操作员的图片审核界面,可集中查看任务、图片、AI 标注图、缺陷信息和历史记录。人工可快速完成 OK/NG 判定,减少线下沟通和文件查找。
### 4. 第三方接口能力
系统可对接第三方业务系统,支持任务上传、结果查询和复检状态同步。产线系统、PLC 上位系统、MES 或其他业务平台可以通过接口获取复检结果。
### 5. 工作流闭环能力
系统将图片采集、AI 检测、人工复核和结果回传组织成统一流程。每个任务都有状态,每张图片都有来源和判定,每次操作都可追踪。
### 6. 多场景扩展能力
整体架构可适配不同检测产品、不同图片来源、不同 AI 模型和不同复核策略。既可以服务单工位,也可以扩展到多工位、多模型、多业务线。
## 展示重点
### 系统能力总览
| 层级 | 作用 | 对外表达 |
| --- | --- | --- |
| 边缘取图 | 机器人运动中连续采集图片 | 让检测图片稳定进入系统 |
| AI 检测 | 对图片进行模型推理 | 自动识别缺陷并输出结构化结果 |
| 人工复核 | 对 AI 结果进行确认 | 让关键判定有人工闭环 |
| 第三方接口 | 任务上传与结果查询 | 与产线和业务系统打通 |
| 工作流平台 | 任务、图片、结果统一管理 | 形成可追踪的质量流程 |
### 拍照节拍展示
对外展示时,可以用“每秒拍照数量”表达边缘取图能力,同时避免写成固定承诺。
| 模式 | 每秒拍照数量示意 | 适用场景 |
| --- | ---: | --- |
| 稳定检测模式 | 5-10 张/秒 | 常规外观、定位、尺寸检测 |
| 高速检测模式 | 10-20 张/秒 | 连续路径、多点位检测 |
| 高密度采集模式 | 20+ 张/秒 | 短路径密集取像、节拍优化展示 |
备注:实际拍照数量取决于机器人速度、相机曝光时间、光源响应、图像处理耗时和现场安全策略。
### 时序图展示
```text
机器人运动轨迹 ────────────────────────────────>
边缘飞拍触发 ▲ ▲ ▲ ▲ ▲
图1 图2 图3 图4 图5
AI 检测服务 检测 检测 检测 检测 检测
复检工作流 生成任务 → 图片审核 → 人工判定 → 结果回传
```
配套说明文案:
系统将取图、检测和复核组织在同一条业务时间线上。边缘端负责稳定采集,AI 服务负责自动识别,复检平台负责人工确认和结果流转,最终形成完整的质检闭环。
## 建议 PPT 结构
### 1. 封面
标题:智能视觉质检系统
副标题:从边缘取图到 AI 检测,再到人工复核与结果闭环
### 2. 行业痛点
标题:视觉检测难点不只在 AI 模型
要点:
- 图片来源分散,取图节拍难统一。
- AI 结果需要人工确认和业务留痕。
- 产线系统需要查询最终复检结果。
- 图片、模型结果、人工判断缺少统一工作流。
### 3. 系统总体架构
标题:三层协同,形成端到端质检链路
建议图形:
```text
边缘飞拍取图层
↓
AI 检测服务层
↓
人工复核与工作流层
↓
第三方系统 / 产线系统
```
### 4. 边缘取图能力
标题:机器人运动中连续采集检测图片
展示内容:
- 运动轨迹。
- 拍照触发点。
- 每秒拍照数量示意。
- 取图与检测位置绑定。
### 5. AI 检测能力
标题:接口化 AI 检测,支持多模型结果输出
展示内容:
- 图片输入。
- 模型推理。
- OK/NG 结果。
- 缺陷区域信息。
- 处理后标注图。
### 6. 人工复核能力
标题:AI 结果进入复检工作台
展示内容:
- 当前待复核任务。
- 原图与 AI 标注图对比。
- 人工 OK/NG 判定。
- 批量提交与历史查询。
### 7. 第三方接口与业务闭环
标题:与产线系统打通最终复检结果
展示内容:
- 第三方上传任务。
- 查询最新复检结果。
- 状态同步。
- 结果归档。
### 8. 典型应用场景
标题:适用于多类工业视觉质检场景
要点:
- 焊缝与边缘检测。
- PCB / 工件外观检测。
- 孔位与装配位置检测。
- 多点位连续取像。
- AI 初判 + 人工终判场景。
### 9. 收尾页
标题:让视觉检测从单点识别走向完整工作流
推荐短句:
智能视觉质检系统把边缘取图、AI 检测、人工复核和第三方对接整合为统一流程,让质量判断更高效、更清晰、更可追踪。
## 展会讲解词
这套智能视觉质检系统不是单独展示一个 AI 模型,也不是只展示一个拍照工具,而是展示从现场取图到最终复检结果的完整链路。
在边缘端,机器人通过飞拍方式在运动过程中采集检测图片,适合边缘段、路径型、多点位的工业检测场景。图片采集完成后,进入 AI 检测服务,由模型自动判断 OK/NG,并输出缺陷区域、标注图和结构化结果。
随后,这些图片和 AI 结果会进入复检工作流平台。操作员可以在界面中查看原图、AI 标注图和检测信息,完成最终人工判断。对于产线或第三方业务系统,平台提供任务上传和结果查询能力,使复检结果能够回到业务流程中。
因此,客户看到的是一整套系统能力:前端能稳定取图,中间能自动检测,上层能人工复核,外部系统还能查询最终结果。它解决的是工业视觉检测从“拍到图、看出缺陷”到“形成可追踪质量结论”的完整问题。
## 对外短标题
- 智能视觉质检系统
- 从边缘取图到复检闭环
- AI 检测 + 人工复核一体化
- 让视觉检测进入可追踪工作流
- 机器人飞拍取图,AI 自动识别,人工确认结果
- 面向工业产线的端到端视觉质量平台

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,330 @@
# Rust ICSP Native 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:** 为现有 `ICspPlanner` 增加一套基于 Rust `cdylib` 的原生 ICSP 规划实现,并通过 C ABI + C# P/Invoke 完成可验证的对拍接入,同时不改变当前默认生产路径。
**Architecture:** 新增 `native/flyshot-native-planning` Rust 原生库承载 clamped-zero 三次样条与 ICSP 迭代核心;新增 `src/Flyshot.Native.Planning` 托管互操作项目负责库加载、数组编排、错误转换和结果复制。现有 `Flyshot.Core.Planning.ICspPlanner` 保持不变,`RustIcspPlanner` 仅作为显式新入口并在测试中与托管实现逐项对拍。
**Tech Stack:** Rust `cdylib`、C ABI、.NET 8、`LibraryImport`、`SafeHandle`、xUnit、PowerShell、Cargo
---
### Task 1: 建立原生与托管骨架
**Files:**
- Create: `native/flyshot-native-planning/Cargo.toml`
- Create: `native/flyshot-native-planning/src/lib.rs`
- Create: `native/flyshot-native-planning/src/error.rs`
- Create: `native/flyshot-native-planning/src/ffi.rs`
- Create: `native/flyshot-native-planning/src/icsp.rs`
- Create: `native/flyshot-native-planning/src/spline.rs`
- Create: `src/Flyshot.Native.Planning/Flyshot.Native.Planning.csproj`
- Create: `src/Flyshot.Native.Planning/NativeIcspMethods.cs`
- Create: `src/Flyshot.Native.Planning/NativeIcspSafeHandle.cs`
- Create: `src/Flyshot.Native.Planning/NativePlanningException.cs`
- Create: `src/Flyshot.Native.Planning/NativePlanningLibraryResolver.cs`
- Modify: `FlyshotReplacement.sln`
- Modify: `tests/Flyshot.Core.Tests/Flyshot.Core.Tests.csproj`
- [x] **Step 1: 先写托管侧失败测试,锁定缺少原生库时的报错边界**
```csharp
[Fact]
public void RustIcspPlanner_ThrowsClearError_WhenNativeLibraryIsMissing()
{
var planner = new RustIcspPlanner(
options: NativeIcspPlannerOptions.Default,
libraryResolver: new NativePlanningLibraryResolver(_ => "missing-library"));
var exception = Assert.Throws<NativePlanningException>(() => planner.Plan(CreateSimpleRequest()));
Assert.Contains("missing-library", exception.Message);
}
```
- [x] **Step 2: 运行单测确认当前失败**
Run: `dotnet test tests/Flyshot.Core.Tests/Flyshot.Core.Tests.csproj --filter RustIcspPlanner_ThrowsClearError_WhenNativeLibraryIsMissing -v minimal`
Expected: FAIL,提示 `RustIcspPlanner` 或互操作类型不存在
- [x] **Step 3: 创建 Rust `cdylib` 和托管互操作项目骨架**
```toml
[package]
name = "flyshot_planning"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[profile.release]
lto = true
codegen-units = 1
strip = "symbols"
panic = "abort"
```
```xml
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Flyshot.Core.Domain\Flyshot.Core.Domain.csproj" />
<ProjectReference Include="..\Flyshot.Core.Planning\Flyshot.Core.Planning.csproj" />
</ItemGroup>
</Project>
```
- [x] **Step 4: 添加最小库解析与异常包装实现**
```csharp
public sealed class NativePlanningException : Exception
{
public NativePlanningException(string message, int errorCode, Exception? innerException = null)
: base(message, innerException)
{
ErrorCodeValue = errorCode;
}
public int ErrorCodeValue { get; }
}
```
```csharp
public sealed class NativePlanningLibraryResolver
{
private readonly Func<RuntimeInformation, string> _resolveLibraryPath;
}
```
- [x] **Step 5: 重新运行单测,确认错误已变为清晰的原生库缺失异常**
Run: `dotnet test tests/Flyshot.Core.Tests/Flyshot.Core.Tests.csproj --filter RustIcspPlanner_ThrowsClearError_WhenNativeLibraryIsMissing -v minimal`
Expected: PASS
### Task 2: 先做样条基础能力
**Files:**
- Modify: `native/flyshot-native-planning/src/spline.rs`
- Modify: `native/flyshot-native-planning/src/lib.rs`
- Create: `native/flyshot-native-planning/tests/spline_tests.rs`
- [x] **Step 1: 先写 Rust 失败测试,锁定 clamped-zero 样条边界和值/导数接口**
```rust
#[test]
fn clamped_zero_spline_hits_waypoints_and_zero_boundary_velocity() {
let times = [0.0, 1.0, 2.0, 3.0];
let values = [0.0, 1.0, 0.5, 2.0];
let spline = CubicSpline1D::new_clamped_zero(&times, &values).unwrap();
assert_abs_diff_eq!(spline.position(0.0), 0.0, epsilon = 1e-12);
assert_abs_diff_eq!(spline.position(3.0), 2.0, epsilon = 1e-12);
assert_abs_diff_eq!(spline.velocity(0.0), 0.0, epsilon = 1e-12);
assert_abs_diff_eq!(spline.velocity(3.0), 0.0, epsilon = 1e-12);
}
```
- [x] **Step 2: 运行 Rust 测试确认失败**
Run: `cargo test --manifest-path native/flyshot-native-planning/Cargo.toml spline_tests::clamped_zero_spline_hits_waypoints_and_zero_boundary_velocity -- --exact`
Expected: FAIL,提示样条类型或构造函数不存在
- [x] **Step 3: 实现三对角求解、样条系数和每段导数峰值接口**
```rust
pub struct CubicSpline1D {
knots: Vec<f64>,
coefficients: Vec<[f64; 4]>,
}
impl CubicSpline1D {
pub fn new_clamped_zero(times: &[f64], values: &[f64]) -> Result<Self, NativePlanningError> { /* ... */ }
pub fn position(&self, time: f64) -> f64 { /* ... */ }
pub fn velocity(&self, time: f64) -> f64 { /* ... */ }
pub fn acceleration(&self, time: f64) -> f64 { /* ... */ }
pub fn jerk(&self, time: f64) -> f64 { /* ... */ }
pub fn segment_peak_derivatives(&self, segment_index: usize) -> [f64; 3] { /* ... */ }
}
```
- [x] **Step 4: 重新运行 Rust 样条测试**
Run: `cargo test --manifest-path native/flyshot-native-planning/Cargo.toml spline_tests::clamped_zero_spline_hits_waypoints_and_zero_boundary_velocity -- --exact`
Expected: PASS
### Task 3: 实现纯 Rust ICSP 核心
**Files:**
- Modify: `native/flyshot-native-planning/src/icsp.rs`
- Create: `native/flyshot-native-planning/tests/icsp_tests.rs`
- [x] **Step 1: 先写 Rust 失败测试,锁定初始段时长、scale 计算和未收敛路径**
```rust
#[test]
fn icsp_returns_same_iteration_count_as_managed_baseline_fixture() {
let input = test_fixture_three_segment_case();
let result = plan_icsp(&input).unwrap();
assert_eq!(result.iterations, 2);
assert_abs_diff_eq!(result.threshold, 0.0, epsilon = 1e-12);
}
```
- [x] **Step 2: 运行 Rust ICSP 测试确认失败**
Run: `cargo test --manifest-path native/flyshot-native-planning/Cargo.toml icsp_tests::icsp_returns_same_iteration_count_as_managed_baseline_fixture -- --exact`
Expected: FAIL,提示 `plan_icsp` 或结果结构不存在
- [x] **Step 3: 实现最小 ICSP 纯算法结构,逐项复刻 C# 收敛语义**
```rust
pub struct IcspInput<'a> {
pub waypoints: &'a [f64],
pub waypoint_count: usize,
pub dof: usize,
pub velocity_limits: &'a [f64],
pub acceleration_limits: &'a [f64],
pub jerk_limits: &'a [f64],
pub options: IcspOptions,
}
pub fn plan_icsp(input: &IcspInput<'_>) -> Result<IcspPlanResult, NativePlanningError> { /* ... */ }
```
- [x] **Step 4: 重新运行 Rust ICSP 测试**
Run: `cargo test --manifest-path native/flyshot-native-planning/Cargo.toml icsp_tests::icsp_returns_same_iteration_count_as_managed_baseline_fixture -- --exact`
Expected: PASS
### Task 4: 接通 C ABI 和托管结果复制
**Files:**
- Modify: `native/flyshot-native-planning/src/ffi.rs`
- Modify: `native/flyshot-native-planning/src/error.rs`
- Modify: `src/Flyshot.Native.Planning/NativeIcspMethods.cs`
- Modify: `src/Flyshot.Native.Planning/NativeIcspSafeHandle.cs`
- Create: `src/Flyshot.Native.Planning/NativeIcspInteropModels.cs`
- Create: `src/Flyshot.Native.Planning/RustIcspPlanner.cs`
- Create: `tests/Flyshot.Core.Tests/RustIcspPlannerInteropTests.cs`
- [x] **Step 1: 先写托管失败测试,锁定数组布局、结果复制和释放路径**
```csharp
[Fact]
public void RustIcspPlanner_ReturnsManagedPlannedTrajectory_FromNativeResult()
{
var planner = CreateRustPlannerAgainstBuiltNativeLibrary();
var result = planner.Plan(CreateSimpleRequest());
Assert.Equal(PlanningMethod.Icsp, result.Method);
Assert.Equal(result.PlannedWaypointCount, result.WaypointTimes.Count);
}
```
- [x] **Step 2: 运行单测确认失败**
Run: `dotnet test tests/Flyshot.Core.Tests/Flyshot.Core.Tests.csproj --filter RustIcspPlanner_ReturnsManagedPlannedTrajectory_FromNativeResult -v minimal`
Expected: FAIL,提示 P/Invoke 签名、结果结构或释放逻辑未实现
- [x] **Step 3: 实现 C ABI 入口、线程局部错误文本和 `SafeHandle` 释放**
```rust
#[no_mangle]
pub extern "C" fn flyshot_icsp_plan(/* ... */) -> i32 { /* ... */ }
#[no_mangle]
pub extern "C" fn flyshot_icsp_result_free(result: *mut FlyshotIcspResult) { /* ... */ }
#[no_mangle]
pub extern "C" fn flyshot_last_error_message() -> *const c_char { /* ... */ }
```
```csharp
[LibraryImport(NativeLibraryName, EntryPoint = "flyshot_icsp_plan")]
internal static unsafe partial int FlyshotIcspPlan(/* ... */);
```
- [x] **Step 4: 重新运行托管互操作测试**
Run: `dotnet test tests/Flyshot.Core.Tests/Flyshot.Core.Tests.csproj --filter RustIcspPlanner_ReturnsManagedPlannedTrajectory_FromNativeResult -v minimal`
Expected: PASS
### Task 5: 增加 C#/Rust 对拍测试和构建复制脚本
**Files:**
- Modify: `tests/Flyshot.Core.Tests/PlanningCompatibilityTests.cs`
- Create: `tests/Flyshot.Core.Tests/RustIcspPlannerParityTests.cs`
- Create: `build/Copy-NativePlanningArtifacts.ps1`
- Modify: `README.md`
- [x] **Step 1: 先写失败测试,锁定 C# 与 Rust 在基础样本上的逐项一致**
```csharp
[Fact]
public void RustIcspPlanner_MatchesManagedIcspPlanner_ForCompatibilityFixture()
{
var request = CreateCompatibilityRequest();
var managed = new ICspPlanner().Plan(request);
var native = CreateRustPlannerAgainstBuiltNativeLibrary().Plan(request);
AssertTrajectoryMatches(managed, native, tolerance: 1e-9);
}
```
- [x] **Step 2: 运行对拍测试确认失败**
Run: `dotnet test tests/Flyshot.Core.Tests/Flyshot.Core.Tests.csproj --filter RustIcspPlanner_MatchesManagedIcspPlanner_ForCompatibilityFixture -v minimal`
Expected: FAIL,提示数值未对齐或原生库复制失败
- [x] **Step 3: 补齐原生库复制脚本、测试输出复制规则和对拍辅助断言**
```powershell
param(
[string]$Configuration = "Release"
)
cargo build --manifest-path native/flyshot-native-planning/Cargo.toml --release
Copy-Item native/flyshot-native-planning/target/release/flyshot_planning.dll tests/Flyshot.Core.Tests/bin/$Configuration/net8.0/ -Force
```
- [x] **Step 4: 重新运行对拍测试**
Run: `dotnet test tests/Flyshot.Core.Tests/Flyshot.Core.Tests.csproj --filter RustIcspPlanner_MatchesManagedIcspPlanner_ForCompatibilityFixture -v minimal`
Expected: PASS
### Task 6: 解决方案级验证
**Files:**
- Modify: `README.md`
- Modify: `docs/superpowers/specs/2026-06-22-rust-icsp-native-design.md`
- [x] **Step 1: 运行 Rust 全量测试**
Run: `cargo test --manifest-path native/flyshot-native-planning/Cargo.toml`
Expected: PASS
- [ ] **Step 2: 运行 Core 测试**
Run: `dotnet test tests/Flyshot.Core.Tests/Flyshot.Core.Tests.csproj -v minimal`
Expected: PASS
当前验证:新增 Rust 相关测试全部通过;Core 全套仍保留实施前已存在的两项
`FlyshotExecutionSendSequenceBuilder` speed-ratio 失败,本计划未修改该行为。
- [x] **Step 3: 运行解决方案构建**
Run: `dotnet build FlyshotReplacement.sln -v minimal`
Expected: PASS
- [x] **Step 4: 更新文档,记录本地构建、库复制和当前上线边界**
```markdown
- Rust 规划器当前仅显式使用,不替换默认 `ICspPlanner`
- Windows 需要 `flyshot_planning.dll`
- Linux 需要 `libflyshot_planning.so`
```

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,271 @@
# Rust ICSP 原生规划核心设计
## 1. 目标
在不改变现有上层业务接口的前提下,使用 Rust 重新实现当前 C# 版本的 ICSP 和 clamped-zero 三次样条算法,并通过稳定的 C ABI 供 .NET 8 调用。
第一阶段必须实现:
- Rust `cdylib` 中的 clamped-zero 三次样条系数求解。
- 样条位置求值和每段一至三阶导数绝对值峰值解析计算。
- ICSP 初始分段时长、迭代缩放、最优结果保存和最终收敛判定。
- C# P/Invoke 适配层。
- Rust 与现有 C# 算法的自动对拍测试。
- Windows x64 和 Linux x64 原生库加载边界。
第一阶段不实现:
- `SelfAdaptIcspPlanner` 的补中点逻辑。
- 轨迹稠密采样、J519 发送采样和触发绑定。
- 正运动学、MoveJoint 和 MovePose。
- FANUC 协议、Socket 和运行时状态机。
- 删除或替换现有 C# ICSP 实现。
## 2. 总体架构
新增原生算法模块和 C# 适配模块:
```text
ControllerClientTrajectoryOrchestrator
|
ICSP 规划接口
/ \
C# ICspPlanner RustIcspPlanner
|
Flyshot.Native.Interop
|
C ABI
|
flyshot_planning
Rust cdylib
```
现有 `ICspPlanner` 保持不变,继续作为行为基准和回退实现。Rust 规划器返回的数据仍由 C# 组装成现有 `PlannedTrajectory`,因此领域层、兼容层和运行时不感知原生对象。
## 3. Rust 模块边界
原生模块建议位于:
```text
native/
flyshot-native-planning/
Cargo.toml
src/
lib.rs
error.rs
ffi.rs
icsp.rs
spline.rs
```
各文件职责:
- `spline.rs`:三对角方程求解、样条系数、位置求值、解析导数峰值。
- `icsp.rs`:纯 Rust ICSP 输入、迭代规划和结果模型。
- `ffi.rs`:C ABI 数据结构、指针校验、结果分配与释放。
- `error.rs`:原生错误分类和 UTF-8 错误信息。
- `lib.rs`:只导出稳定 ABI,不导出内部算法符号。
Rust 内部算法不依赖 .NET 类型、JSON、日志框架、网络或文件系统。
## 4. C ABI 设计
ABI 使用固定宽度整数、C 布局结构和连续 `double` 数组。路点输入采用行优先扁平布局:
```text
[waypoint0_joint0, waypoint0_joint1, ...,
waypoint1_joint0, waypoint1_joint1, ...]
```
主要入口:
```c
int32_t flyshot_icsp_plan(
const double* waypoints,
int32_t waypoint_count,
int32_t dof,
const double* velocity_limits,
const double* acceleration_limits,
const double* jerk_limits,
const FlyshotIcspOptions* options,
FlyshotIcspResult** result);
void flyshot_icsp_result_free(FlyshotIcspResult* result);
const char* flyshot_last_error_message(void);
```
`FlyshotIcspOptions` 包含:
- `threshold`
- `max_iterations`
- `enforce_final_scale`
- `final_scale_tolerance`
`FlyshotIcspResult` 包含:
- `waypoint_times` 及长度
- `segment_durations` 及长度
- `segment_scales` 及长度
- `iterations`
- `threshold`
调用成功返回 `0`。输入非法、数值计算失败、未收敛和内部异常使用不同错误码。错误文本由线程局部存储保存,只保证在当前线程下一次原生调用前有效。
所有结果内存都由 Rust 分配,并且只能通过 `flyshot_icsp_result_free` 释放。C# 不直接释放 Rust 分配的数组。
所有可预期失败必须在 FFI 内转换为错误码,FFI 路径不得依赖 panic 表达业务错误。Debug 和测试构建使用 `catch_unwind` 检测遗漏的 panic;Release 使用 `panic=abort`,此时 `catch_unwind` 不能恢复 panic,因此实现必须通过边界校验避免索引越界、空指针解引用和显式 `unwrap`。任何构建模式都不得让 Rust unwind 穿过 C ABI。
## 5. C# 适配层
新增一个只负责原生互操作的 C# 项目,建议命名为:
```text
src/Flyshot.Native.Planning/
```
包含:
- `NativeIcspMethods`:使用 `LibraryImport` 声明 C ABI。
- `NativeIcspSafeHandle`:封装 Rust 结果指针和释放函数。
- `RustIcspPlanner`:把 `TrajectoryRequest` 转换为连续数组,再把结果转换为 `PlannedTrajectory`。
- `NativePlanningLibraryResolver`:按当前操作系统和体系结构解析原生库。
- `NativePlanningException`:携带原生错误码和错误文本。
原生库逻辑名称固定为 `flyshot_planning`:
- Windows:`flyshot_planning.dll`
- Linux:`libflyshot_planning.so`
C# 适配层负责:
- 检查输入自由度和数组长度。
- 固定数组,避免调用期间被 GC 移动。
- 将 Rust 返回数据复制到托管数组。
- 无论成功或失败都正确释放结果句柄。
- 将原生错误转换为包含诊断信息的 C# 异常。
`RustIcspPlanner` 的构造参数和 `Plan` 行为应与 `ICspPlanner` 对齐,但第一阶段不自动替换现有依赖注入注册。
## 6. 数值一致性
Rust 实现必须逐项复刻当前 C# 行为:
- 初始段时长使用相邻路点的关节空间欧氏距离。
- 使用 clamped-zero 边界条件求三次样条。
- 每轮解析计算各段速度、加速度和 jerk 峰值。
- 分段倍率分别使用一次方、平方根和立方根。
- 收敛指标为 `sum(abs(scale - 1))`。
- 始终保存历史最小收敛指标对应的结果。
- 迭代范围与当前 C# 保持一致,即包含第 `max_iterations` 轮。
- 最终按 `global_scale > 1 + tolerance` 判定是否失败。
第一阶段采用以下对拍容差:
- 路点时间:绝对误差不超过 `1e-9`。
- 段时长:绝对误差不超过 `1e-9`。
- 段缩放:绝对误差不超过 `1e-9`。
- 最终 threshold:绝对误差不超过 `1e-9`。
- 迭代次数:必须完全一致。
如果真实样本因浮点指令或编译器优化无法满足 `1e-9`,必须先定位首个分歧计算步骤,再基于证据调整容差,不直接放宽整个测试。
## 7. 测试策略
遵循 TDD,先增加失败测试,再实现 Rust 和 C# 适配。
测试分四层:
1. Rust 单元测试
- 三次样条边界值。
- 一至三阶导数。
- 解析峰值。
- ICSP 收敛与不收敛。
- 非法指针之外的纯算法输入校验。
2. Rust FFI 测试
- 正常调用和结果释放。
- 空指针、错误维度、非有限数。
- 错误码与错误文本。
- 重复释放由 C# `SafeHandle` 防止,不把未定义行为包装成可接受用法。
3. C# 互操作测试
- 原生库加载。
- 托管数组到 C ABI 的转换。
- 结果复制和释放。
- 原生错误到 C# 异常的转换。
4. C#/Rust 对拍测试
- 仓库中的稳定规划样本。
- 当前 `PlanningCompatibilityTests` 使用的基础输入。
- 多自由度、多路点和极限参数。
- `maxIterations=0` 的未收敛路径。
- 至少一个父目录真实样本固化后的 golden case。
对拍测试必须同时调用现有 `ICspPlanner` 和 `RustIcspPlanner`,不能把预期值只写成 Rust 自身输出。
## 8. 构建与发布
本地构建脚本负责:
- 执行 `cargo build --release`。
- 根据目标平台复制原生库到 C# 测试和发布输出目录。
- Windows x64 构建 Rust `x86_64-pc-windows-msvc`。
- Linux x64 构建 Rust `x86_64-unknown-linux-gnu`。
第一阶段不要求在 Windows 主机上完成 Linux 交叉编译;Linux 原生库可以在 Linux 构建环境生成。仓库必须提供对应命令和 CI/离线构建说明。
发布时原生库与托管程序集放在同一部署目录。现有 Obfuscar 继续处理 `Flyshot.*.dll` 托管程序集,不处理 Rust 原生库。
Rust Release 配置启用:
```toml
[profile.release]
lto = true
codegen-units = 1
strip = "symbols"
panic = "abort"
```
## 9. 回退与上线边界
第一阶段只提供显式的 Rust 规划器,不改变生产默认算法。完成全部对拍和跨平台验证后,再单独设计配置切换:
```text
Planning:Backend = Managed | Rust
```
原生库缺失、架构不匹配或加载失败时,显式选择 Rust 后应返回清晰错误,不静默回退。静默回退会掩盖部署缺陷,也无法确认现场实际使用的是哪套算法。
保留 C# 实现至少经过一个完整现场验证周期。后续是否删除托管算法,需要独立评审,不属于本设计范围。
## 10. 验收标准
- Rust 单元测试全部通过。
- C# 原生互操作测试全部通过。
- 选定样本的 C#/Rust 对拍全部通过。
- `dotnet test` 和解决方案构建通过。
- Windows x64 实际加载 `.dll` 并完成规划。
- Linux x64 实际加载 `.so` 并完成规划。
- 缺少原生库时能得到明确且可诊断的错误。
- 现有 C# `ICspPlanner` 和上层运行链路行为不变。
## 11. 第一阶段落地状态(2026-06-23)
当前已落地:
- `native/flyshot-native-planning` Rust `cdylib`,包含 clamped-zero 样条、
一至三阶导数解析峰值、ICSP 迭代和稳定 C ABI。
- `src/Flyshot.Native.Planning` 托管互操作项目,包含显式平台解析、
`LibraryImport`、`SafeHandle`、错误码转换和结果复制。
- Rust 样条、ICSP、FFI 测试,以及 C#/Rust `1e-9` 自动对拍测试。
- `build/Copy-NativePlanningArtifacts.ps1`,用于初始化 Windows MSVC 环境、
构建原生库并复制到测试或发布目录。
- Windows x64 已实际生成并加载 `flyshot_planning.dll` 完成规划。
当前上线边界保持不变:
- 默认生产路径继续使用 C# `ICspPlanner`。
- Rust 规划器只允许显式选择,库缺失、路径不一致或架构不支持时返回明确错误。
- Linux x64 的源码、库名和构建脚本边界已落地,但 `.so` 的实际加载验证必须在
Linux x64 构建环境执行后才能标记完成。

View File

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

View File

@@ -0,0 +1,7 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "flyshot_planning"
version = "0.1.0"

View File

@@ -0,0 +1,13 @@
[package]
name = "flyshot_planning"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[profile.release]
lto = true
codegen-units = 1
strip = "symbols"
panic = "abort"

View File

@@ -0,0 +1,33 @@
//! 原生规划错误定义。
use std::fmt::{Display, Formatter};
/// 表示 Rust 规划核心中可预期、可转换为 C ABI 错误码的失败。
#[derive(Debug, Clone, PartialEq)]
pub enum NativePlanningError {
/// 表示输入参数不满足算法前置条件。
InvalidInput(String),
/// 表示数值计算无法继续。
NumericalFailure(String),
/// 表示规划迭代结束后仍未满足约束。
NotConverged(String),
}
impl Display for NativePlanningError {
/// 将错误格式化为可供托管侧诊断的中文文本。
///
/// # 参数
/// - `formatter`: 接收格式化文本的输出缓冲区。
///
/// # 返回值
/// 格式化成功时返回 `Ok(())`,否则返回底层格式化错误。
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidInput(message) => write!(formatter, "输入非法:{message}"),
Self::NumericalFailure(message) => write!(formatter, "数值计算失败:{message}"),
Self::NotConverged(message) => write!(formatter, "ICSP 未收敛:{message}"),
}
}
}
impl std::error::Error for NativePlanningError {}

View File

@@ -0,0 +1,436 @@
//! C ABI 边界定义。
use std::cell::RefCell;
use std::ffi::{c_char, CString};
use std::panic::{catch_unwind, AssertUnwindSafe};
use std::ptr;
use std::slice;
use crate::error::NativePlanningError;
use crate::icsp::{plan_icsp, IcspInput, IcspOptions};
/// 原生调用成功时返回的固定错误码。
pub const FLYSHOT_SUCCESS: i32 = 0;
/// 输入指针、维度或数值非法时返回的错误码。
pub const FLYSHOT_ERROR_INVALID_INPUT: i32 = 1;
/// 样条或 ICSP 数值计算无法继续时返回的错误码。
pub const FLYSHOT_ERROR_NUMERICAL_FAILURE: i32 = 2;
/// 迭代完成但最终 scale 仍超限时返回的错误码。
pub const FLYSHOT_ERROR_NOT_CONVERGED: i32 = 3;
/// FFI 捕获到未预期 panic 时返回的错误码。
pub const FLYSHOT_ERROR_INTERNAL: i32 = 4;
thread_local! {
/// 保存当前线程最近一次原生错误文本,指针仅在下一次原生调用前有效。
static LAST_ERROR_MESSAGE: RefCell<CString> = RefCell::new(CString::default());
}
/// 表示 C ABI 输入的 ICSP 迭代选项。
#[repr(C)]
#[derive(Debug, Clone, Copy)]
pub struct FlyshotIcspOptions {
/// 收敛指标停止阈值。
pub threshold: f64,
/// 最大迭代索引,必须为非负数。
pub max_iterations: i32,
/// 非零表示启用最终 scale 检查。
pub enforce_final_scale: i32,
/// 最终 scale 允许的非负容差。
pub final_scale_tolerance: f64,
}
impl Default for FlyshotIcspOptions {
/// 返回与托管 ICSP 默认行为一致的 C ABI 选项。
///
/// # 返回值
/// 可直接传入 `flyshot_icsp_plan` 的默认选项。
fn default() -> Self {
let options = IcspOptions::default();
Self {
threshold: options.threshold,
max_iterations: options.max_iterations as i32,
enforce_final_scale: i32::from(options.enforce_final_scale),
final_scale_tolerance: options.final_scale_tolerance,
}
}
}
/// 表示 Rust 分配并通过 C ABI 返回的 ICSP 结果。
#[repr(C)]
#[derive(Debug)]
pub struct FlyshotIcspResult {
/// 路点绝对时间连续数组指针。
pub waypoint_times: *mut f64,
/// 路点时间数组长度。
pub waypoint_times_len: i32,
/// 分段时长连续数组指针。
pub segment_durations: *mut f64,
/// 分段时长数组长度。
pub segment_durations_len: i32,
/// 分段缩放连续数组指针。
pub segment_scales: *mut f64,
/// 分段缩放数组长度。
pub segment_scales_len: i32,
/// 历史最优结果对应的迭代轮数。
pub iterations: i32,
/// 历史最小收敛指标。
pub threshold: f64,
}
/// 通过稳定 C ABI 执行 ICSP 规划。
///
/// # Safety
/// 调用方必须保证所有非空输入指针在调用期间指向满足声明长度的可读连续数组,
/// `result` 必须指向可写的结果指针槽位;返回的结果只能交给
/// `flyshot_icsp_result_free` 释放。
#[no_mangle]
pub unsafe extern "C" fn flyshot_icsp_plan(
waypoints: *const f64,
waypoint_count: i32,
dof: i32,
velocity_limits: *const f64,
acceleration_limits: *const f64,
jerk_limits: *const f64,
options: *const FlyshotIcspOptions,
result: *mut *mut FlyshotIcspResult,
) -> i32 {
clear_last_error();
// Debug/测试构建可恢复遗漏 panic;Release panic=abort 下仍依赖边界校验避免 panic。
match catch_unwind(AssertUnwindSafe(|| unsafe {
flyshot_icsp_plan_impl(
waypoints,
waypoint_count,
dof,
velocity_limits,
acceleration_limits,
jerk_limits,
options,
result,
)
})) {
Ok(code) => code,
Err(_) => {
set_last_error("Rust ICSP FFI 捕获到未预期的内部 panic");
FLYSHOT_ERROR_INTERNAL
}
}
}
/// 释放由 `flyshot_icsp_plan` 返回的结果及其全部连续数组。
///
/// # Safety
/// `result` 必须为空指针,或是尚未释放且由本库当前版本分配的结果指针。
#[no_mangle]
pub unsafe extern "C" fn flyshot_icsp_result_free(result: *mut FlyshotIcspResult) {
if result.is_null() {
return;
}
// 先收回结果结构所有权,再按原始长度重建 boxed slice 归还数组内存。
let result = unsafe { Box::from_raw(result) };
unsafe {
free_f64_array(result.waypoint_times, result.waypoint_times_len);
free_f64_array(result.segment_durations, result.segment_durations_len);
free_f64_array(result.segment_scales, result.segment_scales_len);
}
}
/// 返回当前线程最近一次原生错误的 UTF-8 C 字符串。
///
/// # 返回值
/// 指针在当前线程下一次原生调用前有效;没有错误时指向空字符串。
#[no_mangle]
pub extern "C" fn flyshot_last_error_message() -> *const c_char {
LAST_ERROR_MESSAGE.with(|message| message.borrow().as_ptr())
}
/// 执行已通过 panic 边界保护的实际 FFI 参数校验和规划调用。
///
/// # Safety
/// 参数约束与公开 `flyshot_icsp_plan` 相同,本方法会在解引用前检查空指针和维度。
#[allow(clippy::too_many_arguments)]
unsafe fn flyshot_icsp_plan_impl(
waypoints: *const f64,
waypoint_count: i32,
dof: i32,
velocity_limits: *const f64,
acceleration_limits: *const f64,
jerk_limits: *const f64,
options: *const FlyshotIcspOptions,
result: *mut *mut FlyshotIcspResult,
) -> i32 {
if result.is_null() {
return fail(FLYSHOT_ERROR_INVALID_INPUT, "result 输出指针不能为空");
}
unsafe {
*result = ptr::null_mut();
}
if waypoints.is_null() {
return fail(FLYSHOT_ERROR_INVALID_INPUT, "waypoints 输入指针不能为空");
}
if velocity_limits.is_null() || acceleration_limits.is_null() || jerk_limits.is_null() {
return fail(
FLYSHOT_ERROR_INVALID_INPUT,
"velocity/acceleration/jerk 限值指针不能为空",
);
}
if options.is_null() {
return fail(FLYSHOT_ERROR_INVALID_INPUT, "options 输入指针不能为空");
}
if waypoint_count < 4 || dof <= 0 {
return fail(
FLYSHOT_ERROR_INVALID_INPUT,
"waypoint_count 必须至少为 4,dof 必须大于零",
);
}
let Ok(waypoint_count_usize) = usize::try_from(waypoint_count) else {
return fail(FLYSHOT_ERROR_INVALID_INPUT, "waypoint_count 转换失败");
};
let Ok(dof_usize) = usize::try_from(dof) else {
return fail(FLYSHOT_ERROR_INVALID_INPUT, "dof 转换失败");
};
let Some(waypoint_value_count) = waypoint_count_usize.checked_mul(dof_usize) else {
return fail(FLYSHOT_ERROR_INVALID_INPUT, "路点数组长度计算溢出");
};
let ffi_options = unsafe { *options };
if ffi_options.max_iterations < 0 {
return fail(FLYSHOT_ERROR_INVALID_INPUT, "max_iterations 不能为负数");
}
// 所有原始指针都已完成非空及长度上界校验,此处才建立只读切片。
let input = IcspInput {
waypoints: unsafe { slice::from_raw_parts(waypoints, waypoint_value_count) },
waypoint_count: waypoint_count_usize,
dof: dof_usize,
velocity_limits: unsafe { slice::from_raw_parts(velocity_limits, dof_usize) },
acceleration_limits: unsafe { slice::from_raw_parts(acceleration_limits, dof_usize) },
jerk_limits: unsafe { slice::from_raw_parts(jerk_limits, dof_usize) },
options: IcspOptions {
threshold: ffi_options.threshold,
max_iterations: ffi_options.max_iterations as usize,
enforce_final_scale: ffi_options.enforce_final_scale != 0,
final_scale_tolerance: ffi_options.final_scale_tolerance,
},
};
match plan_icsp(&input) {
Ok(plan) => {
let Ok(iterations) = i32::try_from(plan.iterations) else {
return fail(FLYSHOT_ERROR_INTERNAL, "迭代次数超出 C ABI i32 范围");
};
let (waypoint_times, waypoint_times_len) = leak_f64_array(plan.waypoint_times);
let (segment_durations, segment_durations_len) = leak_f64_array(plan.segment_durations);
let (segment_scales, segment_scales_len) = leak_f64_array(plan.segment_scales);
let native_result = Box::new(FlyshotIcspResult {
waypoint_times,
waypoint_times_len,
segment_durations,
segment_durations_len,
segment_scales,
segment_scales_len,
iterations,
threshold: plan.threshold,
});
unsafe {
*result = Box::into_raw(native_result);
}
FLYSHOT_SUCCESS
}
Err(error) => {
let code = error_code(&error);
set_last_error(&error.to_string());
code
}
}
}
/// 将 Rust 规划错误映射为稳定 C ABI 错误码。
///
/// # 参数
/// - `error`: 纯算法层返回的错误。
///
/// # 返回值
/// 与错误类别一一对应的固定整数。
fn error_code(error: &NativePlanningError) -> i32 {
match error {
NativePlanningError::InvalidInput(_) => FLYSHOT_ERROR_INVALID_INPUT,
NativePlanningError::NumericalFailure(_) => FLYSHOT_ERROR_NUMERICAL_FAILURE,
NativePlanningError::NotConverged(_) => FLYSHOT_ERROR_NOT_CONVERGED,
}
}
/// 写入错误文本并返回指定错误码。
///
/// # 参数
/// - `code`: 需要返回给调用方的稳定错误码。
/// - `message`: 当前线程可读取的诊断文本。
///
/// # 返回值
/// 原样返回 `code`,便于校验分支直接结束。
fn fail(code: i32, message: &str) -> i32 {
set_last_error(message);
code
}
/// 清空当前线程的最近错误文本。
fn clear_last_error() {
set_last_error("");
}
/// 将诊断文本保存到当前线程;内部 NUL 会替换为空格以保持合法 C 字符串。
///
/// # 参数
/// - `message`: 需要保存到线程局部缓冲区的 UTF-8 文本。
fn set_last_error(message: &str) {
let sanitized = message.replace('\0', " ");
// FFI 路径明确不使用 unwrap;即使替换逻辑未来变化,也保持无 panic 回退。
#[allow(clippy::manual_unwrap_or_default)]
let c_string = match CString::new(sanitized) {
Ok(value) => value,
Err(_) => CString::default(),
};
LAST_ERROR_MESSAGE.with(|slot| {
*slot.borrow_mut() = c_string;
});
}
/// 将 `Vec<f64>` 转为由 C ABI 结果持有的裸指针和 i32 长度。
///
/// # 参数
/// - `values`: 需要转移给结果对象的连续数组。
///
/// # 返回值
/// 数组裸指针及其长度;当前输入长度来源于 i32 路点维度,因此不会溢出。
fn leak_f64_array(values: Vec<f64>) -> (*mut f64, i32) {
let mut values = values.into_boxed_slice();
let length = i32::try_from(values.len()).unwrap_or(i32::MAX);
let pointer = values.as_mut_ptr();
std::mem::forget(values);
(pointer, length)
}
/// 按原始长度重建并释放 Rust 分配的 `f64` boxed slice。
///
/// # Safety
/// 指针和长度必须来自 `leak_f64_array` 且尚未释放。
unsafe fn free_f64_array(pointer: *mut f64, length: i32) {
if pointer.is_null() || length <= 0 {
return;
}
let Ok(length) = usize::try_from(length) else {
return;
};
let slice_pointer = ptr::slice_from_raw_parts_mut(pointer, length);
drop(unsafe { Box::from_raw(slice_pointer) });
}
#[cfg(test)]
mod tests {
use std::ffi::CStr;
use std::ptr;
use super::{
flyshot_icsp_plan, flyshot_icsp_result_free, flyshot_last_error_message,
FlyshotIcspOptions, FlyshotIcspResult, FLYSHOT_ERROR_INVALID_INPUT, FLYSHOT_SUCCESS,
};
/// 验证空输入指针会返回输入错误,并在线程局部错误文本中保留可诊断信息。
#[test]
fn ffi_rejects_null_waypoint_pointer_with_error_message() {
let options = FlyshotIcspOptions::default();
let mut result: *mut FlyshotIcspResult = ptr::null_mut();
let error_code = unsafe {
flyshot_icsp_plan(
ptr::null(),
4,
1,
[2.0].as_ptr(),
[4.0].as_ptr(),
[8.0].as_ptr(),
&options,
&mut result,
)
};
assert_eq!(error_code, FLYSHOT_ERROR_INVALID_INPUT);
assert!(result.is_null());
let message = unsafe { CStr::from_ptr(flyshot_last_error_message()) }
.to_string_lossy()
.into_owned();
assert!(message.contains("waypoints"));
}
/// 验证正常 C ABI 调用会返回 Rust 分配的结果,并可通过唯一释放函数归还。
#[test]
fn ffi_returns_and_frees_successful_plan_result() {
let waypoints = [0.0, 0.5, 1.0, 1.5];
let velocity_limits = [2.0];
let acceleration_limits = [4.0];
let jerk_limits = [8.0];
let options = FlyshotIcspOptions {
enforce_final_scale: 0,
..FlyshotIcspOptions::default()
};
let mut result: *mut FlyshotIcspResult = ptr::null_mut();
let error_code = unsafe {
flyshot_icsp_plan(
waypoints.as_ptr(),
4,
1,
velocity_limits.as_ptr(),
acceleration_limits.as_ptr(),
jerk_limits.as_ptr(),
&options,
&mut result,
)
};
let message = unsafe { CStr::from_ptr(flyshot_last_error_message()) }
.to_string_lossy()
.into_owned();
assert_eq!(error_code, FLYSHOT_SUCCESS, "{message}");
assert!(!result.is_null());
unsafe {
assert_eq!((*result).waypoint_times_len, 4);
assert_eq!((*result).segment_durations_len, 3);
assert_eq!((*result).segment_scales_len, 3);
flyshot_icsp_result_free(result);
}
}
/// 验证包含非有限数的路点会在 FFI 内转换为输入错误而不是触发 panic。
#[test]
fn ffi_rejects_non_finite_waypoint_values() {
let waypoints = [0.0, 0.5, f64::NAN, 1.5];
let velocity_limits = [2.0];
let acceleration_limits = [4.0];
let jerk_limits = [8.0];
let options = FlyshotIcspOptions::default();
let mut result: *mut FlyshotIcspResult = ptr::null_mut();
let error_code = unsafe {
flyshot_icsp_plan(
waypoints.as_ptr(),
4,
1,
velocity_limits.as_ptr(),
acceleration_limits.as_ptr(),
jerk_limits.as_ptr(),
&options,
&mut result,
)
};
assert_eq!(error_code, FLYSHOT_ERROR_INVALID_INPUT);
assert!(result.is_null());
let message = unsafe { CStr::from_ptr(flyshot_last_error_message()) }
.to_string_lossy()
.into_owned();
assert!(message.contains("有限数"));
}
}

View File

@@ -0,0 +1,367 @@
//! 纯 Rust ICSP 规划算法。
use crate::error::NativePlanningError;
use crate::spline::CubicSpline1D;
/// 描述 ICSP 迭代和最终约束检查选项。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct IcspOptions {
/// 收敛指标 `sum(abs(scale - 1))` 的停止阈值。
pub threshold: f64,
/// 最大迭代索引;算法与托管实现一致,会执行 `0..=max_iterations`。
pub max_iterations: usize,
/// 是否在历史最优结果仍有 scale 超限时返回失败。
pub enforce_final_scale: bool,
/// 最终 scale 检查允许的非负容差。
pub final_scale_tolerance: f64,
}
impl Default for IcspOptions {
/// 返回与现有 C# `ICspPlanner` 一致的默认参数。
///
/// # 返回值
/// threshold=1e-3、max_iterations=1000、启用最终 scale 检查且容差为 1e-2。
fn default() -> Self {
Self {
threshold: 1e-3,
max_iterations: 1000,
enforce_final_scale: true,
final_scale_tolerance: 1e-2,
}
}
}
/// 描述一次纯 Rust ICSP 调用的扁平输入。
#[derive(Debug, Clone, Copy)]
pub struct IcspInput<'a> {
/// 按路点行优先排列的连续关节位置数组。
pub waypoints: &'a [f64],
/// 路点数量。
pub waypoint_count: usize,
/// 每个路点的关节自由度。
pub dof: usize,
/// 每个关节的正速度上限。
pub velocity_limits: &'a [f64],
/// 每个关节的正加速度上限。
pub acceleration_limits: &'a [f64],
/// 每个关节的正 jerk 上限。
pub jerk_limits: &'a [f64],
/// 本次规划使用的迭代选项。
pub options: IcspOptions,
}
/// 表示 ICSP 历史最优迭代对应的完整规划结果。
#[derive(Debug, Clone, PartialEq)]
pub struct IcspPlanResult {
/// 每个路点对应的绝对时间,首项固定为零。
pub waypoint_times: Vec<f64>,
/// 历史最优结果的逐段时长。
pub segment_durations: Vec<f64>,
/// 历史最优结果的逐段约束缩放因子。
pub segment_scales: Vec<f64>,
/// 历史最优结果产生时已执行的迭代轮数。
pub iterations: usize,
/// 历史最小 `sum(abs(scale - 1))`。
pub threshold: f64,
}
/// 执行 ICSP 迭代规划并返回历史最优结果。
///
/// # 参数
/// - `input`: 行优先路点、逐关节约束和迭代选项。
///
/// # 返回值
/// 输入合法并通过最终 scale 检查时返回规划结果;否则返回明确错误。
pub fn plan_icsp(input: &IcspInput<'_>) -> Result<IcspPlanResult, NativePlanningError> {
validate_input(input)?;
let mut segment_durations = compute_initial_durations(input)?;
let segment_count = segment_durations.len();
let mut best_result: Option<IcspPlanResult> = None;
let mut best_threshold = f64::INFINITY;
// 迭代范围必须包含 max_iterations 对应轮次,以复刻托管实现的 `<=` 语义。
for iteration in 0..=input.options.max_iterations {
let waypoint_times = cumulative_times(&segment_durations)?;
let splines = build_joint_splines(input, &waypoint_times)?;
let mut segment_scales = vec![0.0; segment_count];
for (segment, segment_scale) in segment_scales.iter_mut().enumerate() {
let mut scale = 0.0_f64;
for (joint, spline) in splines.iter().enumerate() {
let [max_velocity, max_acceleration, max_jerk] =
spline.segment_peak_derivatives(segment)?;
let velocity_scale = (max_velocity / input.velocity_limits[joint]).abs();
let acceleration_scale = (max_acceleration / input.acceleration_limits[joint])
.abs()
.sqrt();
let jerk_scale = (max_jerk / input.jerk_limits[joint]).abs().cbrt();
scale = scale.max(velocity_scale.max(acceleration_scale.max(jerk_scale)));
}
if !scale.is_finite() {
return Err(NativePlanningError::NumericalFailure(format!(
"段 {segment} 的缩放因子不是有限数"
)));
}
*segment_scale = scale;
}
let current_threshold = segment_scales
.iter()
.map(|scale| (scale - 1.0).abs())
.sum::<f64>();
if !current_threshold.is_finite() {
return Err(NativePlanningError::NumericalFailure(
"ICSP 收敛指标不是有限数".to_owned(),
));
}
// 始终保存历史最小指标对应的数据,不能只保留最后一轮。
if current_threshold < best_threshold {
best_threshold = current_threshold;
best_result = Some(IcspPlanResult {
waypoint_times: waypoint_times.clone(),
segment_durations: segment_durations.clone(),
segment_scales: segment_scales.clone(),
iterations: iteration + 1,
threshold: current_threshold,
});
}
if current_threshold < input.options.threshold {
break;
}
for (duration, scale) in segment_durations.iter_mut().zip(segment_scales.iter()) {
*duration *= *scale;
}
}
let Some(result) = best_result else {
return Err(NativePlanningError::NumericalFailure(
"ICSP 未产生任何有效迭代结果".to_owned(),
));
};
let global_scale = result
.segment_scales
.iter()
.copied()
.fold(0.0_f64, f64::max);
if input.options.enforce_final_scale && global_scale > 1.0 + input.options.final_scale_tolerance
{
return Err(NativePlanningError::NotConverged(format!(
"global_scale={global_scale:.6} > {:.6}",
1.0 + input.options.final_scale_tolerance
)));
}
Ok(result)
}
/// 校验 ICSP 输入维度、有限性和正约束。
///
/// # 参数
/// - `input`: 待校验的纯算法输入。
///
/// # 返回值
/// 全部约束满足时返回 `Ok(())`,否则返回输入错误。
fn validate_input(input: &IcspInput<'_>) -> Result<(), NativePlanningError> {
if input.waypoint_count < 4 {
return Err(NativePlanningError::InvalidInput(
"ICSP 至少需要 4 个示教点".to_owned(),
));
}
if input.dof == 0 {
return Err(NativePlanningError::InvalidInput(
"自由度必须大于零".to_owned(),
));
}
let expected_waypoint_values = input
.waypoint_count
.checked_mul(input.dof)
.ok_or_else(|| NativePlanningError::InvalidInput("路点维度乘积溢出".to_owned()))?;
if input.waypoints.len() != expected_waypoint_values {
return Err(NativePlanningError::InvalidInput(format!(
"路点扁平数组长度应为 {expected_waypoint_values},实际为 {}",
input.waypoints.len()
)));
}
if input.velocity_limits.len() != input.dof
|| input.acceleration_limits.len() != input.dof
|| input.jerk_limits.len() != input.dof
{
return Err(NativePlanningError::InvalidInput(
"速度、加速度和 jerk 限值长度必须等于自由度".to_owned(),
));
}
if input.waypoints.iter().any(|value| !value.is_finite()) {
return Err(NativePlanningError::InvalidInput(
"路点位置必须全部为有限数".to_owned(),
));
}
if input
.velocity_limits
.iter()
.chain(input.acceleration_limits.iter())
.chain(input.jerk_limits.iter())
.any(|limit| !limit.is_finite() || *limit <= 0.0)
{
return Err(NativePlanningError::InvalidInput(
"速度、加速度和 jerk 限值必须全部为有限正数".to_owned(),
));
}
if !input.options.threshold.is_finite() || input.options.threshold <= 0.0 {
return Err(NativePlanningError::InvalidInput(
"收敛阈值必须为有限正数".to_owned(),
));
}
if !input.options.final_scale_tolerance.is_finite() || input.options.final_scale_tolerance < 0.0
{
return Err(NativePlanningError::InvalidInput(
"最终 scale 容差必须为有限非负数".to_owned(),
));
}
Ok(())
}
/// 计算相邻路点在关节空间的欧氏距离,作为初始段时长。
///
/// # 参数
/// - `input`: 已通过维度校验的 ICSP 输入。
///
/// # 返回值
/// 每对相邻路点对应的正段时长。
fn compute_initial_durations(input: &IcspInput<'_>) -> Result<Vec<f64>, NativePlanningError> {
let mut durations = Vec::with_capacity(input.waypoint_count - 1);
for segment in 0..input.waypoint_count - 1 {
let start_offset = segment * input.dof;
let end_offset = (segment + 1) * input.dof;
let mut squared_distance = 0.0;
for joint in 0..input.dof {
let difference =
input.waypoints[end_offset + joint] - input.waypoints[start_offset + joint];
squared_distance += difference * difference;
}
let duration = squared_distance.sqrt();
if !duration.is_finite() || duration <= 0.0 {
return Err(NativePlanningError::InvalidInput(format!(
"相邻路点段 {segment} 的欧氏距离必须为有限正数"
)));
}
durations.push(duration);
}
Ok(durations)
}
/// 将逐段时长累加为首项为零的绝对时间节点。
///
/// # 参数
/// - `segment_durations`: 当前迭代的逐段时长。
///
/// # 返回值
/// 长度比分段时长多一的严格递增时间轴。
fn cumulative_times(segment_durations: &[f64]) -> Result<Vec<f64>, NativePlanningError> {
let mut times = Vec::with_capacity(segment_durations.len() + 1);
times.push(0.0);
let mut current = 0.0;
for (segment, duration) in segment_durations.iter().copied().enumerate() {
if !duration.is_finite() || duration <= 0.0 {
return Err(NativePlanningError::NumericalFailure(format!(
"段 {segment} 的迭代时长必须为有限正数"
)));
}
current += duration;
if !current.is_finite() {
return Err(NativePlanningError::NumericalFailure(
"累计时间轴发生溢出".to_owned(),
));
}
times.push(current);
}
Ok(times)
}
/// 为每个关节独立构造 clamped-zero 样条。
///
/// # 参数
/// - `input`: 行优先路点及自由度信息。
/// - `waypoint_times`: 当前迭代的绝对时间节点。
///
/// # 返回值
/// 按关节顺序排列的单自由度样条集合。
fn build_joint_splines(
input: &IcspInput<'_>,
waypoint_times: &[f64],
) -> Result<Vec<CubicSpline1D>, NativePlanningError> {
let mut splines = Vec::with_capacity(input.dof);
for joint in 0..input.dof {
let mut values = Vec::with_capacity(input.waypoint_count);
for waypoint in 0..input.waypoint_count {
values.push(input.waypoints[waypoint * input.dof + joint]);
}
splines.push(CubicSpline1D::new_clamped_zero(waypoint_times, &values)?);
}
Ok(splines)
}
#[cfg(test)]
mod tests {
use super::{plan_icsp, IcspInput, IcspOptions};
use crate::error::NativePlanningError;
/// 验证 ICSP 对基础四路点样本返回完整时间轴、分段结果和有限收敛信息。
#[test]
fn icsp_returns_complete_result_for_basic_fixture() {
let waypoints = [0.0, 0.0, 1.0, 0.5, 2.0, 1.0, 3.0, 1.5];
let velocity_limits = [2.0, 2.0];
let acceleration_limits = [4.0, 4.0];
let jerk_limits = [8.0, 8.0];
let input = IcspInput {
waypoints: &waypoints,
waypoint_count: 4,
dof: 2,
velocity_limits: &velocity_limits,
acceleration_limits: &acceleration_limits,
jerk_limits: &jerk_limits,
options: IcspOptions::default(),
};
let result = plan_icsp(&input).unwrap();
assert_eq!(result.waypoint_times.len(), 4);
assert_eq!(result.segment_durations.len(), 3);
assert_eq!(result.segment_scales.len(), 3);
assert!(result.iterations >= 1);
assert!(result.threshold.is_finite());
assert!(result
.waypoint_times
.windows(2)
.all(|pair| pair[1] > pair[0]));
}
/// 验证禁用迭代且最终 scale 明显超限时,纯 Rust 核心返回未收敛错误。
#[test]
fn icsp_reports_not_converged_when_zero_iterations_cannot_meet_limits() {
let waypoints = [0.0, 1.0, 2.0, 3.0];
let velocity_limits = [0.01];
let acceleration_limits = [0.01];
let jerk_limits = [0.01];
let input = IcspInput {
waypoints: &waypoints,
waypoint_count: 4,
dof: 1,
velocity_limits: &velocity_limits,
acceleration_limits: &acceleration_limits,
jerk_limits: &jerk_limits,
options: IcspOptions {
max_iterations: 0,
..IcspOptions::default()
},
};
let error = plan_icsp(&input).unwrap_err();
assert!(matches!(error, NativePlanningError::NotConverged(_)));
}
}

View File

@@ -0,0 +1,6 @@
//! Flyshot ICSP 原生规划库的稳定入口。
mod error;
mod ffi;
mod icsp;
mod spline;

View File

@@ -0,0 +1,277 @@
//! clamped-zero 三次样条算法。
use crate::error::NativePlanningError;
/// 表示单自由度 clamped-zero 三次样条及其逐段多项式系数。
#[derive(Debug, Clone)]
pub struct CubicSpline1D {
knots: Vec<f64>,
coefficients: Vec<[f64; 4]>,
}
// 位置和逐阶导数求值是第一阶段要求的原生能力,当前 ICSP 热路径只直接使用解析峰值。
#[allow(dead_code)]
impl CubicSpline1D {
/// 使用严格递增时间节点和对应值构造首尾速度为零的三次样条。
///
/// # 参数
/// - `times`: 严格递增且全部有限的时间节点,长度至少为 2。
/// - `values`: 与时间节点一一对应的有限位置值。
///
/// # 返回值
/// 输入合法且三对角方程可解时返回样条,否则返回输入或数值错误。
pub fn new_clamped_zero(times: &[f64], values: &[f64]) -> Result<Self, NativePlanningError> {
if times.len() < 2 {
return Err(NativePlanningError::InvalidInput(
"时间节点至少需要 2 个".to_owned(),
));
}
if values.len() != times.len() {
return Err(NativePlanningError::InvalidInput(
"位置值数量必须与时间节点数量一致".to_owned(),
));
}
if times.iter().any(|value| !value.is_finite())
|| values.iter().any(|value| !value.is_finite())
{
return Err(NativePlanningError::InvalidInput(
"时间节点和位置值必须全部为有限数".to_owned(),
));
}
let segment_count = times.len() - 1;
let mut durations = Vec::with_capacity(segment_count);
for segment in 0..segment_count {
let duration = times[segment + 1] - times[segment];
if duration <= 0.0 || !duration.is_finite() {
return Err(NativePlanningError::InvalidInput(format!(
"时间节点必须严格递增,段 {segment} 的时长为 {duration}"
)));
}
durations.push(duration);
}
// 与托管实现保持一致:先组装 clamped-zero 边界下的三对角方程右端项。
let node_count = times.len();
let mut alpha = vec![0.0; node_count];
alpha[0] = 6.0 * ((values[1] - values[0]) / durations[0]);
alpha[node_count - 1] = 6.0
* (0.0 - (values[node_count - 1] - values[node_count - 2]) / durations[node_count - 2]);
for index in 1..node_count - 1 {
alpha[index] = 6.0
* ((values[index + 1] - values[index]) / durations[index]
- (values[index] - values[index - 1]) / durations[index - 1]);
}
let mut diagonal = vec![0.0; node_count];
let mut upper_ratio = vec![0.0; node_count];
let mut solved_rhs = vec![0.0; node_count];
diagonal[0] = 2.0 * durations[0];
upper_ratio[0] = 0.5;
solved_rhs[0] = alpha[0] / diagonal[0];
// Thomas 消元逐行处理内部节点,避免建立完整稠密矩阵。
for index in 1..node_count - 1 {
diagonal[index] = 2.0 * (durations[index - 1] + durations[index])
- durations[index - 1] * upper_ratio[index - 1];
if diagonal[index].abs() <= f64::EPSILON || !diagonal[index].is_finite() {
return Err(NativePlanningError::NumericalFailure(format!(
"三对角方程在节点 {index} 出现无效主元"
)));
}
upper_ratio[index] = durations[index] / diagonal[index];
solved_rhs[index] =
(alpha[index] - durations[index - 1] * solved_rhs[index - 1]) / diagonal[index];
}
diagonal[node_count - 1] = 2.0 * durations[node_count - 2]
- durations[node_count - 2] * upper_ratio[node_count - 2];
if diagonal[node_count - 1].abs() <= f64::EPSILON || !diagonal[node_count - 1].is_finite() {
return Err(NativePlanningError::NumericalFailure(
"三对角方程末端出现无效主元".to_owned(),
));
}
solved_rhs[node_count - 1] = (alpha[node_count - 1]
- durations[node_count - 2] * solved_rhs[node_count - 2])
/ diagonal[node_count - 1];
// 回代得到各节点二阶导数,再转换为每段局部三次多项式系数。
let mut second_derivatives = vec![0.0; node_count];
second_derivatives[node_count - 1] = solved_rhs[node_count - 1];
for index in (0..node_count - 1).rev() {
second_derivatives[index] =
solved_rhs[index] - upper_ratio[index] * second_derivatives[index + 1];
}
let mut coefficients = Vec::with_capacity(segment_count);
for segment in 0..segment_count {
let duration = durations[segment];
let cubic =
(second_derivatives[segment + 1] - second_derivatives[segment]) / (6.0 * duration);
let quadratic = second_derivatives[segment] / 2.0;
let linear = (values[segment + 1] - values[segment]) / duration
- duration * (2.0 * second_derivatives[segment] + second_derivatives[segment + 1])
/ 6.0;
coefficients.push([cubic, quadratic, linear, values[segment]]);
}
Ok(Self {
knots: times.to_vec(),
coefficients,
})
}
/// 在指定绝对时间求样条位置;超出范围时使用首段或末段外推点。
///
/// # 参数
/// - `time`: 需要求值的绝对时间。
///
/// # 返回值
/// 对应样条位置。
pub fn position(&self, time: f64) -> f64 {
let (segment, local_time) = self.segment_and_local_time(time);
let [cubic, quadratic, linear, constant] = self.coefficients[segment];
cubic * local_time * local_time * local_time
+ quadratic * local_time * local_time
+ linear * local_time
+ constant
}
/// 在指定绝对时间求一阶导数。
///
/// # 参数
/// - `time`: 需要求值的绝对时间。
///
/// # 返回值
/// 对应样条速度。
pub fn velocity(&self, time: f64) -> f64 {
let (segment, local_time) = self.segment_and_local_time(time);
let [cubic, quadratic, linear, _] = self.coefficients[segment];
3.0 * cubic * local_time * local_time + 2.0 * quadratic * local_time + linear
}
/// 在指定绝对时间求二阶导数。
///
/// # 参数
/// - `time`: 需要求值的绝对时间。
///
/// # 返回值
/// 对应样条加速度。
pub fn acceleration(&self, time: f64) -> f64 {
let (segment, local_time) = self.segment_and_local_time(time);
let [cubic, quadratic, _, _] = self.coefficients[segment];
6.0 * cubic * local_time + 2.0 * quadratic
}
/// 在指定绝对时间求三阶导数。
///
/// # 参数
/// - `time`: 需要求值的绝对时间。
///
/// # 返回值
/// 对应样条 jerk。
pub fn jerk(&self, time: f64) -> f64 {
let (segment, _) = self.segment_and_local_time(time);
6.0 * self.coefficients[segment][0]
}
/// 解析计算指定段的一至三阶导数绝对值峰值。
///
/// # 参数
/// - `segment_index`: 从零开始的段索引。
///
/// # 返回值
/// `[速度峰值, 加速度峰值, jerk 峰值]`;索引越界时返回输入错误。
pub fn segment_peak_derivatives(
&self,
segment_index: usize,
) -> Result<[f64; 3], NativePlanningError> {
let Some([cubic, quadratic, linear, _]) = self.coefficients.get(segment_index).copied()
else {
return Err(NativePlanningError::InvalidInput(format!(
"样条段索引 {segment_index} 越界"
)));
};
let duration = self.knots[segment_index + 1] - self.knots[segment_index];
// 一阶导为二次函数,绝对值峰值只可能出现在端点或段内顶点。
let mut velocity_peak = linear
.abs()
.max((3.0 * cubic * duration * duration + 2.0 * quadratic * duration + linear).abs());
if cubic.abs() > 1e-12 {
let critical = -quadratic / (3.0 * cubic);
if critical > 0.0 && critical < duration {
velocity_peak = velocity_peak.max(
(3.0 * cubic * critical * critical + 2.0 * quadratic * critical + linear).abs(),
);
}
}
let acceleration_peak = (2.0 * quadratic)
.abs()
.max((6.0 * cubic * duration + 2.0 * quadratic).abs());
let jerk_peak = (6.0 * cubic).abs();
Ok([velocity_peak, acceleration_peak, jerk_peak])
}
/// 定位时间所在样条段并计算相对段起点的局部时间。
///
/// # 参数
/// - `time`: 需要定位的绝对时间。
///
/// # 返回值
/// `(段索引, 局部时间)`;时间超界时钳制到首尾节点。
fn segment_and_local_time(&self, time: f64) -> (usize, f64) {
let last_segment = self.coefficients.len() - 1;
if time <= self.knots[0] {
return (0, 0.0);
}
let last_knot = self.knots[self.knots.len() - 1];
if time >= last_knot {
return (
last_segment,
self.knots[last_segment + 1] - self.knots[last_segment],
);
}
let insertion = self.knots.partition_point(|knot| *knot <= time);
let segment = insertion.saturating_sub(1).min(last_segment);
(segment, time - self.knots[segment])
}
}
#[cfg(test)]
mod tests {
use super::CubicSpline1D;
/// 验证 clamped-zero 样条严格命中首尾路点,并满足两端零速度边界。
#[test]
fn clamped_zero_spline_hits_waypoints_and_zero_boundary_velocity() {
let times = [0.0, 1.0, 2.0, 3.0];
let values = [0.0, 1.0, 0.5, 2.0];
let spline = CubicSpline1D::new_clamped_zero(&times, &values).unwrap();
assert!((spline.position(0.0) - 0.0).abs() <= 1e-12);
assert!((spline.position(3.0) - 2.0).abs() <= 1e-12);
assert!(spline.velocity(0.0).abs() <= 1e-12);
assert!(spline.velocity(3.0).abs() <= 1e-12);
assert!(spline.acceleration(1.5).is_finite());
assert!(spline.jerk(1.5).is_finite());
}
/// 验证每段速度、加速度和 jerk 的解析峰值不会漏掉段内速度极值。
#[test]
fn segment_peak_derivatives_include_internal_velocity_extremum() {
let times = [0.0, 1.0, 2.0, 3.0];
let values = [0.0, 1.0, 0.5, 2.0];
let spline = CubicSpline1D::new_clamped_zero(&times, &values).unwrap();
let peaks = spline.segment_peak_derivatives(0).unwrap();
assert!(peaks[0] >= spline.velocity(0.0).abs());
assert!(peaks[0] >= spline.velocity(1.0).abs());
assert!(peaks[1].is_finite() && peaks[1] > 0.0);
assert!(peaks[2].is_finite() && peaks[2] > 0.0);
}
}

View File

@@ -0,0 +1,642 @@
<#
.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
# Rust ICSP 原生库必须随框架依赖包一起交付,否则默认 method=icsp 会在运行时加载失败。
$nativePlanningLibraryPath = Join-Path $PublishedDirectory 'flyshot_planning.dll'
if (-not (Test-Path -LiteralPath $nativePlanningLibraryPath -PathType Leaf))
{
throw "发布目录缺少 Rust 原生规划库 flyshot_planning.dll:$nativePlanningLibraryPath"
}
}
<#
.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

@@ -0,0 +1,44 @@
using Flyshot.Core.Domain;
using Flyshot.Core.Planning;
using Microsoft.Extensions.Logging;
namespace Flyshot.CSharp.Planning;
/// <summary>
/// 将拆分后的 C# ICSP 规划器暴露为可选规划后端。
/// </summary>
public sealed class CSharpIcspPlanningBackend : IPlanningBackend
{
private readonly ICspPlanner _planner;
/// <summary>
/// 初始化 C# ICSP 后端。
/// </summary>
/// <param name="logger">C# ICSP 规划器日志记录器;允许为空。</param>
public CSharpIcspPlanningBackend(ILogger<ICspPlanner>? logger = null)
{
_planner = new ICspPlanner(logger: logger);
}
/// <summary>
/// 获取外部调用 C# ICSP 后端时使用的准确方法名。
/// </summary>
public string MethodName => "icsp-csharp";
/// <summary>
/// 获取 C# ICSP 后端写入结果对象的规划方法。
/// </summary>
public PlanningMethod ResultMethod => PlanningMethod.IcspCSharp;
/// <summary>
/// 执行 C# ICSP 规划,并将结果标记为 C# 后端。
/// </summary>
/// <param name="request">包含机器人约束和示教路点的规划请求。</param>
/// <param name="context">规划上下文;普通 C# ICSP 当前不使用该参数。</param>
/// <returns>由 C# ICSP 规划器生成的轨迹结果。</returns>
public PlannedTrajectory Plan(TrajectoryRequest request, PlanningBackendContext context)
{
ArgumentNullException.ThrowIfNull(context);
return _planner.Plan(request);
}
}

View File

@@ -0,0 +1,24 @@
using Flyshot.Core.Planning;
using Microsoft.Extensions.DependencyInjection;
namespace Flyshot.CSharp.Planning;
/// <summary>
/// 提供可选 C# 规划算法后端的依赖注入注册入口。
/// </summary>
public static class CSharpPlanningServiceCollectionExtensions
{
/// <summary>
/// 显式注册 C# ICSP 与 self-adapt-icsp 后端。
/// </summary>
/// <param name="services">当前宿主服务集合。</param>
/// <returns>同一服务集合,便于链式调用。</returns>
public static IServiceCollection AddCSharpPlanningBackends(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
services.AddSingleton<IPlanningBackend, CSharpIcspPlanningBackend>();
services.AddSingleton<IPlanningBackend, SelfAdaptIcspPlanningBackend>();
return services;
}
}

View File

@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\Flyshot.Core.Domain\Flyshot.Core.Domain.csproj" />
<ProjectReference Include="..\Flyshot.Core.Planning\Flyshot.Core.Planning.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.2" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.1" />
</ItemGroup>
</Project>

View File

@@ -1,6 +1,6 @@
# ICSP 算法说明(`ICspPlanner`)
本文档用于解释 `Flyshot.Core.Planning.ICspPlanner` 当前实现的 **ICSP 规划算法**在本仓库中的真实含义与计算逻辑,便于与逆向结论对照、以及指导后续改造(例如“按约束生成中间点位”)。
本文档用于解释 `Flyshot.CSharp.Planning.ICspPlanner` 当前实现的 **C# ICSP 规划算法**在本仓库中的真实含义与计算逻辑,便于与逆向结论对照、以及指导后续改造(例如“按约束生成中间点位”)。
> 适用范围:本文描述的是当前 C# 实现的 **“CubicSpline + 逐段时间缩放迭代(retiming)”** 版本。
> 重要澄清:`ICspPlanner` 的主要输出是 **时间轴**(每个示教点的时间戳),而不是直接输出固定周期的稠密点序列;稠密点在后续采样层生成。
@@ -186,9 +186,8 @@ globalScale > 1 + \text{finalScaleTolerance}
## 7. 关联实现位置(便于跳转)
- 算法入口:`src/Flyshot.Core.Planning/ICspPlanner.cs`
- 自适应补点:`src/Flyshot.Core.Planning/SelfAdaptIcspPlanner.cs`
- 算法入口:`src/Flyshot.CSharp.Planning/ICspPlanner.cs`
- 自适应补点:`src/Flyshot.CSharp.Planning/SelfAdaptIcspPlanner.cs`
- 三次样条实现(clamped-zero + 解析导峰值):`src/Flyshot.Core.Planning/CubicSplineInterpolator.cs`
- 规划层稠密采样:`src/Flyshot.Core.Planning/Sampling/TrajectorySampler.cs`
- J519 实发重采样:`src/Flyshot.Core.Planning/Sampling/J519SendTrajectorySampler.cs`

View File

@@ -1,7 +1,8 @@
using Flyshot.Core.Domain;
using Flyshot.Core.Planning;
using Microsoft.Extensions.Logging;
namespace Flyshot.Core.Planning;
namespace Flyshot.CSharp.Planning;
/// <summary>
/// 基于 CubicSpline + 逐段时间缩放迭代的 ICSP 规划器。
@@ -196,7 +197,7 @@ public sealed class ICspPlanner
waypointTimes: bestWaypointTimes,
segmentDurations: bestDurations,
segmentScales: bestScales,
method: PlanningMethod.Icsp,
method: PlanningMethod.IcspCSharp,
iterations: bestIterations,
threshold: bestThreshold);
}

View File

@@ -1,7 +1,8 @@
using Flyshot.Core.Domain;
using Flyshot.Core.Planning;
using Microsoft.Extensions.Logging;
namespace Flyshot.Core.Planning;
namespace Flyshot.CSharp.Planning;
/// <summary>
/// 在 ICSP 外层包裹补中点策略,实现 self-adapt-icsp 行为。

View File

@@ -0,0 +1,44 @@
using Flyshot.Core.Domain;
using Flyshot.Core.Planning;
using Microsoft.Extensions.Logging;
namespace Flyshot.CSharp.Planning;
/// <summary>
/// 将 C# self-adapt-icsp 规划器暴露为可选规划后端。
/// </summary>
public sealed class SelfAdaptIcspPlanningBackend : IPlanningBackend
{
private readonly SelfAdaptIcspPlanner _planner;
/// <summary>
/// 初始化 C# self-adapt-icsp 后端。
/// </summary>
/// <param name="logger">self-adapt-icsp 规划器日志记录器;允许为空。</param>
public SelfAdaptIcspPlanningBackend(ILogger<SelfAdaptIcspPlanner>? logger = null)
{
_planner = new SelfAdaptIcspPlanner(logger);
}
/// <summary>
/// 获取外部调用 self-adapt-icsp 后端时使用的准确方法名。
/// </summary>
public string MethodName => "self-adapt-icsp";
/// <summary>
/// 获取 self-adapt-icsp 后端写入结果对象的规划方法。
/// </summary>
public PlanningMethod ResultMethod => PlanningMethod.SelfAdaptIcsp;
/// <summary>
/// 执行 C# 自适应 ICSP 规划,按上下文使用配置中的补点次数。
/// </summary>
/// <param name="request">包含机器人约束、示教路点和触发元数据的规划请求。</param>
/// <param name="context">包含 self-adapt-icsp 最大补点次数的规划上下文。</param>
/// <returns>由 self-adapt-icsp 规划器生成的轨迹结果。</returns>
public PlannedTrajectory Plan(TrajectoryRequest request, PlanningBackendContext context)
{
ArgumentNullException.ThrowIfNull(context);
return _planner.Plan(request, context.AdaptIcspTryNum);
}
}

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

@@ -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,47 +92,19 @@ 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();
lock (_stateLock)
{
_initializationStage = "LoadingModel";
}
try
{
var robotSettings = TryLoadRobotSettings() ?? _robotSettings ?? CreateDefaultRobotSettings();
var robotProfile = _robotCatalog.LoadProfile(
robotName,
robotSettings.AccLimitScale,
@@ -130,25 +112,18 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
lock (_stateLock)
{
// 机器人重新初始化时同步重置运行时和上传轨迹目录,保持旧服务初始化语义。
// 重新加载模型只重置 Runtime;飞拍资产继续保留在固定 RobotConfig.json 中。
_configuredRobotName = robotName;
_activeRobotProfile = robotProfile;
_uploadedTrajectories.Clear();
_runtime.ResetRobot(robotProfile, robotName);
// RobotConfig.json 的 speed_ratio 是启动默认值;在线 set_speedRatio 仍可覆盖后续执行。
_runtime.SetSpeedRatio(robotSettings.SpeedRatio);
_robotSettings = robotSettings;
// 从持久化存储恢复该机器人名下之前已上传的轨迹。
var savedTrajectories = _trajectoryStore.LoadAll(robotName, out _);
foreach (var saved in savedTrajectories)
{
_uploadedTrajectories[saved.Key] = saved.Value;
}
_operatingMode = "OfflineReady";
_initializationStage = "ModelLoaded";
}
_logger?.LogInformation(
"SetUpRobot 完成: robotName={RobotName}, dof={Dof}, accLimit={AccLimit}, jerkLimit={JerkLimit}, speedRatio={SpeedRatio}, 恢复轨迹数={TrajCount}",
"SetUpRobot 完成: robotName={RobotName}, dof={Dof}, accLimit={AccLimit}, jerkLimit={JerkLimit}, speedRatio={SpeedRatio}, 保留轨迹数={TrajCount}",
robotName,
robotProfile.DegreesOfFreedom,
robotSettings.AccLimitScale,
@@ -156,51 +131,72 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
robotSettings.SpeedRatio,
_uploadedTrajectories.Count);
}
/// <inheritdoc />
public void SetUpRobotFromEnv(string envFile)
catch
{
if (string.IsNullOrWhiteSpace(envFile))
try
{
throw new ArgumentException("环境文件路径不能为空。", nameof(envFile));
_runtime.Disconnect();
}
throw new NotSupportedException("SetUpRobotFromEnv 尚未接入环境文件解析。");
}
/// <inheritdoc />
public void SetShowTcp(bool isShow, double axisLength, int axisSize)
catch
{
if (axisLength <= 0.0)
{
throw new ArgumentOutOfRangeException(nameof(axisLength), "TCP 坐标轴长度必须大于 0。");
}
if (axisSize <= 0)
{
throw new ArgumentOutOfRangeException(nameof(axisSize), "TCP 坐标轴线宽必须大于 0。");
// 模型切换失败时优先清理上层状态;底层若尚未初始化,Disconnect 失败可忽略。
}
lock (_stateLock)
{
EnsureRobotSetup();
_activeRobotProfile = null;
_configuredRobotName = null;
_operatingMode = "ModelLoadFailed";
_initializationStage = "ModelLoadFailed";
}
// 当前无 GUI 渲染层,先保存显示参数,保证旧 SDK 参数不会在 HTTP 边界丢失。
_showTcp = isShow;
_showTcpAxisLength = axisLength;
_showTcpAxisSize = axisSize;
throw;
}
}
/// <inheritdoc />
public void SetActiveController(bool sim)
public void InitializeRobot(string robotName, string robotIp, int bufferSize = 8)
{
lock (_stateLock)
{
EnsureRobotSetup();
_lastInitializationAttemptAt = DateTimeOffset.UtcNow;
_robotIp = robotIp;
_lastConnectionError = null;
_lastConnectionErrorAt = null;
}
_runtime.SetActiveController(sim);
SetUpRobot(robotName);
try
{
lock (_stateLock)
{
_initializationStage = "Connecting";
}
// 真机优先初始化固定执行连接和使能,不再选择生产仿真控制器。
Connect(robotIp);
EnableRobot(bufferSize);
lock (_stateLock)
{
_operatingMode = "RealReady";
_initializationStage = "RealReady";
}
}
catch (Exception exception)
{
lock (_stateLock)
{
// 模型已加载时,真机失败必须保留离线规划能力。
_operatingMode = _activeRobotProfile is null ? "ModelLoadFailed" : "OfflineReady";
_initializationStage = "OfflineReady";
_lastConnectionError = exception.Message;
_lastConnectionErrorAt = DateTimeOffset.UtcNow;
}
throw;
}
}
/// <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);
}
@@ -743,7 +764,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 +813,7 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
speedRatio);
_logger?.LogInformation("SaveTrajectoryInfo 完成: name={Name}", name);
return CreateOfflinePlanningResult(name, method, robot, bundle, speedRatio);
}
/// <inheritdoc />
@@ -858,7 +880,6 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
_logger?.LogInformation("DeleteTrajectory 开始: name={Name}", name);
string robotName;
lock (_stateLock)
{
if (!_uploadedTrajectories.Remove(name))
@@ -867,11 +888,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 +1086,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 +1165,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

@@ -1,4 +1,6 @@
using Flyshot.Core.Config;
using Flyshot.Core.Planning;
using Flyshot.Native.Planning;
using Flyshot.Runtime.Common;
using Flyshot.Runtime.Fanuc;
using Microsoft.Extensions.Configuration;
@@ -31,6 +33,7 @@ public static class ControllerClientCompatServiceCollectionExtensions
services.AddSingleton<RobotModelLoader>();
services.AddSingleton<RobotConfigLoader>();
services.AddSingleton<ControllerClientCompatRobotCatalog>();
services.AddSingleton<IPlanningBackend, RustIcspPlanningBackend>();
services.AddSingleton<ControllerClientTrajectoryOrchestrator>();
services.AddSingleton<FlyshotTrajectoryArtifactWriter>();
services.AddSingleton<JsonFlyshotTrajectoryStore>();

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

@@ -3,6 +3,7 @@ using Flyshot.Core.Domain;
using Flyshot.Core.Planning;
using Flyshot.Core.Planning.Sampling;
using Flyshot.Core.Triggering;
using Flyshot.Native.Planning;
using Microsoft.Extensions.Logging;
namespace Flyshot.ControllerClientCompat;
@@ -28,8 +29,7 @@ public sealed class ControllerClientTrajectoryOrchestrator
/// </summary>
private const double SmoothStartStopBlend = 0.60;
private readonly ICspPlanner _icspPlanner;
private readonly SelfAdaptIcspPlanner _selfAdaptIcspPlanner;
private readonly IReadOnlyDictionary<string, IPlanningBackend> _planningBackends;
private readonly ShotTimelineBuilder _shotTimelineBuilder = new(new WaypointTimestampResolver());
private readonly Dictionary<string, PlannedExecutionBundle> _flyshotCache = new(StringComparer.Ordinal);
private readonly ILogger<ControllerClientTrajectoryOrchestrator>? _logger;
@@ -37,15 +37,16 @@ public sealed class ControllerClientTrajectoryOrchestrator
/// <summary>
/// 初始化轨迹编排器。
/// </summary>
/// <param name="planningBackends">显式注册的规划后端;为空时仅启用默认 Rust icsp 后端。</param>
/// <param name="logger">日志记录器;允许 null。</param>
/// <param name="loggerFactory">日志工厂;允许 null。</param>
public ControllerClientTrajectoryOrchestrator(
IEnumerable<IPlanningBackend>? planningBackends = null,
ILogger<ControllerClientTrajectoryOrchestrator>? logger = null,
ILoggerFactory? loggerFactory = null)
{
_logger = logger;
_icspPlanner = new(logger: loggerFactory?.CreateLogger<ICspPlanner>());
_selfAdaptIcspPlanner = new(logger: loggerFactory?.CreateLogger<SelfAdaptIcspPlanner>());
_planningBackends = BuildPlanningBackendMap(planningBackends);
}
/// <summary>
@@ -81,14 +82,15 @@ public sealed class ControllerClientTrajectoryOrchestrator
offsetValues: Enumerable.Repeat(0, waypoints.Count),
addressGroups: Enumerable.Range(0, waypoints.Count).Select(static _ => Array.Empty<int>()));
var method = ParseOrdinaryMethod(options.Method);
var methodName = ParseOrdinaryMethod(options.Method);
var backend = ResolvePlanningBackend(methodName);
var request = new TrajectoryRequest(
robot: planningRobot,
program: program,
method: method,
method: backend.ResultMethod,
saveTrajectoryArtifacts: options.SaveTrajectory);
var plannedTrajectory = PlanByMethod(request, method);
var plannedTrajectory = PlanByBackend(request, backend);
var executionTrajectory = plannedTrajectory;
var denseJointTrajectory = CreateLimitCompliantDenseTrajectory(ref executionTrajectory, shapeTrajectoryEdges: false);
var shotTimeline = new ShotTimeline(Array.Empty<ShotEvent>(), Array.Empty<TrajectoryDoEvent>());
@@ -152,7 +154,8 @@ public sealed class ControllerClientTrajectoryOrchestrator
offsetValues: uploaded.OffsetValues,
addressGroups: uploaded.AddressGroups);
var method = ParseFlyshotMethod(options.Method);
var methodName = ParseFlyshotMethod(options.Method);
var backend = ResolvePlanningBackend(methodName);
var cacheKey = CreateFlyshotCacheKey(planningRobot, uploaded, options, settings, effectivePlanningSpeedScale);
if (options.UseCache && _flyshotCache.TryGetValue(cacheKey, out var cachedBundle))
{
@@ -165,12 +168,12 @@ public sealed class ControllerClientTrajectoryOrchestrator
var request = new TrajectoryRequest(
robot: planningRobot,
program: program,
method: method,
method: backend.ResultMethod,
moveToStart: options.MoveToStart,
saveTrajectoryArtifacts: options.SaveTrajectory,
useCache: options.UseCache);
var plannedTrajectory = PlanByMethod(request, method, settings);
var plannedTrajectory = PlanByBackend(request, backend, settings);
var smoothedExecutionTrajectory = ApplyExecutionTiming(plannedTrajectory, settings);
var denseJointTrajectory = CreateLimitCompliantDenseTrajectory(ref smoothedExecutionTrajectory, shapeTrajectoryEdges: false);
var shotTimeline = _shotTimelineBuilder.Build(
@@ -203,13 +206,15 @@ public sealed class ControllerClientTrajectoryOrchestrator
/// </summary>
/// <param name="method">旧 SDK 传入的方法名。</param>
/// <returns>领域层规划方法。</returns>
private static PlanningMethod ParseOrdinaryMethod(string method)
private static string ParseOrdinaryMethod(string method)
{
var normalized = NormalizeMethod(method);
return normalized switch
{
"icsp" => PlanningMethod.Icsp,
"doubles" => PlanningMethod.Doubles,
"icsp" => normalized,
"icsp-csharp" => normalized,
"icsp_rust" => throw new ArgumentException("method=\"icsp_rust\" 已改名为 method=\"icsp\"。", nameof(method)),
"doubles" => normalized,
_ => throw new ArgumentException($"Unsupported ExecuteTrajectory method: {method}", nameof(method))
};
}
@@ -219,33 +224,80 @@ public sealed class ControllerClientTrajectoryOrchestrator
/// </summary>
/// <param name="method">旧 SDK 传入的方法名。</param>
/// <returns>领域层规划方法。</returns>
private static PlanningMethod ParseFlyshotMethod(string method)
private static string ParseFlyshotMethod(string method)
{
var normalized = NormalizeMethod(method);
return normalized switch
{
"icsp" => PlanningMethod.Icsp,
"self-adapt-icsp" => PlanningMethod.SelfAdaptIcsp,
"doubles" => PlanningMethod.Doubles,
"icsp" => normalized,
"icsp-csharp" => normalized,
"icsp_rust" => throw new ArgumentException("method=\"icsp_rust\" 已改名为 method=\"icsp\"。", nameof(method)),
"self-adapt-icsp" => normalized,
"doubles" => normalized,
_ => throw new ArgumentException($"Unsupported ExecuteFlyShotTraj method: {method}", nameof(method))
};
}
/// <summary>
/// 按领域枚举分派到当前已经落地的规划器。
/// 使用显式注册的规划后端执行规划。
/// </summary>
/// <param name="request">规划请求。</param>
/// <param name="method">规划方法。</param>
/// <param name="backend">已解析的规划后端。</param>
/// <param name="settings">当前机器人兼容配置;为空时使用默认补点次数。</param>
/// <returns>规划轨迹。</returns>
private PlannedTrajectory PlanByMethod(TrajectoryRequest request, PlanningMethod method, CompatibilityRobotSettings? settings = null)
private static PlannedTrajectory PlanByBackend(
TrajectoryRequest request,
IPlanningBackend backend,
CompatibilityRobotSettings? settings = null)
{
return method switch
var context = new PlanningBackendContext(settings?.AdaptIcspTryNum ?? 5);
return backend.Plan(request, context);
}
/// <summary>
/// 从已注册后端中解析 method 对应的规划实现。
/// </summary>
/// <param name="methodName">已经归一化的外部 method 名称。</param>
/// <returns>可执行该 method 的规划后端。</returns>
private IPlanningBackend ResolvePlanningBackend(string methodName)
{
PlanningMethod.Icsp => _icspPlanner.Plan(request),
PlanningMethod.SelfAdaptIcsp => _selfAdaptIcspPlanner.Plan(request, settings?.AdaptIcspTryNum ?? 5),
PlanningMethod.Doubles => throw new NotSupportedException("doubles 轨迹规划尚未落地。"),
_ => throw new ArgumentOutOfRangeException(nameof(method), method, "未知轨迹规划方法。")
};
if (methodName == "doubles")
{
throw new NotSupportedException("doubles 轨迹规划尚未落地。");
}
if (_planningBackends.TryGetValue(methodName, out var backend))
{
return backend;
}
throw new NotSupportedException($"轨迹规划方法 {methodName} 未注册对应后端。");
}
/// <summary>
/// 构造规划后端查找表,未显式注册时只启用默认 Rust icsp 后端。
/// </summary>
/// <param name="planningBackends">调用方显式注册的规划后端集合。</param>
/// <returns>按 method 名称索引的后端表。</returns>
private static IReadOnlyDictionary<string, IPlanningBackend> BuildPlanningBackendMap(IEnumerable<IPlanningBackend>? planningBackends)
{
var backends = planningBackends?.ToArray() ?? [];
if (backends.Length == 0)
{
backends = [new RustIcspPlanningBackend()];
}
var map = new Dictionary<string, IPlanningBackend>(StringComparer.Ordinal);
foreach (var backend in backends)
{
var methodName = NormalizeMethod(backend.MethodName);
if (!map.TryAdd(methodName, backend))
{
throw new InvalidOperationException($"规划后端 method={methodName} 被重复注册。");
}
}
return map;
}
/// <summary>

View File

@@ -5,6 +5,7 @@
<ProjectReference Include="..\Flyshot.Core.Domain\Flyshot.Core.Domain.csproj" />
<ProjectReference Include="..\Flyshot.Core.Planning\Flyshot.Core.Planning.csproj" />
<ProjectReference Include="..\Flyshot.Core.Triggering\Flyshot.Core.Triggering.csproj" />
<ProjectReference Include="..\Flyshot.Native.Planning\Flyshot.Native.Planning.csproj" />
<ProjectReference Include="..\Flyshot.Runtime.Common\Flyshot.Runtime.Common.csproj" />
<ProjectReference Include="..\Flyshot.Runtime.Fanuc\Flyshot.Runtime.Fanuc.csproj" />
</ItemGroup>

View File

@@ -9,7 +9,7 @@ public sealed class FlyshotExecutionOptions
/// 初始化飞拍轨迹执行参数。
/// </summary>
/// <param name="moveToStart">执行前是否自动移动到轨迹起点。</param>
/// <param name="method">轨迹生成方法,支持 `icsp`、`doubles` 或 `self-adapt-icsp`。</param>
/// <param name="method">轨迹生成方法,支持默认 Rust `icsp`、可选 C# `icsp-csharp`、`self-adapt-icsp` 或 `doubles`。</param>
/// <param name="saveTrajectory">是否保存轨迹信息。</param>
/// <param name="useCache">是否优先复用已规划轨迹缓存。</param>
/// <param name="wait">是否等待机器人执行完整条飞拍轨迹后再返回。</param>

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

@@ -8,7 +8,7 @@ public sealed class TrajectoryExecutionOptions
/// <summary>
/// 初始化普通轨迹执行参数。
/// </summary>
/// <param name="method">轨迹生成方法,支持 `icsp` 或 `doubles`。</param>
/// <param name="method">轨迹生成方法,支持默认 Rust `icsp`、可选 C# `icsp-csharp` 或 `doubles`。</param>
/// <param name="saveTrajectory">是否保存轨迹信息。</param>
public TrajectoryExecutionOptions(string method = "icsp", bool saveTrajectory = false)
{

View File

@@ -70,10 +70,15 @@ public sealed class TrajectoryRequest
public enum PlanningMethod
{
/// <summary>
/// Uses the cubic-spline ICSP planner.
/// 使用正式 icsp 后端;当前默认实现为 Rust 原生 ICSP。
/// </summary>
Icsp,
/// <summary>
/// 使用拆分到可选程序集中的 C# ICSP 规划器。
/// </summary>
IcspCSharp,
/// <summary>
/// Uses the adaptive ICSP planner that may insert additional points.
/// </summary>

View File

@@ -0,0 +1,27 @@
using Flyshot.Core.Domain;
namespace Flyshot.Core.Planning;
/// <summary>
/// 定义可由兼容层按 method 字符串分派的轨迹规划后端。
/// </summary>
public interface IPlanningBackend
{
/// <summary>
/// 获取外部 HTTP/ControllerClient method 参数使用的准确方法名。
/// </summary>
string MethodName { get; }
/// <summary>
/// 获取该后端成功规划后写入结果对象的领域规划方法。
/// </summary>
PlanningMethod ResultMethod { get; }
/// <summary>
/// 执行轨迹规划。
/// </summary>
/// <param name="request">包含机器人约束、轨迹程序和调用标志的规划请求。</param>
/// <param name="context">兼容层传入的规划上下文参数。</param>
/// <returns>规划后的轨迹时间轴、路点和收敛信息。</returns>
PlannedTrajectory Plan(TrajectoryRequest request, PlanningBackendContext context);
}

View File

@@ -0,0 +1,21 @@
namespace Flyshot.Core.Planning;
/// <summary>
/// 表示规划后端执行时由兼容层补充的运行参数。
/// </summary>
public sealed class PlanningBackendContext
{
/// <summary>
/// 初始化规划后端上下文。
/// </summary>
/// <param name="adaptIcspTryNum">self-adapt-icsp 允许的最大补点重试次数。</param>
public PlanningBackendContext(int adaptIcspTryNum = 5)
{
AdaptIcspTryNum = adaptIcspTryNum;
}
/// <summary>
/// 获取 self-adapt-icsp 允许的最大补点重试次数。
/// </summary>
public int AdaptIcspTryNum { get; }
}

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

@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Flyshot.Core.Domain\Flyshot.Core.Domain.csproj" />
<ProjectReference Include="..\Flyshot.Core.Planning\Flyshot.Core.Planning.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,77 @@
using System.Runtime.InteropServices;
namespace Flyshot.Native.Planning;
/// <summary>
/// 与 Rust <c>FlyshotIcspOptions</c> 保持逐字段一致的 C ABI 选项结构。
/// </summary>
[StructLayout(LayoutKind.Sequential)]
internal struct NativeIcspOptions
{
/// <summary>
/// 收敛指标停止阈值。
/// </summary>
internal double Threshold;
/// <summary>
/// 最大迭代索引。
/// </summary>
internal int MaxIterations;
/// <summary>
/// 非零表示启用最终 scale 检查。
/// </summary>
internal int EnforceFinalScale;
/// <summary>
/// 最终 scale 允许的非负容差。
/// </summary>
internal double FinalScaleTolerance;
}
/// <summary>
/// 与 Rust <c>FlyshotIcspResult</c> 保持逐字段一致的 C ABI 结果结构。
/// </summary>
[StructLayout(LayoutKind.Sequential)]
internal struct NativeIcspResult
{
/// <summary>
/// 路点绝对时间连续数组指针。
/// </summary>
internal nint WaypointTimes;
/// <summary>
/// 路点绝对时间数组长度。
/// </summary>
internal int WaypointTimesLength;
/// <summary>
/// 分段时长连续数组指针。
/// </summary>
internal nint SegmentDurations;
/// <summary>
/// 分段时长数组长度。
/// </summary>
internal int SegmentDurationsLength;
/// <summary>
/// 分段缩放连续数组指针。
/// </summary>
internal nint SegmentScales;
/// <summary>
/// 分段缩放数组长度。
/// </summary>
internal int SegmentScalesLength;
/// <summary>
/// 历史最优结果对应的迭代轮数。
/// </summary>
internal int Iterations;
/// <summary>
/// 历史最小收敛指标。
/// </summary>
internal double Threshold;
}

View File

@@ -0,0 +1,130 @@
using System.Runtime.InteropServices;
namespace Flyshot.Native.Planning;
/// <summary>
/// 声明 Rust 原生规划库导出的稳定 C ABI 方法。
/// </summary>
internal static partial class NativeIcspMethods
{
/// <summary>
/// 保护原生库句柄首次初始化,避免并发规划重复加载。
/// </summary>
private static readonly object LibraryLoadLock = new();
/// <summary>
/// 保存已加载原生库句柄,使 P/Invoke 生命周期覆盖整个进程。
/// </summary>
private static nint _libraryHandle;
/// <summary>
/// 保存当前进程已绑定到 P/Invoke 逻辑名称的实际库路径。
/// </summary>
private static string? _loadedLibraryPath;
/// <summary>
/// 初始化程序集级 DllImport 解析器,把逻辑库名绑定到显式加载句柄。
/// </summary>
static NativeIcspMethods()
{
NativeLibrary.SetDllImportResolver(
typeof(NativeIcspMethods).Assembly,
ResolveImportedLibrary);
}
/// <summary>
/// 使用指定解析器确保原生规划库已加载。
/// </summary>
/// <param name="resolver">负责生成平台路径并加载原生库的解析器。</param>
internal static void EnsureLibraryLoaded(NativePlanningLibraryResolver resolver)
{
ArgumentNullException.ThrowIfNull(resolver);
string requestedPath = resolver.ResolveLibraryPath();
lock (LibraryLoadLock)
{
if (_libraryHandle != nint.Zero)
{
StringComparison comparison = OperatingSystem.IsWindows()
? StringComparison.OrdinalIgnoreCase
: StringComparison.Ordinal;
if (!string.Equals(_loadedLibraryPath, requestedPath, comparison))
{
throw new NativePlanningException(
$"Rust 原生规划库已绑定到“{_loadedLibraryPath}”,不能在同一进程中改为“{requestedPath}”。",
errorCode: -1008);
}
return;
}
_libraryHandle = resolver.LoadLibrary();
_loadedLibraryPath = requestedPath;
}
}
/// <summary>
/// 调用 Rust ICSP 入口并返回错误码和结果指针。
/// </summary>
/// <param name="waypoints">行优先连续路点数组。</param>
/// <param name="waypointCount">路点数量。</param>
/// <param name="degreesOfFreedom">每个路点的关节自由度。</param>
/// <param name="velocityLimits">逐关节速度上限。</param>
/// <param name="accelerationLimits">逐关节加速度上限。</param>
/// <param name="jerkLimits">逐关节 jerk 上限。</param>
/// <param name="options">与 Rust C ABI 对齐的规划选项。</param>
/// <param name="result">接收 Rust 分配的结果指针。</param>
/// <returns>零表示成功,非零表示固定类别的原生错误。</returns>
[LibraryImport(
NativePlanningLibraryResolver.LogicalLibraryName,
EntryPoint = "flyshot_icsp_plan")]
internal static unsafe partial int FlyshotIcspPlan(
double* waypoints,
int waypointCount,
int degreesOfFreedom,
double* velocityLimits,
double* accelerationLimits,
double* jerkLimits,
NativeIcspOptions* options,
out nint result);
/// <summary>
/// 释放由 Rust 分配的 ICSP 结果对象。
/// </summary>
/// <param name="result">Rust 返回的结果对象指针;允许为空指针。</param>
[LibraryImport(
NativePlanningLibraryResolver.LogicalLibraryName,
EntryPoint = "flyshot_icsp_result_free")]
internal static partial void FlyshotIcspResultFree(nint result);
/// <summary>
/// 获取当前线程最近一次 Rust 调用保存的 UTF-8 错误文本。
/// </summary>
/// <returns>指向线程局部 C 字符串的只读指针。</returns>
[LibraryImport(
NativePlanningLibraryResolver.LogicalLibraryName,
EntryPoint = "flyshot_last_error_message")]
internal static partial nint FlyshotLastErrorMessage();
/// <summary>
/// 将固定逻辑库名解析为已经显式加载的系统句柄。
/// </summary>
/// <param name="libraryName">P/Invoke 请求的逻辑库名。</param>
/// <param name="assembly">发起请求的托管程序集。</param>
/// <param name="searchPath">调用方提供的可选搜索路径。</param>
/// <returns>逻辑名称匹配时返回已加载句柄,否则返回零交给系统继续解析。</returns>
private static nint ResolveImportedLibrary(
string libraryName,
System.Reflection.Assembly assembly,
DllImportSearchPath? searchPath)
{
_ = assembly;
_ = searchPath;
return string.Equals(
libraryName,
NativePlanningLibraryResolver.LogicalLibraryName,
StringComparison.Ordinal)
? _libraryHandle
: nint.Zero;
}
}

View File

@@ -0,0 +1,24 @@
namespace Flyshot.Native.Planning;
/// <summary>
/// 描述 Rust ICSP 规划器使用的收敛和最终约束选项。
/// </summary>
/// <param name="Threshold">收敛指标 <c>sum(abs(scale - 1))</c> 的停止阈值。</param>
/// <param name="MaxIterations">最大迭代索引;执行范围包含该索引对应轮次。</param>
/// <param name="EnforceFinalScale">是否拒绝历史最优结果中仍然超限的段。</param>
/// <param name="FinalScaleTolerance">最终段缩放因子相对 1.0 的非负容差。</param>
public sealed record NativeIcspPlannerOptions(
double Threshold,
int MaxIterations,
bool EnforceFinalScale,
double FinalScaleTolerance)
{
/// <summary>
/// 获取与现有托管 <c>ICspPlanner</c> 一致的默认选项。
/// </summary>
public static NativeIcspPlannerOptions Default { get; } = new(
Threshold: 1e-3,
MaxIterations: 1000,
EnforceFinalScale: true,
FinalScaleTolerance: 1e-2);
}

View File

@@ -0,0 +1,37 @@
using Microsoft.Win32.SafeHandles;
namespace Flyshot.Native.Planning;
/// <summary>
/// 封装 Rust 分配的 ICSP 结果对象,确保托管异常路径也会调用原生释放函数。
/// </summary>
internal sealed class NativeIcspSafeHandle : SafeHandleZeroOrMinusOneIsInvalid
{
/// <summary>
/// 初始化一个尚未持有原生结果的安全句柄。
/// </summary>
public NativeIcspSafeHandle()
: base(ownsHandle: true)
{
}
/// <summary>
/// 初始化并接管 Rust 返回的非零结果指针。
/// </summary>
/// <param name="result">由 <c>flyshot_icsp_plan</c> 分配的结果指针。</param>
internal NativeIcspSafeHandle(nint result)
: this()
{
SetHandle(result);
}
/// <summary>
/// 调用 Rust 导出的释放函数归还结果及其连续数组内存。
/// </summary>
/// <returns>释放函数调用完成后返回 <see langword="true"/>。</returns>
protected override bool ReleaseHandle()
{
NativeIcspMethods.FlyshotIcspResultFree(handle);
return true;
}
}

View File

@@ -0,0 +1,24 @@
namespace Flyshot.Native.Planning;
/// <summary>
/// 表示原生规划库加载、调用或数值计算失败。
/// </summary>
public sealed class NativePlanningException : Exception
{
/// <summary>
/// 初始化包含原生错误码和底层异常的规划异常。
/// </summary>
/// <param name="message">供调用方诊断的错误说明。</param>
/// <param name="errorCode">Rust C ABI 返回的错误码;托管加载错误使用负数。</param>
/// <param name="innerException">触发本异常的底层异常;没有时为 <see langword="null"/>。</param>
public NativePlanningException(string message, int errorCode, Exception? innerException = null)
: base(message, innerException)
{
ErrorCodeValue = errorCode;
}
/// <summary>
/// 获取 Rust C ABI 错误码或托管互操作错误码。
/// </summary>
public int ErrorCodeValue { get; }
}

View File

@@ -0,0 +1,104 @@
using System.Runtime.InteropServices;
namespace Flyshot.Native.Planning;
/// <summary>
/// 描述当前进程加载原生规划库时使用的平台信息。
/// </summary>
/// <param name="OperatingSystem">当前操作系统名称。</param>
/// <param name="Architecture">当前进程体系结构。</param>
public readonly record struct NativePlanningPlatform(string OperatingSystem, Architecture Architecture);
/// <summary>
/// 按当前操作系统和体系结构解析并加载 Rust 原生规划库。
/// </summary>
public sealed class NativePlanningLibraryResolver
{
/// <summary>
/// 原生规划库的逻辑名称。
/// </summary>
public const string LogicalLibraryName = "flyshot_planning";
private readonly Func<NativePlanningPlatform, string> _resolveLibraryPath;
/// <summary>
/// 初始化使用默认部署目录解析规则的原生库解析器。
/// </summary>
public NativePlanningLibraryResolver()
: this(ResolveDefaultLibraryPath)
{
}
/// <summary>
/// 初始化使用自定义路径解析规则的原生库解析器。
/// </summary>
/// <param name="resolveLibraryPath">根据当前平台返回原生库路径或名称的函数。</param>
public NativePlanningLibraryResolver(Func<NativePlanningPlatform, string> resolveLibraryPath)
{
_resolveLibraryPath = resolveLibraryPath ?? throw new ArgumentNullException(nameof(resolveLibraryPath));
}
/// <summary>
/// 解析当前平台应加载的原生库路径。
/// </summary>
/// <returns>原生库路径或可被系统加载器识别的库名称。</returns>
public string ResolveLibraryPath()
{
string operatingSystem = OperatingSystem.IsWindows()
? "Windows"
: OperatingSystem.IsLinux()
? "Linux"
: "Unsupported";
return _resolveLibraryPath(new NativePlanningPlatform(operatingSystem, RuntimeInformation.ProcessArchitecture));
}
/// <summary>
/// 加载当前平台的原生规划库并返回系统句柄。
/// </summary>
/// <returns>由 <see cref="NativeLibrary"/> 管理的非零库句柄。</returns>
/// <exception cref="NativePlanningException">库缺失、架构不匹配或系统不受支持时抛出。</exception>
public nint LoadLibrary()
{
string path = ResolveLibraryPath();
try
{
return NativeLibrary.Load(path);
}
catch (Exception exception) when (
exception is DllNotFoundException
or BadImageFormatException
or FileLoadException)
{
throw new NativePlanningException(
$"无法加载 Rust 原生规划库“{path}”。请确认库文件存在且与 {RuntimeInformation.ProcessArchitecture} 架构匹配。",
errorCode: -1001,
exception);
}
}
/// <summary>
/// 根据当前平台生成与托管程序集同目录的默认原生库路径。
/// </summary>
/// <param name="platform">当前操作系统和进程体系结构。</param>
/// <returns>默认原生库绝对路径。</returns>
private static string ResolveDefaultLibraryPath(NativePlanningPlatform platform)
{
if (platform.Architecture != Architecture.X64)
{
throw new NativePlanningException(
$"Rust 原生规划库第一阶段仅支持 x64,当前进程架构为 {platform.Architecture}。",
errorCode: -1002);
}
string fileName = platform.OperatingSystem switch
{
"Windows" => "flyshot_planning.dll",
"Linux" => "libflyshot_planning.so",
_ => throw new NativePlanningException(
$"Rust 原生规划库不支持当前操作系统:{platform.OperatingSystem}。",
errorCode: -1003)
};
return Path.Combine(AppContext.BaseDirectory, fileName);
}
}

View File

@@ -0,0 +1,220 @@
using System.Runtime.InteropServices;
using Flyshot.Core.Domain;
using Flyshot.Core.Planning;
namespace Flyshot.Native.Planning;
/// <summary>
/// 通过稳定 C ABI 调用 Rust ICSP 核心,并把结果转换为现有托管轨迹模型。
/// </summary>
public sealed class RustIcspPlanner
{
private readonly NativeIcspPlannerOptions _options;
private readonly NativePlanningLibraryResolver _libraryResolver;
/// <summary>
/// 初始化 Rust ICSP 规划器。
/// </summary>
/// <param name="options">与托管 ICSP 对齐的收敛及最终约束选项。</param>
/// <param name="libraryResolver">负责解析 Windows/Linux x64 原生库的加载器。</param>
public RustIcspPlanner(
NativeIcspPlannerOptions? options = null,
NativePlanningLibraryResolver? libraryResolver = null)
{
_options = options ?? NativeIcspPlannerOptions.Default;
_libraryResolver = libraryResolver ?? new NativePlanningLibraryResolver();
ValidateOptions(_options);
}
/// <summary>
/// 使用 Rust 原生核心规划给定关节路点。
/// </summary>
/// <param name="request">包含机器人约束和示教路点的规划请求。</param>
/// <returns>与现有托管规划链路兼容的轨迹结果。</returns>
/// <exception cref="NativePlanningException">原生库加载或规划调用失败时抛出。</exception>
public unsafe PlannedTrajectory Plan(TrajectoryRequest request)
{
ArgumentNullException.ThrowIfNull(request);
ValidateRequest(request);
NativeIcspMethods.EnsureLibraryLoaded(_libraryResolver);
int waypointCount = request.Program.Waypoints.Count;
int degreesOfFreedom = request.Robot.DegreesOfFreedom;
double[] waypoints = FlattenWaypoints(request, waypointCount, degreesOfFreedom);
double[] velocityLimits = request.Robot.JointLimits
.Select(limit => limit.VelocityLimit)
.ToArray();
double[] accelerationLimits = request.Robot.JointLimits
.Select(limit => limit.AccelerationLimit)
.ToArray();
double[] jerkLimits = request.Robot.JointLimits
.Select(limit => limit.JerkLimit)
.ToArray();
var nativeOptions = new NativeIcspOptions
{
Threshold = _options.Threshold,
MaxIterations = _options.MaxIterations,
EnforceFinalScale = _options.EnforceFinalScale ? 1 : 0,
FinalScaleTolerance = _options.FinalScaleTolerance
};
fixed (double* waypointsPointer = waypoints)
fixed (double* velocityLimitsPointer = velocityLimits)
fixed (double* accelerationLimitsPointer = accelerationLimits)
fixed (double* jerkLimitsPointer = jerkLimits)
{
int errorCode = NativeIcspMethods.FlyshotIcspPlan(
waypointsPointer,
waypointCount,
degreesOfFreedom,
velocityLimitsPointer,
accelerationLimitsPointer,
jerkLimitsPointer,
&nativeOptions,
out nint resultPointer);
if (errorCode != 0)
{
string errorMessage = ReadLastErrorMessage();
throw new NativePlanningException(
$"Rust ICSP 规划失败,错误码={errorCode}:{errorMessage}",
errorCode);
}
if (resultPointer == nint.Zero)
{
throw new NativePlanningException(
"Rust ICSP 返回成功,但结果指针为空。",
errorCode: -1005);
}
using var resultHandle = new NativeIcspSafeHandle(resultPointer);
NativeIcspResult nativeResult = Marshal.PtrToStructure<NativeIcspResult>(resultPointer);
double[] waypointTimes = CopyNativeArray(
nativeResult.WaypointTimes,
nativeResult.WaypointTimesLength,
"waypoint_times");
double[] segmentDurations = CopyNativeArray(
nativeResult.SegmentDurations,
nativeResult.SegmentDurationsLength,
"segment_durations");
double[] segmentScales = CopyNativeArray(
nativeResult.SegmentScales,
nativeResult.SegmentScalesLength,
"segment_scales");
return new PlannedTrajectory(
robot: request.Robot,
originalProgram: request.Program,
plannedWaypoints: request.Program.Waypoints,
waypointTimes: waypointTimes,
segmentDurations: segmentDurations,
segmentScales: segmentScales,
method: PlanningMethod.Icsp,
iterations: nativeResult.Iterations,
threshold: nativeResult.Threshold);
}
}
/// <summary>
/// 校验托管规划选项,防止非法数值穿过 C ABI。
/// </summary>
/// <param name="options">待校验的原生规划选项。</param>
private static void ValidateOptions(NativeIcspPlannerOptions options)
{
if (!double.IsFinite(options.Threshold) || options.Threshold <= 0.0)
{
throw new ArgumentOutOfRangeException(nameof(options), "收敛阈值必须为有限正数。");
}
if (options.MaxIterations < 0)
{
throw new ArgumentOutOfRangeException(nameof(options), "最大迭代次数不能为负数。");
}
if (!double.IsFinite(options.FinalScaleTolerance) || options.FinalScaleTolerance < 0.0)
{
throw new ArgumentOutOfRangeException(nameof(options), "最终 scale 容差必须为有限非负数。");
}
}
/// <summary>
/// 校验规划请求与机器人自由度,确保扁平数组布局可安全建立。
/// </summary>
/// <param name="request">待传入 Rust 的规划请求。</param>
private static void ValidateRequest(TrajectoryRequest request)
{
if (request.Program.Waypoints.Count < 4)
{
throw new ArgumentException("ICSP 至少需要 4 个示教点。", nameof(request));
}
int degreesOfFreedom = request.Robot.DegreesOfFreedom;
if (request.Program.Waypoints.Any(waypoint => waypoint.Positions.Count != degreesOfFreedom))
{
throw new ArgumentException("所有路点维度必须与机器人自由度一致。", nameof(request));
}
}
/// <summary>
/// 将托管路点按行优先顺序复制为连续数组。
/// </summary>
/// <param name="request">包含路点的规划请求。</param>
/// <param name="waypointCount">已校验的路点数量。</param>
/// <param name="degreesOfFreedom">已校验的机器人自由度。</param>
/// <returns>长度为路点数量乘自由度的连续数组。</returns>
private static double[] FlattenWaypoints(
TrajectoryRequest request,
int waypointCount,
int degreesOfFreedom)
{
var flattened = new double[checked(waypointCount * degreesOfFreedom)];
for (int waypoint = 0; waypoint < waypointCount; waypoint++)
{
for (int joint = 0; joint < degreesOfFreedom; joint++)
{
flattened[waypoint * degreesOfFreedom + joint] =
request.Program.Waypoints[waypoint].Positions[joint];
}
}
return flattened;
}
/// <summary>
/// 将 Rust 连续数组复制到独立托管数组,避免结果释放后继续引用原生内存。
/// </summary>
/// <param name="pointer">Rust 结果中的连续数组指针。</param>
/// <param name="length">数组元素数量。</param>
/// <param name="fieldName">用于异常诊断的 ABI 字段名。</param>
/// <returns>与原生结果生命周期解耦的托管数组。</returns>
private static double[] CopyNativeArray(nint pointer, int length, string fieldName)
{
if (length < 0)
{
throw new NativePlanningException(
$"Rust ICSP 结果字段 {fieldName} 的长度为负数:{length}。",
errorCode: -1006);
}
if (length > 0 && pointer == nint.Zero)
{
throw new NativePlanningException(
$"Rust ICSP 结果字段 {fieldName} 长度为 {length},但指针为空。",
errorCode: -1007);
}
var values = new double[length];
if (length > 0)
{
Marshal.Copy(pointer, values, 0, length);
}
return values;
}
/// <summary>
/// 读取 Rust 当前线程保存的最近错误文本。
/// </summary>
/// <returns>UTF-8 错误文本;空指针时返回占位诊断。</returns>
private static string ReadLastErrorMessage()
{
nint messagePointer = NativeIcspMethods.FlyshotLastErrorMessage();
return messagePointer == nint.Zero
? "原生库未提供错误文本"
: Marshal.PtrToStringUTF8(messagePointer) ?? "原生错误文本不是有效 UTF-8";
}
}

View File

@@ -0,0 +1,43 @@
using Flyshot.Core.Domain;
using Flyshot.Core.Planning;
namespace Flyshot.Native.Planning;
/// <summary>
/// 将 Rust ICSP 规划器暴露为默认正式 icsp 后端。
/// </summary>
public sealed class RustIcspPlanningBackend : IPlanningBackend
{
private readonly RustIcspPlanner _planner;
/// <summary>
/// 初始化 Rust ICSP 后端。
/// </summary>
/// <param name="planner">可选的 Rust ICSP 规划器实例;为空时使用默认原生库解析规则。</param>
public RustIcspPlanningBackend(RustIcspPlanner? planner = null)
{
_planner = planner ?? new RustIcspPlanner();
}
/// <summary>
/// 获取外部调用 Rust ICSP 后端时使用的正式方法名。
/// </summary>
public string MethodName => "icsp";
/// <summary>
/// 获取 Rust ICSP 后端写入结果对象的规划方法。
/// </summary>
public PlanningMethod ResultMethod => PlanningMethod.Icsp;
/// <summary>
/// 执行 Rust ICSP 规划。
/// </summary>
/// <param name="request">包含机器人约束和示教路点的规划请求。</param>
/// <param name="context">规划上下文;Rust ICSP 当前不使用该参数。</param>
/// <returns>由 Rust 原生库生成的轨迹结果。</returns>
public PlannedTrajectory Plan(TrajectoryRequest request, PlanningBackendContext context)
{
ArgumentNullException.ThrowIfNull(context);
return _planner.Plan(request);
}
}

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;
@@ -88,7 +86,6 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
DisconnectClients();
_robot = robot;
_robotName = robotName;
_activeControllerIsSimulation = null;
_connectedRobotIp = null;
_isEnabled = false;
_isInMotion = false;
@@ -101,21 +98,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 +106,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 +148,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 +156,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 +177,8 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
{
EnsureRobotSetup();
CancelSendTaskLocked();
if (!IsSimulationMode)
{
_j519Client.StopMotionAsync().GetAwaiter().GetResult();
_commandClient.StopProgramAsync("RVBUSTSM").GetAwaiter().GetResult();
}
_isEnabled = false;
_isInMotion = false;
@@ -231,10 +194,7 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
{
EnsureRobotSetup();
CancelSendTaskLocked();
if (!IsSimulationMode)
{
_j519Client.StopMotionAsync().GetAwaiter().GetResult();
}
_isInMotion = false;
}
@@ -247,11 +207,8 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
lock (_stateLock)
{
EnsureConnected();
if (!IsSimulationMode)
{
var response = _commandClient.GetSpeedRatioAsync().GetAwaiter().GetResult();
_speedRatio = response.Ratio;
}
return _speedRatio;
}
@@ -271,11 +228,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 +240,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 +256,8 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
lock (_stateLock)
{
EnsureRobotSetup();
if (_activeControllerIsSimulation is false)
{
EnsureConnected();
_commandClient.SetTcpAsync(1, CreateTcpPose(x, y, z)).GetAwaiter().GetResult();
}
_tcp = [x, y, z];
}
@@ -327,15 +276,10 @@ 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;
}
}
/// <inheritdoc />
@@ -351,10 +295,7 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
lock (_stateLock)
{
EnsureEnabled();
if (!IsSimulationMode)
{
_commandClient.SetIoAsync(port, value, normalizedIoType).GetAwaiter().GetResult();
}
_ioValues[(normalizedIoType, port)] = value;
}
@@ -366,7 +307,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 +326,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 +355,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 +426,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 +441,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);
}
_isInMotion = true;
_jointPositions = finalJointPositions.ToArray();
@@ -558,11 +496,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;
@@ -572,15 +510,12 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
_logger?.LogInformation("ExecuteCartesianTrajectory 开始同步直角稠密发送任务");
}
else
{
if (!IsSimulationMode)
{
var command = new FanucJ519Command(
sequence: 0,
targetValues: BuildCartesianTargetValues(finalPose),
dataStyle: 0);
_j519Client.UpdateCommand(command);
}
_isInMotion = true;
_pose = MergeFinalCartesianPose(finalPose);
@@ -802,6 +737,18 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
sample.TrajectoryTime);
}
}
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 +812,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 +1001,7 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
}
/// <summary>
/// 将六维 WPR 直角目标合并回运行时缓存,保留旧 GetPose 仿真路径的 7 维外形。
/// 将六维 WPR 直角目标合并回运行时缓存,保留 GetPose 的 7 维兼容外形。
/// </summary>
private double[] MergeFinalCartesianPose(IReadOnlyList<double> pose)
{
@@ -1154,22 +1114,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 +1171,7 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
return "Disconnected";
}
return _activeControllerIsSimulation is false
? ResolveRealConnectionState(_stateClient.GetStatus())
: "Connected";
return ResolveRealConnectionState(_stateClient.GetStatus());
}
/// <summary>
@@ -1302,24 +1244,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>
@@ -16,6 +16,9 @@
<Content Include="..\..\NLog.config" Link="NLog.config" CopyToOutputDirectory="PreserveNewest" />
<!-- 运行时配置根目录固定为输出目录 Config,调试和发布都复制仓库内固化配置。 -->
<Content Include="..\..\Config\**\*" Link="Config\%(RecursiveDir)%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<!-- Rust ICSP 原生规划库:从 src/Libs 固定复制到运行与发布目录,供默认 method=icsp 加载。 -->
<Content Include="..\Libs\flyshot_planning.dll" Link="flyshot_planning.dll" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" Condition="Exists('..\Libs\flyshot_planning.dll')" />
<Content Include="..\Libs\libflyshot_planning.so" Link="libflyshot_planning.so" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" Condition="Exists('..\Libs\libflyshot_planning.so')" />
</ItemGroup>
<ItemGroup>

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": "LR_Mate_200iD",
"RobotIp": "127.0.0.1"
}
},
"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;
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,59 @@
<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="icsp-csharp">icsp-csharp</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 +92,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`;`icsp-csharp` 需要显式注册 C# 后端。" },
{ "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`;`icsp-csharp` 和 `self-adapt-icsp` 需要显式注册 C# 后端。" },
{ "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`;`icsp-csharp` 和 `self-adapt-icsp` 需要显式注册 C# 后端。" }
]
},
"POST /is_flyShotTrajValid/": {
"summary": "校验飞拍轨迹有效性",
"description": "检查指定飞拍轨迹是否可生成有效执行结果,并返回轨迹时长;底层未实现时会返回 501。",
"requestBodyFields": [
{ "name": "name", "type": "string", "required": true, "description": "要校验的飞拍轨迹名称。" },
{ "name": "method", "type": "string", "required": false, "description": "轨迹规划方法,默认 `icsp`;`icsp-csharp` 和 `self-adapt-icsp` 需要显式注册 C# 后端。" },
{ "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>

Binary file not shown.

Binary file not shown.

View File

@@ -60,15 +60,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 +119,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 +195,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 +221,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 +400,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 +475,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 +530,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 +676,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 +728,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 +834,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 +892,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 +956,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 +1112,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

@@ -279,6 +279,119 @@ public sealed class FanucJ519ClientTests : IDisposable
Assert.True(client.IsCommandQueueDrainedForTests());
}
/// <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>

View File

@@ -27,8 +27,16 @@
<ProjectReference Include="..\..\src\Flyshot.Core.Config\Flyshot.Core.Config.csproj" />
<ProjectReference Include="..\..\src\Flyshot.Core.Domain\Flyshot.Core.Domain.csproj" />
<ProjectReference Include="..\..\src\Flyshot.Core.Planning\Flyshot.Core.Planning.csproj" />
<ProjectReference Include="..\..\src\Flyshot.CSharp.Planning\Flyshot.CSharp.Planning.csproj" />
<ProjectReference Include="..\..\src\Flyshot.Core.Triggering\Flyshot.Core.Triggering.csproj" />
<ProjectReference Include="..\..\src\Flyshot.Native.Planning\Flyshot.Native.Planning.csproj" />
<ProjectReference Include="..\..\src\Flyshot.Runtime.Fanuc\Flyshot.Runtime.Fanuc.csproj" />
</ItemGroup>
<ItemGroup>
<!-- Rust ICSP 原生规划库:测试运行时需要与托管程序集同目录加载。 -->
<Content Include="..\..\src\Libs\flyshot_planning.dll" Link="flyshot_planning.dll" CopyToOutputDirectory="PreserveNewest" Condition="Exists('..\..\src\Libs\flyshot_planning.dll')" />
<Content Include="..\..\src\Libs\libflyshot_planning.so" Link="libflyshot_planning.so" CopyToOutputDirectory="PreserveNewest" Condition="Exists('..\..\src\Libs\libflyshot_planning.so')" />
</ItemGroup>
</Project>

View File

@@ -1,4 +1,5 @@
using Flyshot.Core.Config;
using Flyshot.CSharp.Planning;
using Flyshot.Core.Domain;
using Flyshot.Core.Planning;
using Flyshot.Core.Planning.Export;
@@ -55,7 +56,7 @@ public sealed class OfflinePlanTests
var request = new TrajectoryRequest(
robot: scaledProfile,
program: program,
method: useSelfAdapt ? PlanningMethod.SelfAdaptIcsp : PlanningMethod.Icsp);
method: useSelfAdapt ? PlanningMethod.SelfAdaptIcsp : PlanningMethod.IcspCSharp);
PlannedTrajectory trajectory;
if (useSelfAdapt)

View File

@@ -1,4 +1,5 @@
using Flyshot.Core.Config;
using Flyshot.CSharp.Planning;
using Flyshot.Core.Domain;
using Flyshot.Core.Planning;
using Flyshot.Core.Planning.Sampling;
@@ -27,7 +28,7 @@ public sealed class PlanningCompatibilityTests
new[] { 0.8, 0.3, 0.0, 0.0, 0.0, 0.0 },
new[] { 1.0, 0.2, 0.0, 0.0, 0.0, 0.0 }
}),
method: PlanningMethod.Icsp);
method: PlanningMethod.IcspCSharp);
var trajectory = new ICspPlanner().Plan(request);
@@ -51,7 +52,7 @@ public sealed class PlanningCompatibilityTests
new[] { 20.0 },
new[] { 30.0 }
}),
method: PlanningMethod.Icsp);
method: PlanningMethod.IcspCSharp);
var planner = new ICspPlanner(maxIterations: 0);

View File

@@ -1,4 +1,5 @@
using Flyshot.ControllerClientCompat;
using Flyshot.CSharp.Planning;
using Flyshot.Core.Config;
using Flyshot.Core.Domain;
using Flyshot.Core.Planning;
@@ -17,37 +18,16 @@ namespace Flyshot.Core.Tests;
public sealed class RuntimeOrchestrationTests
{
/// <summary>
/// 验证 FANUC 最小运行时执行轨迹后会更新状态快照与最终关节位置。
/// 创建显式注册 C# ICSP 后端的编排器,用于验证可选算法 DLL 的行为边界。
/// </summary>
[Fact]
public void FanucControllerRuntime_ExecuteTrajectory_UpdatesSnapshotAndFinalJointPositions()
/// <returns>包含 C# 普通 ICSP 与 self-adapt-icsp 后端的轨迹编排器。</returns>
private static ControllerClientTrajectoryOrchestrator CreateOrchestratorWithCSharpBackends()
{
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);
return new ControllerClientTrajectoryOrchestrator(
[
new CSharpIcspPlanningBackend(),
new SelfAdaptIcspPlanningBackend()
]);
}
/// <summary>
@@ -140,6 +120,155 @@ public sealed class RuntimeOrchestrationTests
Assert.Single(bundle.Result.TriggerTimeline);
}
/// <summary>
/// 验证普通轨迹默认 icsp 会真实调用 Rust DLL,并在结果中保留正式 icsp 方法标记。
/// </summary>
[Fact]
public void ControllerClientTrajectoryOrchestrator_PlanOrdinaryTrajectory_DefaultIcspUsesRustPlanner()
{
var orchestrator = new ControllerClientTrajectoryOrchestrator();
var robot = TestRobotFactory.CreateRobotProfile();
var bundle = orchestrator.PlanOrdinaryTrajectory(
robot,
[
[0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
[0.001, 0.0, 0.0, 0.0, 0.0, 0.0],
[0.002, 0.0, 0.0, 0.0, 0.0, 0.0],
[0.003, 0.0, 0.0, 0.0, 0.0, 0.0]
],
new TrajectoryExecutionOptions(method: "icsp"));
Assert.Equal(PlanningMethod.Icsp, bundle.PlannedTrajectory.Method);
Assert.Equal(PlanningMethod.Icsp, bundle.Result.Method);
}
/// <summary>
/// 验证旧的 icsp_rust 字串不再作为正式入口,并提示调用方改用 icsp。
/// </summary>
[Fact]
public void ControllerClientTrajectoryOrchestrator_PlanOrdinaryTrajectory_RejectsLegacyIcspRustName()
{
var orchestrator = new ControllerClientTrajectoryOrchestrator();
var robot = TestRobotFactory.CreateRobotProfile();
var exception = Assert.Throws<ArgumentException>(() => orchestrator.PlanOrdinaryTrajectory(
robot,
[
[0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
[0.001, 0.0, 0.0, 0.0, 0.0, 0.0],
[0.002, 0.0, 0.0, 0.0, 0.0, 0.0],
[0.003, 0.0, 0.0, 0.0, 0.0, 0.0]
],
new TrajectoryExecutionOptions(method: "icsp_rust")));
Assert.Contains("icsp", exception.Message);
Assert.Contains("icsp_rust", exception.Message);
}
/// <summary>
/// 验证未注册 C# 后端时,icsp-csharp 会明确报错而不是静默回退。
/// </summary>
[Fact]
public void ControllerClientTrajectoryOrchestrator_PlanOrdinaryTrajectory_RejectsCSharpIcspWhenBackendIsMissing()
{
var orchestrator = new ControllerClientTrajectoryOrchestrator();
var robot = TestRobotFactory.CreateRobotProfile();
var exception = Assert.Throws<NotSupportedException>(() => orchestrator.PlanOrdinaryTrajectory(
robot,
[
[0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
[0.001, 0.0, 0.0, 0.0, 0.0, 0.0],
[0.002, 0.0, 0.0, 0.0, 0.0, 0.0],
[0.003, 0.0, 0.0, 0.0, 0.0, 0.0]
],
new TrajectoryExecutionOptions(method: "icsp-csharp")));
Assert.Contains("icsp-csharp", exception.Message);
}
/// <summary>
/// 验证显式注册 C# 后端后,icsp-csharp 使用拆分后的托管规划器。
/// </summary>
[Fact]
public void ControllerClientTrajectoryOrchestrator_PlanOrdinaryTrajectory_UsesRegisteredCSharpIcspPlanner()
{
var orchestrator = CreateOrchestratorWithCSharpBackends();
var robot = TestRobotFactory.CreateRobotProfile();
var bundle = orchestrator.PlanOrdinaryTrajectory(
robot,
[
[0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
[0.001, 0.0, 0.0, 0.0, 0.0, 0.0],
[0.002, 0.0, 0.0, 0.0, 0.0, 0.0],
[0.003, 0.0, 0.0, 0.0, 0.0, 0.0]
],
new TrajectoryExecutionOptions(method: "icsp-csharp"));
Assert.Equal(PlanningMethod.IcspCSharp, bundle.PlannedTrajectory.Method);
Assert.Equal(PlanningMethod.IcspCSharp, bundle.Result.Method);
}
/// <summary>
/// 验证未注册 C# 后端时,self-adapt-icsp 会明确报错而不是把行为藏在主服务里。
/// </summary>
[Fact]
public void ControllerClientTrajectoryOrchestrator_PlanUploadedFlyshot_RejectsSelfAdaptWhenBackendIsMissing()
{
var orchestrator = new ControllerClientTrajectoryOrchestrator();
var robot = TestRobotFactory.CreateRobotProfile();
var uploaded = TestRobotFactory.CreateUploadedTrajectoryWithSingleShot();
var exception = Assert.Throws<NotSupportedException>(() => orchestrator.PlanUploadedFlyshot(
robot,
uploaded,
new FlyshotExecutionOptions(method: "self-adapt-icsp", useCache: false)));
Assert.Contains("self-adapt-icsp", exception.Message);
}
/// <summary>
/// 验证显式注册 C# 后端后,self-adapt-icsp 仍使用 C# 自适应规划器。
/// </summary>
[Fact]
public void ControllerClientTrajectoryOrchestrator_PlanUploadedFlyshot_UsesRegisteredManagedSelfAdaptPlanner()
{
var orchestrator = CreateOrchestratorWithCSharpBackends();
var robot = new RobotProfile(
name: "ManagedSelfAdaptTestRobot",
modelPath: "Models/ManagedSelfAdaptTest.robot",
degreesOfFreedom: 6,
jointLimits: Enumerable.Range(1, 6)
.Select(static index => new JointLimit($"J{index}", 1.0, 2.0, 10.0))
.ToArray(),
jointCouplings: [],
servoPeriod: TimeSpan.FromMilliseconds(8),
triggerPeriod: TimeSpan.FromMilliseconds(8));
var uploaded = new ControllerClientCompatUploadedTrajectory(
name: "managed-self-adapt",
waypoints:
[
[0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
[0.4, 0.1, 0.0, 0.0, 0.0, 0.0],
[0.8, 0.3, 0.0, 0.0, 0.0, 0.0],
[1.0, 0.2, 0.0, 0.0, 0.0, 0.0]
],
shotFlags: [false, true, false, false],
offsetValues: [0, 0, 0, 0],
addressGroups: [[], [7], [], []]);
var bundle = orchestrator.PlanUploadedFlyshot(
robot,
uploaded,
new FlyshotExecutionOptions(method: "self-adapt-icsp", useCache: false));
Assert.Equal(PlanningMethod.SelfAdaptIcsp, bundle.PlannedTrajectory.Method);
Assert.Equal(PlanningMethod.SelfAdaptIcsp, bundle.Result.Method);
Assert.Single(bundle.Result.TriggerTimeline);
}
/// <summary>
/// 验证飞拍规划会把规划限速倍率纳入速度/加速度/Jerk 限制,而不是复用运行时下发倍率。
/// </summary>
@@ -761,7 +890,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 +931,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);
@@ -849,7 +976,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 +1020,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 +1063,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 +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());
@@ -1030,7 +1153,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 +1203,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 +1361,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 +1492,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 +2209,7 @@ internal static class TestRobotFactory
return new ControllerClientCompatService(
options,
new ControllerClientCompatRobotCatalog(options, new RobotModelLoader()),
new FanucControllerRuntime(),
new RecordingControllerRuntime(),
new ControllerClientTrajectoryOrchestrator(),
new RobotConfigLoader());
}
@@ -2024,6 +2281,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 +2300,10 @@ internal sealed class RecordingControllerRuntime : IControllerRuntime
/// <inheritdoc />
public void Connect(string robotIp)
{
if (ThrowOnConnect)
{
throw new InvalidOperationException("测试连接失败。");
}
}
/// <inheritdoc />

View File

@@ -0,0 +1,136 @@
using Flyshot.Core.Domain;
using Flyshot.CSharp.Planning;
using Flyshot.Core.Planning;
using Flyshot.Native.Planning;
namespace Flyshot.Core.Tests;
/// <summary>
/// 验证 Rust ICSP 托管互操作边界,包括原生库加载失败时的诊断信息。
/// </summary>
public sealed class RustIcspPlannerInteropTests
{
/// <summary>
/// 验证显式指定不存在的原生库时,规划器会抛出包含库路径的明确异常。
/// </summary>
[Fact]
public void RustIcspPlanner_ThrowsClearError_WhenNativeLibraryIsMissing()
{
var planner = new RustIcspPlanner(
options: NativeIcspPlannerOptions.Default,
libraryResolver: new NativePlanningLibraryResolver(_ => "missing-library"));
var exception = Assert.Throws<NativePlanningException>(() => planner.Plan(CreateSimpleRequest()));
Assert.Contains("missing-library", exception.Message);
}
/// <summary>
/// 验证实际原生规划结果会被复制为现有 <see cref="Flyshot.Core.Planning.PlannedTrajectory"/>。
/// </summary>
[Fact]
public void RustIcspPlanner_ReturnsManagedPlannedTrajectory_FromNativeResult()
{
var planner = new RustIcspPlanner(
NativeIcspPlannerOptions.Default with { EnforceFinalScale = false });
var result = planner.Plan(CreateSimpleRequest());
Assert.Equal(PlanningMethod.Icsp, result.Method);
Assert.Equal(result.PlannedWaypointCount, result.WaypointTimes.Count);
Assert.Equal(result.PlannedWaypointCount - 1, result.SegmentDurations.Count);
Assert.Equal(result.SegmentDurations.Count, result.SegmentScales.Count);
}
/// <summary>
/// 验证互操作测试夹具在两套实现中都由相同的最终 scale 规则拒绝。
/// </summary>
[Fact]
public void ManagedIcspPlanner_RejectsInteropFixtureWithExpectedFinalScale()
{
var planner = new ICspPlanner();
var exception = Assert.Throws<InvalidOperationException>(
() => planner.Plan(CreateSimpleRequest()));
Assert.Contains("global_scale=1.058684", exception.Message);
}
/// <summary>
/// 验证进程已加载默认 DLL 后,显式指定其他缺失库仍会暴露部署错误。
/// </summary>
[Fact]
public void RustIcspPlanner_DoesNotReuseLoadedLibrary_ForDifferentExplicitPath()
{
var defaultPlanner = new RustIcspPlanner(
NativeIcspPlannerOptions.Default with { EnforceFinalScale = false });
_ = defaultPlanner.Plan(CreateSimpleRequest());
var missingPlanner = new RustIcspPlanner(
options: NativeIcspPlannerOptions.Default,
libraryResolver: new NativePlanningLibraryResolver(_ => "different-missing-library"));
var exception = Assert.Throws<NativePlanningException>(
() => missingPlanner.Plan(CreateSimpleRequest()));
Assert.Contains("different-missing-library", exception.Message);
}
/// <summary>
/// 验证 Rust 未收敛错误码和线程局部错误文本会转换为可诊断的托管异常。
/// </summary>
[Fact]
public void RustIcspPlanner_ConvertsNativeNotConvergedError()
{
var planner = new RustIcspPlanner(
NativeIcspPlannerOptions.Default with { MaxIterations = 0 });
var exception = Assert.Throws<NativePlanningException>(
() => planner.Plan(CreateSimpleRequest()));
Assert.Equal(3, exception.ErrorCodeValue);
Assert.Contains("global_scale", exception.Message);
}
/// <summary>
/// 创建用于互操作边界测试的最小四路点单自由度规划请求。
/// </summary>
/// <returns>满足 ICSP 最小路点数量要求的轨迹请求。</returns>
private static TrajectoryRequest CreateSimpleRequest()
{
var robot = new RobotProfile(
name: "rust-interop-test",
modelPath: "test.robot",
degreesOfFreedom: 1,
jointLimits:
[
new JointLimit(
jointName: "J1",
velocityLimit: 2.0,
accelerationLimit: 4.0,
jerkLimit: 8.0)
],
jointCouplings: [],
servoPeriod: TimeSpan.FromMilliseconds(8),
triggerPeriod: TimeSpan.FromMilliseconds(8));
var program = new FlyshotProgram(
name: "rust-interop-test",
waypoints:
[
new JointWaypoint([0.0]),
new JointWaypoint([0.5]),
new JointWaypoint([1.0]),
new JointWaypoint([1.5])
],
shotFlags: [false, false, false, false],
offsetValues: [0, 0, 0, 0],
addressGroups:
[
new IoAddressGroup([]),
new IoAddressGroup([]),
new IoAddressGroup([]),
new IoAddressGroup([])
]);
return new TrajectoryRequest(robot, program, PlanningMethod.Icsp);
}
}

View File

@@ -0,0 +1,161 @@
using Flyshot.Core.Domain;
using Flyshot.CSharp.Planning;
using Flyshot.Core.Planning;
using Flyshot.Native.Planning;
namespace Flyshot.Core.Tests;
/// <summary>
/// 验证 Rust ICSP 与现有 C# 基准实现保持逐项数值一致。
/// </summary>
public sealed class RustIcspPlannerParityTests
{
/// <summary>
/// 验证基础六自由度兼容样本的时间轴、段时长、段缩放和迭代信息完全对齐。
/// </summary>
[Fact]
public void RustIcspPlanner_MatchesManagedIcspPlanner_ForCompatibilityFixture()
{
TrajectoryRequest request = CreateCompatibilityRequest();
PlannedTrajectory managed = new ICspPlanner().Plan(request);
PlannedTrajectory native = new RustIcspPlanner().Plan(request);
Assert.Equal(PlanningMethod.IcspCSharp, managed.Method);
Assert.Equal(PlanningMethod.Icsp, native.Method);
AssertTrajectoryMatches(managed, native, tolerance: 1e-9);
}
/// <summary>
/// 验证关闭最终 scale 拒绝后,多自由度非均匀路点仍与托管算法逐项对齐。
/// </summary>
[Fact]
public void RustIcspPlanner_MatchesManagedIcspPlanner_ForNonUniformFixture()
{
TrajectoryRequest request = CreateNonUniformRequest();
var options = NativeIcspPlannerOptions.Default with { EnforceFinalScale = false };
PlannedTrajectory managed = new ICspPlanner(enforceFinalScale: false).Plan(request);
PlannedTrajectory native = new RustIcspPlanner(options).Plan(request);
Assert.Equal(PlanningMethod.IcspCSharp, managed.Method);
Assert.Equal(PlanningMethod.Icsp, native.Method);
AssertTrajectoryMatches(managed, native, tolerance: 1e-9);
}
/// <summary>
/// 创建与现有 PlanningCompatibilityTests 基础用例一致的六自由度请求。
/// </summary>
/// <returns>可由默认 ICSP 配置收敛的稳定规划请求。</returns>
private static TrajectoryRequest CreateCompatibilityRequest()
{
return CreateRequest(
velocityLimits: [1, 1, 1, 1, 1, 1],
accelerationLimits: [2, 2, 2, 2, 2, 2],
jerkLimits: [10, 10, 10, 10, 10, 10],
waypointRows:
[
[0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
[0.4, 0.1, 0.0, 0.0, 0.0, 0.0],
[0.8, 0.3, 0.0, 0.0, 0.0, 0.0],
[1.0, 0.2, 0.0, 0.0, 0.0, 0.0]
]);
}
/// <summary>
/// 创建用于覆盖多自由度、非均匀段距离和不同约束的规划请求。
/// </summary>
/// <returns>关闭最终 scale 拒绝后可用于数值对拍的稳定请求。</returns>
private static TrajectoryRequest CreateNonUniformRequest()
{
return CreateRequest(
velocityLimits: [1.2, 0.9, 1.5],
accelerationLimits: [2.5, 1.8, 3.0],
jerkLimits: [12.0, 9.0, 15.0],
waypointRows:
[
[0.0, 0.0, 0.0],
[0.2, -0.1, 0.4],
[0.9, 0.3, 0.7],
[1.1, 0.8, 0.2],
[1.6, 0.4, -0.1]
]);
}
/// <summary>
/// 根据逐关节限制和路点矩阵创建完整领域请求。
/// </summary>
/// <param name="velocityLimits">逐关节速度上限。</param>
/// <param name="accelerationLimits">逐关节加速度上限。</param>
/// <param name="jerkLimits">逐关节 jerk 上限。</param>
/// <param name="waypointRows">按路点排列的关节位置矩阵。</param>
/// <returns>可同时传给 C# 与 Rust 规划器的请求。</returns>
private static TrajectoryRequest CreateRequest(
IReadOnlyList<double> velocityLimits,
IReadOnlyList<double> accelerationLimits,
IReadOnlyList<double> jerkLimits,
IReadOnlyList<double[]> waypointRows)
{
var robot = new RobotProfile(
name: "RustParityRobot",
modelPath: "Models/RustParity.robot",
degreesOfFreedom: velocityLimits.Count,
jointLimits: velocityLimits
.Select((velocity, index) => new JointLimit(
$"J{index + 1}",
velocity,
accelerationLimits[index],
jerkLimits[index]))
.ToArray(),
jointCouplings: [],
servoPeriod: TimeSpan.FromMilliseconds(8),
triggerPeriod: TimeSpan.FromMilliseconds(8));
var program = new FlyshotProgram(
name: "rust-parity",
waypoints: waypointRows.Select(row => new JointWaypoint(row)).ToArray(),
shotFlags: Enumerable.Repeat(false, waypointRows.Count).ToArray(),
offsetValues: Enumerable.Repeat(0, waypointRows.Count).ToArray(),
addressGroups: Enumerable.Range(0, waypointRows.Count)
.Select(_ => new IoAddressGroup([]))
.ToArray());
return new TrajectoryRequest(robot, program, PlanningMethod.IcspCSharp);
}
/// <summary>
/// 逐字段比较两套规划实现的数值结果。
/// </summary>
/// <param name="managed">现有 C# ICSP 基准结果。</param>
/// <param name="native">Rust ICSP 原生结果。</param>
/// <param name="tolerance">所有浮点数组和 threshold 使用的绝对误差。</param>
private static void AssertTrajectoryMatches(
PlannedTrajectory managed,
PlannedTrajectory native,
double tolerance)
{
Assert.Equal(managed.Iterations, native.Iterations);
Assert.Equal(managed.WaypointTimes.Count, native.WaypointTimes.Count);
Assert.Equal(managed.SegmentDurations.Count, native.SegmentDurations.Count);
Assert.Equal(managed.SegmentScales.Count, native.SegmentScales.Count);
Assert.InRange(Math.Abs(managed.Threshold - native.Threshold), 0.0, tolerance);
AssertSequenceMatches(managed.WaypointTimes, native.WaypointTimes, tolerance);
AssertSequenceMatches(managed.SegmentDurations, native.SegmentDurations, tolerance);
AssertSequenceMatches(managed.SegmentScales, native.SegmentScales, tolerance);
}
/// <summary>
/// 按相同索引比较两个浮点序列的绝对误差。
/// </summary>
/// <param name="expected">托管基准序列。</param>
/// <param name="actual">Rust 原生序列。</param>
/// <param name="tolerance">允许的最大绝对误差。</param>
private static void AssertSequenceMatches(
IReadOnlyList<double> expected,
IReadOnlyList<double> actual,
double tolerance)
{
for (int index = 0; index < expected.Count; index++)
{
Assert.InRange(Math.Abs(expected[index] - actual[index]), 0.0, tolerance);
}
}
}

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'