5 Commits

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

5
.gitignore vendored
View File

@@ -400,3 +400,8 @@ Config/Data/*
.dotnet-home/*
codex-dotnet-home/*
.dotnet-sdk8/*
# Flyshot 混淆发布产物、映射文件和临时工作目录
outputs/obfuscated/
outputs/obfuscation-maps/
.tmp/obfuscation/

15
.vscode/tasks.json vendored
View File

@@ -142,19 +142,16 @@
}
},
{
// Release 配置发布到 publish/,用于现场部署包打包
// 调用环境中的 Obfuscar,生成 Windows/Linux 两套自包含混淆发布包
"label": "publish",
"command": "dotnet",
"command": "pwsh",
"type": "process",
"args": [
"publish",
"${workspaceFolder}/src/Flyshot.Server.Host/Flyshot.Server.Host.csproj",
"-c",
"Release",
"-o",
"${workspaceFolder}/publish"
"-NoProfile",
"-File",
"${workspaceFolder}/scripts/Publish-Obfuscated.ps1"
],
"problemMatcher": "$msCompile"
"problemMatcher": []
}
]
}

View File

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

View File

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

View File

@@ -82,7 +82,7 @@
文档把它定义为“停止状态包输出”的控制包,不是命令流正常终止的首选动作。命令流结束应通过 command packet 的 `Last data=1` 表达。
当前实现:`FanucJ519Client.StopMotionAsync()` 当前会停止状态包驱动发送并发送 packet type `2`,而稠密轨迹执行期间保持 `LastData=0`。这是与 FANUC 文档最明显的语义差异之一;已有多数 UTTC 抓包显示主运行窗口 `LastData=0`,但 `../j519 协议.pcap` 中存在 1 个 `LastData=1` 后紧跟 packet type `2` 的样本,后续应单独校准停止语义。
当前实现:`FanucJ519Client.StopMotionAsync()` 仍保留 packet type `2` 作为禁用、异常中断或断开前的强制停止路径。正常稠密运动结束由内部开关 `EndCommandStreamAfterQueueDrained` 控制,默认会在队列末尾追加一帧 `LastData=1` 的结束命令;该帧发出后客户端进入仅监控状态,继续接收 J519 状态包用于位置监控,不再持续回发保姿命令。关闭该开关时保留旧行为:队列结束后继续保持最后目标或反馈位置。`LastData` 是单帧 command packet 字段,不是全局状态;下一轮运动重新装载的新队列会从 `LastData=0` 的普通命令帧开始。
## 4. 通信时序重点
@@ -100,7 +100,7 @@
1. `FanucJ519Client` 已改为收到机器人 132B status 包后立即回发当前命令,不再由上位机本地固定 8ms 发送循环主动发包。
2. 命令包 sequence 已按刚收到的 status packet sequence 写入,避免第一帧从本地 `0` 起步。
3. `FanucControllerRuntime.ExecuteTrajectory()` 启动前会检查已有 J519 响应中的 `AcceptsCommand` 和 `SystemReady`;但如果还没收到状态包,则会放行,后续命令仍要等第一帧 status 到达才会发出。
4. 当前稠密轨迹结束不发送 `LastData=1`,而是依赖停止 J519 状态包驱动发送和 packet type `2` stop 控制包。
4. 默认新路径会在稠密轨迹队列末尾追加独立的 `LastData=1` 结束帧;结束后只监控状态,不发送 packet type `2`。旧保持路径仍可通过内部开关回退。
序号和节拍已经按手册方向校准;停止语义仍需在真实 R30iB 联调中继续确认。
@@ -220,13 +220,13 @@
主要差异/风险:
1. 当前未实现命令缓冲预填,也未读取 `$PKT_STACK / $START_MOVE`。
2. 当前停止运动依赖 packet type `2` stop 控制包,没有稳定发送 `LastData=1` 的最后 command packet;这与手册标准结束语义不同。
2. 默认正常运动结束已经通过追加 `LastData=1` 结束帧靠近手册语义;packet type `2` 仍仅作为强制停止状态输出路径。
3. 当前未实现 packet type `3` 的速度/加速度/jerk 限制表查询,也未实现 payload/speed 20 档动态限制算法。
4. 当前没有自动校验 `AUTO / 100% OVERRIDE / brake control / resume offset / payload` 等控制器前置状态。
5. 当前没有报警码读取和 `MOTN-* / PRIO-*` 映射。
建议后续联调优先级:
1. 验证运动结束是否必须补 `LastData=1`;如果当前 stop 控制包能稳定工作,也应在文档中标为现场兼容路径,而不是手册标准路径。
1. 联调验证 `LastData=1` 后机器人是否稳定退出命令消费并继续输出状态包;如现场程序仍要求持续命令包,可通过内部开关回退旧保持路径。
2. 抓一次报警现场包,确认 `MOTN-600/603/617` 等是否能从包序号与状态位直接定位。
3. 如果后续追求更稳的真实机运行,补 packet type `3` 限制表查询,并把规划器的速度、加速度、jerk 校验与 FANUC 手册算法靠近。

View File

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

After

Width:  |  Height:  |  Size: 8.3 KiB

View File

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

View File

@@ -0,0 +1,55 @@
# Flyshot 跨平台混淆发布 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 为全部 `Flyshot.*` 自研程序集建立依赖环境 Obfuscar 的跨平台框架依赖混淆发布流程。
**Architecture:** 使用一个 PowerShell 入口编排 `dotnet publish`、绝对路径 Obfuscar XML 生成、混淆执行、产物完整性检查和映射文件隔离。发布脚本不修改项目运行时代码,也不引入混淆器包依赖。
**Tech Stack:** PowerShell 7、.NET 8 SDK、Obfuscar.GlobalTool 2.2.50、ASP.NET Core 自包含发布。
---
### Task 1: 发布脚本契约测试
**Files:**
- Create: `tests/Publishing/Publish-Obfuscated.Tests.ps1`
- [ ] 编写测试夹具,创建包含八个 `Flyshot.*.dll` 和一个第三方 DLL 的临时发布目录。
- [ ] 调用尚不存在的配置生成入口,断言测试因缺少 `scripts/Publish-Obfuscated.ps1` 而失败。
- [ ] 保留测试入口,使后续实现可以在 `-GenerateConfigOnly` 模式下验证配置内容而不执行真实发布。
### Task 2: 双平台混淆发布脚本
**Files:**
- Create: `scripts/Publish-Obfuscated.ps1`
- [ ] 定义带完整注释的参数、静态程序集清单和辅助函数。
- [ ] 检查 PowerShell、`dotnet` 与 `obfuscar.console` 环境命令。
- [ ] 使用 `dotnet publish -c Release -r <rid> --self-contained true -p:PublishSingleFile=false` 生成原始产物。
- [ ] 为八个 `Flyshot.*.dll` 生成使用绝对路径的 Obfuscar XML。
- [ ] 执行 Obfuscar,并检查每个输入模块都有混淆输出。
- [ ] 将混淆 DLL/PDB 替换进发布目录,并将 `Mapping.txt` 隔离到 `outputs/obfuscation-maps/<rid>/`。
- [ ] 不传 RID,生成一份 Windows/Linux 共用的 .NET 8 框架依赖发布包。
### Task 3: 发布任务与文档
**Files:**
- Modify: `.vscode/tasks.json`
- Modify: `README.md`
- [ ] 将 VS Code `publish` 任务改为调用 PowerShell 混淆发布脚本。
- [ ] 在 README 发布章节记录环境安装、发布命令、输出目录和保护边界。
### Task 4: 验证
**Files:**
- Modify: `docs/superpowers/plans/2026-06-22-cross-platform-obfuscation.md`
- [ ] 运行脚本契约测试并确认通过。
- [ ] 运行 `dotnet test FlyshotReplacement.sln -c Release -v minimal`。
- [ ] 运行 `dotnet build FlyshotReplacement.sln -c Release --no-restore -v minimal`。
- [ ] 环境安装 Obfuscar 后执行 `pwsh -File scripts/Publish-Obfuscated.ps1`。
- [ ] 启动 Windows 混淆产物并请求 `/healthz`。
- [ ] 检查 Linux 混淆产物结构;存在 WSL 时启动并请求 `/healthz`。
- [ ] 检查 `git diff`,确认没有第三方程序集或映射文件进入部署包。

View File

@@ -0,0 +1,44 @@
# Flyshot 跨平台混淆发布设计
## 目标
为全部 `Flyshot.*` 自研程序集增加统一的 Obfuscar 混淆发布流程,生成一份 Windows 与 Linux 共用的 .NET 8 框架依赖部署包。混淆流程必须显式失败,不能在工具缺失或混淆失败时留下看似成功的未保护发布包。
## 工具与环境边界
- 使用环境中安装的 Obfuscar 全局工具,命令名为 `obfuscar.console`。
- 仓库不增加 Obfuscar NuGet `PackageReference`,也不增加 `.config/dotnet-tools.json`。
- 构建环境需预先执行 `dotnet tool install --global Obfuscar.GlobalTool --version 2.2.50`。
- 发布脚本只依赖 PowerShell 7、.NET 8 SDK 和环境中的 Obfuscar。
## 发布结构
发布脚本不接受运行平台或 RID,固定执行一次框架依赖发布:
1. 使用 `dotnet publish` 生成自包含、多文件的原始发布目录。
2. 动态生成包含绝对路径的 Obfuscar XML 配置。
3. 校验当前八个核心程序集不可缺失,并将发布目录中的全部 `Flyshot.*.dll` 加入混淆模块列表。
4. Obfuscar 输出到独立临时目录。
5. 仅在混淆成功且输出程序集完整时,用混淆程序集替换原始发布目录中的对应文件。
6. 将映射文件移入不随部署包交付的 `outputs/obfuscation-maps/<rid>/`。
7. 最终部署包输出到 `outputs/obfuscated/framework-dependent/`。
## 混淆边界
- `KeepPublicApi=true`:保留公共 API 名称,保护 ASP.NET Core 控制器发现、配置绑定、DI、JSON DTO、Swagger 和程序集间调用。
- `HidePrivateApi=true`:重命名内部类型、私有方法和字段。
- `RenameProperties=false`、`RenameEvents=false`:避免配置绑定、JSON 序列化和框架约定受属性或事件重命名影响。
- 不处理 `Microsoft.*`、`System.*`、NLog、Swashbuckle 等第三方程序集。
- 所有 `Flyshot.*.dll` 必须被发现并加入配置;缺少任何当前核心程序集都视为发布失败,未来新增模块会自动纳入。
## 验证
- 脚本契约测试验证:RID 列表、环境工具缺失失败、全部自研程序集进入配置、第三方程序集不进入配置、映射文件不进入部署目录。
- 执行现有解决方案测试与 Release 构建。
- 真实执行框架依赖发布及混淆,并分别在 Windows 与 Linux 的 .NET 8 运行时上启动。
- Windows 包实际启动并请求 `/healthz`。
- Linux 包至少验证 ELF 启动文件、依赖文件和全部混淆程序集完整;环境存在 WSL 时再实际启动并请求 `/healthz`。
## 风险与限制
Obfuscar 能显著提高普通反编译的阅读成本,但不能绝对阻止有经验的逆向分析。保留公共 API 是当前 ASP.NET Core 稳定运行所需的安全边界;若未来需要更强的控制流混淆、字符串加密或防篡改,应评估商业混淆器。

View File

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

View File

@@ -0,0 +1,635 @@
<#
.SYNOPSIS
发布并混淆 Flyshot 跨平台框架依赖部署包。
.DESCRIPTION
脚本先通过 dotnet publish 生成不区分运行平台的框架依赖产物,再调用环境中安装的
Obfuscar 全局工具混淆全部 Flyshot.* 自研程序集。第三方程序集不会进入混淆模块,
映射文件会保存到独立目录,不会随部署包交付。目标机器需要安装 .NET 8 运行时。
.PARAMETER OutputRoot
最终混淆部署包目录。
.PARAMETER MappingRoot
Obfuscar 映射文件的保存根目录。该目录不得作为部署包交付。
.PARAMETER GenerateConfigOnly
仅生成 Obfuscar 配置,供自动化测试和问题诊断使用,不执行发布或混淆。
.PARAMETER InputDirectory
GenerateConfigOnly 模式下的程序集输入目录。
.PARAMETER ObfuscatedDirectory
GenerateConfigOnly 模式下的混淆输出目录。
.PARAMETER ConfigPath
GenerateConfigOnly 模式下生成的 Obfuscar XML 文件路径。
.PARAMETER ObfuscateExistingPublish
仅混淆已经由 Visual Studio 或 dotnet publish 生成的现有发布目录,不再次执行发布。
.PARAMETER ExistingPublishDirectory
ObfuscateExistingPublish 模式下需要原地替换自研程序集的发布目录。
#>
[CmdletBinding()]
param(
[string]$OutputRoot,
[string]$MappingRoot,
[switch]$GenerateConfigOnly,
[string]$InputDirectory,
[string]$ObfuscatedDirectory,
[string]$ConfigPath,
[switch]$ObfuscateExistingPublish,
[string]$ExistingPublishDirectory
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
# 该清单固定了当前解决方案必须保护的全部自研程序集,避免新增或丢失模块时静默发布。
$script:FlyshotAssemblyNames = @(
'Flyshot.Server.Host.dll'
'Flyshot.ControllerClientCompat.dll'
'Flyshot.Core.Config.dll'
'Flyshot.Core.Domain.dll'
'Flyshot.Core.Planning.dll'
'Flyshot.Core.Triggering.dll'
'Flyshot.Runtime.Fanuc.dll'
'Flyshot.Runtime.Common.dll'
)
<#
.SYNOPSIS
将路径转换为规范化绝对路径。
.PARAMETER Path
需要规范化的文件或目录路径,可以是相对路径或绝对路径。
.OUTPUTS
返回由 System.IO.Path 解析后的绝对路径字符串。
#>
function ConvertTo-AbsolutePath
{
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$Path
)
return [System.IO.Path]::GetFullPath($Path)
}
<#
.SYNOPSIS
检查发布目录是否包含全部预期的 Flyshot 自研程序集。
.PARAMETER Directory
待检查的发布输入目录。
.OUTPUTS
按固定清单顺序返回每个程序集的绝对路径。
.EXCEPTION
当目录不存在或任一预期程序集缺失时抛出异常,阻止生成未完整保护的发布包。
#>
function Get-FlyshotAssemblyPaths
{
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$Directory
)
$absoluteDirectory = ConvertTo-AbsolutePath -Path $Directory
if (-not (Test-Path -LiteralPath $absoluteDirectory -PathType Container))
{
throw "程序集输入目录不存在:$absoluteDirectory"
}
foreach ($assemblyName in $script:FlyshotAssemblyNames)
{
$assemblyPath = Join-Path $absoluteDirectory $assemblyName
if (-not (Test-Path -LiteralPath $assemblyPath -PathType Leaf))
{
throw "发布目录缺少必须混淆的自研程序集:$assemblyName"
}
}
# 固定清单负责阻止当前核心模块缺失,通配发现负责自动覆盖未来新增的 Flyshot 自研程序集。
$assemblyPaths = Get-ChildItem -LiteralPath $absoluteDirectory -Filter 'Flyshot.*.dll' -File |
Sort-Object Name |
ForEach-Object { ConvertTo-AbsolutePath -Path $_.FullName }
return @($assemblyPaths)
}
<#
.SYNOPSIS
发现当前环境中与项目目标框架匹配的 .NET 8 共享框架目录。
.DESCRIPTION
框架依赖发布不会把 Microsoft.NETCore.App 与 Microsoft.AspNetCore.App 的程序集复制到发布目录。
Obfuscar 仍需要这些目录解析继承关系,因此通过 dotnet --list-runtimes 动态发现实际安装位置。
.OUTPUTS
返回 Microsoft.NETCore.App 与 Microsoft.AspNetCore.App 最新 .NET 8 运行时的绝对目录。
.EXCEPTION
当环境未安装所需的 .NET 8 共享框架时抛出异常,避免生成无法解析依赖的混淆配置。
#>
function Get-DotNetSharedFrameworkSearchPaths
{
[CmdletBinding()]
param()
$runtimeEntries = & dotnet --list-runtimes
if ($LASTEXITCODE -ne 0)
{
throw "无法查询已安装的 .NET 运行时,dotnet --list-runtimes 退出码为 $LASTEXITCODE。"
}
$requiredFrameworks = @(
'Microsoft.NETCore.App'
'Microsoft.AspNetCore.App'
)
$searchPaths = foreach ($frameworkName in $requiredFrameworks)
{
$candidates = foreach ($runtimeEntry in $runtimeEntries)
{
if ($runtimeEntry -match '^(?<name>\S+)\s+(?<version>8\.\d+\.\d+)\s+\[(?<root>.+)\]$')
{
if ($Matches.name -eq $frameworkName)
{
[pscustomobject]@{
Version = [version]$Matches.version
Path = Join-Path $Matches.root $Matches.version
}
}
}
}
$selectedRuntime = $candidates |
Sort-Object Version -Descending |
Select-Object -First 1
if ($null -eq $selectedRuntime -or -not (Test-Path -LiteralPath $selectedRuntime.Path -PathType Container))
{
throw "环境缺少 Obfuscar 解析依赖所需的 .NET 8 共享框架:$frameworkName"
}
ConvertTo-AbsolutePath -Path $selectedRuntime.Path
}
return @($searchPaths)
}
<#
.SYNOPSIS
生成仅包含 Flyshot 自研程序集的 Obfuscar XML 配置。
.PARAMETER SourceDirectory
包含发布程序集及其依赖项的输入目录。
.PARAMETER TargetDirectory
Obfuscar 写入混淆程序集的独立输出目录。
.PARAMETER DestinationPath
要写入的 Obfuscar XML 配置文件路径。
.OUTPUTS
返回生成后的 Obfuscar XML 配置绝对路径。
#>
function New-ObfuscarConfiguration
{
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$SourceDirectory,
[Parameter(Mandatory)]
[string]$TargetDirectory,
[Parameter(Mandatory)]
[string]$DestinationPath
)
$absoluteSourceDirectory = ConvertTo-AbsolutePath -Path $SourceDirectory
$absoluteTargetDirectory = ConvertTo-AbsolutePath -Path $TargetDirectory
$absoluteDestinationPath = ConvertTo-AbsolutePath -Path $DestinationPath
$assemblyPaths = Get-FlyshotAssemblyPaths -Directory $absoluteSourceDirectory
$assemblySearchPaths = Get-DotNetSharedFrameworkSearchPaths
$destinationParent = Split-Path -Parent $absoluteDestinationPath
New-Item -ItemType Directory -Path $destinationParent -Force | Out-Null
New-Item -ItemType Directory -Path $absoluteTargetDirectory -Force | Out-Null
$settings = [ordered]@{
InPath = $absoluteSourceDirectory
OutPath = $absoluteTargetDirectory
KeepPublicApi = 'true'
HidePrivateApi = 'true'
RenameProperties = 'false'
RenameEvents = 'false'
RenameFields = 'true'
SkipGenerated = 'true'
SkipSpecialName = 'true'
}
$xmlSettings = [System.Xml.XmlWriterSettings]::new()
$xmlSettings.Indent = $true
$xmlSettings.Encoding = [System.Text.UTF8Encoding]::new($false)
$writer = [System.Xml.XmlWriter]::Create($absoluteDestinationPath, $xmlSettings)
try
{
$writer.WriteStartDocument()
$writer.WriteStartElement('Obfuscator')
foreach ($setting in $settings.GetEnumerator())
{
$writer.WriteStartElement('Var')
$writer.WriteAttributeString('name', [string]$setting.Key)
$writer.WriteAttributeString('value', [string]$setting.Value)
$writer.WriteEndElement()
}
foreach ($assemblySearchPath in $assemblySearchPaths)
{
# 框架依赖发布不携带共享框架 DLL,Obfuscar 通过这些目录解析 ASP.NET Core 与运行时依赖。
$writer.WriteStartElement('AssemblySearchPath')
$writer.WriteAttributeString('path', $assemblySearchPath)
$writer.WriteEndElement()
}
foreach ($assemblyPath in $assemblyPaths)
{
# 使用明确的绝对路径,兼容 Obfuscar 当前版本并避免工作目录差异。
$writer.WriteStartElement('Module')
$writer.WriteAttributeString('file', $assemblyPath)
# 匿名类型会被 ASP.NET Core 的 System.Text.Json 反射序列化,必须完整保留类型及构造参数元数据。
$writer.WriteStartElement('SkipType')
$writer.WriteAttributeString('name', '^<>f__AnonymousType.*')
$writer.WriteAttributeString('skipMethods', 'true')
$writer.WriteAttributeString('skipFields', 'true')
$writer.WriteAttributeString('skipProperties', 'true')
$writer.WriteAttributeString('skipEvents', 'true')
$writer.WriteEndElement()
$writer.WriteEndElement()
}
$writer.WriteEndElement()
$writer.WriteEndDocument()
}
finally
{
$writer.Dispose()
}
return $absoluteDestinationPath
}
<#
.SYNOPSIS
确认发布依赖的外部命令已安装并可从 PATH 调用。
.PARAMETER CommandName
要检查的命令名称。
.PARAMETER InstallHint
命令缺失时展示给构建人员的安装提示。
#>
function Assert-EnvironmentCommand
{
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$CommandName,
[Parameter(Mandatory)]
[string]$InstallHint
)
if (-not (Get-Command $CommandName -ErrorAction SilentlyContinue))
{
throw "环境缺少命令 '$CommandName'。$InstallHint"
}
}
<#
.SYNOPSIS
执行外部命令并在非零退出码时终止发布。
.PARAMETER FilePath
需要执行的外部命令。
.PARAMETER ArgumentList
按顺序传入外部命令的参数数组。
#>
function Invoke-CheckedCommand
{
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$FilePath,
[Parameter(Mandatory)]
[string[]]$ArgumentList
)
& $FilePath @ArgumentList
if ($LASTEXITCODE -ne 0)
{
throw "命令执行失败,退出码为 $LASTEXITCODE:$FilePath $($ArgumentList -join ' ')"
}
}
<#
.SYNOPSIS
将 Obfuscar 输出安全替换进发布目录并隔离映射文件。
.PARAMETER PublishedDirectory
dotnet publish 生成的最终部署目录。
.PARAMETER ObfuscatedOutputDirectory
Obfuscar 独立输出目录。
.PARAMETER RuntimeMappingDirectory
框架依赖发布对应的映射文件保存目录。
#>
function Install-ObfuscatedAssemblies
{
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$PublishedDirectory,
[Parameter(Mandatory)]
[string]$ObfuscatedOutputDirectory,
[Parameter(Mandatory)]
[string]$RuntimeMappingDirectory
)
$assemblyNames = Get-FlyshotAssemblyPaths -Directory $PublishedDirectory |
ForEach-Object { [System.IO.Path]::GetFileName($_) }
foreach ($assemblyName in $assemblyNames)
{
$obfuscatedAssemblyPath = Join-Path $ObfuscatedOutputDirectory $assemblyName
if (-not (Test-Path -LiteralPath $obfuscatedAssemblyPath -PathType Leaf))
{
throw "Obfuscar 未生成预期程序集:$assemblyName"
}
}
foreach ($assemblyName in $assemblyNames)
{
Copy-Item `
-LiteralPath (Join-Path $ObfuscatedOutputDirectory $assemblyName) `
-Destination (Join-Path $PublishedDirectory $assemblyName) `
-Force
$pdbName = [System.IO.Path]::ChangeExtension($assemblyName, '.pdb')
$obfuscatedPdbPath = Join-Path $ObfuscatedOutputDirectory $pdbName
if (Test-Path -LiteralPath $obfuscatedPdbPath -PathType Leaf)
{
Copy-Item `
-LiteralPath $obfuscatedPdbPath `
-Destination (Join-Path $PublishedDirectory $pdbName) `
-Force
}
}
$mappingPath = Join-Path $ObfuscatedOutputDirectory 'Mapping.txt'
if (-not (Test-Path -LiteralPath $mappingPath -PathType Leaf))
{
throw 'Obfuscar 未生成 Mapping.txt,无法确认混淆映射结果。'
}
New-Item -ItemType Directory -Path $RuntimeMappingDirectory -Force | Out-Null
Copy-Item -LiteralPath $mappingPath -Destination (Join-Path $RuntimeMappingDirectory 'Mapping.txt') -Force
# 部署目录不得包含符号映射,避免把重命名关系直接交付给最终用户。
$publishedMappingPath = Join-Path $PublishedDirectory 'Mapping.txt'
if (Test-Path -LiteralPath $publishedMappingPath)
{
Remove-Item -LiteralPath $publishedMappingPath -Force
}
# XML 文档包含源码 /// 注释,PDB 包含调试符号;生产混淆包不得向反编译器暴露这些辅助信息。
Get-ChildItem -LiteralPath $PublishedDirectory -File |
Where-Object {
$_.Name -like 'Flyshot*.xml' -or
$_.Name -like 'Flyshot*.pdb'
} |
Remove-Item -Force
}
<#
.SYNOPSIS
发布并混淆 Flyshot 框架依赖主程序。
.PARAMETER RepositoryRoot
Flyshot Replacement 仓库根目录。
.PARAMETER PublishedOutputDirectory
最终混淆部署包目录。
.PARAMETER MappingOutputRoot
全部混淆映射文件的根目录。
#>
function Publish-ObfuscatedFrameworkDependent
{
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$RepositoryRoot,
[Parameter(Mandatory)]
[string]$PublishedOutputDirectory,
[Parameter(Mandatory)]
[string]$MappingOutputRoot
)
$hostProject = Join-Path $RepositoryRoot 'src\Flyshot.Server.Host\Flyshot.Server.Host.csproj'
$workingDirectory = Join-Path $RepositoryRoot '.tmp\obfuscation\framework-dependent'
$obfuscatedOutputDirectory = Join-Path $workingDirectory 'obfuscated'
$configurationPath = Join-Path $workingDirectory 'obfuscar.xml'
$runtimeMappingDirectory = Join-Path $MappingOutputRoot 'framework-dependent'
# 每次发布使用干净目录,避免旧程序集或旧映射掩盖本轮失败。
foreach ($directory in @($PublishedOutputDirectory, $workingDirectory, $runtimeMappingDirectory))
{
if (Test-Path -LiteralPath $directory)
{
Remove-Item -LiteralPath $directory -Recurse -Force
}
}
New-Item -ItemType Directory -Path $PublishedOutputDirectory -Force | Out-Null
Write-Host '正在发布跨平台框架依赖包 ...'
Invoke-CheckedCommand -FilePath 'dotnet' -ArgumentList @(
'publish'
$hostProject
'-c'
'Release'
'--self-contained'
'false'
'-p:PublishSingleFile=false'
'-p:UseAppHost=false'
'-p:DebugType=none'
'-o'
$PublishedOutputDirectory
)
New-ObfuscarConfiguration `
-SourceDirectory $PublishedOutputDirectory `
-TargetDirectory $obfuscatedOutputDirectory `
-DestinationPath $configurationPath | Out-Null
Write-Host '正在混淆框架依赖包中的 Flyshot 自研程序集 ...'
Invoke-CheckedCommand -FilePath 'obfuscar.console' -ArgumentList @($configurationPath)
Install-ObfuscatedAssemblies `
-PublishedDirectory $PublishedOutputDirectory `
-ObfuscatedOutputDirectory $obfuscatedOutputDirectory `
-RuntimeMappingDirectory $runtimeMappingDirectory
Write-Host "已生成跨平台框架依赖混淆包:$PublishedOutputDirectory"
}
<#
.SYNOPSIS
混淆 Visual Studio 已生成的现有发布目录。
.PARAMETER PublishedDirectory
包含全部发布文件的目录;混淆成功后会原地替换其中的 Flyshot 自研程序集。
.PARAMETER RepositoryRoot
Flyshot Replacement 仓库根目录。
.PARAMETER MappingOutputRoot
混淆映射文件的根目录;映射文件不会进入发布目录。
#>
function Protect-ExistingPublishedDirectory
{
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$PublishedDirectory,
[Parameter(Mandatory)]
[string]$RepositoryRoot,
[Parameter(Mandatory)]
[string]$MappingOutputRoot
)
$absolutePublishedDirectory = ConvertTo-AbsolutePath -Path $PublishedDirectory
$workingDirectory = Join-Path $RepositoryRoot '.tmp\obfuscation\visual-studio\framework-dependent'
$obfuscatedOutputDirectory = Join-Path $workingDirectory 'obfuscated'
$configurationPath = Join-Path $workingDirectory 'obfuscar.xml'
$runtimeMappingDirectory = Join-Path $MappingOutputRoot 'framework-dependent'
if (-not (Test-Path -LiteralPath $absolutePublishedDirectory -PathType Container))
{
throw "Visual Studio 发布目录不存在:$absolutePublishedDirectory"
}
# 后处理必须使用独立输出目录,避免 Obfuscar 读写同一个程序集导致文件锁或半成品。
foreach ($directory in @($workingDirectory, $runtimeMappingDirectory))
{
if (Test-Path -LiteralPath $directory)
{
Remove-Item -LiteralPath $directory -Recurse -Force
}
}
New-ObfuscarConfiguration `
-SourceDirectory $absolutePublishedDirectory `
-TargetDirectory $obfuscatedOutputDirectory `
-DestinationPath $configurationPath | Out-Null
Write-Host "正在混淆 Visual Studio 发布目录中的 Flyshot 自研程序集:$absolutePublishedDirectory"
Invoke-CheckedCommand -FilePath 'obfuscar.console' -ArgumentList @($configurationPath)
Install-ObfuscatedAssemblies `
-PublishedDirectory $absolutePublishedDirectory `
-ObfuscatedOutputDirectory $obfuscatedOutputDirectory `
-RuntimeMappingDirectory $runtimeMappingDirectory
Write-Host "Visual Studio 发布目录混淆完成:$absolutePublishedDirectory"
}
try
{
if ($GenerateConfigOnly)
{
foreach ($requiredValue in @{
InputDirectory = $InputDirectory
ObfuscatedDirectory = $ObfuscatedDirectory
ConfigPath = $ConfigPath
}.GetEnumerator())
{
if ([string]::IsNullOrWhiteSpace([string]$requiredValue.Value))
{
throw "GenerateConfigOnly 模式缺少参数:$($requiredValue.Key)"
}
}
New-ObfuscarConfiguration `
-SourceDirectory $InputDirectory `
-TargetDirectory $ObfuscatedDirectory `
-DestinationPath $ConfigPath | Out-Null
exit 0
}
$repositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
if ([string]::IsNullOrWhiteSpace($OutputRoot))
{
$OutputRoot = Join-Path $repositoryRoot 'outputs\obfuscated\framework-dependent'
}
if ([string]::IsNullOrWhiteSpace($MappingRoot))
{
$MappingRoot = Join-Path $repositoryRoot 'outputs\obfuscation-maps'
}
Assert-EnvironmentCommand -CommandName 'dotnet' -InstallHint '请安装仓库 global.json 指定的 .NET SDK。'
Assert-EnvironmentCommand `
-CommandName 'obfuscar.console' `
-InstallHint '请执行:dotnet tool install --global Obfuscar.GlobalTool --version 2.2.50'
if ($ObfuscateExistingPublish)
{
if ([string]::IsNullOrWhiteSpace($ExistingPublishDirectory))
{
throw 'ObfuscateExistingPublish 模式缺少参数:ExistingPublishDirectory'
}
Protect-ExistingPublishedDirectory `
-PublishedDirectory $ExistingPublishDirectory `
-RepositoryRoot $repositoryRoot `
-MappingOutputRoot (ConvertTo-AbsolutePath -Path $MappingRoot)
exit 0
}
Publish-ObfuscatedFrameworkDependent `
-RepositoryRoot $repositoryRoot `
-PublishedOutputDirectory (ConvertTo-AbsolutePath -Path $OutputRoot) `
-MappingOutputRoot (ConvertTo-AbsolutePath -Path $MappingRoot)
Write-Host 'Flyshot 跨平台框架依赖混淆发布包已生成。'
exit 0
}
catch
{
Write-Error $_
exit 1
}

View File

@@ -20,6 +20,11 @@ public sealed class ControllerClientCompatOptions
/// </summary>
public string? WorkspaceRoot { get; set; }
/// <summary>
/// 获取或设置服务启动时自动初始化机器人的配置;为 null 时跳过自动初始化。
/// </summary>
public AutoInitOptions? AutoInit { get; set; }
/// <summary>
/// 解析运行配置根目录,确保运行时默认不再依赖源码仓库位置。
/// </summary>
@@ -44,3 +49,19 @@ public sealed class ControllerClientCompatOptions
: Path.GetFullPath(WorkspaceRoot);
}
}
/// <summary>
/// 表示服务启动时自动初始化机器人的配置项。
/// </summary>
public sealed class AutoInitOptions
{
/// <summary>
/// 获取或设置机器人名称,用于加载机器人配置文件(如 LR_Mate_200iD)。
/// </summary>
public string RobotName { get; set; } = string.Empty;
/// <summary>
/// 获取或设置机器人 IP 地址,用于连接真机或仿真控制器。
/// </summary>
public string RobotIp { get; set; } = string.Empty;
}

View File

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

View File

@@ -12,6 +12,10 @@ public sealed class FanucJ519Client : IDisposable
private readonly object _commandLock = new();
private readonly object _responseLock = new();
private readonly ILogger<FanucJ519Client>? _logger;
/// <summary>
/// 指示预装队列发出 LastData 结束帧后是否进入仅监控状态,而不是继续发送保姿命令。
/// </summary>
private readonly bool _endCommandStreamAfterQueueDrained;
private UdpClient? _udpClient;
private CancellationTokenSource? _cts;
private Thread? _receiveThread;
@@ -25,8 +29,14 @@ public sealed class FanucJ519Client : IDisposable
private long _slowSendCount;
private long _maxReceiveToSendTicks;
private uint _sequenceBufferSize;
// 标记 StartMotion 前是否刚装载过新目标,用于区分新命令和上次运动残留目标。
/// <summary>
/// 在 StartMotion 前是否有待发送的新命令;如果为 true 则 StartMotion 不会清空当前目标,而是继续发送新目标。
/// </summary>
private bool _hasPendingCommandForStart;
/// <summary>
/// 标记命令流已通过 LastData 正常结束;该状态下仅接收状态包,不继续回发 hold 命令。
/// </summary>
private bool _monitorOnlyAfterLastData;
private bool _motionStarted;
private bool _disposed;
@@ -35,13 +45,22 @@ public sealed class FanucJ519Client : IDisposable
/// </summary>
public bool IsConnected => _udpClient is not null;
/// <summary>
/// 获取正常队列结束时是否通过 LastData 结束命令流并保留状态监控。
/// </summary>
internal bool EndCommandStreamAfterQueueDrained => _endCommandStreamAfterQueueDrained;
/// <summary>
/// 初始化 FANUC J519 客户端。
/// </summary>
/// <param name="logger">日志记录器;允许 null,供无日志场景使用。</param>
public FanucJ519Client(ILogger<FanucJ519Client>? logger = null)
/// <param name="endCommandStreamAfterQueueDrained">队列结束帧发出后是否停止命令流并保留状态监控。</param>
public FanucJ519Client(
ILogger<FanucJ519Client>? logger = null,
bool endCommandStreamAfterQueueDrained = true)
{
_logger = logger;
_endCommandStreamAfterQueueDrained = endCommandStreamAfterQueueDrained;
}
/// <summary>
@@ -113,6 +132,7 @@ public sealed class FanucJ519Client : IDisposable
}
_hasPendingCommandForStart = false;
_monitorOnlyAfterLastData = false;
_motionStarted = true;
}
@@ -156,6 +176,7 @@ public sealed class FanucJ519Client : IDisposable
{
_motionStarted = false;
_hasPendingCommandForStart = false;
_monitorOnlyAfterLastData = false;
CompleteCommandQueueLocked();
}
@@ -178,6 +199,7 @@ public sealed class FanucJ519Client : IDisposable
CompleteCommandQueueLocked();
_currentCommand = command;
_hasPendingCommandForStart = true;
_monitorOnlyAfterLastData = false;
_commandHistoryForTests?.Add(command);
}
@@ -211,6 +233,8 @@ public sealed class FanucJ519Client : IDisposable
// 队列耗尽后继续保持最后一帧目标,避免运动结束后回落到旧目标或空目标。
_currentCommand = commands[^1];
_hasPendingCommandForStart = true;
_monitorOnlyAfterLastData = false;
_motionStarted = true;
_commandQueueDrainedCompletion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
_commandHistoryForTests?.AddRange(commands);
}
@@ -337,6 +361,7 @@ public sealed class FanucJ519Client : IDisposable
CompleteCommandQueueLocked();
_commandHistoryForTests = null;
_hasPendingCommandForStart = false;
_monitorOnlyAfterLastData = false;
_motionStarted = false;
}
@@ -375,6 +400,7 @@ public sealed class FanucJ519Client : IDisposable
{
CompleteCommandQueueLocked();
_hasPendingCommandForStart = false;
_monitorOnlyAfterLastData = false;
}
}
@@ -526,9 +552,15 @@ public sealed class FanucJ519Client : IDisposable
{
FanucJ519Command? command;
var willDrainQueue = false;
var suppressHoldCommand = false;
lock (_commandLock)
{
if (!_motionStarted)
if (_monitorOnlyAfterLastData)
{
command = null;
suppressHoldCommand = true;
}
else if (!_motionStarted)
{
command = null;
}
@@ -546,7 +578,7 @@ public sealed class FanucJ519Client : IDisposable
}
}
if (command is null)
if (command is null && !suppressHoldCommand)
{
command = TryBuildHoldCommandFromLatestResponse(response);
}
@@ -574,7 +606,14 @@ public sealed class FanucJ519Client : IDisposable
lock (_commandLock)
{
_lastSentCommand = command;
if (willDrainQueue && _commandQueue is { Count: 0 })
if (_endCommandStreamAfterQueueDrained && command.LastData == 1)
{
// LastData 是命令流的正常结束语义;结束后保留状态接收,但不继续回发保姿包。
_monitorOnlyAfterLastData = true;
_motionStarted = false;
CompleteCommandQueueLocked();
}
else if (willDrainQueue && _commandQueue is { Count: 0 })
{
CompleteCommandQueueLocked();
}

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<!-- 生成 XML 文档以便 Swashbuckle 把控制器/DTO 上的 /// summary 注释注入 OpenAPI 文档,
调试页和 Swagger UI 的端点标题都依赖这一份文档。1591 抑制掉 “缺失 XML 注释” 的噪音。 -->
<!-- 生成 XML 文档以便 Swashbuckle 把控制器/DTO 上的 /// summary 注释注入 OpenAPI 文档;
调试页说明文本已经静态内嵌在 debug.html 中,这份 XML 现在只服务 Swagger UI。1591 抑制掉 “缺失 XML 注释” 的噪音。 -->
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);1591</NoWarn>
</PropertyGroup>

View File

@@ -1,6 +1,7 @@
using Flyshot.ControllerClientCompat;
using Flyshot.Server.Host;
using Flyshot.Server.Host.Middleware;
using Flyshot.Server.Host.Services;
using Microsoft.Extensions.Options;
using Microsoft.OpenApi.Models;
using NLog.Web;
@@ -23,6 +24,10 @@ try
builder.Services.Configure<HostSwaggerOptions>(builder.Configuration.GetSection("Swagger"));
builder.Services.Configure<HostCorsOptions>(builder.Configuration.GetSection("Cors"));
builder.Services.AddControllerClientCompat(builder.Configuration);
// 注册服务启动时自动初始化机器人的托管服务;配置缺失时自动跳过。
builder.Services.AddHostedService<AutoInitRobotHostedService>();
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
@@ -35,7 +40,8 @@ try
Version = swaggerOptions.Version
});
// 把控制器与 DTO 上的 /// summary 注释纳入 OpenAPI 文档;调试页据此渲染端点标题。
// 把控制器与 DTO 上的 /// summary 注释纳入 OpenAPI 文档;当前主要服务 Swagger UI,
// 调试页的人类可读说明已经静态内嵌在 debug.html 中,不再依赖发布目录中的 XML 文档。
var xmlDocumentationPath = Path.Combine(AppContext.BaseDirectory, $"{typeof(Program).Assembly.GetName().Name}.xml");
if (File.Exists(xmlDocumentationPath))
{

View File

@@ -0,0 +1,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;
}
}

View File

@@ -6,7 +6,11 @@
}
},
"ControllerClientCompat": {
"ServerVersion": "flyshot-replacement-controller-client-compat/0.1.0"
"ServerVersion": "flyshot-replacement-controller-client-compat/0.1.0",
"AutoInit": {
"RobotName": "LR_Mate_200iD",
"RobotIp": "127.0.0.1"
}
},
"Swagger": {
"Enabled": true,

View File

@@ -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 = "";

View File

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

View File

@@ -60,15 +60,20 @@ public sealed class FanucControllerRuntimeDenseTests
WaitUntilIdle(runtime);
var commands = j519Client.GetCommandHistoryForTests();
Assert.Equal(3, commands.Count);
Assert.Equal(4, commands.Count);
Assert.All(commands, static command => Assert.Equal(0u, command.Sequence));
var expectedJ1Targets = new[] { 0.0, 0.08, 0.16 };
var actualJ1Targets = commands.Select(static command => command.TargetJoints[0]).ToArray();
var actualJ1Targets = commands.Take(3).Select(static command => command.TargetJoints[0]).ToArray();
Assert.Equal(expectedJ1Targets.Length, actualJ1Targets.Length);
for (var index = 0; index < expectedJ1Targets.Length; index++)
{
Assert.Equal(expectedJ1Targets[index], actualJ1Targets[index], precision: 6);
}
Assert.All(commands.Take(3), static command => Assert.Equal(0, command.LastData));
Assert.Equal(1, commands[^1].LastData);
Assert.Equal(0, commands[^1].WriteIoMask);
Assert.Equal(0, commands[^1].WriteIoValue);
Assert.Equal(0.16, commands[^1].TargetJoints[0], precision: 6);
Assert.False(j519Client.IsCommandQueueDrainedForTests());
}
@@ -114,12 +119,12 @@ public sealed class FanucControllerRuntimeDenseTests
denseJointTrajectory: denseTrajectory);
var executeTask = Task.Run(() => runtime.ExecuteTrajectory(result, [DegreesToRadians(0.16), 0.0, 0.0, 0.0, 0.0, 0.0]), cts.Token);
await WaitUntilAsync(() => j519Client.GetCommandHistoryForTests().Count == 3, cts.Token);
await WaitUntilAsync(() => j519Client.GetCommandHistoryForTests().Count == 4, cts.Token);
// 只有机器人状态包把队列全部取出后,ExecuteTrajectory 才能向上层返回。
Assert.False(executeTask.IsCompleted);
for (uint sequence = 100; sequence < 103; sequence++)
for (uint sequence = 100; sequence < 104; sequence++)
{
await SendStatusPacketAsync(server, initResult.RemoteEndPoint, sequence, cts.Token);
_ = await server.ReceiveAsync(cts.Token);
@@ -190,9 +195,9 @@ public sealed class FanucControllerRuntimeDenseTests
var timingLines = File.ReadAllLines(timingPath);
var jerkLines = File.ReadAllLines(jerkPath);
var shotEventsJson = File.ReadAllText(shotEventsPath);
Assert.Equal(commands.Count, pointLines.Length);
Assert.Equal(commands.Count, timingLines.Length);
Assert.Equal(Math.Max(0, commands.Count - 1), jerkLines.Length);
Assert.Equal(denseTrajectory.Length, pointLines.Length);
Assert.Equal(denseTrajectory.Length, timingLines.Length);
Assert.Equal(Math.Max(0, denseTrajectory.Length - 1), jerkLines.Length);
var firstColumns = ParseColumns(pointLines[0]);
var secondColumns = ParseColumns(pointLines[1]);
@@ -216,7 +221,7 @@ public sealed class FanucControllerRuntimeDenseTests
Assert.Equal(1.0, secondTimingColumns[0], precision: 6);
Assert.Equal(0.008, secondTimingColumns[1], precision: 6);
Assert.Equal(0.008, secondTimingColumns[2], precision: 6);
Assert.Equal(commands.Count - 1, lastTimingColumns[0], precision: 6);
Assert.Equal(denseTrajectory.Length - 1, lastTimingColumns[0], precision: 6);
Assert.Equal(0.016, lastTimingColumns[2], precision: 6);
var firstJerkColumns = ParseColumns(jerkLines[0]);
@@ -395,10 +400,11 @@ public sealed class FanucControllerRuntimeDenseTests
var commands = j519Client.GetCommandHistoryForTests();
Assert.NotEmpty(commands);
Assert.Equal(preparedExecution.Samples.Count, commands.Count);
Assert.Equal(speedRatio, preparedExecution.FinalSpeedRatio, precision: 6);
Assert.Equal(preparedExecution.Samples.Count + 1, commands.Count);
Assert.Equal(speedRatio, preparedExecution.RequestSpeedRatio, precision: 6);
Assert.Equal(0, preparedExecution.StretchIterationCount);
Assert.Equal(preparedExecution.Samples[0].JointsDegrees, commands[0].TargetJoints);
Assert.Equal(1, commands[^1].LastData);
}
finally
{
@@ -469,8 +475,9 @@ public sealed class FanucControllerRuntimeDenseTests
WaitUntilIdle(runtime);
var commands = j519Client.GetCommandHistoryForTests();
Assert.Equal(preparedSamples.Length, commands.Count);
Assert.Equal([1.0, 2.0, 3.0], commands.Select(static command => command.TargetJoints[0]));
Assert.Equal(preparedSamples.Length + 1, commands.Count);
Assert.Equal([1.0, 2.0, 3.0], commands.Take(preparedSamples.Length).Select(static command => command.TargetJoints[0]));
Assert.Equal(1, commands[^1].LastData);
}
/// <summary>
@@ -523,17 +530,57 @@ public sealed class FanucControllerRuntimeDenseTests
WaitUntilIdle(runtime);
var commands = j519Client.GetCommandHistoryForTests();
Assert.Equal(expectedResult.DenseJointTrajectory!.Count, commands.Count);
Assert.Equal(expectedResult.DenseJointTrajectory!.Count + 1, commands.Count);
AssertJointDegreesEqual(startJoints, commands[0].TargetJoints);
AssertJointDegreesEqual(targetJoints, commands[^1].TargetJoints);
Assert.Equal(1, commands[^1].LastData);
Assert.All(commands.Take(commands.Count - 1), static command => Assert.Equal(0, command.LastData));
var middleAlpha = ComputeLineAlpha(commands[commands.Count / 2].TargetJoints, startJoints, targetJoints);
var middleAlpha = ComputeLineAlpha(commands[(commands.Count - 1) / 2].TargetJoints, startJoints, targetJoints);
Assert.InRange(middleAlpha, 0.45, 0.55);
var earlyAlpha = ComputeLineAlpha(commands[Math.Min(5, commands.Count - 1)].TargetJoints, startJoints, targetJoints);
Assert.InRange(earlyAlpha, 0.0, 0.02);
}
/// <summary>
/// 验证关闭命令流结束开关时,MoveJoint 保持旧队列长度和最终目标保持语义。
/// </summary>
[Fact]
public void MoveJoint_RealMode_WithEndCommandStreamDisabled_KeepsLegacyQueueShape()
{
using var commandClient = new FanucCommandClient();
using var stateClient = new FanucStateClient();
using var j519Client = new FanucJ519Client(endCommandStreamAfterQueueDrained: false);
using var runtime = new FanucControllerRuntime(commandClient, stateClient, j519Client);
var service = CreateCompatService(runtime);
var startJoints = new[] { 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 };
var targetJoints = new[] { 0.2, -0.1, 0.05, 0.0, 0.0, 0.1 };
service.SetUpRobot("FANUC_LR_Mate_200iD");
j519Client.EnableCommandHistoryForTests();
ForceRealModeEnabled(runtime, speedRatio: 1.0);
SetPrivateField(runtime, "_jointPositions", startJoints);
var options = new ControllerClientCompatOptions
{
ConfigRoot = TestRobotFactory.GetConfigRoot()
};
var robot = new ControllerClientCompatRobotCatalog(options, new RobotModelLoader())
.LoadProfile("FANUC_LR_Mate_200iD", accLimitScale: 1.0, jerkLimitScale: 1.0);
var expectedResult = MoveJointTrajectoryGenerator.CreateResult(robot, startJoints, targetJoints, speedRatio: 1.0);
service.MoveJoint(targetJoints);
WaitUntilIdle(runtime);
var commands = j519Client.GetCommandHistoryForTests();
Assert.Equal(expectedResult.DenseJointTrajectory!.Count, commands.Count);
Assert.All(commands, static command => Assert.Equal(0, command.LastData));
var currentCommand = j519Client.GetCurrentCommand();
Assert.NotNull(currentCommand);
AssertJointDegreesEqual(targetJoints, currentCommand.TargetJoints);
}
[Fact]
public void MoveJointTrajectoryGenerator_LowerSpeedUsesFixedServoPeriodAndLongerPlannedDuration()
{
@@ -629,6 +676,10 @@ public sealed class FanucControllerRuntimeDenseTests
Assert.All(commands, static command => Assert.Equal(0, command.DataStyle));
AssertPoseEqual(startPose, commands[0].TargetValues.Take(6).ToArray());
AssertPoseEqual(targetPose, commands[^1].TargetValues.Take(6).ToArray());
Assert.All(commands.Take(commands.Count - 1), static command => Assert.Equal(0, command.LastData));
Assert.Equal(1, commands[^1].LastData);
Assert.Equal(0, commands[^1].WriteIoMask);
Assert.Equal(0, commands[^1].WriteIoValue);
Assert.All(commands, static command =>
{
Assert.Equal(0.0, command.TargetValues[6], precision: 6);
@@ -677,8 +728,9 @@ public sealed class FanucControllerRuntimeDenseTests
WaitUntilIdle(runtime);
var commands = j519Client.GetCommandHistoryForTests();
Assert.Equal(3, commands.Count);
Assert.Equal(4, commands.Count);
Assert.Equal(0.16, commands[^1].TargetJoints[0], precision: 6);
Assert.Equal(1, commands[^1].LastData);
}
/// <summary>
@@ -782,9 +834,12 @@ public sealed class FanucControllerRuntimeDenseTests
WaitUntilIdle(runtime);
var commands = j519Client.GetCommandHistoryForTests();
Assert.Equal(4, commands.Count);
Assert.Equal([(ushort)0, (ushort)5, (ushort)5, (ushort)5], commands.Select(static command => command.WriteIoMask));
Assert.Equal([(ushort)0, (ushort)5, (ushort)5, (ushort)0], commands.Select(static command => command.WriteIoValue));
Assert.Equal(5, commands.Count);
Assert.Equal([(ushort)0, (ushort)5, (ushort)5, (ushort)5], commands.Take(4).Select(static command => command.WriteIoMask));
Assert.Equal([(ushort)0, (ushort)5, (ushort)5, (ushort)0], commands.Take(4).Select(static command => command.WriteIoValue));
Assert.Equal(1, commands[^1].LastData);
Assert.Equal(0, commands[^1].WriteIoMask);
Assert.Equal(0, commands[^1].WriteIoValue);
}
/// <summary>
@@ -837,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>

View File

@@ -279,6 +279,119 @@ public sealed class FanucJ519ClientTests : IDisposable
Assert.True(client.IsCommandQueueDrainedForTests());
}
/// <summary>
/// 验证启用命令流结束开关后,LastData 结束帧发送完成会进入仅监控状态,不再继续回发保姿命令。
/// </summary>
[Fact]
public async Task LoadCommandQueue_WithEndCommandStreamEnabled_StopsSendingAfterLastDataAndKeepsMonitoring()
{
using var client = new FanucJ519Client();
await client.ConnectAsync("127.0.0.1", Port, _cts.Token);
var initResult = await _server.ReceiveAsync(_cts.Token);
client.StartMotion();
client.LoadCommandQueue(
[
new FanucJ519Command(sequence: 0, targetJoints: [1.0, 0.0, 0.0, 0.0, 0.0, 0.0]),
new FanucJ519Command(sequence: 0, targetJoints: [2.0, 0.0, 0.0, 0.0, 0.0, 0.0], lastData: 1)
]);
await SendStatusPacketAsync(initResult.RemoteEndPoint, sequence: 800);
var first = await _server.ReceiveAsync(_cts.Token);
await SendStatusPacketAsync(initResult.RemoteEndPoint, sequence: 801);
var end = await _server.ReceiveAsync(_cts.Token);
await SendStatusPacketAsync(
initResult.RemoteEndPoint,
sequence: 802,
jointDegrees: [7.0, 8.0, 9.0, 10.0, 11.0, 12.0]);
await Assert.ThrowsAsync<TimeoutException>(
() => _server.ReceiveAsync(_cts.Token).AsTask().WaitAsync(TimeSpan.FromMilliseconds(120)));
var latest = client.GetLatestResponse();
Assert.Equal(FanucJ519Protocol.CommandPacketLength, first.Buffer.Length);
Assert.Equal(FanucJ519Protocol.CommandPacketLength, end.Buffer.Length);
Assert.Equal(0, first.Buffer[0x0c]);
Assert.Equal(1, end.Buffer[0x0c]);
Assert.NotNull(latest);
Assert.Equal(802u, latest.Sequence);
Assert.Equal(7.0, latest.JointDegrees[0], precision: 6);
await client.StopMotionAsync(_cts.Token);
}
/// <summary>
/// 验证禁用命令流结束开关后,即使队列里出现 LastData 也继续沿用旧的最后目标保持行为。
/// </summary>
[Fact]
public async Task LoadCommandQueue_WithEndCommandStreamDisabled_HoldsLastCommandAfterLastData()
{
using var client = new FanucJ519Client(endCommandStreamAfterQueueDrained: false);
await client.ConnectAsync("127.0.0.1", Port, _cts.Token);
var initResult = await _server.ReceiveAsync(_cts.Token);
client.StartMotion();
client.LoadCommandQueue(
[
new FanucJ519Command(sequence: 0, targetJoints: [1.0, 0.0, 0.0, 0.0, 0.0, 0.0]),
new FanucJ519Command(sequence: 0, targetJoints: [2.0, 0.0, 0.0, 0.0, 0.0, 0.0], lastData: 1)
]);
var packets = new List<byte[]>();
for (uint sequence = 810; sequence < 813; sequence++)
{
await SendStatusPacketAsync(initResult.RemoteEndPoint, sequence);
var result = await _server.ReceiveAsync(_cts.Token);
packets.Add(result.Buffer);
}
Assert.Equal([810u, 811u, 812u], packets
.Select(packet => BinaryPrimitives.ReadUInt32BigEndian(packet.AsSpan(0x08, 4)))
.ToArray());
Assert.Equal([1.0f, 2.0f, 2.0f], packets
.Select(packet => BinaryPrimitives.ReadSingleBigEndian(packet.AsSpan(0x1c, 4)))
.ToArray());
await client.StopMotionAsync(_cts.Token);
}
/// <summary>
/// 验证 LastData 后的仅监控状态不会阻止下一轮队列重新开始发送。
/// </summary>
[Fact]
public async Task LoadCommandQueue_AfterLastDataMonitoring_CanStreamNewQueue()
{
using var client = new FanucJ519Client();
await client.ConnectAsync("127.0.0.1", Port, _cts.Token);
var initResult = await _server.ReceiveAsync(_cts.Token);
client.StartMotion();
client.LoadCommandQueue(
[
new FanucJ519Command(sequence: 0, targetJoints: [1.0, 0.0, 0.0, 0.0, 0.0, 0.0]),
new FanucJ519Command(sequence: 0, targetJoints: [2.0, 0.0, 0.0, 0.0, 0.0, 0.0], lastData: 1)
]);
await SendStatusPacketAsync(initResult.RemoteEndPoint, sequence: 820);
_ = await _server.ReceiveAsync(_cts.Token);
await SendStatusPacketAsync(initResult.RemoteEndPoint, sequence: 821);
_ = await _server.ReceiveAsync(_cts.Token);
client.LoadCommandQueue(
[
new FanucJ519Command(sequence: 0, targetJoints: [3.0, 0.0, 0.0, 0.0, 0.0, 0.0]),
new FanucJ519Command(sequence: 0, targetJoints: [4.0, 0.0, 0.0, 0.0, 0.0, 0.0], lastData: 1)
]);
await SendStatusPacketAsync(initResult.RemoteEndPoint, sequence: 822);
var restarted = await _server.ReceiveAsync(_cts.Token);
Assert.Equal(FanucJ519Protocol.CommandPacketLength, restarted.Buffer.Length);
Assert.Equal(822u, BinaryPrimitives.ReadUInt32BigEndian(restarted.Buffer.AsSpan(0x08, 4)));
Assert.Equal(0, restarted.Buffer[0x0c]);
Assert.Equal(3.0f, BinaryPrimitives.ReadSingleBigEndian(restarted.Buffer.AsSpan(0x1c, 4)), precision: 6);
await client.StopMotionAsync(_cts.Token);
}
/// <summary>
/// 验证停止运动后可在同一连接内重启发送,命令序号仍由新的状态包决定。
/// </summary>

View File

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

View File

@@ -0,0 +1,252 @@
<#
.SYNOPSIS
验证混淆发布脚本生成的 Obfuscar 配置满足仓库发布契约。
.DESCRIPTION
该测试使用临时空文件模拟发布目录,仅验证模块筛选、绝对路径和失败语义,
不会执行 dotnet publish,也不会要求测试环境安装 Obfuscar。
#>
$ErrorActionPreference = 'Stop'
$repositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
$publishScriptPath = Join-Path $repositoryRoot 'scripts\Publish-Obfuscated.ps1'
$publishProfilePath = Join-Path $repositoryRoot 'src\Flyshot.Server.Host\Properties\PublishProfiles\FolderProfile.pubxml'
$temporaryRoot = Join-Path ([System.IO.Path]::GetTempPath()) "flyshot-obfuscation-test-$([guid]::NewGuid().ToString('N'))"
$inputDirectory = Join-Path $temporaryRoot 'input'
$outputDirectory = Join-Path $temporaryRoot 'obfuscated'
$configPath = Join-Path $temporaryRoot 'obfuscar.xml'
$expectedAssemblies = @(
'Flyshot.Server.Host.dll'
'Flyshot.ControllerClientCompat.dll'
'Flyshot.Core.Config.dll'
'Flyshot.Core.Domain.dll'
'Flyshot.Core.Planning.dll'
'Flyshot.Core.Triggering.dll'
'Flyshot.Runtime.Fanuc.dll'
'Flyshot.Runtime.Common.dll'
)
$futureAssemblyName = 'Flyshot.Future.Module.dll'
try
{
$publishScriptContent = Get-Content -LiteralPath $publishScriptPath -Raw
$publishScriptTokens = $null
$publishScriptErrors = $null
$publishScriptAst = [System.Management.Automation.Language.Parser]::ParseInput(
$publishScriptContent,
[ref]$publishScriptTokens,
[ref]$publishScriptErrors)
if ($publishScriptErrors.Count -ne 0)
{
throw "Publish-Obfuscated.ps1 存在 PowerShell 语法错误:$($publishScriptErrors[0].Message)"
}
$scriptParameters = @($publishScriptAst.ParamBlock.Parameters | ForEach-Object {
$_.Name.VariablePath.UserPath
})
if ('RuntimeIdentifiers' -in $scriptParameters -or 'ExistingRuntimeIdentifier' -in $scriptParameters)
{
throw '发布脚本不应再暴露运行平台或 RID 参数。'
}
if ($publishScriptContent -match "'-r'|--self-contained'\s*,?\s*'true'|win-x64|linux-x64")
{
throw '发布脚本默认发布流程不得再指定 RID 或生成自包含平台包。'
}
if ($publishScriptContent -notmatch '-p:UseAppHost=false')
{
throw '框架依赖发布必须关闭平台相关 apphost,只保留跨平台 DLL 入口。'
}
[xml]$publishProfile = Get-Content -LiteralPath $publishProfilePath -Raw
$publishNamespace = [System.Xml.XmlNamespaceManager]::new($publishProfile.NameTable)
$publishNamespace.AddNamespace('msb', $publishProfile.Project.NamespaceURI)
$useAppHost = $publishProfile.SelectSingleNode(
'/msb:Project/msb:PropertyGroup/msb:UseAppHost',
$publishNamespace)
if ($null -eq $useAppHost -or $useAppHost.InnerText -ne 'false')
{
throw 'FolderProfile.pubxml 必须关闭平台相关 apphost。'
}
$afterPublishTarget = $publishProfile.SelectSingleNode(
'/msb:Project/msb:Target[@Name="ObfuscatePublishedFlyshotAssemblies"]',
$publishNamespace)
if ($null -eq $afterPublishTarget -or $afterPublishTarget.AfterTargets -ne 'Publish')
{
throw 'FolderProfile.pubxml 必须注册 Publish 完成后的混淆 Target。'
}
$publishExec = $afterPublishTarget.SelectSingleNode('msb:Exec', $publishNamespace)
if (
$null -eq $publishExec -or
$publishExec.Command -notmatch '-ObfuscateExistingPublish' -or
$publishExec.Command -notmatch '\$\(PublishDir\)\.' -or
$publishExec.Command -match 'RuntimeIdentifier'
)
{
throw 'Visual Studio 发布 Target 必须把 PublishDir 以安全的非反斜杠结尾形式交给混淆模式。'
}
New-Item -ItemType Directory -Path $inputDirectory -Force | Out-Null
foreach ($assemblyName in $expectedAssemblies)
{
New-Item -ItemType File -Path (Join-Path $inputDirectory $assemblyName) -Force | Out-Null
}
# 模拟未来新增模块,证明脚本会自动保护所有 Flyshot.* 程序集,而不是只处理当前固定清单。
New-Item -ItemType File -Path (Join-Path $inputDirectory $futureAssemblyName) -Force | Out-Null
# 第三方程序集用于证明配置生成器只选择 Flyshot 自研模块。
New-Item -ItemType File -Path (Join-Path $inputDirectory 'NLog.dll') -Force | Out-Null
& pwsh -NoProfile -File $publishScriptPath `
-GenerateConfigOnly `
-InputDirectory $inputDirectory `
-ObfuscatedDirectory $outputDirectory `
-ConfigPath $configPath
if ($LASTEXITCODE -ne 0)
{
throw "配置生成命令退出码不是 0:$LASTEXITCODE"
}
[xml]$configuration = Get-Content -LiteralPath $configPath -Raw
$modulePaths = @($configuration.Obfuscator.Module | ForEach-Object { [string]$_.file })
$allExpectedAssemblies = @($expectedAssemblies) + $futureAssemblyName
if ($modulePaths.Count -ne $allExpectedAssemblies.Count)
{
throw "预期生成 $($allExpectedAssemblies.Count) 个模块,实际为 $($modulePaths.Count) 个。"
}
foreach ($assemblyName in $allExpectedAssemblies)
{
$expectedPath = [System.IO.Path]::GetFullPath((Join-Path $inputDirectory $assemblyName))
if ($expectedPath -notin $modulePaths)
{
throw "Obfuscar 配置缺少自研程序集:$assemblyName"
}
}
if ($modulePaths | Where-Object { [System.IO.Path]::GetFileName($_) -eq 'NLog.dll' })
{
throw '第三方程序集 NLog.dll 不应进入 Obfuscar 模块列表。'
}
foreach ($path in $modulePaths)
{
if (-not [System.IO.Path]::IsPathFullyQualified($path))
{
throw "Obfuscar 模块路径不是绝对路径:$path"
}
}
foreach ($module in @($configuration.Obfuscator.Module))
{
$anonymousTypeRule = @($module.SkipType | Where-Object {
[string]$_.name -eq '^<>f__AnonymousType.*'
})
if ($anonymousTypeRule.Count -ne 1)
{
throw "模块 $($module.file) 必须显式保留匿名类型元数据。"
}
if (
[string]$anonymousTypeRule[0].skipMethods -ne 'true' -or
[string]$anonymousTypeRule[0].skipFields -ne 'true' -or
[string]$anonymousTypeRule[0].skipProperties -ne 'true' -or
[string]$anonymousTypeRule[0].skipEvents -ne 'true'
)
{
throw "模块 $($module.file) 的匿名类型排除规则不完整。"
}
}
$assemblySearchPaths = @($configuration.Obfuscator.AssemblySearchPath | ForEach-Object {
[string]$_.path
})
if ($assemblySearchPaths.Count -lt 2)
{
throw 'Obfuscar 配置必须包含 .NETCore.App 与 ASP.NET Core 共享框架搜索目录。'
}
foreach ($assemblySearchPath in $assemblySearchPaths)
{
if (-not [System.IO.Path]::IsPathFullyQualified($assemblySearchPath))
{
throw "共享框架搜索目录不是绝对路径:$assemblySearchPath"
}
}
if (-not ($assemblySearchPaths | Where-Object { $_ -match 'Microsoft\.NETCore\.App' }))
{
throw 'Obfuscar 配置缺少 Microsoft.NETCore.App 搜索目录。'
}
if (-not ($assemblySearchPaths | Where-Object { $_ -match 'Microsoft\.AspNetCore\.App' }))
{
throw 'Obfuscar 配置缺少 Microsoft.AspNetCore.App 搜索目录。'
}
$variables = @{}
foreach ($variable in $configuration.Obfuscator.Var)
{
$variables[[string]$variable.name] = [string]$variable.value
}
if ($variables['KeepPublicApi'] -ne 'true')
{
throw 'KeepPublicApi 必须为 true,以保护 ASP.NET Core 和公共契约。'
}
if ($variables['HidePrivateApi'] -ne 'true')
{
throw 'HidePrivateApi 必须为 true,以混淆内部实现。'
}
if ($variables['RenameProperties'] -ne 'false' -or $variables['RenameEvents'] -ne 'false')
{
throw '属性和事件重命名必须关闭,以保护配置绑定及序列化契约。'
}
if ($variables['SkipGenerated'] -ne 'true')
{
throw '编译器生成类型必须跳过混淆,以保护匿名 JSON 响应和异步状态机。'
}
if ($variables['SkipSpecialName'] -ne 'true')
{
throw 'SpecialName 成员必须跳过混淆,以保护属性、事件和框架反射约定。'
}
Remove-Item -LiteralPath (Join-Path $inputDirectory 'Flyshot.Runtime.Common.dll') -Force
$missingAssemblyConfigPath = Join-Path $temporaryRoot 'missing-assembly.xml'
& pwsh -NoProfile -File $publishScriptPath `
-GenerateConfigOnly `
-InputDirectory $inputDirectory `
-ObfuscatedDirectory $outputDirectory `
-ConfigPath $missingAssemblyConfigPath *> $null
if ($LASTEXITCODE -eq 0)
{
throw '缺少预期的 Flyshot 程序集时,配置生成必须失败。'
}
Write-Host 'Publish-Obfuscated.Tests.ps1: PASS'
}
finally
{
if (Test-Path -LiteralPath $temporaryRoot)
{
Remove-Item -LiteralPath $temporaryRoot -Recurse -Force
}
}

View File

@@ -0,0 +1,35 @@
<#
.SYNOPSIS
验证混淆发布包不会泄露 Flyshot 源码文档或调试符号。
.PARAMETER PublishDirectory
需要检查的框架依赖混淆发布目录。
#>
[CmdletBinding()]
param(
[string]$PublishDirectory = (Join-Path $PSScriptRoot '..\..\outputs\obfuscated\framework-dependent')
)
$ErrorActionPreference = 'Stop'
$absolutePublishDirectory = [System.IO.Path]::GetFullPath($PublishDirectory)
if (-not (Test-Path -LiteralPath $absolutePublishDirectory -PathType Container))
{
throw "混淆发布目录不存在:$absolutePublishDirectory"
}
$sensitiveFiles = @(
Get-ChildItem -LiteralPath $absolutePublishDirectory -File |
Where-Object {
$_.Name -like 'Flyshot*.xml' -or
$_.Name -like 'Flyshot*.pdb'
}
)
if ($sensitiveFiles.Count -gt 0)
{
throw "混淆发布包仍包含源码文档或调试符号:$($sensitiveFiles.Name -join ', ')"
}
Write-Host 'Test-PublishedArtifactPrivacy.ps1: PASS'