Compare commits
5 Commits
legacy-gat
...
070d2ba5c0
| Author | SHA1 | Date | |
|---|---|---|---|
| 070d2ba5c0 | |||
| ac41d04a54 | |||
| 5c5eaf850d | |||
| 4aae6219cc | |||
| 35bdb08d11 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -400,3 +400,8 @@ Config/Data/*
|
||||
.dotnet-home/*
|
||||
codex-dotnet-home/*
|
||||
.dotnet-sdk8/*
|
||||
|
||||
# Flyshot 混淆发布产物、映射文件和临时工作目录
|
||||
outputs/obfuscated/
|
||||
outputs/obfuscation-maps/
|
||||
.tmp/obfuscation/
|
||||
|
||||
15
.vscode/tasks.json
vendored
15
.vscode/tasks.json
vendored
@@ -142,19 +142,16 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
// Release 配置发布到 publish/,用于现场部署包打包
|
||||
// 调用环境中的 Obfuscar,生成 Windows/Linux 两套自包含混淆发布包
|
||||
"label": "publish",
|
||||
"command": "dotnet",
|
||||
"command": "pwsh",
|
||||
"type": "process",
|
||||
"args": [
|
||||
"publish",
|
||||
"${workspaceFolder}/src/Flyshot.Server.Host/Flyshot.Server.Host.csproj",
|
||||
"-c",
|
||||
"Release",
|
||||
"-o",
|
||||
"${workspaceFolder}/publish"
|
||||
"-NoProfile",
|
||||
"-File",
|
||||
"${workspaceFolder}/scripts/Publish-Obfuscated.ps1"
|
||||
],
|
||||
"problemMatcher": "$msCompile"
|
||||
"problemMatcher": []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
45
README.md
45
README.md
@@ -24,6 +24,7 @@
|
||||
- 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`,不再创建独立轨迹存储文件。
|
||||
- 服务启动时支持自动初始化机器人:在 `appsettings.json` 的 `ControllerClientCompat.AutoInit` 节配置 `RobotName` 和 `RobotIp`,启动后会自动执行 `SetUpRobot → SetActiveController → Connect → EnableRobot`;配置缺失或连接失败不会阻止服务启动。
|
||||
|
||||
单位约定总览:
|
||||
|
||||
@@ -66,11 +67,13 @@
|
||||
- [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`,启动时自动执行初始化链路
|
||||
|
||||
剩余 Todo:
|
||||
|
||||
@@ -120,6 +123,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。
|
||||
|
||||
@@ -159,7 +159,8 @@ POST /disconnect_robot/
|
||||
|
||||
真机模式下:
|
||||
|
||||
- `StopMove()` 取消当前稠密轨迹生成任务并停止 J519 状态包驱动发送。
|
||||
- 正常飞拍、MoveJoint、MovePose 结束时,默认在 J519 队列末尾追加 `LastData=1` 结束帧;结束帧发出后进入仅监控状态,继续接收 J519 状态包,不再回发保姿命令。
|
||||
- `StopMove()` 取消当前稠密轨迹生成任务并走强制停止路径,可停止 J519 状态包驱动发送。
|
||||
- `DisableRobot()` 发送 J519 packet type 2 状态输出停止包,然后 `StopProg("RVBUSTSM")`。
|
||||
- `Disconnect()` 关闭状态、命令和 J519 三条通道,并清理本地运行状态。
|
||||
|
||||
@@ -178,5 +179,5 @@ POST /disconnect_robot/
|
||||
- 主运行窗口命令序号连续,无重复 seq;J519 客户端单元测试覆盖按最新 status sequence 回发命令。
|
||||
- 响应 `status=15` 段覆盖主运行窗口,响应相对命令滞后 2 到 8 帧。
|
||||
- 实发点位相对重采样期望的全局 RMS 小于 `0.012deg`,最大绝对误差小于 `0.07deg`。
|
||||
- `lastData=0`,结束运动当前依赖 J519 packet type 2 状态输出停止包;`../j519 协议.pcap` 中另有 1 个 `LastData=1` 后紧跟 type 2 的样本,停止语义后续单独验证。
|
||||
- 历史抓包主运行窗口 `lastData=0`;当前实现默认在生成的新 J519 队列末尾追加一帧 `LastData=1` 结束命令,随后保留状态监控且不发送 packet type 2。packet type 2 仅用于禁用、异常停止、断开等强制停止路径;如现场需要旧保持行为,可通过内部开关关闭结束帧路径。
|
||||
- IO 脉冲数量和 mask 集合 `10/12/14` 与抓包一致。
|
||||
|
||||
@@ -82,7 +82,7 @@
|
||||
|
||||
文档把它定义为“停止状态包输出”的控制包,不是命令流正常终止的首选动作。命令流结束应通过 command packet 的 `Last data=1` 表达。
|
||||
|
||||
当前实现:`FanucJ519Client.StopMotionAsync()` 当前会停止状态包驱动发送并发送 packet type `2`,而稠密轨迹执行期间保持 `LastData=0`。这是与 FANUC 文档最明显的语义差异之一;已有多数 UTTC 抓包显示主运行窗口 `LastData=0`,但 `../j519 协议.pcap` 中存在 1 个 `LastData=1` 后紧跟 packet type `2` 的样本,后续应单独校准停止语义。
|
||||
当前实现:`FanucJ519Client.StopMotionAsync()` 仍保留 packet type `2` 作为禁用、异常中断或断开前的强制停止路径。正常稠密运动结束由内部开关 `EndCommandStreamAfterQueueDrained` 控制,默认会在队列末尾追加一帧 `LastData=1` 的结束命令;该帧发出后客户端进入仅监控状态,继续接收 J519 状态包用于位置监控,不再持续回发保姿命令。关闭该开关时保留旧行为:队列结束后继续保持最后目标或反馈位置。`LastData` 是单帧 command packet 字段,不是全局状态;下一轮运动重新装载的新队列会从 `LastData=0` 的普通命令帧开始。
|
||||
|
||||
## 4. 通信时序重点
|
||||
|
||||
@@ -100,7 +100,7 @@
|
||||
1. `FanucJ519Client` 已改为收到机器人 132B status 包后立即回发当前命令,不再由上位机本地固定 8ms 发送循环主动发包。
|
||||
2. 命令包 sequence 已按刚收到的 status packet sequence 写入,避免第一帧从本地 `0` 起步。
|
||||
3. `FanucControllerRuntime.ExecuteTrajectory()` 启动前会检查已有 J519 响应中的 `AcceptsCommand` 和 `SystemReady`;但如果还没收到状态包,则会放行,后续命令仍要等第一帧 status 到达才会发出。
|
||||
4. 当前稠密轨迹结束不发送 `LastData=1`,而是依赖停止 J519 状态包驱动发送和 packet type `2` stop 控制包。
|
||||
4. 默认新路径会在稠密轨迹队列末尾追加独立的 `LastData=1` 结束帧;结束后只监控状态,不发送 packet type `2`。旧保持路径仍可通过内部开关回退。
|
||||
|
||||
序号和节拍已经按手册方向校准;停止语义仍需在真实 R30iB 联调中继续确认。
|
||||
|
||||
@@ -220,13 +220,13 @@
|
||||
主要差异/风险:
|
||||
|
||||
1. 当前未实现命令缓冲预填,也未读取 `$PKT_STACK / $START_MOVE`。
|
||||
2. 当前停止运动依赖 packet type `2` stop 控制包,没有稳定发送 `LastData=1` 的最后 command packet;这与手册标准结束语义不同。
|
||||
2. 默认正常运动结束已经通过追加 `LastData=1` 结束帧靠近手册语义;packet type `2` 仍仅作为强制停止状态输出路径。
|
||||
3. 当前未实现 packet type `3` 的速度/加速度/jerk 限制表查询,也未实现 payload/speed 20 档动态限制算法。
|
||||
4. 当前没有自动校验 `AUTO / 100% OVERRIDE / brake control / resume offset / payload` 等控制器前置状态。
|
||||
5. 当前没有报警码读取和 `MOTN-* / PRIO-*` 映射。
|
||||
|
||||
建议后续联调优先级:
|
||||
|
||||
1. 验证运动结束是否必须补 `LastData=1`;如果当前 stop 控制包能稳定工作,也应在文档中标为现场兼容路径,而不是手册标准路径。
|
||||
1. 联调验证 `LastData=1` 后机器人是否稳定退出命令消费并继续输出状态包;如现场程序仍要求持续命令包,可通过内部开关回退旧保持路径。
|
||||
2. 抓一次报警现场包,确认 `MOTN-600/603/617` 等是否能从包序号与状态位直接定位。
|
||||
3. 如果后续追求更稳的真实机运行,补 packet type `3` 限制表查询,并把规划器的速度、加速度、jerk 校验与 FANUC 手册算法靠近。
|
||||
|
||||
138
docs/figures/icsp-algorithm-ppt.svg
Normal file
138
docs/figures/icsp-algorithm-ppt.svg
Normal file
@@ -0,0 +1,138 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="900" viewBox="0 0 1600 900" role="img" aria-labelledby="title desc">
|
||||
<title id="title">飞拍轨迹规划逻辑</title>
|
||||
<desc id="desc">用于 PPT 展示的飞拍轨迹规划逻辑图,展示输入、三次样条、约束检查、逐段缩放迭代、输出时间轴与采样层关系。</desc>
|
||||
<defs>
|
||||
<marker id="arrow-blue" markerWidth="14" markerHeight="14" refX="11" refY="7" orient="auto" markerUnits="strokeWidth">
|
||||
<path d="M2,2 L12,7 L2,12 Z" fill="#2563eb" />
|
||||
</marker>
|
||||
<marker id="arrow-green" markerWidth="14" markerHeight="14" refX="11" refY="7" orient="auto" markerUnits="strokeWidth">
|
||||
<path d="M2,2 L12,7 L2,12 Z" fill="#059669" />
|
||||
</marker>
|
||||
<marker id="arrow-amber" markerWidth="14" markerHeight="14" refX="11" refY="7" orient="auto" markerUnits="strokeWidth">
|
||||
<path d="M2,2 L12,7 L2,12 Z" fill="#d97706" />
|
||||
</marker>
|
||||
<linearGradient id="title-grad" x1="0" x2="1" y1="0" y2="0">
|
||||
<stop offset="0" stop-color="#1d4ed8" />
|
||||
<stop offset="0.55" stop-color="#0f766e" />
|
||||
<stop offset="1" stop-color="#7c3aed" />
|
||||
</linearGradient>
|
||||
<filter id="soft-shadow" x="-10%" y="-10%" width="120%" height="130%">
|
||||
<feDropShadow dx="0" dy="8" stdDeviation="8" flood-color="#0f172a" flood-opacity="0.12" />
|
||||
</filter>
|
||||
<style>
|
||||
.bg { fill: #f8fafc; }
|
||||
.title { font: 700 42px "Microsoft YaHei", "Segoe UI", Arial, sans-serif; fill: #0f172a; letter-spacing: 0; }
|
||||
.subtitle { font: 400 23px "Microsoft YaHei", "Segoe UI", Arial, sans-serif; fill: #475569; letter-spacing: 0; }
|
||||
.section-title { font: 700 25px "Microsoft YaHei", "Segoe UI", Arial, sans-serif; fill: #0f172a; letter-spacing: 0; }
|
||||
.label { font: 600 21px "Microsoft YaHei", "Segoe UI", Arial, sans-serif; fill: #0f172a; letter-spacing: 0; }
|
||||
.small { font: 400 18px "Microsoft YaHei", "Segoe UI", Arial, sans-serif; fill: #334155; letter-spacing: 0; }
|
||||
.tiny { font: 400 15px "Microsoft YaHei", "Segoe UI", Arial, sans-serif; fill: #475569; letter-spacing: 0; }
|
||||
.formula { font: 500 20px "Consolas", "Microsoft YaHei", monospace; fill: #0f172a; letter-spacing: 0; }
|
||||
.math { font: 500 25px "Cambria Math", "Times New Roman", "Microsoft YaHei", serif; fill: #0f172a; letter-spacing: 0; }
|
||||
.math-small { font: 500 21px "Cambria Math", "Times New Roman", "Microsoft YaHei", serif; fill: #0f172a; letter-spacing: 0; }
|
||||
.box { fill: #ffffff; stroke: #cbd5e1; stroke-width: 2; rx: 18; filter: url(#soft-shadow); }
|
||||
.box-blue { fill: #eff6ff; stroke: #93c5fd; stroke-width: 2; rx: 18; }
|
||||
.box-green { fill: #ecfdf5; stroke: #86efac; stroke-width: 2; rx: 18; }
|
||||
.box-amber { fill: #fffbeb; stroke: #fcd34d; stroke-width: 2; rx: 18; }
|
||||
.box-purple { fill: #f5f3ff; stroke: #c4b5fd; stroke-width: 2; rx: 18; }
|
||||
.pill { rx: 22; }
|
||||
.line { fill: none; stroke: #2563eb; stroke-width: 4; marker-end: url(#arrow-blue); }
|
||||
.line-green { fill: none; stroke: #059669; stroke-width: 4; marker-end: url(#arrow-green); }
|
||||
.line-amber { fill: none; stroke: #d97706; stroke-width: 4; marker-end: url(#arrow-amber); }
|
||||
.loop { fill: none; stroke: #d97706; stroke-width: 4; stroke-dasharray: 10 8; marker-end: url(#arrow-amber); }
|
||||
.axis { stroke: #64748b; stroke-width: 2; }
|
||||
.grid { stroke: #e2e8f0; stroke-width: 1; }
|
||||
.curve { fill: none; stroke: #2563eb; stroke-width: 5; stroke-linecap: round; }
|
||||
.curve2 { fill: none; stroke: #0f766e; stroke-width: 4; stroke-linecap: round; }
|
||||
.dot { fill: #1d4ed8; stroke: #ffffff; stroke-width: 4; }
|
||||
.dot-green { fill: #059669; stroke: #ffffff; stroke-width: 4; }
|
||||
.accent-bar { fill: url(#title-grad); rx: 4; }
|
||||
.divider { stroke: #cbd5e1; stroke-width: 2; stroke-dasharray: 8 8; }
|
||||
</style>
|
||||
</defs>
|
||||
|
||||
<rect class="bg" width="1600" height="900" />
|
||||
<rect class="accent-bar" x="80" y="58" width="540" height="8" />
|
||||
<text class="title" x="80" y="120">飞拍轨迹规划逻辑</text>
|
||||
<text class="subtitle" x="80" y="158">用三次样条连接示教点,通过反复拉长超限段,让整条轨迹满足关节约束。</text>
|
||||
|
||||
<!-- 主流程卡片 -->
|
||||
<rect class="box-blue" x="80" y="215" width="255" height="180" />
|
||||
<text class="section-title" x="112" y="258">输入</text>
|
||||
<text class="small" x="112" y="312">示教点</text>
|
||||
<text class="small" x="112" y="346">关节约束</text>
|
||||
|
||||
<path class="line" d="M335 305 H420" />
|
||||
|
||||
<rect class="box" x="420" y="215" width="285" height="180" />
|
||||
<text class="section-title" x="452" y="258">初始时间轴</text>
|
||||
<text class="small" x="452" y="318">按相邻点距离</text>
|
||||
<text class="small" x="452" y="352">估算每段时长</text>
|
||||
|
||||
<path class="line" d="M705 305 H790" />
|
||||
|
||||
<rect class="box-green" x="790" y="215" width="305" height="180" />
|
||||
<text class="section-title" x="822" y="258">三次样条</text>
|
||||
<text class="small" x="822" y="318">连接所有示教点</text>
|
||||
<text class="small" x="822" y="352">形成连续轨迹</text>
|
||||
|
||||
<path class="line" d="M1095 305 H1180" />
|
||||
|
||||
<rect class="box-amber" x="1180" y="215" width="340" height="180" />
|
||||
<text class="section-title" x="1212" y="258">约束检查</text>
|
||||
<text class="small" x="1212" y="312">速度 / 加速度 / jerk</text>
|
||||
<text class="small" x="1212" y="346">找出超限段</text>
|
||||
|
||||
<!-- 曲线示意 -->
|
||||
<rect class="box" x="105" y="455" width="585" height="285" />
|
||||
<text class="section-title" x="135" y="497">轨迹形状:经过原始示教点</text>
|
||||
<line class="grid" x1="150" y1="680" x2="650" y2="680" />
|
||||
<line class="grid" x1="150" y1="620" x2="650" y2="620" />
|
||||
<line class="grid" x1="150" y1="560" x2="650" y2="560" />
|
||||
<line class="axis" x1="150" y1="690" x2="650" y2="690" />
|
||||
<line class="axis" x1="150" y1="690" x2="150" y2="535" />
|
||||
<path class="curve" d="M165 665 C205 618, 240 565, 285 585 C340 610, 375 695, 425 645 C475 595, 500 515, 558 548 C595 570, 620 625, 640 600" />
|
||||
<circle class="dot" cx="165" cy="665" r="9" />
|
||||
<circle class="dot" cx="285" cy="585" r="9" />
|
||||
<circle class="dot" cx="425" cy="645" r="9" />
|
||||
<circle class="dot" cx="558" cy="548" r="9" />
|
||||
<circle class="dot" cx="640" cy="600" r="9" />
|
||||
<text class="tiny" x="160" y="720">q0</text>
|
||||
<text class="tiny" x="277" y="720">q1</text>
|
||||
<text class="tiny" x="417" y="720">q2</text>
|
||||
<text class="tiny" x="550" y="720">q3</text>
|
||||
<text class="tiny" x="630" y="720">qn</text>
|
||||
<text class="small" x="135" y="777">点位不变,主要调整每段用时。</text>
|
||||
|
||||
<!-- 核心公式卡片 -->
|
||||
<rect class="box-purple" x="735" y="455" width="785" height="285" />
|
||||
<text class="section-title" x="765" y="497">迭代逻辑</text>
|
||||
<text class="label" x="780" y="560">1. 计算每段是否超限</text>
|
||||
<text class="label" x="780" y="620">2. 超限段按比例拉长时间</text>
|
||||
<text class="label" x="780" y="680">3. 重新生成样条,再检查一次</text>
|
||||
<text class="math-small" x="1195" y="590">
|
||||
<tspan>scale</tspan><tspan baseline-shift="sub" font-size="14">i</tspan>
|
||||
<tspan> = max(s</tspan><tspan baseline-shift="sub" font-size="14">v</tspan><tspan>, s</tspan><tspan baseline-shift="sub" font-size="14">a</tspan><tspan>, s</tspan><tspan baseline-shift="sub" font-size="14">j</tspan><tspan>)</tspan>
|
||||
</text>
|
||||
<text class="math-small" x="1195" y="650">
|
||||
<tspan>T</tspan><tspan baseline-shift="sub" font-size="14">i</tspan>
|
||||
<tspan> ← T</tspan><tspan baseline-shift="sub" font-size="14">i</tspan>
|
||||
<tspan> * scale</tspan><tspan baseline-shift="sub" font-size="14">i</tspan>
|
||||
</text>
|
||||
|
||||
<!-- 迭代回路 -->
|
||||
<path class="loop" d="M1350 395 C1350 430, 1270 435, 1190 425 C1075 410, 988 410, 945 397" />
|
||||
<text class="small" x="1015" y="435" fill="#92400e">未收敛就继续拉长、重算</text>
|
||||
|
||||
<!-- 输出层 -->
|
||||
<rect class="box-green" x="80" y="805" width="330" height="58" />
|
||||
<text class="label" x="110" y="842">输出时间轴</text>
|
||||
<path class="line-green" d="M410 834 H515" />
|
||||
<rect class="box" x="515" y="790" width="430" height="88" />
|
||||
<text class="label" x="545" y="826">按周期采样</text>
|
||||
<text class="tiny" x="545" y="856">后续模块生成稠密轨迹点</text>
|
||||
<path class="line-green" d="M945 834 H1050" />
|
||||
<rect class="box-amber" x="1050" y="790" width="470" height="88" />
|
||||
<text class="label" x="1080" y="826">运行时下发</text>
|
||||
<text class="tiny" x="1080" y="856">进入飞拍执行链路</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 8.3 KiB |
257
docs/marketing/飞拍控制系统宣传文稿.md
Normal file
257
docs/marketing/飞拍控制系统宣传文稿.md
Normal file
@@ -0,0 +1,257 @@
|
||||
# 智能视觉质检系统宣传文稿
|
||||
|
||||
## 一句话定位
|
||||
|
||||
智能视觉质检系统是一套覆盖“边缘取图、AI 检测、人工复核、第三方对接、结果闭环”的工业视觉质量管理平台,帮助产线把图像采集、缺陷识别和复检工作流整合为一条连续、可追踪、可协同的质检链路。
|
||||
|
||||
## 宣传主文案
|
||||
|
||||
在工业视觉检测现场,真正影响效率的不只是模型能否识别缺陷,还包括图片如何稳定采集、AI 结果如何进入业务系统、人工如何快速复核、第三方系统如何查询结果,以及异常任务如何被追踪和处理。
|
||||
|
||||
智能视觉质检系统围绕这一完整链路设计:前端由边缘飞拍系统负责在机器人运动过程中完成连续取图;中间由 AI 检测服务对图片进行模型推理,输出 OK/NG、缺陷区域和结构化检测信息;上层由复检工作流平台承接图片、AI 结果和人工判断,完成任务管理、复核流转、结果查询和第三方接口对接。
|
||||
|
||||
这不是单个检测工具,而是一整套面向产线落地的视觉质检系统。它把“取图、识别、复核、上传、查询、归档”串成统一流程,让现场从分散的图片文件、模型结果和人工沟通,升级为可管理、可复核、可追溯的质量工作流。
|
||||
|
||||
## 系统组成
|
||||
|
||||
### 1. 边缘飞拍取图层
|
||||
|
||||
飞拍系统负责在机器人运动过程中完成边缘段取图。它根据检测路径和拍照节拍,在连续运动中触发相机采集图片,减少停顿拍照对产线节拍的影响。
|
||||
|
||||
核心作用:
|
||||
|
||||
- 支持机器人运动中连续取图。
|
||||
- 支持按路径、节拍或检测点触发拍照。
|
||||
- 适合边缘、焊缝、孔位、外观等路径型检测场景。
|
||||
- 为后续 AI 检测提供稳定、连续、带位置语义的图片输入。
|
||||
|
||||
### 2. AI 检测服务层
|
||||
|
||||
AI 检测服务负责接收图片并执行模型推理。系统支持通过接口提交图片,可按业务需要调用单模型或多模型检测,并返回结构化结果。
|
||||
|
||||
核心作用:
|
||||
|
||||
- 接收产线图片进行 AI 检测。
|
||||
- 输出 OK/NG、缺陷区域、模型结果和处理信息。
|
||||
- 支持多模型组合检测,适配不同检测面、不同缺陷类型。
|
||||
- 通过标准接口与上层复检平台、现场系统集成。
|
||||
|
||||
### 3. 人工复核与工作流层
|
||||
|
||||
复检工作流平台负责承接 AI 检测结果和图片数据,形成可操作的人工复核流程。它提供任务管理、图片浏览、AI 结果展示、人工判定、历史查询、第三方接口对接和结果归档能力。
|
||||
|
||||
核心作用:
|
||||
|
||||
- 管理待复核、已完成、历史任务。
|
||||
- 支持原图、AI 标注图和检测元数据统一展示。
|
||||
- 支持人工 OK/NG 判断与批量提交。
|
||||
- 支持第三方系统上传任务、查询最新复检结果。
|
||||
- 形成从图片到判定结果的完整追溯链。
|
||||
|
||||
## 端到端业务闭环
|
||||
|
||||
```text
|
||||
机器人/相机飞拍取图
|
||||
↓
|
||||
图片进入 AI 检测服务
|
||||
↓
|
||||
AI 输出 OK/NG 与缺陷信息
|
||||
↓
|
||||
复检平台生成审核任务
|
||||
↓
|
||||
人工查看原图与 AI 标注图
|
||||
↓
|
||||
人工确认最终结果
|
||||
↓
|
||||
第三方系统查询或接收复检结果
|
||||
↓
|
||||
质检数据归档与追溯
|
||||
```
|
||||
|
||||
这条链路把现场设备、AI 推理、人工经验和业务系统连接在一起,使视觉检测不再停留在单点识别,而是进入可运行的质量管理流程。
|
||||
|
||||
## 核心能力
|
||||
|
||||
### 1. 连续取图能力
|
||||
|
||||
系统支持机器人边运动边拍照,适合路径型工件和多点位检测。通过拍照节拍配置,可根据产线速度、相机曝光和检测需求调整采集频率。
|
||||
|
||||
### 2. AI 自动检测能力
|
||||
|
||||
系统支持图片接口化检测,能够将模型推理结果结构化输出。AI 检测结果可直接进入复检平台,作为人工判断的重要依据。
|
||||
|
||||
### 3. 人工复核能力
|
||||
|
||||
复检平台提供面向操作员的图片审核界面,可集中查看任务、图片、AI 标注图、缺陷信息和历史记录。人工可快速完成 OK/NG 判定,减少线下沟通和文件查找。
|
||||
|
||||
### 4. 第三方接口能力
|
||||
|
||||
系统可对接第三方业务系统,支持任务上传、结果查询和复检状态同步。产线系统、PLC 上位系统、MES 或其他业务平台可以通过接口获取复检结果。
|
||||
|
||||
### 5. 工作流闭环能力
|
||||
|
||||
系统将图片采集、AI 检测、人工复核和结果回传组织成统一流程。每个任务都有状态,每张图片都有来源和判定,每次操作都可追踪。
|
||||
|
||||
### 6. 多场景扩展能力
|
||||
|
||||
整体架构可适配不同检测产品、不同图片来源、不同 AI 模型和不同复核策略。既可以服务单工位,也可以扩展到多工位、多模型、多业务线。
|
||||
|
||||
## 展示重点
|
||||
|
||||
### 系统能力总览
|
||||
|
||||
| 层级 | 作用 | 对外表达 |
|
||||
| --- | --- | --- |
|
||||
| 边缘取图 | 机器人运动中连续采集图片 | 让检测图片稳定进入系统 |
|
||||
| AI 检测 | 对图片进行模型推理 | 自动识别缺陷并输出结构化结果 |
|
||||
| 人工复核 | 对 AI 结果进行确认 | 让关键判定有人工闭环 |
|
||||
| 第三方接口 | 任务上传与结果查询 | 与产线和业务系统打通 |
|
||||
| 工作流平台 | 任务、图片、结果统一管理 | 形成可追踪的质量流程 |
|
||||
|
||||
### 拍照节拍展示
|
||||
|
||||
对外展示时,可以用“每秒拍照数量”表达边缘取图能力,同时避免写成固定承诺。
|
||||
|
||||
| 模式 | 每秒拍照数量示意 | 适用场景 |
|
||||
| --- | ---: | --- |
|
||||
| 稳定检测模式 | 5-10 张/秒 | 常规外观、定位、尺寸检测 |
|
||||
| 高速检测模式 | 10-20 张/秒 | 连续路径、多点位检测 |
|
||||
| 高密度采集模式 | 20+ 张/秒 | 短路径密集取像、节拍优化展示 |
|
||||
|
||||
备注:实际拍照数量取决于机器人速度、相机曝光时间、光源响应、图像处理耗时和现场安全策略。
|
||||
|
||||
### 时序图展示
|
||||
|
||||
```text
|
||||
机器人运动轨迹 ────────────────────────────────>
|
||||
|
||||
边缘飞拍触发 ▲ ▲ ▲ ▲ ▲
|
||||
图1 图2 图3 图4 图5
|
||||
|
||||
AI 检测服务 检测 检测 检测 检测 检测
|
||||
|
||||
复检工作流 生成任务 → 图片审核 → 人工判定 → 结果回传
|
||||
```
|
||||
|
||||
配套说明文案:
|
||||
|
||||
系统将取图、检测和复核组织在同一条业务时间线上。边缘端负责稳定采集,AI 服务负责自动识别,复检平台负责人工确认和结果流转,最终形成完整的质检闭环。
|
||||
|
||||
## 建议 PPT 结构
|
||||
|
||||
### 1. 封面
|
||||
|
||||
标题:智能视觉质检系统
|
||||
|
||||
副标题:从边缘取图到 AI 检测,再到人工复核与结果闭环
|
||||
|
||||
### 2. 行业痛点
|
||||
|
||||
标题:视觉检测难点不只在 AI 模型
|
||||
|
||||
要点:
|
||||
|
||||
- 图片来源分散,取图节拍难统一。
|
||||
- AI 结果需要人工确认和业务留痕。
|
||||
- 产线系统需要查询最终复检结果。
|
||||
- 图片、模型结果、人工判断缺少统一工作流。
|
||||
|
||||
### 3. 系统总体架构
|
||||
|
||||
标题:三层协同,形成端到端质检链路
|
||||
|
||||
建议图形:
|
||||
|
||||
```text
|
||||
边缘飞拍取图层
|
||||
↓
|
||||
AI 检测服务层
|
||||
↓
|
||||
人工复核与工作流层
|
||||
↓
|
||||
第三方系统 / 产线系统
|
||||
```
|
||||
|
||||
### 4. 边缘取图能力
|
||||
|
||||
标题:机器人运动中连续采集检测图片
|
||||
|
||||
展示内容:
|
||||
|
||||
- 运动轨迹。
|
||||
- 拍照触发点。
|
||||
- 每秒拍照数量示意。
|
||||
- 取图与检测位置绑定。
|
||||
|
||||
### 5. AI 检测能力
|
||||
|
||||
标题:接口化 AI 检测,支持多模型结果输出
|
||||
|
||||
展示内容:
|
||||
|
||||
- 图片输入。
|
||||
- 模型推理。
|
||||
- OK/NG 结果。
|
||||
- 缺陷区域信息。
|
||||
- 处理后标注图。
|
||||
|
||||
### 6. 人工复核能力
|
||||
|
||||
标题:AI 结果进入复检工作台
|
||||
|
||||
展示内容:
|
||||
|
||||
- 当前待复核任务。
|
||||
- 原图与 AI 标注图对比。
|
||||
- 人工 OK/NG 判定。
|
||||
- 批量提交与历史查询。
|
||||
|
||||
### 7. 第三方接口与业务闭环
|
||||
|
||||
标题:与产线系统打通最终复检结果
|
||||
|
||||
展示内容:
|
||||
|
||||
- 第三方上传任务。
|
||||
- 查询最新复检结果。
|
||||
- 状态同步。
|
||||
- 结果归档。
|
||||
|
||||
### 8. 典型应用场景
|
||||
|
||||
标题:适用于多类工业视觉质检场景
|
||||
|
||||
要点:
|
||||
|
||||
- 焊缝与边缘检测。
|
||||
- PCB / 工件外观检测。
|
||||
- 孔位与装配位置检测。
|
||||
- 多点位连续取像。
|
||||
- AI 初判 + 人工终判场景。
|
||||
|
||||
### 9. 收尾页
|
||||
|
||||
标题:让视觉检测从单点识别走向完整工作流
|
||||
|
||||
推荐短句:
|
||||
|
||||
智能视觉质检系统把边缘取图、AI 检测、人工复核和第三方对接整合为统一流程,让质量判断更高效、更清晰、更可追踪。
|
||||
|
||||
## 展会讲解词
|
||||
|
||||
这套智能视觉质检系统不是单独展示一个 AI 模型,也不是只展示一个拍照工具,而是展示从现场取图到最终复检结果的完整链路。
|
||||
|
||||
在边缘端,机器人通过飞拍方式在运动过程中采集检测图片,适合边缘段、路径型、多点位的工业检测场景。图片采集完成后,进入 AI 检测服务,由模型自动判断 OK/NG,并输出缺陷区域、标注图和结构化结果。
|
||||
|
||||
随后,这些图片和 AI 结果会进入复检工作流平台。操作员可以在界面中查看原图、AI 标注图和检测信息,完成最终人工判断。对于产线或第三方业务系统,平台提供任务上传和结果查询能力,使复检结果能够回到业务流程中。
|
||||
|
||||
因此,客户看到的是一整套系统能力:前端能稳定取图,中间能自动检测,上层能人工复核,外部系统还能查询最终结果。它解决的是工业视觉检测从“拍到图、看出缺陷”到“形成可追踪质量结论”的完整问题。
|
||||
|
||||
## 对外短标题
|
||||
|
||||
- 智能视觉质检系统
|
||||
- 从边缘取图到复检闭环
|
||||
- AI 检测 + 人工复核一体化
|
||||
- 让视觉检测进入可追踪工作流
|
||||
- 机器人飞拍取图,AI 自动识别,人工确认结果
|
||||
- 面向工业产线的端到端视觉质量平台
|
||||
@@ -0,0 +1,55 @@
|
||||
# Flyshot 跨平台混淆发布 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 为全部 `Flyshot.*` 自研程序集建立依赖环境 Obfuscar 的跨平台框架依赖混淆发布流程。
|
||||
|
||||
**Architecture:** 使用一个 PowerShell 入口编排 `dotnet publish`、绝对路径 Obfuscar XML 生成、混淆执行、产物完整性检查和映射文件隔离。发布脚本不修改项目运行时代码,也不引入混淆器包依赖。
|
||||
|
||||
**Tech Stack:** PowerShell 7、.NET 8 SDK、Obfuscar.GlobalTool 2.2.50、ASP.NET Core 自包含发布。
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 发布脚本契约测试
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/Publishing/Publish-Obfuscated.Tests.ps1`
|
||||
|
||||
- [ ] 编写测试夹具,创建包含八个 `Flyshot.*.dll` 和一个第三方 DLL 的临时发布目录。
|
||||
- [ ] 调用尚不存在的配置生成入口,断言测试因缺少 `scripts/Publish-Obfuscated.ps1` 而失败。
|
||||
- [ ] 保留测试入口,使后续实现可以在 `-GenerateConfigOnly` 模式下验证配置内容而不执行真实发布。
|
||||
|
||||
### Task 2: 双平台混淆发布脚本
|
||||
|
||||
**Files:**
|
||||
- Create: `scripts/Publish-Obfuscated.ps1`
|
||||
|
||||
- [ ] 定义带完整注释的参数、静态程序集清单和辅助函数。
|
||||
- [ ] 检查 PowerShell、`dotnet` 与 `obfuscar.console` 环境命令。
|
||||
- [ ] 使用 `dotnet publish -c Release -r <rid> --self-contained true -p:PublishSingleFile=false` 生成原始产物。
|
||||
- [ ] 为八个 `Flyshot.*.dll` 生成使用绝对路径的 Obfuscar XML。
|
||||
- [ ] 执行 Obfuscar,并检查每个输入模块都有混淆输出。
|
||||
- [ ] 将混淆 DLL/PDB 替换进发布目录,并将 `Mapping.txt` 隔离到 `outputs/obfuscation-maps/<rid>/`。
|
||||
- [ ] 不传 RID,生成一份 Windows/Linux 共用的 .NET 8 框架依赖发布包。
|
||||
|
||||
### Task 3: 发布任务与文档
|
||||
|
||||
**Files:**
|
||||
- Modify: `.vscode/tasks.json`
|
||||
- Modify: `README.md`
|
||||
|
||||
- [ ] 将 VS Code `publish` 任务改为调用 PowerShell 混淆发布脚本。
|
||||
- [ ] 在 README 发布章节记录环境安装、发布命令、输出目录和保护边界。
|
||||
|
||||
### Task 4: 验证
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/superpowers/plans/2026-06-22-cross-platform-obfuscation.md`
|
||||
|
||||
- [ ] 运行脚本契约测试并确认通过。
|
||||
- [ ] 运行 `dotnet test FlyshotReplacement.sln -c Release -v minimal`。
|
||||
- [ ] 运行 `dotnet build FlyshotReplacement.sln -c Release --no-restore -v minimal`。
|
||||
- [ ] 环境安装 Obfuscar 后执行 `pwsh -File scripts/Publish-Obfuscated.ps1`。
|
||||
- [ ] 启动 Windows 混淆产物并请求 `/healthz`。
|
||||
- [ ] 检查 Linux 混淆产物结构;存在 WSL 时启动并请求 `/healthz`。
|
||||
- [ ] 检查 `git diff`,确认没有第三方程序集或映射文件进入部署包。
|
||||
@@ -0,0 +1,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 稳定运行所需的安全边界;若未来需要更强的控制流混淆、字符串加密或防篡改,应评估商业混淆器。
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"sdk": {
|
||||
"version": "8.0.420",
|
||||
"rollForward": "disable"
|
||||
"version": "8.0.422",
|
||||
"rollForward": "latestPatch"
|
||||
}
|
||||
}
|
||||
|
||||
635
scripts/Publish-Obfuscated.ps1
Normal file
635
scripts/Publish-Obfuscated.ps1
Normal file
@@ -0,0 +1,635 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
发布并混淆 Flyshot 跨平台框架依赖部署包。
|
||||
|
||||
.DESCRIPTION
|
||||
脚本先通过 dotnet publish 生成不区分运行平台的框架依赖产物,再调用环境中安装的
|
||||
Obfuscar 全局工具混淆全部 Flyshot.* 自研程序集。第三方程序集不会进入混淆模块,
|
||||
映射文件会保存到独立目录,不会随部署包交付。目标机器需要安装 .NET 8 运行时。
|
||||
|
||||
.PARAMETER OutputRoot
|
||||
最终混淆部署包目录。
|
||||
|
||||
.PARAMETER MappingRoot
|
||||
Obfuscar 映射文件的保存根目录。该目录不得作为部署包交付。
|
||||
|
||||
.PARAMETER GenerateConfigOnly
|
||||
仅生成 Obfuscar 配置,供自动化测试和问题诊断使用,不执行发布或混淆。
|
||||
|
||||
.PARAMETER InputDirectory
|
||||
GenerateConfigOnly 模式下的程序集输入目录。
|
||||
|
||||
.PARAMETER ObfuscatedDirectory
|
||||
GenerateConfigOnly 模式下的混淆输出目录。
|
||||
|
||||
.PARAMETER ConfigPath
|
||||
GenerateConfigOnly 模式下生成的 Obfuscar XML 文件路径。
|
||||
|
||||
.PARAMETER ObfuscateExistingPublish
|
||||
仅混淆已经由 Visual Studio 或 dotnet publish 生成的现有发布目录,不再次执行发布。
|
||||
|
||||
.PARAMETER ExistingPublishDirectory
|
||||
ObfuscateExistingPublish 模式下需要原地替换自研程序集的发布目录。
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$OutputRoot,
|
||||
[string]$MappingRoot,
|
||||
[switch]$GenerateConfigOnly,
|
||||
[string]$InputDirectory,
|
||||
[string]$ObfuscatedDirectory,
|
||||
[string]$ConfigPath,
|
||||
[switch]$ObfuscateExistingPublish,
|
||||
[string]$ExistingPublishDirectory
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
# 该清单固定了当前解决方案必须保护的全部自研程序集,避免新增或丢失模块时静默发布。
|
||||
$script:FlyshotAssemblyNames = @(
|
||||
'Flyshot.Server.Host.dll'
|
||||
'Flyshot.ControllerClientCompat.dll'
|
||||
'Flyshot.Core.Config.dll'
|
||||
'Flyshot.Core.Domain.dll'
|
||||
'Flyshot.Core.Planning.dll'
|
||||
'Flyshot.Core.Triggering.dll'
|
||||
'Flyshot.Runtime.Fanuc.dll'
|
||||
'Flyshot.Runtime.Common.dll'
|
||||
)
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
将路径转换为规范化绝对路径。
|
||||
|
||||
.PARAMETER Path
|
||||
需要规范化的文件或目录路径,可以是相对路径或绝对路径。
|
||||
|
||||
.OUTPUTS
|
||||
返回由 System.IO.Path 解析后的绝对路径字符串。
|
||||
#>
|
||||
function ConvertTo-AbsolutePath
|
||||
{
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$Path
|
||||
)
|
||||
|
||||
return [System.IO.Path]::GetFullPath($Path)
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
检查发布目录是否包含全部预期的 Flyshot 自研程序集。
|
||||
|
||||
.PARAMETER Directory
|
||||
待检查的发布输入目录。
|
||||
|
||||
.OUTPUTS
|
||||
按固定清单顺序返回每个程序集的绝对路径。
|
||||
|
||||
.EXCEPTION
|
||||
当目录不存在或任一预期程序集缺失时抛出异常,阻止生成未完整保护的发布包。
|
||||
#>
|
||||
function Get-FlyshotAssemblyPaths
|
||||
{
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$Directory
|
||||
)
|
||||
|
||||
$absoluteDirectory = ConvertTo-AbsolutePath -Path $Directory
|
||||
if (-not (Test-Path -LiteralPath $absoluteDirectory -PathType Container))
|
||||
{
|
||||
throw "程序集输入目录不存在:$absoluteDirectory"
|
||||
}
|
||||
|
||||
foreach ($assemblyName in $script:FlyshotAssemblyNames)
|
||||
{
|
||||
$assemblyPath = Join-Path $absoluteDirectory $assemblyName
|
||||
if (-not (Test-Path -LiteralPath $assemblyPath -PathType Leaf))
|
||||
{
|
||||
throw "发布目录缺少必须混淆的自研程序集:$assemblyName"
|
||||
}
|
||||
}
|
||||
|
||||
# 固定清单负责阻止当前核心模块缺失,通配发现负责自动覆盖未来新增的 Flyshot 自研程序集。
|
||||
$assemblyPaths = Get-ChildItem -LiteralPath $absoluteDirectory -Filter 'Flyshot.*.dll' -File |
|
||||
Sort-Object Name |
|
||||
ForEach-Object { ConvertTo-AbsolutePath -Path $_.FullName }
|
||||
|
||||
return @($assemblyPaths)
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
发现当前环境中与项目目标框架匹配的 .NET 8 共享框架目录。
|
||||
|
||||
.DESCRIPTION
|
||||
框架依赖发布不会把 Microsoft.NETCore.App 与 Microsoft.AspNetCore.App 的程序集复制到发布目录。
|
||||
Obfuscar 仍需要这些目录解析继承关系,因此通过 dotnet --list-runtimes 动态发现实际安装位置。
|
||||
|
||||
.OUTPUTS
|
||||
返回 Microsoft.NETCore.App 与 Microsoft.AspNetCore.App 最新 .NET 8 运行时的绝对目录。
|
||||
|
||||
.EXCEPTION
|
||||
当环境未安装所需的 .NET 8 共享框架时抛出异常,避免生成无法解析依赖的混淆配置。
|
||||
#>
|
||||
function Get-DotNetSharedFrameworkSearchPaths
|
||||
{
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
|
||||
$runtimeEntries = & dotnet --list-runtimes
|
||||
if ($LASTEXITCODE -ne 0)
|
||||
{
|
||||
throw "无法查询已安装的 .NET 运行时,dotnet --list-runtimes 退出码为 $LASTEXITCODE。"
|
||||
}
|
||||
|
||||
$requiredFrameworks = @(
|
||||
'Microsoft.NETCore.App'
|
||||
'Microsoft.AspNetCore.App'
|
||||
)
|
||||
|
||||
$searchPaths = foreach ($frameworkName in $requiredFrameworks)
|
||||
{
|
||||
$candidates = foreach ($runtimeEntry in $runtimeEntries)
|
||||
{
|
||||
if ($runtimeEntry -match '^(?<name>\S+)\s+(?<version>8\.\d+\.\d+)\s+\[(?<root>.+)\]$')
|
||||
{
|
||||
if ($Matches.name -eq $frameworkName)
|
||||
{
|
||||
[pscustomobject]@{
|
||||
Version = [version]$Matches.version
|
||||
Path = Join-Path $Matches.root $Matches.version
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$selectedRuntime = $candidates |
|
||||
Sort-Object Version -Descending |
|
||||
Select-Object -First 1
|
||||
|
||||
if ($null -eq $selectedRuntime -or -not (Test-Path -LiteralPath $selectedRuntime.Path -PathType Container))
|
||||
{
|
||||
throw "环境缺少 Obfuscar 解析依赖所需的 .NET 8 共享框架:$frameworkName"
|
||||
}
|
||||
|
||||
ConvertTo-AbsolutePath -Path $selectedRuntime.Path
|
||||
}
|
||||
|
||||
return @($searchPaths)
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
生成仅包含 Flyshot 自研程序集的 Obfuscar XML 配置。
|
||||
|
||||
.PARAMETER SourceDirectory
|
||||
包含发布程序集及其依赖项的输入目录。
|
||||
|
||||
.PARAMETER TargetDirectory
|
||||
Obfuscar 写入混淆程序集的独立输出目录。
|
||||
|
||||
.PARAMETER DestinationPath
|
||||
要写入的 Obfuscar XML 配置文件路径。
|
||||
|
||||
.OUTPUTS
|
||||
返回生成后的 Obfuscar XML 配置绝对路径。
|
||||
#>
|
||||
function New-ObfuscarConfiguration
|
||||
{
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$SourceDirectory,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[string]$TargetDirectory,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[string]$DestinationPath
|
||||
)
|
||||
|
||||
$absoluteSourceDirectory = ConvertTo-AbsolutePath -Path $SourceDirectory
|
||||
$absoluteTargetDirectory = ConvertTo-AbsolutePath -Path $TargetDirectory
|
||||
$absoluteDestinationPath = ConvertTo-AbsolutePath -Path $DestinationPath
|
||||
$assemblyPaths = Get-FlyshotAssemblyPaths -Directory $absoluteSourceDirectory
|
||||
$assemblySearchPaths = Get-DotNetSharedFrameworkSearchPaths
|
||||
|
||||
$destinationParent = Split-Path -Parent $absoluteDestinationPath
|
||||
New-Item -ItemType Directory -Path $destinationParent -Force | Out-Null
|
||||
New-Item -ItemType Directory -Path $absoluteTargetDirectory -Force | Out-Null
|
||||
|
||||
$settings = [ordered]@{
|
||||
InPath = $absoluteSourceDirectory
|
||||
OutPath = $absoluteTargetDirectory
|
||||
KeepPublicApi = 'true'
|
||||
HidePrivateApi = 'true'
|
||||
RenameProperties = 'false'
|
||||
RenameEvents = 'false'
|
||||
RenameFields = 'true'
|
||||
SkipGenerated = 'true'
|
||||
SkipSpecialName = 'true'
|
||||
}
|
||||
|
||||
$xmlSettings = [System.Xml.XmlWriterSettings]::new()
|
||||
$xmlSettings.Indent = $true
|
||||
$xmlSettings.Encoding = [System.Text.UTF8Encoding]::new($false)
|
||||
|
||||
$writer = [System.Xml.XmlWriter]::Create($absoluteDestinationPath, $xmlSettings)
|
||||
try
|
||||
{
|
||||
$writer.WriteStartDocument()
|
||||
$writer.WriteStartElement('Obfuscator')
|
||||
|
||||
foreach ($setting in $settings.GetEnumerator())
|
||||
{
|
||||
$writer.WriteStartElement('Var')
|
||||
$writer.WriteAttributeString('name', [string]$setting.Key)
|
||||
$writer.WriteAttributeString('value', [string]$setting.Value)
|
||||
$writer.WriteEndElement()
|
||||
}
|
||||
|
||||
foreach ($assemblySearchPath in $assemblySearchPaths)
|
||||
{
|
||||
# 框架依赖发布不携带共享框架 DLL,Obfuscar 通过这些目录解析 ASP.NET Core 与运行时依赖。
|
||||
$writer.WriteStartElement('AssemblySearchPath')
|
||||
$writer.WriteAttributeString('path', $assemblySearchPath)
|
||||
$writer.WriteEndElement()
|
||||
}
|
||||
|
||||
foreach ($assemblyPath in $assemblyPaths)
|
||||
{
|
||||
# 使用明确的绝对路径,兼容 Obfuscar 当前版本并避免工作目录差异。
|
||||
$writer.WriteStartElement('Module')
|
||||
$writer.WriteAttributeString('file', $assemblyPath)
|
||||
|
||||
# 匿名类型会被 ASP.NET Core 的 System.Text.Json 反射序列化,必须完整保留类型及构造参数元数据。
|
||||
$writer.WriteStartElement('SkipType')
|
||||
$writer.WriteAttributeString('name', '^<>f__AnonymousType.*')
|
||||
$writer.WriteAttributeString('skipMethods', 'true')
|
||||
$writer.WriteAttributeString('skipFields', 'true')
|
||||
$writer.WriteAttributeString('skipProperties', 'true')
|
||||
$writer.WriteAttributeString('skipEvents', 'true')
|
||||
$writer.WriteEndElement()
|
||||
|
||||
$writer.WriteEndElement()
|
||||
}
|
||||
|
||||
$writer.WriteEndElement()
|
||||
$writer.WriteEndDocument()
|
||||
}
|
||||
finally
|
||||
{
|
||||
$writer.Dispose()
|
||||
}
|
||||
|
||||
return $absoluteDestinationPath
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
确认发布依赖的外部命令已安装并可从 PATH 调用。
|
||||
|
||||
.PARAMETER CommandName
|
||||
要检查的命令名称。
|
||||
|
||||
.PARAMETER InstallHint
|
||||
命令缺失时展示给构建人员的安装提示。
|
||||
#>
|
||||
function Assert-EnvironmentCommand
|
||||
{
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$CommandName,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[string]$InstallHint
|
||||
)
|
||||
|
||||
if (-not (Get-Command $CommandName -ErrorAction SilentlyContinue))
|
||||
{
|
||||
throw "环境缺少命令 '$CommandName'。$InstallHint"
|
||||
}
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
执行外部命令并在非零退出码时终止发布。
|
||||
|
||||
.PARAMETER FilePath
|
||||
需要执行的外部命令。
|
||||
|
||||
.PARAMETER ArgumentList
|
||||
按顺序传入外部命令的参数数组。
|
||||
#>
|
||||
function Invoke-CheckedCommand
|
||||
{
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$FilePath,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[string[]]$ArgumentList
|
||||
)
|
||||
|
||||
& $FilePath @ArgumentList
|
||||
if ($LASTEXITCODE -ne 0)
|
||||
{
|
||||
throw "命令执行失败,退出码为 $LASTEXITCODE:$FilePath $($ArgumentList -join ' ')"
|
||||
}
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
将 Obfuscar 输出安全替换进发布目录并隔离映射文件。
|
||||
|
||||
.PARAMETER PublishedDirectory
|
||||
dotnet publish 生成的最终部署目录。
|
||||
|
||||
.PARAMETER ObfuscatedOutputDirectory
|
||||
Obfuscar 独立输出目录。
|
||||
|
||||
.PARAMETER RuntimeMappingDirectory
|
||||
框架依赖发布对应的映射文件保存目录。
|
||||
#>
|
||||
function Install-ObfuscatedAssemblies
|
||||
{
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$PublishedDirectory,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[string]$ObfuscatedOutputDirectory,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[string]$RuntimeMappingDirectory
|
||||
)
|
||||
|
||||
$assemblyNames = Get-FlyshotAssemblyPaths -Directory $PublishedDirectory |
|
||||
ForEach-Object { [System.IO.Path]::GetFileName($_) }
|
||||
|
||||
foreach ($assemblyName in $assemblyNames)
|
||||
{
|
||||
$obfuscatedAssemblyPath = Join-Path $ObfuscatedOutputDirectory $assemblyName
|
||||
if (-not (Test-Path -LiteralPath $obfuscatedAssemblyPath -PathType Leaf))
|
||||
{
|
||||
throw "Obfuscar 未生成预期程序集:$assemblyName"
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($assemblyName in $assemblyNames)
|
||||
{
|
||||
Copy-Item `
|
||||
-LiteralPath (Join-Path $ObfuscatedOutputDirectory $assemblyName) `
|
||||
-Destination (Join-Path $PublishedDirectory $assemblyName) `
|
||||
-Force
|
||||
|
||||
$pdbName = [System.IO.Path]::ChangeExtension($assemblyName, '.pdb')
|
||||
$obfuscatedPdbPath = Join-Path $ObfuscatedOutputDirectory $pdbName
|
||||
if (Test-Path -LiteralPath $obfuscatedPdbPath -PathType Leaf)
|
||||
{
|
||||
Copy-Item `
|
||||
-LiteralPath $obfuscatedPdbPath `
|
||||
-Destination (Join-Path $PublishedDirectory $pdbName) `
|
||||
-Force
|
||||
}
|
||||
}
|
||||
|
||||
$mappingPath = Join-Path $ObfuscatedOutputDirectory 'Mapping.txt'
|
||||
if (-not (Test-Path -LiteralPath $mappingPath -PathType Leaf))
|
||||
{
|
||||
throw 'Obfuscar 未生成 Mapping.txt,无法确认混淆映射结果。'
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path $RuntimeMappingDirectory -Force | Out-Null
|
||||
Copy-Item -LiteralPath $mappingPath -Destination (Join-Path $RuntimeMappingDirectory 'Mapping.txt') -Force
|
||||
|
||||
# 部署目录不得包含符号映射,避免把重命名关系直接交付给最终用户。
|
||||
$publishedMappingPath = Join-Path $PublishedDirectory 'Mapping.txt'
|
||||
if (Test-Path -LiteralPath $publishedMappingPath)
|
||||
{
|
||||
Remove-Item -LiteralPath $publishedMappingPath -Force
|
||||
}
|
||||
|
||||
# XML 文档包含源码 /// 注释,PDB 包含调试符号;生产混淆包不得向反编译器暴露这些辅助信息。
|
||||
Get-ChildItem -LiteralPath $PublishedDirectory -File |
|
||||
Where-Object {
|
||||
$_.Name -like 'Flyshot*.xml' -or
|
||||
$_.Name -like 'Flyshot*.pdb'
|
||||
} |
|
||||
Remove-Item -Force
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
发布并混淆 Flyshot 框架依赖主程序。
|
||||
|
||||
.PARAMETER RepositoryRoot
|
||||
Flyshot Replacement 仓库根目录。
|
||||
|
||||
.PARAMETER PublishedOutputDirectory
|
||||
最终混淆部署包目录。
|
||||
|
||||
.PARAMETER MappingOutputRoot
|
||||
全部混淆映射文件的根目录。
|
||||
#>
|
||||
function Publish-ObfuscatedFrameworkDependent
|
||||
{
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$RepositoryRoot,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[string]$PublishedOutputDirectory,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[string]$MappingOutputRoot
|
||||
)
|
||||
|
||||
$hostProject = Join-Path $RepositoryRoot 'src\Flyshot.Server.Host\Flyshot.Server.Host.csproj'
|
||||
$workingDirectory = Join-Path $RepositoryRoot '.tmp\obfuscation\framework-dependent'
|
||||
$obfuscatedOutputDirectory = Join-Path $workingDirectory 'obfuscated'
|
||||
$configurationPath = Join-Path $workingDirectory 'obfuscar.xml'
|
||||
$runtimeMappingDirectory = Join-Path $MappingOutputRoot 'framework-dependent'
|
||||
|
||||
# 每次发布使用干净目录,避免旧程序集或旧映射掩盖本轮失败。
|
||||
foreach ($directory in @($PublishedOutputDirectory, $workingDirectory, $runtimeMappingDirectory))
|
||||
{
|
||||
if (Test-Path -LiteralPath $directory)
|
||||
{
|
||||
Remove-Item -LiteralPath $directory -Recurse -Force
|
||||
}
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path $PublishedOutputDirectory -Force | Out-Null
|
||||
|
||||
Write-Host '正在发布跨平台框架依赖包 ...'
|
||||
Invoke-CheckedCommand -FilePath 'dotnet' -ArgumentList @(
|
||||
'publish'
|
||||
$hostProject
|
||||
'-c'
|
||||
'Release'
|
||||
'--self-contained'
|
||||
'false'
|
||||
'-p:PublishSingleFile=false'
|
||||
'-p:UseAppHost=false'
|
||||
'-p:DebugType=none'
|
||||
'-o'
|
||||
$PublishedOutputDirectory
|
||||
)
|
||||
|
||||
New-ObfuscarConfiguration `
|
||||
-SourceDirectory $PublishedOutputDirectory `
|
||||
-TargetDirectory $obfuscatedOutputDirectory `
|
||||
-DestinationPath $configurationPath | Out-Null
|
||||
|
||||
Write-Host '正在混淆框架依赖包中的 Flyshot 自研程序集 ...'
|
||||
Invoke-CheckedCommand -FilePath 'obfuscar.console' -ArgumentList @($configurationPath)
|
||||
|
||||
Install-ObfuscatedAssemblies `
|
||||
-PublishedDirectory $PublishedOutputDirectory `
|
||||
-ObfuscatedOutputDirectory $obfuscatedOutputDirectory `
|
||||
-RuntimeMappingDirectory $runtimeMappingDirectory
|
||||
|
||||
Write-Host "已生成跨平台框架依赖混淆包:$PublishedOutputDirectory"
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
混淆 Visual Studio 已生成的现有发布目录。
|
||||
|
||||
.PARAMETER PublishedDirectory
|
||||
包含全部发布文件的目录;混淆成功后会原地替换其中的 Flyshot 自研程序集。
|
||||
|
||||
.PARAMETER RepositoryRoot
|
||||
Flyshot Replacement 仓库根目录。
|
||||
|
||||
.PARAMETER MappingOutputRoot
|
||||
混淆映射文件的根目录;映射文件不会进入发布目录。
|
||||
#>
|
||||
function Protect-ExistingPublishedDirectory
|
||||
{
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$PublishedDirectory,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[string]$RepositoryRoot,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[string]$MappingOutputRoot
|
||||
)
|
||||
|
||||
$absolutePublishedDirectory = ConvertTo-AbsolutePath -Path $PublishedDirectory
|
||||
$workingDirectory = Join-Path $RepositoryRoot '.tmp\obfuscation\visual-studio\framework-dependent'
|
||||
$obfuscatedOutputDirectory = Join-Path $workingDirectory 'obfuscated'
|
||||
$configurationPath = Join-Path $workingDirectory 'obfuscar.xml'
|
||||
$runtimeMappingDirectory = Join-Path $MappingOutputRoot 'framework-dependent'
|
||||
|
||||
if (-not (Test-Path -LiteralPath $absolutePublishedDirectory -PathType Container))
|
||||
{
|
||||
throw "Visual Studio 发布目录不存在:$absolutePublishedDirectory"
|
||||
}
|
||||
|
||||
# 后处理必须使用独立输出目录,避免 Obfuscar 读写同一个程序集导致文件锁或半成品。
|
||||
foreach ($directory in @($workingDirectory, $runtimeMappingDirectory))
|
||||
{
|
||||
if (Test-Path -LiteralPath $directory)
|
||||
{
|
||||
Remove-Item -LiteralPath $directory -Recurse -Force
|
||||
}
|
||||
}
|
||||
|
||||
New-ObfuscarConfiguration `
|
||||
-SourceDirectory $absolutePublishedDirectory `
|
||||
-TargetDirectory $obfuscatedOutputDirectory `
|
||||
-DestinationPath $configurationPath | Out-Null
|
||||
|
||||
Write-Host "正在混淆 Visual Studio 发布目录中的 Flyshot 自研程序集:$absolutePublishedDirectory"
|
||||
Invoke-CheckedCommand -FilePath 'obfuscar.console' -ArgumentList @($configurationPath)
|
||||
|
||||
Install-ObfuscatedAssemblies `
|
||||
-PublishedDirectory $absolutePublishedDirectory `
|
||||
-ObfuscatedOutputDirectory $obfuscatedOutputDirectory `
|
||||
-RuntimeMappingDirectory $runtimeMappingDirectory
|
||||
|
||||
Write-Host "Visual Studio 发布目录混淆完成:$absolutePublishedDirectory"
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if ($GenerateConfigOnly)
|
||||
{
|
||||
foreach ($requiredValue in @{
|
||||
InputDirectory = $InputDirectory
|
||||
ObfuscatedDirectory = $ObfuscatedDirectory
|
||||
ConfigPath = $ConfigPath
|
||||
}.GetEnumerator())
|
||||
{
|
||||
if ([string]::IsNullOrWhiteSpace([string]$requiredValue.Value))
|
||||
{
|
||||
throw "GenerateConfigOnly 模式缺少参数:$($requiredValue.Key)"
|
||||
}
|
||||
}
|
||||
|
||||
New-ObfuscarConfiguration `
|
||||
-SourceDirectory $InputDirectory `
|
||||
-TargetDirectory $ObfuscatedDirectory `
|
||||
-DestinationPath $ConfigPath | Out-Null
|
||||
exit 0
|
||||
}
|
||||
|
||||
$repositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
|
||||
if ([string]::IsNullOrWhiteSpace($OutputRoot))
|
||||
{
|
||||
$OutputRoot = Join-Path $repositoryRoot 'outputs\obfuscated\framework-dependent'
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($MappingRoot))
|
||||
{
|
||||
$MappingRoot = Join-Path $repositoryRoot 'outputs\obfuscation-maps'
|
||||
}
|
||||
|
||||
Assert-EnvironmentCommand -CommandName 'dotnet' -InstallHint '请安装仓库 global.json 指定的 .NET SDK。'
|
||||
Assert-EnvironmentCommand `
|
||||
-CommandName 'obfuscar.console' `
|
||||
-InstallHint '请执行:dotnet tool install --global Obfuscar.GlobalTool --version 2.2.50'
|
||||
|
||||
if ($ObfuscateExistingPublish)
|
||||
{
|
||||
if ([string]::IsNullOrWhiteSpace($ExistingPublishDirectory))
|
||||
{
|
||||
throw 'ObfuscateExistingPublish 模式缺少参数:ExistingPublishDirectory'
|
||||
}
|
||||
|
||||
Protect-ExistingPublishedDirectory `
|
||||
-PublishedDirectory $ExistingPublishDirectory `
|
||||
-RepositoryRoot $repositoryRoot `
|
||||
-MappingOutputRoot (ConvertTo-AbsolutePath -Path $MappingRoot)
|
||||
exit 0
|
||||
}
|
||||
|
||||
Publish-ObfuscatedFrameworkDependent `
|
||||
-RepositoryRoot $repositoryRoot `
|
||||
-PublishedOutputDirectory (ConvertTo-AbsolutePath -Path $OutputRoot) `
|
||||
-MappingOutputRoot (ConvertTo-AbsolutePath -Path $MappingRoot)
|
||||
|
||||
Write-Host 'Flyshot 跨平台框架依赖混淆发布包已生成。'
|
||||
exit 0
|
||||
}
|
||||
catch
|
||||
{
|
||||
Write-Error $_
|
||||
exit 1
|
||||
}
|
||||
@@ -20,6 +20,11 @@ public sealed class ControllerClientCompatOptions
|
||||
/// </summary>
|
||||
public string? WorkspaceRoot { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置服务启动时自动初始化机器人的配置;为 null 时跳过自动初始化。
|
||||
/// </summary>
|
||||
public AutoInitOptions? AutoInit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 解析运行配置根目录,确保运行时默认不再依赖源码仓库位置。
|
||||
/// </summary>
|
||||
@@ -44,3 +49,19 @@ public sealed class ControllerClientCompatOptions
|
||||
: Path.GetFullPath(WorkspaceRoot);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 表示服务启动时自动初始化机器人的配置项。
|
||||
/// </summary>
|
||||
public sealed class AutoInitOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取或设置机器人名称,用于加载机器人配置文件(如 LR_Mate_200iD)。
|
||||
/// </summary>
|
||||
public string RobotName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置机器人 IP 地址,用于连接真机或仿真控制器。
|
||||
/// </summary>
|
||||
public string RobotIp { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
@@ -802,6 +802,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 +877,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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<!-- 生成 XML 文档以便 Swashbuckle 把控制器/DTO 上的 /// summary 注释注入 OpenAPI 文档,
|
||||
调试页和 Swagger UI 的端点标题都依赖这一份文档。1591 抑制掉 “缺失 XML 注释” 的噪音。 -->
|
||||
<!-- 生成 XML 文档以便 Swashbuckle 把控制器/DTO 上的 /// summary 注释注入 OpenAPI 文档;
|
||||
调试页说明文本已经静态内嵌在 debug.html 中,这份 XML 现在只服务 Swagger UI。1591 抑制掉 “缺失 XML 注释” 的噪音。 -->
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);1591</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Flyshot.ControllerClientCompat;
|
||||
using Flyshot.Server.Host;
|
||||
using Flyshot.Server.Host.Middleware;
|
||||
using Flyshot.Server.Host.Services;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using NLog.Web;
|
||||
@@ -23,6 +24,10 @@ try
|
||||
builder.Services.Configure<HostSwaggerOptions>(builder.Configuration.GetSection("Swagger"));
|
||||
builder.Services.Configure<HostCorsOptions>(builder.Configuration.GetSection("Cors"));
|
||||
builder.Services.AddControllerClientCompat(builder.Configuration);
|
||||
|
||||
// 注册服务启动时自动初始化机器人的托管服务;配置缺失时自动跳过。
|
||||
builder.Services.AddHostedService<AutoInitRobotHostedService>();
|
||||
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
|
||||
@@ -35,7 +40,8 @@ try
|
||||
Version = swaggerOptions.Version
|
||||
});
|
||||
|
||||
// 把控制器与 DTO 上的 /// summary 注释纳入 OpenAPI 文档;调试页据此渲染端点标题。
|
||||
// 把控制器与 DTO 上的 /// summary 注释纳入 OpenAPI 文档;当前主要服务 Swagger UI,
|
||||
// 调试页的人类可读说明已经静态内嵌在 debug.html 中,不再依赖发布目录中的 XML 文档。
|
||||
var xmlDocumentationPath = Path.Combine(AppContext.BaseDirectory, $"{typeof(Program).Assembly.GetName().Name}.xml");
|
||||
if (File.Exists(xmlDocumentationPath))
|
||||
{
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
using Flyshot.ControllerClientCompat;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Flyshot.Server.Host.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 服务启动时自动初始化机器人的托管服务。
|
||||
/// 从配置文件读取 robot_name 和 robot_ip,依次执行 SetUpRobot、SetActiveController、Connect、EnableRobot。
|
||||
/// 初始化失败不会阻止服务启动,仅记录警告日志。
|
||||
/// </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
|
||||
{
|
||||
// 步骤1:加载机器人配置
|
||||
_compatService.SetUpRobot(autoInit.RobotName);
|
||||
if (!_compatService.IsSetUp)
|
||||
{
|
||||
_logger.LogWarning("自动初始化失败: SetUpRobot 返回 IsSetUp=false,robot_name={RobotName}", autoInit.RobotName);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// 步骤2:设置控制器模式(默认非仿真)
|
||||
_compatService.SetActiveController(sim: false);
|
||||
|
||||
// 步骤3:连接机器人控制器
|
||||
_compatService.Connect(autoInit.RobotIp);
|
||||
|
||||
// 步骤4:使能机器人(上伺服)
|
||||
_compatService.EnableRobot(8);
|
||||
|
||||
_logger.LogInformation("自动初始化成功: robot_name={RobotName}", autoInit.RobotName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 初始化失败不阻止服务启动,仅记录警告
|
||||
_logger.LogWarning(ex, "自动初始化机器人失败,服务将继续启动。robot_name={RobotName}", autoInit.RobotName);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 服务停止时的清理操作。当前无需额外清理。
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">取消令牌。</param>
|
||||
/// <returns>异步任务。</returns>
|
||||
public Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,11 @@
|
||||
}
|
||||
},
|
||||
"ControllerClientCompat": {
|
||||
"ServerVersion": "flyshot-replacement-controller-client-compat/0.1.0"
|
||||
"ServerVersion": "flyshot-replacement-controller-client-compat/0.1.0",
|
||||
"AutoInit": {
|
||||
"RobotName": "LR_Mate_200iD",
|
||||
"RobotIp": "127.0.0.1"
|
||||
}
|
||||
},
|
||||
"Swagger": {
|
||||
"Enabled": true,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
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 路径
|
||||
@@ -13,7 +14,8 @@ const groupTitleByPrefix = [
|
||||
const state = {
|
||||
spec: null,
|
||||
operations: [],
|
||||
history: []
|
||||
history: [],
|
||||
staticDocsByKey: loadStaticDocs()
|
||||
};
|
||||
|
||||
const requestBodySamples = {
|
||||
@@ -34,6 +36,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 +74,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 +131,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 +139,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 +212,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 +224,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 +251,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 +370,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 +514,7 @@ function pushHistory(entry) {
|
||||
renderHistory();
|
||||
}
|
||||
|
||||
/** 渲染请求历史列表。 */
|
||||
function renderHistory() {
|
||||
const list = document.getElementById("history-list");
|
||||
list.innerHTML = "";
|
||||
@@ -376,7 +549,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 +566,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 +584,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 +634,7 @@ function renderOperationCard(op) {
|
||||
responseBlock.style.display = "none";
|
||||
body.appendChild(responseBlock);
|
||||
|
||||
/** 在卡片内渲染 HTTP 响应摘要、头和正文。 */
|
||||
function renderResponse(payload) {
|
||||
responseBlock.style.display = "block";
|
||||
responseBlock.innerHTML = "";
|
||||
|
||||
@@ -39,6 +39,247 @@
|
||||
</h3>
|
||||
<ul id="history-list"></ul>
|
||||
</aside>
|
||||
<script id="debug-static-docs" type="application/json">
|
||||
{
|
||||
"GET /": {
|
||||
"summary": "根路径探活",
|
||||
"description": "返回旧 HTTP 服务约定的 Hello World 响应,用来确认兼容层宿主已启动。",
|
||||
"requestBodyFields": []
|
||||
},
|
||||
"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 /connect_server/": {
|
||||
"summary": "记录旧客户端目标服务",
|
||||
"description": "兼容旧 `connect_server` 调用形状;在 replacement 宿主中只记录调用方期望连接的服务端地址,不会再启动旧 TCP+JSON 网关。",
|
||||
"requestBodyFields": []
|
||||
},
|
||||
"GET /get_server_version/": {
|
||||
"summary": "读取服务端版本",
|
||||
"description": "返回当前 replacement 宿主暴露给旧客户端语义的服务端版本号。",
|
||||
"requestBodyFields": []
|
||||
},
|
||||
"GET /get_client_version/": {
|
||||
"summary": "读取客户端版本",
|
||||
"description": "返回兼容层对外声明的客户端版本号,便于旧链路做版本诊断。",
|
||||
"requestBodyFields": []
|
||||
},
|
||||
"POST /setup_robot/": {
|
||||
"summary": "按名称加载机器人",
|
||||
"description": "根据机器人名称加载对应模型和运行配置,为后续连接、使能和执行做准备。",
|
||||
"requestBodyFields": []
|
||||
},
|
||||
"POST /setup_robot_from_env/": {
|
||||
"summary": "按环境文件加载机器人",
|
||||
"description": "兼容旧 `env_file` 入口,根据环境文件路径完成机器人初始化配置加载。",
|
||||
"requestBodyFields": []
|
||||
},
|
||||
"POST /set_show_tcp/": {
|
||||
"summary": "设置 TCP 显示开关",
|
||||
"description": "兼容旧可视化参数接口,记录是否显示 TCP 坐标轴及其尺寸参数。",
|
||||
"requestBodyFields": []
|
||||
},
|
||||
"GET /is_setup/": {
|
||||
"summary": "查询是否已初始化",
|
||||
"description": "返回当前兼容层是否已经完成 `SetUpRobot` 初始化。",
|
||||
"requestBodyFields": []
|
||||
},
|
||||
"GET /enable_robot/": {
|
||||
"summary": "使能机器人",
|
||||
"description": "调用兼容层使能机器人,并可通过查询参数传入控制器执行缓冲区大小。",
|
||||
"requestBodyFields": []
|
||||
},
|
||||
"GET /disable_robot/": {
|
||||
"summary": "去使能机器人",
|
||||
"description": "调用兼容层关闭机器人使能,通常用于结束调试或执行链路回收。",
|
||||
"requestBodyFields": []
|
||||
},
|
||||
"GET /stop_move/": {
|
||||
"summary": "停止当前运动",
|
||||
"description": "触发兼容层的 `StopMove` 语义,用于终止当前点动或轨迹执行。",
|
||||
"requestBodyFields": []
|
||||
},
|
||||
"POST /set_active_controller/": {
|
||||
"summary": "切换控制器模式",
|
||||
"description": "通过查询参数 `sim` 指定当前使用仿真控制器还是真机控制器。",
|
||||
"requestBodyFields": []
|
||||
},
|
||||
"POST /connect_robot/": {
|
||||
"summary": "连接机器人控制器",
|
||||
"description": "根据查询参数 `ip` 连接机器人控制器,建立后续命令与状态链路。",
|
||||
"requestBodyFields": []
|
||||
},
|
||||
"POST /disconnect_robot/": {
|
||||
"summary": "断开机器人连接",
|
||||
"description": "主动断开当前机器人控制器连接,释放兼容层运行时资源。",
|
||||
"requestBodyFields": []
|
||||
},
|
||||
"GET /robot_info/": {
|
||||
"summary": "读取机器人信息",
|
||||
"description": "返回机器人名称、自由度、速度倍率和服务端版本等聚合元信息。",
|
||||
"requestBodyFields": []
|
||||
},
|
||||
"POST /set_tcp/": {
|
||||
"summary": "设置 TCP 偏移",
|
||||
"description": "设置当前工具中心点的三维偏移,单位按旧接口约定使用毫米。",
|
||||
"requestBodyFields": [
|
||||
{ "name": "x", "type": "number", "required": true, "description": "TCP 的 X 偏移,单位 mm。" },
|
||||
{ "name": "y", "type": "number", "required": true, "description": "TCP 的 Y 偏移,单位 mm。" },
|
||||
{ "name": "z", "type": "number", "required": true, "description": "TCP 的 Z 偏移,单位 mm。" }
|
||||
]
|
||||
},
|
||||
"GET /get_tcp/": {
|
||||
"summary": "读取 TCP 偏移",
|
||||
"description": "返回当前生效的 TCP 三维偏移值。",
|
||||
"requestBodyFields": []
|
||||
},
|
||||
"POST /set_io/": {
|
||||
"summary": "设置数字 IO",
|
||||
"description": "通过查询参数设置指定端口的 IO 值和 IO 类型,常用于现场脉冲或夹具联动调试。",
|
||||
"requestBodyFields": []
|
||||
},
|
||||
"GET /get_io/": {
|
||||
"summary": "读取数字 IO",
|
||||
"description": "读取指定端口和类型的当前 IO 值,便于确认控制柜当前输入输出状态。",
|
||||
"requestBodyFields": []
|
||||
},
|
||||
"GET /get_joint_position/": {
|
||||
"summary": "读取当前关节位置",
|
||||
"description": "返回旧 HTTP 层兼容的关节数组外形,便于现场确认当前姿态。",
|
||||
"requestBodyFields": []
|
||||
},
|
||||
"POST /move_joint/": {
|
||||
"summary": "执行关节点到点运动",
|
||||
"description": "根据目标关节数组执行一次关节空间点到点运动;实际执行链路会进入兼容层与 J519 运行时。",
|
||||
"requestBodyFields": [
|
||||
{ "name": "joints", "type": "array<number>", "required": true, "description": "目标关节数组,按关节顺序给出数值。" }
|
||||
]
|
||||
},
|
||||
"POST /move_pose/": {
|
||||
"summary": "执行笛卡尔点到点运动",
|
||||
"description": "以 `[x,y,z,w,p,r]` 直角位姿执行点到点移动。六个字段必须完整提供,`x/y/z` 使用 mm,`w/p/r` 使用 deg,并受当前入口限幅约束。",
|
||||
"requestBodyFields": [
|
||||
{ "name": "x", "type": "number", "required": true, "description": "TCP X,单位 mm,当前入口限制在 -1000 到 1000 之间。" },
|
||||
{ "name": "y", "type": "number", "required": true, "description": "TCP Y,单位 mm,当前入口限制在 -1000 到 1000 之间。" },
|
||||
{ "name": "z", "type": "number", "required": true, "description": "TCP Z,单位 mm,当前入口限制在 0 到 1200 之间。" },
|
||||
{ "name": "w", "type": "number", "required": true, "description": "姿态 W,单位 deg,当前入口限制在 -180 到 180 之间。" },
|
||||
{ "name": "p", "type": "number", "required": true, "description": "姿态 P,单位 deg,当前入口限制在 -90 到 90 之间。" },
|
||||
{ "name": "r", "type": "number", "required": true, "description": "姿态 R,单位 deg,当前入口限制在 -180 到 180 之间。" }
|
||||
]
|
||||
},
|
||||
"POST /get_nearest_ik/": {
|
||||
"summary": "求最近 IK",
|
||||
"description": "根据目标位姿和 seed 关节数组求最近一组 IK 解;当前若底层未实现,会返回 501。",
|
||||
"requestBodyFields": [
|
||||
{ "name": "pose", "type": "array<number>", "required": true, "description": "目标位姿数组,约定为 `[x,y,z,qx,qy,qz,qw]`。" },
|
||||
{ "name": "seed", "type": "array<number>", "required": true, "description": "IK seed 关节数组,用作就近求解参考。" }
|
||||
]
|
||||
},
|
||||
"GET /list_flyShotTraj/": {
|
||||
"summary": "列出已上传飞拍轨迹",
|
||||
"description": "返回当前运行目录中已经保存的飞拍轨迹名称列表。",
|
||||
"requestBodyFields": []
|
||||
},
|
||||
"POST /execute_trajectory/": {
|
||||
"summary": "执行普通轨迹",
|
||||
"description": "执行普通关节轨迹。该接口兼容两种历史请求体形态:一是对象形态 `{ waypoints, method, save_traj }`,二是直接传纯数组路点;查询参数里的 `method` 和 `save_traj` 仍可覆盖请求体值。",
|
||||
"requestBodyFields": [
|
||||
{ "name": "waypoints", "type": "array<array<number>> | array<object>", "required": true, "description": "历史兼容对象形态中的轨迹路点集合;每个路点既可直接是关节数组,也可写成 `{ joints: [...] }`。" },
|
||||
{ "name": "method", "type": "string", "required": false, "description": "对象形态中的轨迹规划方法,默认 `icsp`。" },
|
||||
{ "name": "save_traj", "type": "boolean", "required": false, "description": "对象形态中的是否保存轨迹标记,默认 `false`。" },
|
||||
{ "name": "根数组", "type": "array<array<number>> | array<object>", "required": true, "description": "纯数组形态时,请直接提交路点数组本身,而不是对象包裹层。" },
|
||||
{ "name": "根数组[].joints", "type": "array<number>", "required": false, "description": "当路点元素是对象形态时,兼容层会读取其中的 `joints` 数组。" }
|
||||
]
|
||||
},
|
||||
"POST /upload_flyshot/": {
|
||||
"summary": "上传飞拍轨迹",
|
||||
"description": "上传一条带拍照触发信息的飞拍轨迹。`waypoints`、`shot_flags`、`offset_values`、`addrs` 四组数据长度必须一致。",
|
||||
"requestBodyFields": [
|
||||
{ "name": "name", "type": "string", "required": true, "description": "飞拍轨迹名称,后续执行、校验和删除都通过它定位。" },
|
||||
{ "name": "waypoints", "type": "array<array<number>>", "required": true, "description": "飞拍轨迹的关节路点集合。" },
|
||||
{ "name": "shot_flags", "type": "array<boolean>", "required": true, "description": "与每个路点对齐的拍照触发标志数组。" },
|
||||
{ "name": "offset_values", "type": "array<number>", "required": true, "description": "与每个路点对齐的触发偏移周期集合。" },
|
||||
{ "name": "addrs", "type": "array<array<integer>>", "required": true, "description": "与每个路点对齐的 IO 地址组集合。" }
|
||||
]
|
||||
},
|
||||
"POST /execute_flyshot/": {
|
||||
"summary": "执行已上传飞拍轨迹",
|
||||
"description": "按名称执行一条已上传的飞拍轨迹,可控制是否先移动到起点、是否复用缓存、是否保存轨迹以及是否等待执行完成。",
|
||||
"requestBodyFields": [
|
||||
{ "name": "name", "type": "string", "required": true, "description": "要执行的飞拍轨迹名称。" },
|
||||
{ "name": "move_to_start", "type": "boolean", "required": false, "description": "是否先用临时 PTP 轨迹移动到飞拍起点,默认 `true`。" },
|
||||
{ "name": "method", "type": "string", "required": false, "description": "飞拍轨迹生成方法,默认 `icsp`。" },
|
||||
{ "name": "save_traj", "type": "boolean", "required": false, "description": "是否保存轨迹信息,默认 `true`。" },
|
||||
{ "name": "use_cache", "type": "boolean", "required": false, "description": "是否优先复用已缓存的轨迹结果,默认 `true`。" },
|
||||
{ "name": "wait", "type": "boolean", "required": false, "description": "是否等待整条飞拍轨迹执行完成后再返回,默认 `true`。" }
|
||||
]
|
||||
},
|
||||
"POST /save_traj_info/": {
|
||||
"summary": "导出轨迹文件",
|
||||
"description": "为指定飞拍轨迹生成并导出 `SaveTrajInfo` 兼容文件集合;底层未实现时会返回 501。",
|
||||
"requestBodyFields": [
|
||||
{ "name": "name", "type": "string", "required": true, "description": "目标飞拍轨迹名称。" },
|
||||
{ "name": "method", "type": "string", "required": false, "description": "轨迹规划方法,默认 `icsp`。" }
|
||||
]
|
||||
},
|
||||
"POST /is_flyShotTrajValid/": {
|
||||
"summary": "校验飞拍轨迹有效性",
|
||||
"description": "检查指定飞拍轨迹是否可生成有效执行结果,并返回轨迹时长;底层未实现时会返回 501。",
|
||||
"requestBodyFields": [
|
||||
{ "name": "name", "type": "string", "required": true, "description": "要校验的飞拍轨迹名称。" },
|
||||
{ "name": "method", "type": "string", "required": false, "description": "轨迹规划方法,默认 `icsp`。" },
|
||||
{ "name": "save_traj", "type": "boolean", "required": false, "description": "是否同时保存轨迹信息,默认 `true`。" }
|
||||
]
|
||||
},
|
||||
"POST /set_speedRatio/": {
|
||||
"summary": "设置运行速度倍率",
|
||||
"description": "设置机器人运行速度倍率。当前接口要求 `speed` 在 `0.01` 到 `1.0` 之间,否则会直接返回 400。",
|
||||
"requestBodyFields": [
|
||||
{ "name": "speed", "type": "number", "required": true, "description": "目标速度倍率,允许范围为 0.01 到 1.0。" }
|
||||
]
|
||||
},
|
||||
"POST /delete_flyshot/": {
|
||||
"summary": "删除飞拍轨迹",
|
||||
"description": "按名称删除一条已上传的飞拍轨迹及其持久化记录。",
|
||||
"requestBodyFields": [
|
||||
{ "name": "name", "type": "string", "required": true, "description": "要删除的飞拍轨迹名称。" }
|
||||
]
|
||||
},
|
||||
"POST /init_mpc_robt": {
|
||||
"summary": "一键初始化旧链路",
|
||||
"description": "兼容旧 `init_mpc_robt` 入口,按历史拼写保留。会串联执行 `SetUpRobot -> SetActiveController -> Connect -> EnableRobot`。",
|
||||
"requestBodyFields": [
|
||||
{ "name": "server_ip", "type": "string", "required": true, "description": "旧客户端记录的目标服务端 IP,仅保留兼容语义。" },
|
||||
{ "name": "port", "type": "integer", "required": true, "description": "旧客户端记录的目标服务端端口。" },
|
||||
{ "name": "robot_name", "type": "string", "required": true, "description": "机器人名称,用于加载模型与配置。" },
|
||||
{ "name": "robot_ip", "type": "string", "required": true, "description": "机器人控制器 IP。" },
|
||||
{ "name": "sim", "type": "boolean", "required": false, "description": "是否连接仿真控制器;默认 `false` 表示真机。" }
|
||||
]
|
||||
},
|
||||
"GET /get_pose": {
|
||||
"summary": "读取当前末端位姿",
|
||||
"description": "返回当前末端位姿数组,便于确认机器人 TCP 的当前空间位置。",
|
||||
"requestBodyFields": []
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script src="/assets/debug.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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,9 +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));
|
||||
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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -33,6 +33,9 @@ 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.DoesNotContain("__SWAGGER_JSON_URL__", html, StringComparison.Ordinal);
|
||||
Assert.Contains("/assets/debug.css", html, StringComparison.Ordinal);
|
||||
Assert.Contains("/assets/debug.js", html, StringComparison.Ordinal);
|
||||
@@ -66,6 +69,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>
|
||||
|
||||
252
tests/Publishing/Publish-Obfuscated.Tests.ps1
Normal file
252
tests/Publishing/Publish-Obfuscated.Tests.ps1
Normal file
@@ -0,0 +1,252 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
验证混淆发布脚本生成的 Obfuscar 配置满足仓库发布契约。
|
||||
|
||||
.DESCRIPTION
|
||||
该测试使用临时空文件模拟发布目录,仅验证模块筛选、绝对路径和失败语义,
|
||||
不会执行 dotnet publish,也不会要求测试环境安装 Obfuscar。
|
||||
#>
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$repositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
|
||||
$publishScriptPath = Join-Path $repositoryRoot 'scripts\Publish-Obfuscated.ps1'
|
||||
$publishProfilePath = Join-Path $repositoryRoot 'src\Flyshot.Server.Host\Properties\PublishProfiles\FolderProfile.pubxml'
|
||||
$temporaryRoot = Join-Path ([System.IO.Path]::GetTempPath()) "flyshot-obfuscation-test-$([guid]::NewGuid().ToString('N'))"
|
||||
$inputDirectory = Join-Path $temporaryRoot 'input'
|
||||
$outputDirectory = Join-Path $temporaryRoot 'obfuscated'
|
||||
$configPath = Join-Path $temporaryRoot 'obfuscar.xml'
|
||||
|
||||
$expectedAssemblies = @(
|
||||
'Flyshot.Server.Host.dll'
|
||||
'Flyshot.ControllerClientCompat.dll'
|
||||
'Flyshot.Core.Config.dll'
|
||||
'Flyshot.Core.Domain.dll'
|
||||
'Flyshot.Core.Planning.dll'
|
||||
'Flyshot.Core.Triggering.dll'
|
||||
'Flyshot.Runtime.Fanuc.dll'
|
||||
'Flyshot.Runtime.Common.dll'
|
||||
)
|
||||
$futureAssemblyName = 'Flyshot.Future.Module.dll'
|
||||
|
||||
try
|
||||
{
|
||||
$publishScriptContent = Get-Content -LiteralPath $publishScriptPath -Raw
|
||||
$publishScriptTokens = $null
|
||||
$publishScriptErrors = $null
|
||||
$publishScriptAst = [System.Management.Automation.Language.Parser]::ParseInput(
|
||||
$publishScriptContent,
|
||||
[ref]$publishScriptTokens,
|
||||
[ref]$publishScriptErrors)
|
||||
|
||||
if ($publishScriptErrors.Count -ne 0)
|
||||
{
|
||||
throw "Publish-Obfuscated.ps1 存在 PowerShell 语法错误:$($publishScriptErrors[0].Message)"
|
||||
}
|
||||
|
||||
$scriptParameters = @($publishScriptAst.ParamBlock.Parameters | ForEach-Object {
|
||||
$_.Name.VariablePath.UserPath
|
||||
})
|
||||
if ('RuntimeIdentifiers' -in $scriptParameters -or 'ExistingRuntimeIdentifier' -in $scriptParameters)
|
||||
{
|
||||
throw '发布脚本不应再暴露运行平台或 RID 参数。'
|
||||
}
|
||||
|
||||
if ($publishScriptContent -match "'-r'|--self-contained'\s*,?\s*'true'|win-x64|linux-x64")
|
||||
{
|
||||
throw '发布脚本默认发布流程不得再指定 RID 或生成自包含平台包。'
|
||||
}
|
||||
|
||||
if ($publishScriptContent -notmatch '-p:UseAppHost=false')
|
||||
{
|
||||
throw '框架依赖发布必须关闭平台相关 apphost,只保留跨平台 DLL 入口。'
|
||||
}
|
||||
|
||||
[xml]$publishProfile = Get-Content -LiteralPath $publishProfilePath -Raw
|
||||
$publishNamespace = [System.Xml.XmlNamespaceManager]::new($publishProfile.NameTable)
|
||||
$publishNamespace.AddNamespace('msb', $publishProfile.Project.NamespaceURI)
|
||||
|
||||
$useAppHost = $publishProfile.SelectSingleNode(
|
||||
'/msb:Project/msb:PropertyGroup/msb:UseAppHost',
|
||||
$publishNamespace)
|
||||
if ($null -eq $useAppHost -or $useAppHost.InnerText -ne 'false')
|
||||
{
|
||||
throw 'FolderProfile.pubxml 必须关闭平台相关 apphost。'
|
||||
}
|
||||
|
||||
$afterPublishTarget = $publishProfile.SelectSingleNode(
|
||||
'/msb:Project/msb:Target[@Name="ObfuscatePublishedFlyshotAssemblies"]',
|
||||
$publishNamespace)
|
||||
if ($null -eq $afterPublishTarget -or $afterPublishTarget.AfterTargets -ne 'Publish')
|
||||
{
|
||||
throw 'FolderProfile.pubxml 必须注册 Publish 完成后的混淆 Target。'
|
||||
}
|
||||
|
||||
$publishExec = $afterPublishTarget.SelectSingleNode('msb:Exec', $publishNamespace)
|
||||
if (
|
||||
$null -eq $publishExec -or
|
||||
$publishExec.Command -notmatch '-ObfuscateExistingPublish' -or
|
||||
$publishExec.Command -notmatch '\$\(PublishDir\)\.' -or
|
||||
$publishExec.Command -match 'RuntimeIdentifier'
|
||||
)
|
||||
{
|
||||
throw 'Visual Studio 发布 Target 必须把 PublishDir 以安全的非反斜杠结尾形式交给混淆模式。'
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path $inputDirectory -Force | Out-Null
|
||||
|
||||
foreach ($assemblyName in $expectedAssemblies)
|
||||
{
|
||||
New-Item -ItemType File -Path (Join-Path $inputDirectory $assemblyName) -Force | Out-Null
|
||||
}
|
||||
|
||||
# 模拟未来新增模块,证明脚本会自动保护所有 Flyshot.* 程序集,而不是只处理当前固定清单。
|
||||
New-Item -ItemType File -Path (Join-Path $inputDirectory $futureAssemblyName) -Force | Out-Null
|
||||
|
||||
# 第三方程序集用于证明配置生成器只选择 Flyshot 自研模块。
|
||||
New-Item -ItemType File -Path (Join-Path $inputDirectory 'NLog.dll') -Force | Out-Null
|
||||
|
||||
& pwsh -NoProfile -File $publishScriptPath `
|
||||
-GenerateConfigOnly `
|
||||
-InputDirectory $inputDirectory `
|
||||
-ObfuscatedDirectory $outputDirectory `
|
||||
-ConfigPath $configPath
|
||||
|
||||
if ($LASTEXITCODE -ne 0)
|
||||
{
|
||||
throw "配置生成命令退出码不是 0:$LASTEXITCODE"
|
||||
}
|
||||
|
||||
[xml]$configuration = Get-Content -LiteralPath $configPath -Raw
|
||||
$modulePaths = @($configuration.Obfuscator.Module | ForEach-Object { [string]$_.file })
|
||||
|
||||
$allExpectedAssemblies = @($expectedAssemblies) + $futureAssemblyName
|
||||
if ($modulePaths.Count -ne $allExpectedAssemblies.Count)
|
||||
{
|
||||
throw "预期生成 $($allExpectedAssemblies.Count) 个模块,实际为 $($modulePaths.Count) 个。"
|
||||
}
|
||||
|
||||
foreach ($assemblyName in $allExpectedAssemblies)
|
||||
{
|
||||
$expectedPath = [System.IO.Path]::GetFullPath((Join-Path $inputDirectory $assemblyName))
|
||||
if ($expectedPath -notin $modulePaths)
|
||||
{
|
||||
throw "Obfuscar 配置缺少自研程序集:$assemblyName"
|
||||
}
|
||||
}
|
||||
|
||||
if ($modulePaths | Where-Object { [System.IO.Path]::GetFileName($_) -eq 'NLog.dll' })
|
||||
{
|
||||
throw '第三方程序集 NLog.dll 不应进入 Obfuscar 模块列表。'
|
||||
}
|
||||
|
||||
foreach ($path in $modulePaths)
|
||||
{
|
||||
if (-not [System.IO.Path]::IsPathFullyQualified($path))
|
||||
{
|
||||
throw "Obfuscar 模块路径不是绝对路径:$path"
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($module in @($configuration.Obfuscator.Module))
|
||||
{
|
||||
$anonymousTypeRule = @($module.SkipType | Where-Object {
|
||||
[string]$_.name -eq '^<>f__AnonymousType.*'
|
||||
})
|
||||
|
||||
if ($anonymousTypeRule.Count -ne 1)
|
||||
{
|
||||
throw "模块 $($module.file) 必须显式保留匿名类型元数据。"
|
||||
}
|
||||
|
||||
if (
|
||||
[string]$anonymousTypeRule[0].skipMethods -ne 'true' -or
|
||||
[string]$anonymousTypeRule[0].skipFields -ne 'true' -or
|
||||
[string]$anonymousTypeRule[0].skipProperties -ne 'true' -or
|
||||
[string]$anonymousTypeRule[0].skipEvents -ne 'true'
|
||||
)
|
||||
{
|
||||
throw "模块 $($module.file) 的匿名类型排除规则不完整。"
|
||||
}
|
||||
}
|
||||
|
||||
$assemblySearchPaths = @($configuration.Obfuscator.AssemblySearchPath | ForEach-Object {
|
||||
[string]$_.path
|
||||
})
|
||||
if ($assemblySearchPaths.Count -lt 2)
|
||||
{
|
||||
throw 'Obfuscar 配置必须包含 .NETCore.App 与 ASP.NET Core 共享框架搜索目录。'
|
||||
}
|
||||
|
||||
foreach ($assemblySearchPath in $assemblySearchPaths)
|
||||
{
|
||||
if (-not [System.IO.Path]::IsPathFullyQualified($assemblySearchPath))
|
||||
{
|
||||
throw "共享框架搜索目录不是绝对路径:$assemblySearchPath"
|
||||
}
|
||||
}
|
||||
|
||||
if (-not ($assemblySearchPaths | Where-Object { $_ -match 'Microsoft\.NETCore\.App' }))
|
||||
{
|
||||
throw 'Obfuscar 配置缺少 Microsoft.NETCore.App 搜索目录。'
|
||||
}
|
||||
|
||||
if (-not ($assemblySearchPaths | Where-Object { $_ -match 'Microsoft\.AspNetCore\.App' }))
|
||||
{
|
||||
throw 'Obfuscar 配置缺少 Microsoft.AspNetCore.App 搜索目录。'
|
||||
}
|
||||
|
||||
$variables = @{}
|
||||
foreach ($variable in $configuration.Obfuscator.Var)
|
||||
{
|
||||
$variables[[string]$variable.name] = [string]$variable.value
|
||||
}
|
||||
|
||||
if ($variables['KeepPublicApi'] -ne 'true')
|
||||
{
|
||||
throw 'KeepPublicApi 必须为 true,以保护 ASP.NET Core 和公共契约。'
|
||||
}
|
||||
|
||||
if ($variables['HidePrivateApi'] -ne 'true')
|
||||
{
|
||||
throw 'HidePrivateApi 必须为 true,以混淆内部实现。'
|
||||
}
|
||||
|
||||
if ($variables['RenameProperties'] -ne 'false' -or $variables['RenameEvents'] -ne 'false')
|
||||
{
|
||||
throw '属性和事件重命名必须关闭,以保护配置绑定及序列化契约。'
|
||||
}
|
||||
|
||||
if ($variables['SkipGenerated'] -ne 'true')
|
||||
{
|
||||
throw '编译器生成类型必须跳过混淆,以保护匿名 JSON 响应和异步状态机。'
|
||||
}
|
||||
|
||||
if ($variables['SkipSpecialName'] -ne 'true')
|
||||
{
|
||||
throw 'SpecialName 成员必须跳过混淆,以保护属性、事件和框架反射约定。'
|
||||
}
|
||||
|
||||
Remove-Item -LiteralPath (Join-Path $inputDirectory 'Flyshot.Runtime.Common.dll') -Force
|
||||
|
||||
$missingAssemblyConfigPath = Join-Path $temporaryRoot 'missing-assembly.xml'
|
||||
& pwsh -NoProfile -File $publishScriptPath `
|
||||
-GenerateConfigOnly `
|
||||
-InputDirectory $inputDirectory `
|
||||
-ObfuscatedDirectory $outputDirectory `
|
||||
-ConfigPath $missingAssemblyConfigPath *> $null
|
||||
|
||||
if ($LASTEXITCODE -eq 0)
|
||||
{
|
||||
throw '缺少预期的 Flyshot 程序集时,配置生成必须失败。'
|
||||
}
|
||||
|
||||
Write-Host 'Publish-Obfuscated.Tests.ps1: PASS'
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Test-Path -LiteralPath $temporaryRoot)
|
||||
{
|
||||
Remove-Item -LiteralPath $temporaryRoot -Recurse -Force
|
||||
}
|
||||
}
|
||||
35
tests/Publishing/Test-PublishedArtifactPrivacy.ps1
Normal file
35
tests/Publishing/Test-PublishedArtifactPrivacy.ps1
Normal file
@@ -0,0 +1,35 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
验证混淆发布包不会泄露 Flyshot 源码文档或调试符号。
|
||||
|
||||
.PARAMETER PublishDirectory
|
||||
需要检查的框架依赖混淆发布目录。
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$PublishDirectory = (Join-Path $PSScriptRoot '..\..\outputs\obfuscated\framework-dependent')
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$absolutePublishDirectory = [System.IO.Path]::GetFullPath($PublishDirectory)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $absolutePublishDirectory -PathType Container))
|
||||
{
|
||||
throw "混淆发布目录不存在:$absolutePublishDirectory"
|
||||
}
|
||||
|
||||
$sensitiveFiles = @(
|
||||
Get-ChildItem -LiteralPath $absolutePublishDirectory -File |
|
||||
Where-Object {
|
||||
$_.Name -like 'Flyshot*.xml' -or
|
||||
$_.Name -like 'Flyshot*.pdb'
|
||||
}
|
||||
)
|
||||
|
||||
if ($sensitiveFiles.Count -gt 0)
|
||||
{
|
||||
throw "混淆发布包仍包含源码文档或调试符号:$($sensitiveFiles.Name -join ', ')"
|
||||
}
|
||||
|
||||
Write-Host 'Test-PublishedArtifactPrivacy.ps1: PASS'
|
||||
Reference in New Issue
Block a user