✨ feat(runtime): 添加轨迹持久化与密集执行链路
* 新增飞拍轨迹文件存储,支持上传、加载与删除 * 接通 ControllerClientCompat 到运行时的轨迹编排 * 完善 FANUC 命令与 J519 客户端发送链路 * 补充密集轨迹执行、运行时编排和协议客户端测试 * 更新 README 与 AGENTS 中的当前实现状态
This commit is contained in:
11
AGENTS.md
11
AGENTS.md
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
- 使用 `C# + .NET 8`
|
- 使用 `C# + .NET 8`
|
||||||
- 提供跨平台独立服务端
|
- 提供跨平台独立服务端
|
||||||
- 兼容现有 `50001/TCP+JSON` 上层接入语义
|
- 以新的 ASP.NET Core HTTP API 作为唯一上层接口
|
||||||
- 重写轨迹生成、触发时序、FANUC 控制链路和状态监控
|
- 重写轨迹生成、触发时序、FANUC 控制链路和状态监控
|
||||||
- Windows / Linux 都能运行完整服务端
|
- Windows / Linux 都能运行完整服务端
|
||||||
- 只支持当前现场这套组合
|
- 只支持当前现场这套组合
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
- GUI 桌面程序
|
- GUI 桌面程序
|
||||||
- 多机器人同时控制
|
- 多机器人同时控制
|
||||||
- 面向多控制柜的通用平台化框架
|
- 面向多控制柜的通用平台化框架
|
||||||
|
- 恢复旧 `50001/TCP+JSON` 网关
|
||||||
|
|
||||||
## 2. 代码与资料边界
|
## 2. 代码与资料边界
|
||||||
|
|
||||||
@@ -91,6 +92,7 @@ flyshot-replacement/
|
|||||||
|
|
||||||
### 4.2 实现约束
|
### 4.2 实现约束
|
||||||
|
|
||||||
|
- 旧 `ControllerClient` 资料只作为接口语义参考;运行时入口以新 HTTP API 为准,不恢复旧 `50001/TCP+JSON` 网关。
|
||||||
- 旧协议兼容以“语义兼容”为主,不追求二进制逐字节一致。
|
- 旧协议兼容以“语义兼容”为主,不追求二进制逐字节一致。
|
||||||
- 轨迹规划必须与底层 Socket / HTTP / Web UI 解耦。
|
- 轨迹规划必须与底层 Socket / HTTP / Web UI 解耦。
|
||||||
- 领域层不允许引用 ASP.NET Core、Socket、文件系统 API。
|
- 领域层不允许引用 ASP.NET Core、Socket、文件系统 API。
|
||||||
@@ -169,8 +171,9 @@ flyshot-replacement/
|
|||||||
- `Flyshot.Server.Host` 已提供最小 `/healthz`。
|
- `Flyshot.Server.Host` 已提供最小 `/healthz`。
|
||||||
- 最小集成测试已通过。
|
- 最小集成测试已通过。
|
||||||
- 解决方案构建已通过。
|
- 解决方案构建已通过。
|
||||||
- HTTP-only `ControllerClientCompat` 已覆盖旧 HTTP 控制器后端的主要兼容语义。
|
- 新 HTTP API / HTTP-only `ControllerClientCompat` 已覆盖旧 HTTP 控制器后端的主要兼容语义。
|
||||||
- `Flyshot.Core.Planning` 已落地 `icsp` 与 `self-adapt-icsp` 的最小规划链路。
|
- `Flyshot.Core.Planning` 已落地 `icsp` 与 `self-adapt-icsp`,并已完成旧系统导出轨迹对齐。
|
||||||
- `Flyshot.Core.Triggering` 已能从 `shot_flags / offset_values / addr` 生成触发时间轴。
|
- `Flyshot.Core.Triggering` 已能从 `shot_flags / offset_values / addr` 生成触发时间轴。
|
||||||
- `Flyshot.Runtime.Fanuc` 已提供状态型最小运行时骨架,供兼容服务执行规划结果。
|
- `Flyshot.Runtime.Fanuc` 已固化 `10010 / 10012 / 60015` 基础协议帧编解码,`10010` 状态帧以 `j519 协议.pcap` 真机抓包确认为 90B。
|
||||||
|
- `Flyshot.Runtime.Fanuc` 已具备基础 Socket 客户端和 J519 周期发送链路,但速度倍率、TCP、IO、J519 闭环与现场联调仍需补齐。
|
||||||
- `ExecuteTrajectory` / `ExecuteFlyShotTraj` 已接入 `Planning + Triggering + Runtime`,不再只是兼容层内存赋值。
|
- `ExecuteTrajectory` / `ExecuteFlyShotTraj` 已接入 `Planning + Triggering + Runtime`,不再只是兼容层内存赋值。
|
||||||
|
|||||||
64
README.md
64
README.md
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
当前目标:
|
当前目标:
|
||||||
|
|
||||||
- 兼容现有 `50001/TCP+JSON` 上层接入语义
|
- 以新的 ASP.NET Core HTTP API 作为唯一上层接口
|
||||||
- 重写轨迹生成、触发时序和 FANUC 实时控制链路
|
- 重写轨迹生成、触发时序和 FANUC 实时控制链路
|
||||||
- 提供 Web 状态监控页面
|
- 提供 Web 状态监控页面
|
||||||
- 在 Windows 和 Linux 上运行完整后台服务
|
- 在 Windows 和 Linux 上运行完整后台服务
|
||||||
@@ -13,9 +13,12 @@
|
|||||||
|
|
||||||
- 这是长期运行的无头后台服务,不是 GUI 桌面程序。
|
- 这是长期运行的无头后台服务,不是 GUI 桌面程序。
|
||||||
- 第一版仅面向当前现场组合,后续再扩展机型与控制柜适配。
|
- 第一版仅面向当前现场组合,后续再扩展机型与控制柜适配。
|
||||||
- 当前仓库内已经移除宿主中的 `50001/TCP+JSON` 监听实现;现阶段只保留 ASP.NET Core HTTP 控制器层,以及其后端 `Flyshot.ControllerClientCompat` 兼容服务。
|
- 当前仓库不再恢复旧 `50001/TCP+JSON` 监听入口;旧 `ControllerClient` 逆向资料只作为接口语义参考,不作为运行时目标。
|
||||||
- `ExecuteTrajectory` 与 `ExecuteFlyShotTraj` 已经接入 `Planning + Triggering + Runtime` 最小链路;Web 状态页已通过 `/status` 和 `/api/status/snapshot` 暴露当前兼容层与运行时状态;`Flyshot.Runtime.Fanuc` 已固化 `10010 / 10012 / 60015` 基础协议帧编解码,但运行时仍是状态型骨架,尚未完成真机 Socket 联调。
|
- 宿主只保留 ASP.NET Core HTTP 控制器层,以及其后端 `Flyshot.ControllerClientCompat` 兼容服务。
|
||||||
- `50001/TCP+JSON` 的真实兼容入口如果后续需要恢复,必须基于 `docs/controller-client-api-compatibility-requirements.md` 与 `docs/controller-client-api-reverse-engineering.md` 重新评估,而不是直接把旧的 TCP 网关方向接回宿主。
|
- `ExecuteTrajectory` 与 `ExecuteFlyShotTraj` 已经接入 `Planning + Triggering + Runtime` 链路;Web 状态页已通过 `/status` 和 `/api/status/snapshot` 暴露当前兼容层与运行时状态。
|
||||||
|
- `Flyshot.Core.Planning` 的 ICSP / self-adapt-icsp 轨迹已经完成旧系统导出轨迹对齐;`doubles` 仍未实现。
|
||||||
|
- `Flyshot.Runtime.Fanuc` 已固化 `10010 / 10012 / 60015` 基础协议帧编解码。`10010` 状态通道以 `j519 协议.pcap` 真机抓包确认为 90B 固定帧。
|
||||||
|
- 真机 Socket 客户端已具备基础连接、程序启停和 J519 周期发送能力,但速度倍率、TCP、IO、J519 闭环和现场联调仍需补齐。
|
||||||
|
|
||||||
开发约定:
|
开发约定:
|
||||||
|
|
||||||
@@ -23,15 +26,58 @@
|
|||||||
- 当前仓库内的 `@` 引用主要覆盖本仓库文件;引用父目录资料时,请直接写相对路径,如 `../analysis/ICSP_algorithm_reverse_analysis.md`。
|
- 当前仓库内的 `@` 引用主要覆盖本仓库文件;引用父目录资料时,请直接写相对路径,如 `../analysis/ICSP_algorithm_reverse_analysis.md`。
|
||||||
- 父目录中的 `analysis/`、`FlyingShot/`、`RobotController/`、`RPS/` 主要作为逆向参考资料和样本来源,新实现默认只落地在当前仓库。
|
- 父目录中的 `analysis/`、`FlyingShot/`、`RobotController/`、`RPS/` 主要作为逆向参考资料和样本来源,新实现默认只落地在当前仓库。
|
||||||
|
|
||||||
当前 Todo:
|
当前已完成:
|
||||||
|
|
||||||
- [x] 初始化独立仓库
|
- [x] 初始化独立仓库
|
||||||
- [x] 创建 `dotnet 8` 解决方案骨架
|
- [x] 创建 `dotnet 8` 解决方案骨架
|
||||||
- [x] 打通最小宿主与 `/healthz`
|
- [x] 打通最小宿主与 `/healthz`
|
||||||
- [x] 建立领域模型与模块边界
|
- [x] 建立领域模型与模块边界
|
||||||
- [x] 落地配置兼容与机器人模型解析
|
- [x] 落地配置兼容与机器人模型解析
|
||||||
- [x] 落地轨迹规划与飞拍触发时间轴
|
- [x] 落地 ICSP / self-adapt-icsp 轨迹规划与飞拍触发时间轴
|
||||||
- [x] 将 `ExecuteTrajectory` / `ExecuteFlyShotTraj` 接入最小 FANUC 运行时骨架
|
- [x] 完成 ICSP 轨迹导出结果与旧系统对齐
|
||||||
|
- [x] 将 `ExecuteTrajectory` / `ExecuteFlyShotTraj` 接入 FANUC 运行时链路
|
||||||
- [x] 落地 Web 状态页
|
- [x] 落地 Web 状态页
|
||||||
- [x] 固化 `10010 / 10012 / 60015` FANUC 基础协议帧编解码
|
- [x] 固化 `10010 / 10012 / 60015` FANUC 基础协议帧编解码,确认 `10010` 状态帧为 90B
|
||||||
- [ ] 落地真实 `10010 / 10012 / 60015` FANUC Socket 通讯与现场联调
|
- [x] 使用本地 TCP/UDP 模拟器覆盖命令通道、状态通道和 J519 基础收发
|
||||||
|
- [x] 保留新 HTTP 接口路线,明确不再实现旧 `50001/TCP+JSON` 网关
|
||||||
|
|
||||||
|
剩余 Todo:
|
||||||
|
|
||||||
|
1. 配置与测试基线
|
||||||
|
- [x] 修正 `ConfigCompatibilityTests` 当前样本路径漂移:`Rvbust/EOL10_EAU_0/RobotConfig.json` 不再包含 `001`,应改用稳定样本或更新断言。
|
||||||
|
- [x] 将 `RobotConfig.json` 中的 `use_do`、`io_keep_cycles`、`acc_limit`、`jerk_limit`、`adapt_icsp_try_num` 全部贯通到规划和执行链路。
|
||||||
|
- [ ] 为新 HTTP API 补一份当前现场调用顺序文档,替代旧 `ControllerClient` 工作流。
|
||||||
|
|
||||||
|
2. 轨迹规划
|
||||||
|
- [ ] 补齐 ICSP 最终 `global_scale > 1.0` 失败判定,避免未收敛轨迹被当作有效结果执行。
|
||||||
|
- [x] 将 self-adapt-icsp 的补点次数改为使用配置中的 `adapt_icsp_try_num`。
|
||||||
|
- [ ] 如果现场仍需要 `method="doubles"`,实现 `TrajectoryDoubleS` 等价规划;否则在 HTTP 文档中明确标为不支持。
|
||||||
|
- [ ] 把已完成对齐的旧系统轨迹样本固化为 golden tests,防止后续重构破坏轨迹一致性。
|
||||||
|
|
||||||
|
3. FANUC TCP 10012 命令通道
|
||||||
|
- [ ] 补齐 `GetSpeedRatio` / `SetSpeedRatio` 真机命令体与响应解析。
|
||||||
|
- [ ] 补齐 `GetTCP` / `SetTCP` 真机命令体与响应解析。
|
||||||
|
- [ ] 补齐 `GetIO` / `SetIO` 真机命令体与响应解析。
|
||||||
|
- [x] 所有命令响应必须检查 `result_code`,失败时返回可诊断错误,而不是只更新本地缓存。
|
||||||
|
|
||||||
|
4. FANUC TCP 10010 状态通道
|
||||||
|
- [ ] 用 `j519 协议.pcap` 中的 90B 真机状态帧扩充状态解析测试样本。
|
||||||
|
- [ ] 明确 `pose[6]`、`joint_or_ext[9]`、尾部状态字的字段语义,并映射到 `ControllerStateSnapshot`。
|
||||||
|
- [ ] 补充断线、异常帧、超时和重连策略。
|
||||||
|
|
||||||
|
5. FANUC UDP 60015 J519 运动链路
|
||||||
|
- [ ] 重新确认 J519 发送循环与 `FanucControllerRuntime` 稠密轨迹循环的职责边界,避免双重节拍或命令覆盖。
|
||||||
|
- [ ] 补齐 `accept_cmd`、`received_cmd`、`sysrdy`、`rbt_inmotion` 状态位闭环检查。
|
||||||
|
- [ ] 校验序号递增、响应滞后、丢包、停止包和最后一帧语义。
|
||||||
|
- [ ] 将飞拍 IO 触发的 `write_io_type/index/mask/value` 与现场控制柜实际 IO 地址逐项对齐。
|
||||||
|
|
||||||
|
6. 真机联调与运行安全
|
||||||
|
- [ ] 在真实 R30iB + `RVBUSTSM` 程序上验证 `Connect -> EnableRobot -> ExecuteFlyShotTraj -> StopMove -> DisableRobot -> Disconnect` 全流程。
|
||||||
|
- [ ] 增加急停、伺服未就绪、程序未启动、网络断开、控制柜拒收命令等故障路径处理。
|
||||||
|
- [ ] 给 HTTP 执行接口增加运行互斥、执行中拒绝重复轨迹、取消和超时控制。
|
||||||
|
- [ ] 增加运行日志、协议摘要日志和状态快照导出,便于现场排查。
|
||||||
|
|
||||||
|
7. 发布与部署
|
||||||
|
- [ ] 固化 Windows / Linux 启动脚本和 systemd 服务配置。
|
||||||
|
- [ ] 补充生产配置模板、端口说明和现场部署检查表。
|
||||||
|
- [ ] 给 Web 状态页增加真机连接、程序状态、J519 状态位和最近报警显示。
|
||||||
|
|||||||
11
TrajectoryStore/FANUC_LR_Mate_200iD_trajectories.json
Normal file
11
TrajectoryStore/FANUC_LR_Mate_200iD_trajectories.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"robot": {
|
||||||
|
"use_do": false,
|
||||||
|
"io_addr": [],
|
||||||
|
"io_keep_cycles": 2,
|
||||||
|
"acc_limit": 1,
|
||||||
|
"jerk_limit": 1,
|
||||||
|
"adapt_icsp_try_num": 5
|
||||||
|
},
|
||||||
|
"flying_shots": {}
|
||||||
|
}
|
||||||
@@ -37,8 +37,10 @@ public sealed class ControllerClientCompatRobotCatalog
|
|||||||
/// 根据旧客户端的机器人名称加载对应模型。
|
/// 根据旧客户端的机器人名称加载对应模型。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="robotName">旧客户端传入的机器人名称。</param>
|
/// <param name="robotName">旧客户端传入的机器人名称。</param>
|
||||||
|
/// <param name="accLimitScale">RobotConfig.json 中的加速度倍率。</param>
|
||||||
|
/// <param name="jerkLimitScale">RobotConfig.json 中的 jerk 倍率。</param>
|
||||||
/// <returns>兼容层加载出的机器人模型。</returns>
|
/// <returns>兼容层加载出的机器人模型。</returns>
|
||||||
public RobotProfile LoadProfile(string robotName)
|
public RobotProfile LoadProfile(string robotName, double accLimitScale = 1.0, double jerkLimitScale = 1.0)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(robotName))
|
if (string.IsNullOrWhiteSpace(robotName))
|
||||||
{
|
{
|
||||||
@@ -52,7 +54,7 @@ public sealed class ControllerClientCompatRobotCatalog
|
|||||||
|
|
||||||
var workspaceRoot = ResolveWorkspaceRoot();
|
var workspaceRoot = ResolveWorkspaceRoot();
|
||||||
var modelPath = Path.Combine(workspaceRoot, modelRelativePath);
|
var modelPath = Path.Combine(workspaceRoot, modelRelativePath);
|
||||||
return _robotModelLoader.LoadProfile(modelPath);
|
return _robotModelLoader.LoadProfile(modelPath, accLimitScale, jerkLimitScale);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Flyshot.Core.Config;
|
||||||
using Flyshot.Core.Domain;
|
using Flyshot.Core.Domain;
|
||||||
using Flyshot.Runtime.Common;
|
using Flyshot.Runtime.Common;
|
||||||
|
|
||||||
@@ -14,8 +15,11 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
|
|||||||
private readonly ControllerClientCompatRobotCatalog _robotCatalog;
|
private readonly ControllerClientCompatRobotCatalog _robotCatalog;
|
||||||
private readonly IControllerRuntime _runtime;
|
private readonly IControllerRuntime _runtime;
|
||||||
private readonly ControllerClientTrajectoryOrchestrator _trajectoryOrchestrator;
|
private readonly ControllerClientTrajectoryOrchestrator _trajectoryOrchestrator;
|
||||||
|
private readonly RobotConfigLoader _configLoader;
|
||||||
|
private readonly IFlyshotTrajectoryStore _trajectoryStore;
|
||||||
private RobotProfile? _activeRobotProfile;
|
private RobotProfile? _activeRobotProfile;
|
||||||
private string? _configuredRobotName;
|
private string? _configuredRobotName;
|
||||||
|
private CompatibilityRobotSettings? _robotSettings;
|
||||||
private string? _connectedServerIp;
|
private string? _connectedServerIp;
|
||||||
private int _connectedServerPort;
|
private int _connectedServerPort;
|
||||||
private bool _showTcp = true;
|
private bool _showTcp = true;
|
||||||
@@ -29,16 +33,22 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
|
|||||||
/// <param name="robotCatalog">机器人模型目录。</param>
|
/// <param name="robotCatalog">机器人模型目录。</param>
|
||||||
/// <param name="runtime">控制器运行时。</param>
|
/// <param name="runtime">控制器运行时。</param>
|
||||||
/// <param name="trajectoryOrchestrator">轨迹规划与触发编排器。</param>
|
/// <param name="trajectoryOrchestrator">轨迹规划与触发编排器。</param>
|
||||||
|
/// <param name="configLoader">旧版 RobotConfig.json 加载器。</param>
|
||||||
|
/// <param name="trajectoryStore">已上传轨迹持久化存储。</param>
|
||||||
public ControllerClientCompatService(
|
public ControllerClientCompatService(
|
||||||
ControllerClientCompatOptions options,
|
ControllerClientCompatOptions options,
|
||||||
ControllerClientCompatRobotCatalog robotCatalog,
|
ControllerClientCompatRobotCatalog robotCatalog,
|
||||||
IControllerRuntime runtime,
|
IControllerRuntime runtime,
|
||||||
ControllerClientTrajectoryOrchestrator trajectoryOrchestrator)
|
ControllerClientTrajectoryOrchestrator trajectoryOrchestrator,
|
||||||
|
RobotConfigLoader configLoader,
|
||||||
|
IFlyshotTrajectoryStore trajectoryStore)
|
||||||
{
|
{
|
||||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||||
_robotCatalog = robotCatalog ?? throw new ArgumentNullException(nameof(robotCatalog));
|
_robotCatalog = robotCatalog ?? throw new ArgumentNullException(nameof(robotCatalog));
|
||||||
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
|
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
|
||||||
_trajectoryOrchestrator = trajectoryOrchestrator ?? throw new ArgumentNullException(nameof(trajectoryOrchestrator));
|
_trajectoryOrchestrator = trajectoryOrchestrator ?? throw new ArgumentNullException(nameof(trajectoryOrchestrator));
|
||||||
|
_configLoader = configLoader ?? throw new ArgumentNullException(nameof(configLoader));
|
||||||
|
_trajectoryStore = trajectoryStore ?? throw new ArgumentNullException(nameof(trajectoryStore));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -97,7 +107,11 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void SetUpRobot(string robotName)
|
public void SetUpRobot(string robotName)
|
||||||
{
|
{
|
||||||
var robotProfile = _robotCatalog.LoadProfile(robotName);
|
var robotSettings = TryLoadRobotSettings() ?? CreateDefaultRobotSettings();
|
||||||
|
var robotProfile = _robotCatalog.LoadProfile(
|
||||||
|
robotName,
|
||||||
|
robotSettings.AccLimitScale,
|
||||||
|
robotSettings.JerkLimitScale);
|
||||||
|
|
||||||
lock (_stateLock)
|
lock (_stateLock)
|
||||||
{
|
{
|
||||||
@@ -106,6 +120,14 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
|
|||||||
_activeRobotProfile = robotProfile;
|
_activeRobotProfile = robotProfile;
|
||||||
_uploadedTrajectories.Clear();
|
_uploadedTrajectories.Clear();
|
||||||
_runtime.ResetRobot(robotProfile, robotName);
|
_runtime.ResetRobot(robotProfile, robotName);
|
||||||
|
_robotSettings = robotSettings;
|
||||||
|
|
||||||
|
// 从持久化存储恢复该机器人名下之前已上传的轨迹。
|
||||||
|
var savedTrajectories = _trajectoryStore.LoadAll(robotName, out _);
|
||||||
|
foreach (var saved in savedTrajectories)
|
||||||
|
{
|
||||||
|
_uploadedTrajectories[saved.Key] = saved.Value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,6 +383,10 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
|
|||||||
{
|
{
|
||||||
EnsureRuntimeEnabled();
|
EnsureRuntimeEnabled();
|
||||||
_uploadedTrajectories[trajectory.Name] = trajectory;
|
_uploadedTrajectories[trajectory.Name] = trajectory;
|
||||||
|
|
||||||
|
var robotName = _configuredRobotName ?? throw new InvalidOperationException("Robot has not been setup.");
|
||||||
|
var settings = _robotSettings ?? CreateDefaultRobotSettings();
|
||||||
|
_trajectoryStore.Save(robotName, settings, trajectory);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -398,7 +424,7 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 已上传飞拍轨迹必须按调用方指定 method 生成 shot timeline 后再交给运行时。
|
// 已上传飞拍轨迹必须按调用方指定 method 生成 shot timeline 后再交给运行时。
|
||||||
var bundle = _trajectoryOrchestrator.PlanUploadedFlyshot(robot, trajectory, options);
|
var bundle = _trajectoryOrchestrator.PlanUploadedFlyshot(robot, trajectory, options, RequireRobotSettings());
|
||||||
if (options.MoveToStart)
|
if (options.MoveToStart)
|
||||||
{
|
{
|
||||||
_runtime.ExecuteTrajectory(CreateImmediateMoveResult(), bundle.PlannedTrajectory.PlannedWaypoints[0].Positions);
|
_runtime.ExecuteTrajectory(CreateImmediateMoveResult(), bundle.PlannedTrajectory.PlannedWaypoints[0].Positions);
|
||||||
@@ -425,11 +451,16 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
|
|||||||
throw new InvalidOperationException("FlyShot trajectory does not exist.");
|
throw new InvalidOperationException("FlyShot trajectory does not exist.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 当前阶段没有落地文件导出,先通过 saveTrajectory=true 走规划校验,避免静默接受非法参数。
|
// 先通过规划校验避免静默接受非法参数,同时把轨迹信息强制刷写到本地 JSON。
|
||||||
_ = _trajectoryOrchestrator.PlanUploadedFlyshot(
|
_ = _trajectoryOrchestrator.PlanUploadedFlyshot(
|
||||||
robot,
|
robot,
|
||||||
trajectory,
|
trajectory,
|
||||||
new FlyshotExecutionOptions(saveTrajectory: true, method: method));
|
new FlyshotExecutionOptions(saveTrajectory: true, method: method),
|
||||||
|
RequireRobotSettings());
|
||||||
|
|
||||||
|
var robotName = _configuredRobotName ?? throw new InvalidOperationException("Robot has not been setup.");
|
||||||
|
var settings = _robotSettings ?? CreateDefaultRobotSettings();
|
||||||
|
_trajectoryStore.Save(robotName, settings, trajectory);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -452,7 +483,8 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
|
|||||||
var bundle = _trajectoryOrchestrator.PlanUploadedFlyshot(
|
var bundle = _trajectoryOrchestrator.PlanUploadedFlyshot(
|
||||||
robot,
|
robot,
|
||||||
trajectory,
|
trajectory,
|
||||||
new FlyshotExecutionOptions(method: method, saveTrajectory: saveTrajectory));
|
new FlyshotExecutionOptions(method: method, saveTrajectory: saveTrajectory),
|
||||||
|
RequireRobotSettings());
|
||||||
|
|
||||||
duration = bundle.Result.Duration;
|
duration = bundle.Result.Duration;
|
||||||
return bundle.Result.IsValid;
|
return bundle.Result.IsValid;
|
||||||
@@ -473,6 +505,9 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
|
|||||||
{
|
{
|
||||||
throw new InvalidOperationException("DeleteFlyShotTraj failed");
|
throw new InvalidOperationException("DeleteFlyShotTraj failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var robotName = _configuredRobotName ?? throw new InvalidOperationException("Robot has not been setup.");
|
||||||
|
_trajectoryStore.Delete(robotName, name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -503,6 +538,15 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
|
|||||||
return _activeRobotProfile ?? throw new InvalidOperationException("Robot has not been setup.");
|
return _activeRobotProfile ?? throw new InvalidOperationException("Robot has not been setup.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取当前机器人兼容配置;未加载旧配置时回退到现场默认值。
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>当前机器人配置。</returns>
|
||||||
|
private CompatibilityRobotSettings RequireRobotSettings()
|
||||||
|
{
|
||||||
|
return _robotSettings ?? CreateDefaultRobotSettings();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 校验机器人已经完成初始化。
|
/// 校验机器人已经完成初始化。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -542,4 +586,61 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
|
|||||||
originalWaypointCount: 1,
|
originalWaypointCount: 1,
|
||||||
plannedWaypointCount: 1);
|
plannedWaypointCount: 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 尝试从工作区加载旧版 RobotConfig.json 获取机器人配置;失败时返回 null。
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>加载到的机器人配置,或 null。</returns>
|
||||||
|
private CompatibilityRobotSettings? TryLoadRobotSettings()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var workspaceRoot = !string.IsNullOrWhiteSpace(_options.WorkspaceRoot)
|
||||||
|
? Path.GetFullPath(_options.WorkspaceRoot)
|
||||||
|
: ResolveWorkspaceRootFromBaseDirectory();
|
||||||
|
|
||||||
|
var configPath = PathCompatibility.ResolveConfigPath("RobotConfig.json", workspaceRoot);
|
||||||
|
var loaded = _configLoader.Load(configPath, workspaceRoot);
|
||||||
|
return loaded.Robot;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 构造与旧现场默认行为一致的机器人兼容配置。
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>默认机器人配置。</returns>
|
||||||
|
private static CompatibilityRobotSettings CreateDefaultRobotSettings()
|
||||||
|
{
|
||||||
|
return new CompatibilityRobotSettings(
|
||||||
|
useDo: false,
|
||||||
|
ioAddresses: Array.Empty<int>(),
|
||||||
|
ioKeepCycles: 2,
|
||||||
|
accLimitScale: 1.0,
|
||||||
|
jerkLimitScale: 1.0,
|
||||||
|
adaptIcspTryNum: 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 从当前程序基目录向上搜索 FlyshotReplacement.sln 以推断工作区根目录。
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>父工作区根目录。</returns>
|
||||||
|
private static string ResolveWorkspaceRootFromBaseDirectory()
|
||||||
|
{
|
||||||
|
var current = new DirectoryInfo(AppContext.BaseDirectory);
|
||||||
|
while (current is not null)
|
||||||
|
{
|
||||||
|
if (File.Exists(Path.Combine(current.FullName, "FlyshotReplacement.sln")))
|
||||||
|
{
|
||||||
|
return Path.GetFullPath(Path.Combine(current.FullName, ".."));
|
||||||
|
}
|
||||||
|
|
||||||
|
current = current.Parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new DirectoryNotFoundException("Unable to locate the flyshot workspace root.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,8 +29,10 @@ public static class ControllerClientCompatServiceCollectionExtensions
|
|||||||
|
|
||||||
services.AddSingleton(static serviceProvider => serviceProvider.GetRequiredService<IOptions<ControllerClientCompatOptions>>().Value);
|
services.AddSingleton(static serviceProvider => serviceProvider.GetRequiredService<IOptions<ControllerClientCompatOptions>>().Value);
|
||||||
services.AddSingleton<RobotModelLoader>();
|
services.AddSingleton<RobotModelLoader>();
|
||||||
|
services.AddSingleton<RobotConfigLoader>();
|
||||||
services.AddSingleton<ControllerClientCompatRobotCatalog>();
|
services.AddSingleton<ControllerClientCompatRobotCatalog>();
|
||||||
services.AddSingleton<ControllerClientTrajectoryOrchestrator>();
|
services.AddSingleton<ControllerClientTrajectoryOrchestrator>();
|
||||||
|
services.AddSingleton<IFlyshotTrajectoryStore, JsonFlyshotTrajectoryStore>();
|
||||||
services.AddSingleton<IControllerRuntime, FanucControllerRuntime>();
|
services.AddSingleton<IControllerRuntime, FanucControllerRuntime>();
|
||||||
services.AddSingleton<IControllerClientCompatService, ControllerClientCompatService>();
|
services.AddSingleton<IControllerClientCompatService, ControllerClientCompatService>();
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
using Flyshot.Core.Config;
|
||||||
using Flyshot.Core.Domain;
|
using Flyshot.Core.Domain;
|
||||||
using Flyshot.Core.Planning;
|
using Flyshot.Core.Planning;
|
||||||
|
using Flyshot.Core.Planning.Sampling;
|
||||||
using Flyshot.Core.Triggering;
|
using Flyshot.Core.Triggering;
|
||||||
|
|
||||||
namespace Flyshot.ControllerClientCompat;
|
namespace Flyshot.ControllerClientCompat;
|
||||||
@@ -59,11 +61,13 @@ public sealed class ControllerClientTrajectoryOrchestrator
|
|||||||
public PlannedExecutionBundle PlanUploadedFlyshot(
|
public PlannedExecutionBundle PlanUploadedFlyshot(
|
||||||
RobotProfile robot,
|
RobotProfile robot,
|
||||||
ControllerClientCompatUploadedTrajectory uploaded,
|
ControllerClientCompatUploadedTrajectory uploaded,
|
||||||
FlyshotExecutionOptions? options = null)
|
FlyshotExecutionOptions? options = null,
|
||||||
|
CompatibilityRobotSettings? settings = null)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(robot);
|
ArgumentNullException.ThrowIfNull(robot);
|
||||||
ArgumentNullException.ThrowIfNull(uploaded);
|
ArgumentNullException.ThrowIfNull(uploaded);
|
||||||
options ??= new FlyshotExecutionOptions();
|
options ??= new FlyshotExecutionOptions();
|
||||||
|
settings ??= CreateDefaultRobotSettings();
|
||||||
|
|
||||||
var program = CreateProgram(
|
var program = CreateProgram(
|
||||||
name: uploaded.Name,
|
name: uploaded.Name,
|
||||||
@@ -73,7 +77,7 @@ public sealed class ControllerClientTrajectoryOrchestrator
|
|||||||
addressGroups: uploaded.AddressGroups);
|
addressGroups: uploaded.AddressGroups);
|
||||||
|
|
||||||
var method = ParseFlyshotMethod(options.Method);
|
var method = ParseFlyshotMethod(options.Method);
|
||||||
var cacheKey = CreateFlyshotCacheKey(robot, uploaded, options);
|
var cacheKey = CreateFlyshotCacheKey(robot, uploaded, options, settings);
|
||||||
if (options.UseCache && _flyshotCache.TryGetValue(cacheKey, out var cachedBundle))
|
if (options.UseCache && _flyshotCache.TryGetValue(cacheKey, out var cachedBundle))
|
||||||
{
|
{
|
||||||
// 命中缓存时只替换 TrajectoryResult 的 usedCache 标志,规划轨迹和触发时间轴保持不可变复用。
|
// 命中缓存时只替换 TrajectoryResult 的 usedCache 标志,规划轨迹和触发时间轴保持不可变复用。
|
||||||
@@ -91,11 +95,12 @@ public sealed class ControllerClientTrajectoryOrchestrator
|
|||||||
saveTrajectoryArtifacts: options.SaveTrajectory,
|
saveTrajectoryArtifacts: options.SaveTrajectory,
|
||||||
useCache: options.UseCache);
|
useCache: options.UseCache);
|
||||||
|
|
||||||
var plannedTrajectory = PlanByMethod(request, method);
|
var plannedTrajectory = PlanByMethod(request, method, settings);
|
||||||
var shotTimeline = _shotTimelineBuilder.Build(
|
var shotTimeline = _shotTimelineBuilder.Build(
|
||||||
plannedTrajectory,
|
plannedTrajectory,
|
||||||
holdCycles: 0,
|
holdCycles: settings.IoKeepCycles,
|
||||||
samplePeriod: robot.ServoPeriod);
|
samplePeriod: robot.ServoPeriod,
|
||||||
|
useDo: settings.UseDo);
|
||||||
var result = CreateResult(plannedTrajectory, shotTimeline, usedCache: false);
|
var result = CreateResult(plannedTrajectory, shotTimeline, usedCache: false);
|
||||||
var bundle = new PlannedExecutionBundle(plannedTrajectory, shotTimeline, result);
|
var bundle = new PlannedExecutionBundle(plannedTrajectory, shotTimeline, result);
|
||||||
|
|
||||||
@@ -146,12 +151,12 @@ public sealed class ControllerClientTrajectoryOrchestrator
|
|||||||
/// <param name="request">规划请求。</param>
|
/// <param name="request">规划请求。</param>
|
||||||
/// <param name="method">规划方法。</param>
|
/// <param name="method">规划方法。</param>
|
||||||
/// <returns>规划轨迹。</returns>
|
/// <returns>规划轨迹。</returns>
|
||||||
private PlannedTrajectory PlanByMethod(TrajectoryRequest request, PlanningMethod method)
|
private PlannedTrajectory PlanByMethod(TrajectoryRequest request, PlanningMethod method, CompatibilityRobotSettings? settings = null)
|
||||||
{
|
{
|
||||||
return method switch
|
return method switch
|
||||||
{
|
{
|
||||||
PlanningMethod.Icsp => _icspPlanner.Plan(request),
|
PlanningMethod.Icsp => _icspPlanner.Plan(request),
|
||||||
PlanningMethod.SelfAdaptIcsp => _selfAdaptIcspPlanner.Plan(request),
|
PlanningMethod.SelfAdaptIcsp => _selfAdaptIcspPlanner.Plan(request, settings?.AdaptIcspTryNum ?? 5),
|
||||||
PlanningMethod.Doubles => throw new NotSupportedException("doubles 轨迹规划尚未落地。"),
|
PlanningMethod.Doubles => throw new NotSupportedException("doubles 轨迹规划尚未落地。"),
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(method), method, "未知轨迹规划方法。")
|
_ => throw new ArgumentOutOfRangeException(nameof(method), method, "未知轨迹规划方法。")
|
||||||
};
|
};
|
||||||
@@ -182,7 +187,8 @@ public sealed class ControllerClientTrajectoryOrchestrator
|
|||||||
private static string CreateFlyshotCacheKey(
|
private static string CreateFlyshotCacheKey(
|
||||||
RobotProfile robot,
|
RobotProfile robot,
|
||||||
ControllerClientCompatUploadedTrajectory uploaded,
|
ControllerClientCompatUploadedTrajectory uploaded,
|
||||||
FlyshotExecutionOptions options)
|
FlyshotExecutionOptions options,
|
||||||
|
CompatibilityRobotSettings settings)
|
||||||
{
|
{
|
||||||
var hash = new HashCode();
|
var hash = new HashCode();
|
||||||
hash.Add(robot.Name, StringComparer.Ordinal);
|
hash.Add(robot.Name, StringComparer.Ordinal);
|
||||||
@@ -190,6 +196,9 @@ public sealed class ControllerClientTrajectoryOrchestrator
|
|||||||
hash.Add(NormalizeMethod(options.Method), StringComparer.Ordinal);
|
hash.Add(NormalizeMethod(options.Method), StringComparer.Ordinal);
|
||||||
hash.Add(options.MoveToStart);
|
hash.Add(options.MoveToStart);
|
||||||
hash.Add(options.SaveTrajectory);
|
hash.Add(options.SaveTrajectory);
|
||||||
|
hash.Add(settings.UseDo);
|
||||||
|
hash.Add(settings.IoKeepCycles);
|
||||||
|
hash.Add(settings.AdaptIcspTryNum);
|
||||||
|
|
||||||
foreach (var waypoint in uploaded.Waypoints)
|
foreach (var waypoint in uploaded.Waypoints)
|
||||||
{
|
{
|
||||||
@@ -220,6 +229,21 @@ public sealed class ControllerClientTrajectoryOrchestrator
|
|||||||
return hash.ToHashCode().ToString("X8");
|
return hash.ToHashCode().ToString("X8");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 构造编排器直接调用时的默认兼容配置,保持既有单元测试中的 DO 生成行为。
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>默认机器人兼容配置。</returns>
|
||||||
|
private static CompatibilityRobotSettings CreateDefaultRobotSettings()
|
||||||
|
{
|
||||||
|
return new CompatibilityRobotSettings(
|
||||||
|
useDo: true,
|
||||||
|
ioAddresses: Array.Empty<int>(),
|
||||||
|
ioKeepCycles: 0,
|
||||||
|
accLimitScale: 1.0,
|
||||||
|
jerkLimitScale: 1.0,
|
||||||
|
adaptIcspTryNum: 5);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 把兼容层输入数组转换成领域层 FlyshotProgram。
|
/// 把兼容层输入数组转换成领域层 FlyshotProgram。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -252,6 +276,10 @@ public sealed class ControllerClientTrajectoryOrchestrator
|
|||||||
/// <returns>运行时执行结果描述。</returns>
|
/// <returns>运行时执行结果描述。</returns>
|
||||||
private static TrajectoryResult CreateResult(PlannedTrajectory plannedTrajectory, ShotTimeline shotTimeline, bool usedCache)
|
private static TrajectoryResult CreateResult(PlannedTrajectory plannedTrajectory, ShotTimeline shotTimeline, bool usedCache)
|
||||||
{
|
{
|
||||||
|
var denseJointTrajectory = TrajectorySampler.SampleJointTrajectory(
|
||||||
|
plannedTrajectory,
|
||||||
|
samplePeriod: plannedTrajectory.Robot.ServoPeriod.TotalSeconds);
|
||||||
|
|
||||||
return new TrajectoryResult(
|
return new TrajectoryResult(
|
||||||
programName: plannedTrajectory.OriginalProgram.Name,
|
programName: plannedTrajectory.OriginalProgram.Name,
|
||||||
method: plannedTrajectory.Method,
|
method: plannedTrajectory.Method,
|
||||||
@@ -263,6 +291,7 @@ public sealed class ControllerClientTrajectoryOrchestrator
|
|||||||
failureReason: null,
|
failureReason: null,
|
||||||
usedCache: usedCache,
|
usedCache: usedCache,
|
||||||
originalWaypointCount: plannedTrajectory.OriginalWaypointCount,
|
originalWaypointCount: plannedTrajectory.OriginalWaypointCount,
|
||||||
plannedWaypointCount: plannedTrajectory.PlannedWaypointCount);
|
plannedWaypointCount: plannedTrajectory.PlannedWaypointCount,
|
||||||
|
denseJointTrajectory: denseJointTrajectory);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
231
src/Flyshot.ControllerClientCompat/FlyshotTrajectoryStore.cs
Normal file
231
src/Flyshot.ControllerClientCompat/FlyshotTrajectoryStore.cs
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using Flyshot.Core.Config;
|
||||||
|
using Flyshot.Core.Domain;
|
||||||
|
|
||||||
|
namespace Flyshot.ControllerClientCompat;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 定义已上传飞拍轨迹的持久化存储契约。
|
||||||
|
/// </summary>
|
||||||
|
public interface IFlyshotTrajectoryStore
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 将单条轨迹持久化到本地 JSON,同时更新所属机器人配置段。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="robotName">当前已初始化的机器人名称。</param>
|
||||||
|
/// <param name="settings">当前机器人级兼容配置。</param>
|
||||||
|
/// <param name="trajectory">要保存的已上传轨迹。</param>
|
||||||
|
void Save(string robotName, CompatibilityRobotSettings settings, ControllerClientCompatUploadedTrajectory trajectory);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 从本地 JSON 删除指定名称的轨迹。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="robotName">当前已初始化的机器人名称。</param>
|
||||||
|
/// <param name="trajectoryName">要删除的轨迹名称。</param>
|
||||||
|
void Delete(string robotName, string trajectoryName);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 加载指定机器人名下所有已持久化的轨迹,并回传保存时的机器人配置。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="robotName">当前已初始化的机器人名称。</param>
|
||||||
|
/// <param name="settings">输出保存时的机器人配置;若文件不存在或解析失败则为 null。</param>
|
||||||
|
/// <returns>按轨迹名称索引的已上传轨迹集合。</returns>
|
||||||
|
IReadOnlyDictionary<string, ControllerClientCompatUploadedTrajectory> LoadAll(string robotName, out CompatibilityRobotSettings? settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 使用与旧版 RobotConfig.json 一致的 JSON 格式持久化飞拍轨迹和机器人配置。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class JsonFlyshotTrajectoryStore : IFlyshotTrajectoryStore
|
||||||
|
{
|
||||||
|
private readonly ControllerClientCompatOptions _options;
|
||||||
|
private readonly RobotConfigLoader _configLoader;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 初始化基于 JSON 文件的轨迹存储。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="options">兼容层基础配置,用于定位工作区根目录。</param>
|
||||||
|
/// <param name="configLoader">旧版 RobotConfig.json 加载器,用于反序列化已保存的轨迹。</param>
|
||||||
|
public JsonFlyshotTrajectoryStore(ControllerClientCompatOptions options, RobotConfigLoader configLoader)
|
||||||
|
{
|
||||||
|
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||||
|
_configLoader = configLoader ?? throw new ArgumentNullException(nameof(configLoader));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Save(string robotName, CompatibilityRobotSettings settings, ControllerClientCompatUploadedTrajectory trajectory)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(settings);
|
||||||
|
ArgumentNullException.ThrowIfNull(trajectory);
|
||||||
|
|
||||||
|
var path = ResolveStorePath(robotName);
|
||||||
|
var directory = Path.GetDirectoryName(path)!;
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
|
||||||
|
JsonObject root;
|
||||||
|
if (File.Exists(path))
|
||||||
|
{
|
||||||
|
var existingJson = File.ReadAllText(path);
|
||||||
|
root = JsonNode.Parse(existingJson)?.AsObject() ?? new JsonObject();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
root = new JsonObject();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新 robot 配置段,保持与旧版 RobotConfig.json 字段名一致。
|
||||||
|
root["robot"] = SerializeRobotSettings(settings);
|
||||||
|
|
||||||
|
// 确保 flying_shots 节点存在。
|
||||||
|
if (!root.TryGetPropertyValue("flying_shots", out var flyingShotsNode) || flyingShotsNode is not JsonObject flyingShotsObj)
|
||||||
|
{
|
||||||
|
flyingShotsObj = new JsonObject();
|
||||||
|
root["flying_shots"] = flyingShotsObj;
|
||||||
|
}
|
||||||
|
|
||||||
|
flyingShotsObj[trajectory.Name] = SerializeTrajectory(trajectory);
|
||||||
|
|
||||||
|
var writeOptions = new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
WriteIndented = true,
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
|
||||||
|
};
|
||||||
|
File.WriteAllText(path, root.ToJsonString(writeOptions));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Delete(string robotName, string trajectoryName)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(trajectoryName))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("轨迹名称不能为空。", nameof(trajectoryName));
|
||||||
|
}
|
||||||
|
|
||||||
|
var path = ResolveStorePath(robotName);
|
||||||
|
if (!File.Exists(path))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var existingJson = File.ReadAllText(path);
|
||||||
|
var root = JsonNode.Parse(existingJson)?.AsObject();
|
||||||
|
if (root is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (root.TryGetPropertyValue("flying_shots", out var flyingShotsNode) && flyingShotsNode is JsonObject flyingShotsObj)
|
||||||
|
{
|
||||||
|
flyingShotsObj.Remove(trajectoryName);
|
||||||
|
|
||||||
|
var writeOptions = new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
WriteIndented = true,
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
|
||||||
|
};
|
||||||
|
File.WriteAllText(path, root.ToJsonString(writeOptions));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IReadOnlyDictionary<string, ControllerClientCompatUploadedTrajectory> LoadAll(string robotName, out CompatibilityRobotSettings? settings)
|
||||||
|
{
|
||||||
|
var path = ResolveStorePath(robotName);
|
||||||
|
if (!File.Exists(path))
|
||||||
|
{
|
||||||
|
settings = null;
|
||||||
|
return new Dictionary<string, ControllerClientCompatUploadedTrajectory>(StringComparer.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var workspaceRoot = ResolveWorkspaceRoot();
|
||||||
|
var loaded = _configLoader.Load(path, workspaceRoot);
|
||||||
|
settings = loaded.Robot;
|
||||||
|
|
||||||
|
var dict = new Dictionary<string, ControllerClientCompatUploadedTrajectory>(StringComparer.Ordinal);
|
||||||
|
foreach (var program in loaded.Programs)
|
||||||
|
{
|
||||||
|
var traj = new ControllerClientCompatUploadedTrajectory(
|
||||||
|
name: program.Value.Name,
|
||||||
|
waypoints: program.Value.Waypoints.Select(static wp => wp.Positions),
|
||||||
|
shotFlags: program.Value.ShotFlags,
|
||||||
|
offsetValues: program.Value.OffsetValues,
|
||||||
|
addressGroups: program.Value.AddressGroups.Select(static g => g.Addresses));
|
||||||
|
dict[program.Key] = traj;
|
||||||
|
}
|
||||||
|
|
||||||
|
return dict;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
settings = null;
|
||||||
|
return new Dictionary<string, ControllerClientCompatUploadedTrajectory>(StringComparer.Ordinal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 把机器人兼容配置序列化为 JSON 对象,字段名与旧版 RobotConfig.json 一致。
|
||||||
|
/// </summary>
|
||||||
|
private static JsonObject SerializeRobotSettings(CompatibilityRobotSettings settings)
|
||||||
|
{
|
||||||
|
return new JsonObject
|
||||||
|
{
|
||||||
|
["use_do"] = JsonValue.Create(settings.UseDo),
|
||||||
|
["io_addr"] = JsonSerializer.SerializeToNode(settings.IoAddresses),
|
||||||
|
["io_keep_cycles"] = JsonValue.Create(settings.IoKeepCycles),
|
||||||
|
["acc_limit"] = JsonValue.Create(settings.AccLimitScale),
|
||||||
|
["jerk_limit"] = JsonValue.Create(settings.JerkLimitScale),
|
||||||
|
["adapt_icsp_try_num"] = JsonValue.Create(settings.AdaptIcspTryNum)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 把已上传轨迹序列化为 JSON 对象,字段名与旧版 RobotConfig.json 的 flying_shots 节点一致。
|
||||||
|
/// </summary>
|
||||||
|
private static JsonObject SerializeTrajectory(ControllerClientCompatUploadedTrajectory trajectory)
|
||||||
|
{
|
||||||
|
return new JsonObject
|
||||||
|
{
|
||||||
|
["traj_waypoints"] = JsonSerializer.SerializeToNode(trajectory.Waypoints),
|
||||||
|
["shot_flags"] = JsonSerializer.SerializeToNode(trajectory.ShotFlags),
|
||||||
|
["offset_values"] = JsonSerializer.SerializeToNode(trajectory.OffsetValues),
|
||||||
|
["addr"] = JsonSerializer.SerializeToNode(trajectory.AddressGroups)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 解析当前机器人对应的持久化文件路径。
|
||||||
|
/// </summary>
|
||||||
|
private string ResolveStorePath(string robotName)
|
||||||
|
{
|
||||||
|
var workspaceRoot = ResolveWorkspaceRoot();
|
||||||
|
var storeDir = Path.Combine(workspaceRoot, "flyshot-replacement", "TrajectoryStore");
|
||||||
|
return Path.Combine(storeDir, $"{robotName}_trajectories.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 解析父工作区根目录,优先使用显式配置。
|
||||||
|
/// </summary>
|
||||||
|
private string ResolveWorkspaceRoot()
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(_options.WorkspaceRoot))
|
||||||
|
{
|
||||||
|
return Path.GetFullPath(_options.WorkspaceRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
var current = new DirectoryInfo(AppContext.BaseDirectory);
|
||||||
|
while (current is not null)
|
||||||
|
{
|
||||||
|
if (File.Exists(Path.Combine(current.FullName, "FlyshotReplacement.sln")))
|
||||||
|
{
|
||||||
|
return Path.GetFullPath(Path.Combine(current.FullName, ".."));
|
||||||
|
}
|
||||||
|
|
||||||
|
current = current.Parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new DirectoryNotFoundException("Unable to locate the flyshot workspace root.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,7 +21,8 @@ public sealed class TrajectoryResult
|
|||||||
string? failureReason,
|
string? failureReason,
|
||||||
bool usedCache,
|
bool usedCache,
|
||||||
int originalWaypointCount,
|
int originalWaypointCount,
|
||||||
int plannedWaypointCount)
|
int plannedWaypointCount,
|
||||||
|
IEnumerable<IReadOnlyList<double>>? denseJointTrajectory = null)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(programName))
|
if (string.IsNullOrWhiteSpace(programName))
|
||||||
{
|
{
|
||||||
@@ -51,6 +52,7 @@ public sealed class TrajectoryResult
|
|||||||
var copiedShotEvents = shotEvents.ToArray();
|
var copiedShotEvents = shotEvents.ToArray();
|
||||||
var copiedTriggerTimeline = triggerTimeline.ToArray();
|
var copiedTriggerTimeline = triggerTimeline.ToArray();
|
||||||
var copiedArtifacts = artifacts.ToArray();
|
var copiedArtifacts = artifacts.ToArray();
|
||||||
|
var copiedDenseJointTrajectory = denseJointTrajectory?.Select(static row => row.ToArray()).ToArray();
|
||||||
|
|
||||||
ProgramName = programName;
|
ProgramName = programName;
|
||||||
Method = method;
|
Method = method;
|
||||||
@@ -63,6 +65,7 @@ public sealed class TrajectoryResult
|
|||||||
UsedCache = usedCache;
|
UsedCache = usedCache;
|
||||||
OriginalWaypointCount = originalWaypointCount;
|
OriginalWaypointCount = originalWaypointCount;
|
||||||
PlannedWaypointCount = plannedWaypointCount;
|
PlannedWaypointCount = plannedWaypointCount;
|
||||||
|
DenseJointTrajectory = copiedDenseJointTrajectory;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -130,6 +133,13 @@ public sealed class TrajectoryResult
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
[JsonPropertyName("plannedWaypointCount")]
|
[JsonPropertyName("plannedWaypointCount")]
|
||||||
public int PlannedWaypointCount { get; }
|
public int PlannedWaypointCount { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the dense joint trajectory samples where each row is [time, j1, j2, ...].
|
||||||
|
/// Null when dense sampling was not performed (e.g. simulation fallback).
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("denseJointTrajectory")]
|
||||||
|
public IReadOnlyList<IReadOnlyList<double>>? DenseJointTrajectory { get; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -25,8 +25,9 @@ public sealed class ShotTimelineBuilder
|
|||||||
/// <param name="trajectory">规划后的轨迹(含补中点信息和机器人配置)。</param>
|
/// <param name="trajectory">规划后的轨迹(含补中点信息和机器人配置)。</param>
|
||||||
/// <param name="holdCycles">IO 保持周期数(对应原系统的 io_keep_cycles)。</param>
|
/// <param name="holdCycles">IO 保持周期数(对应原系统的 io_keep_cycles)。</param>
|
||||||
/// <param name="samplePeriod">稠密采样周期,用于离散化 sample_index 和 sample_time。</param>
|
/// <param name="samplePeriod">稠密采样周期,用于离散化 sample_index 和 sample_time。</param>
|
||||||
|
/// <param name="useDo">是否生成可注入伺服流的 DO 事件。</param>
|
||||||
/// <returns>包含 ShotEvent 和 TrajectoryDoEvent 的触发时间轴。</returns>
|
/// <returns>包含 ShotEvent 和 TrajectoryDoEvent 的触发时间轴。</returns>
|
||||||
public ShotTimeline Build(PlannedTrajectory trajectory, int holdCycles, TimeSpan samplePeriod)
|
public ShotTimeline Build(PlannedTrajectory trajectory, int holdCycles, TimeSpan samplePeriod, bool useDo = true)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(trajectory);
|
ArgumentNullException.ThrowIfNull(trajectory);
|
||||||
|
|
||||||
@@ -69,12 +70,16 @@ public sealed class ShotTimelineBuilder
|
|||||||
sampleTime: sampleTime,
|
sampleTime: sampleTime,
|
||||||
addressGroup: addressGroup));
|
addressGroup: addressGroup));
|
||||||
|
|
||||||
triggerTimeline.Add(new TrajectoryDoEvent(
|
if (useDo)
|
||||||
waypointIndex: i,
|
{
|
||||||
triggerTime: triggerTime,
|
// use_do=false 时保留 ShotEvent 诊断信息,但不向运行时下发 IO 脉冲。
|
||||||
offsetCycles: program.OffsetValues[i],
|
triggerTimeline.Add(new TrajectoryDoEvent(
|
||||||
holdCycles: holdCycles,
|
waypointIndex: i,
|
||||||
addressGroup: addressGroup));
|
triggerTime: triggerTime,
|
||||||
|
offsetCycles: program.OffsetValues[i],
|
||||||
|
holdCycles: holdCycles,
|
||||||
|
addressGroup: addressGroup));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return new ShotTimeline(shotEvents, triggerTimeline);
|
return new ShotTimeline(shotEvents, triggerTimeline);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
using Flyshot.Core.Domain;
|
using Flyshot.Core.Domain;
|
||||||
using Flyshot.Runtime.Common;
|
using Flyshot.Runtime.Common;
|
||||||
using Flyshot.Runtime.Fanuc.Protocol;
|
using Flyshot.Runtime.Fanuc.Protocol;
|
||||||
@@ -12,9 +13,9 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
|
|||||||
{
|
{
|
||||||
private readonly object _stateLock = new();
|
private readonly object _stateLock = new();
|
||||||
private readonly Dictionary<(string IoType, int Port), bool> _ioValues = new();
|
private readonly Dictionary<(string IoType, int Port), bool> _ioValues = new();
|
||||||
private readonly FanucCommandClient _commandClient = new();
|
private readonly FanucCommandClient _commandClient;
|
||||||
private readonly FanucStateClient _stateClient = new();
|
private readonly FanucStateClient _stateClient;
|
||||||
private readonly FanucJ519Client _j519Client = new();
|
private readonly FanucJ519Client _j519Client;
|
||||||
|
|
||||||
private RobotProfile? _robot;
|
private RobotProfile? _robot;
|
||||||
private string? _robotName;
|
private string? _robotName;
|
||||||
@@ -28,6 +29,28 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
|
|||||||
private double[] _jointPositions = Array.Empty<double>();
|
private double[] _jointPositions = Array.Empty<double>();
|
||||||
private double[] _pose = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0];
|
private double[] _pose = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0];
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
|
private CancellationTokenSource? _sendCts;
|
||||||
|
private Task? _sendTask;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 初始化 FANUC 控制器运行时。
|
||||||
|
/// </summary>
|
||||||
|
public FanucControllerRuntime()
|
||||||
|
{
|
||||||
|
_commandClient = new FanucCommandClient();
|
||||||
|
_stateClient = new FanucStateClient();
|
||||||
|
_j519Client = new FanucJ519Client();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 供测试注入 mock 客户端的内部构造函数。
|
||||||
|
/// </summary>
|
||||||
|
internal FanucControllerRuntime(FanucCommandClient commandClient, FanucStateClient stateClient, FanucJ519Client j519Client)
|
||||||
|
{
|
||||||
|
_commandClient = commandClient;
|
||||||
|
_stateClient = stateClient;
|
||||||
|
_j519Client = j519Client;
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void ResetRobot(RobotProfile robot, string robotName)
|
public void ResetRobot(RobotProfile robot, string robotName)
|
||||||
@@ -106,6 +129,7 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
|
|||||||
lock (_stateLock)
|
lock (_stateLock)
|
||||||
{
|
{
|
||||||
EnsureRobotSetup();
|
EnsureRobotSetup();
|
||||||
|
CancelSendTaskLocked();
|
||||||
DisconnectClients();
|
DisconnectClients();
|
||||||
_connectedRobotIp = null;
|
_connectedRobotIp = null;
|
||||||
_isEnabled = false;
|
_isEnabled = false;
|
||||||
@@ -149,6 +173,7 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
|
|||||||
lock (_stateLock)
|
lock (_stateLock)
|
||||||
{
|
{
|
||||||
EnsureRobotSetup();
|
EnsureRobotSetup();
|
||||||
|
CancelSendTaskLocked();
|
||||||
if (!IsSimulationMode)
|
if (!IsSimulationMode)
|
||||||
{
|
{
|
||||||
_j519Client.StopMotionAsync().GetAwaiter().GetResult();
|
_j519Client.StopMotionAsync().GetAwaiter().GetResult();
|
||||||
@@ -166,6 +191,7 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
|
|||||||
lock (_stateLock)
|
lock (_stateLock)
|
||||||
{
|
{
|
||||||
EnsureRobotSetup();
|
EnsureRobotSetup();
|
||||||
|
CancelSendTaskLocked();
|
||||||
if (!IsSimulationMode)
|
if (!IsSimulationMode)
|
||||||
{
|
{
|
||||||
_j519Client.StopMotionAsync().GetAwaiter().GetResult();
|
_j519Client.StopMotionAsync().GetAwaiter().GetResult();
|
||||||
@@ -347,11 +373,21 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
|
|||||||
EnsureEnabled();
|
EnsureEnabled();
|
||||||
EnsureValidTrajectory(result);
|
EnsureValidTrajectory(result);
|
||||||
EnsureJointCount(finalJointPositions.Count);
|
EnsureJointCount(finalJointPositions.Count);
|
||||||
|
CancelSendTaskLocked();
|
||||||
|
|
||||||
|
if (!IsSimulationMode && result.DenseJointTrajectory is not null)
|
||||||
|
{
|
||||||
|
// 真机模式且存在稠密路点:启动后台高精度发送任务。
|
||||||
|
_isInMotion = true;
|
||||||
|
_sendCts = new CancellationTokenSource();
|
||||||
|
var ct = _sendCts.Token;
|
||||||
|
_sendTask = Task.Run(() => SendDenseTrajectory(result, finalJointPositions, ct), ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!IsSimulationMode)
|
if (!IsSimulationMode)
|
||||||
{
|
{
|
||||||
// 真机模式:通过 J519 发送最终关节目标。
|
// 真机模式无稠密路点:回退到单点收敛。
|
||||||
// TODO: 后续接入稠密路点流,当前先发送单点收敛。
|
|
||||||
var command = new FanucJ519Command(
|
var command = new FanucJ519Command(
|
||||||
sequence: 0,
|
sequence: 0,
|
||||||
targetJoints: finalJointPositions.Select(j => (double)j).ToArray());
|
targetJoints: finalJointPositions.Select(j => (double)j).ToArray());
|
||||||
@@ -375,12 +411,138 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
_disposed = true;
|
_disposed = true;
|
||||||
|
CancelSendTaskLocked();
|
||||||
DisconnectClients();
|
DisconnectClients();
|
||||||
_commandClient.Dispose();
|
_commandClient.Dispose();
|
||||||
_stateClient.Dispose();
|
_stateClient.Dispose();
|
||||||
_j519Client.Dispose();
|
_j519Client.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 后台高精度发送任务:按伺服周期遍历稠密路点并注入 IO 触发。
|
||||||
|
/// </summary>
|
||||||
|
private void SendDenseTrajectory(TrajectoryResult result, IReadOnlyList<double> finalJointPositions, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var denseTrajectory = result.DenseJointTrajectory!;
|
||||||
|
var triggers = result.TriggerTimeline;
|
||||||
|
var servoPeriodSeconds = _robot!.ServoPeriod.TotalSeconds;
|
||||||
|
var halfServoPeriod = servoPeriodSeconds / 2.0;
|
||||||
|
var periodTicks = (long)(servoPeriodSeconds * Stopwatch.Frequency);
|
||||||
|
|
||||||
|
var stopwatch = Stopwatch.StartNew();
|
||||||
|
long nextTick = stopwatch.ElapsedTicks;
|
||||||
|
uint sequence = 0;
|
||||||
|
ushort ioValue = 0;
|
||||||
|
int holdRemaining = -1;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var row in denseTrajectory)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
nextTick += periodTicks;
|
||||||
|
|
||||||
|
double t = row[0];
|
||||||
|
var joints = row.Skip(1).Select(static v => (double)v).ToArray();
|
||||||
|
|
||||||
|
// 递减 IO 保持计数器;若已到期则清零。
|
||||||
|
if (holdRemaining > 0)
|
||||||
|
{
|
||||||
|
holdRemaining--;
|
||||||
|
}
|
||||||
|
else if (holdRemaining == 0)
|
||||||
|
{
|
||||||
|
ioValue = 0;
|
||||||
|
holdRemaining = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查当前周期是否有新的触发事件。
|
||||||
|
if (holdRemaining < 0)
|
||||||
|
{
|
||||||
|
foreach (var trigger in triggers)
|
||||||
|
{
|
||||||
|
if (Math.Abs(t - trigger.TriggerTime) < halfServoPeriod)
|
||||||
|
{
|
||||||
|
ioValue = ComputeIoValue(trigger.AddressGroup);
|
||||||
|
holdRemaining = trigger.HoldCycles;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var command = new FanucJ519Command(
|
||||||
|
sequence: sequence++,
|
||||||
|
targetJoints: joints,
|
||||||
|
writeIoType: 2,
|
||||||
|
writeIoIndex: 1,
|
||||||
|
writeIoMask: 255,
|
||||||
|
writeIoValue: ioValue);
|
||||||
|
|
||||||
|
_j519Client.UpdateCommand(command);
|
||||||
|
|
||||||
|
// 高精度忙等待直到下一伺服周期。
|
||||||
|
while (stopwatch.ElapsedTicks < nextTick)
|
||||||
|
{
|
||||||
|
Thread.SpinWait(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// 正常取消,轨迹被中断。
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
lock (_stateLock)
|
||||||
|
{
|
||||||
|
_isInMotion = false;
|
||||||
|
_jointPositions = finalJointPositions.ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 取消并等待当前后台发送任务,避免旧任务与新轨迹并发。
|
||||||
|
/// </summary>
|
||||||
|
private void CancelSendTaskLocked()
|
||||||
|
{
|
||||||
|
_sendCts?.Cancel();
|
||||||
|
|
||||||
|
if (_sendTask is not null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_sendTask.Wait(TimeSpan.FromSeconds(2));
|
||||||
|
}
|
||||||
|
catch (AggregateException)
|
||||||
|
{
|
||||||
|
// 忽略取消异常。
|
||||||
|
}
|
||||||
|
|
||||||
|
_sendTask = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_sendCts?.Dispose();
|
||||||
|
_sendCts = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 把 IO 地址组中的地址号映射为 writeIoValue 的位掩码。
|
||||||
|
/// </summary>
|
||||||
|
internal static ushort ComputeIoValue(IoAddressGroup group)
|
||||||
|
{
|
||||||
|
ushort value = 0;
|
||||||
|
foreach (var addr in group.Addresses)
|
||||||
|
{
|
||||||
|
if (addr is >= 0 and < 16)
|
||||||
|
{
|
||||||
|
value |= (ushort)(1 << addr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 判断当前是否处于仿真模式;若尚未选择控制器则抛出异常。
|
/// 判断当前是否处于仿真模式;若尚未选择控制器则抛出异常。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -6,6 +6,12 @@
|
|||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
|
||||||
|
<_Parameter1>Flyshot.Core.Tests</_Parameter1>
|
||||||
|
</AssemblyAttribute>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\Flyshot.Core.Domain\Flyshot.Core.Domain.csproj" />
|
<ProjectReference Include="..\Flyshot.Core.Domain\Flyshot.Core.Domain.csproj" />
|
||||||
<ProjectReference Include="..\Flyshot.Runtime.Common\Flyshot.Runtime.Common.csproj" />
|
<ProjectReference Include="..\Flyshot.Runtime.Common\Flyshot.Runtime.Common.csproj" />
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ public sealed class FanucCommandClient : IDisposable
|
|||||||
{
|
{
|
||||||
var frame = FanucCommandProtocol.PackProgramCommand(messageId, programName);
|
var frame = FanucCommandProtocol.PackProgramCommand(messageId, programName);
|
||||||
var response = await SendRawFrameAsync(frame, cancellationToken).ConfigureAwait(false);
|
var response = await SendRawFrameAsync(frame, cancellationToken).ConfigureAwait(false);
|
||||||
return FanucCommandProtocol.ParseResultResponse(response);
|
return EnsureSuccess(FanucCommandProtocol.ParseResultResponse(response));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -119,7 +119,7 @@ public sealed class FanucCommandClient : IDisposable
|
|||||||
{
|
{
|
||||||
var frame = FanucCommandProtocol.PackEmptyCommand(FanucCommandMessageIds.ResetRobot);
|
var frame = FanucCommandProtocol.PackEmptyCommand(FanucCommandMessageIds.ResetRobot);
|
||||||
var response = await SendRawFrameAsync(frame, cancellationToken).ConfigureAwait(false);
|
var response = await SendRawFrameAsync(frame, cancellationToken).ConfigureAwait(false);
|
||||||
return FanucCommandProtocol.ParseResultResponse(response);
|
return EnsureSuccess(FanucCommandProtocol.ParseResultResponse(response));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -132,7 +132,7 @@ public sealed class FanucCommandClient : IDisposable
|
|||||||
{
|
{
|
||||||
var frame = FanucCommandProtocol.PackProgramCommand(FanucCommandMessageIds.GetProgramStatus, programName);
|
var frame = FanucCommandProtocol.PackProgramCommand(FanucCommandMessageIds.GetProgramStatus, programName);
|
||||||
var response = await SendRawFrameAsync(frame, cancellationToken).ConfigureAwait(false);
|
var response = await SendRawFrameAsync(frame, cancellationToken).ConfigureAwait(false);
|
||||||
return FanucCommandProtocol.ParseProgramStatusResponse(response);
|
return EnsureSuccess(FanucCommandProtocol.ParseProgramStatusResponse(response));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -186,6 +186,41 @@ public sealed class FanucCommandClient : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 校验普通命令响应结果码,失败时抛出包含消息号和结果码的诊断异常。
|
||||||
|
/// </summary>
|
||||||
|
private static FanucCommandResultResponse EnsureSuccess(FanucCommandResultResponse response)
|
||||||
|
{
|
||||||
|
if (!response.IsSuccess)
|
||||||
|
{
|
||||||
|
throw CreateCommandFailureException(response.MessageId, response.ResultCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 校验程序状态响应结果码,失败时抛出包含消息号和结果码的诊断异常。
|
||||||
|
/// </summary>
|
||||||
|
private static FanucProgramStatusResponse EnsureSuccess(FanucProgramStatusResponse response)
|
||||||
|
{
|
||||||
|
if (!response.IsSuccess)
|
||||||
|
{
|
||||||
|
throw CreateCommandFailureException(response.MessageId, response.ResultCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 构造包含 FANUC 命令上下文的失败异常。
|
||||||
|
/// </summary>
|
||||||
|
private static InvalidOperationException CreateCommandFailureException(uint messageId, uint resultCode)
|
||||||
|
{
|
||||||
|
return new InvalidOperationException(
|
||||||
|
$"FANUC command 0x{messageId:X4} failed with result_code {resultCode}.");
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 从流中读取一条完整的 doz/zod 响应帧。
|
/// 从流中读取一条完整的 doz/zod 响应帧。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
using System.Net.Sockets;
|
using System.Net.Sockets;
|
||||||
|
|
||||||
namespace Flyshot.Runtime.Fanuc.Protocol;
|
namespace Flyshot.Runtime.Fanuc.Protocol;
|
||||||
@@ -123,6 +124,20 @@ public sealed class FanucJ519Client : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取最近一次通过 UpdateCommand 设置的 J519 命令;供测试断言使用。
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>当前 J519 命令或 null。</returns>
|
||||||
|
internal FanucJ519Command? GetCurrentCommand()
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||||
|
|
||||||
|
lock (_commandLock)
|
||||||
|
{
|
||||||
|
return _currentCommand;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 获取最近一次解析的 J519 响应;若尚未收到任何响应则返回 null。
|
/// 获取最近一次解析的 J519 响应;若尚未收到任何响应则返回 null。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -225,7 +240,7 @@ public sealed class FanucJ519Client : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 后台发送循环:约 8ms 周期发送当前命令。
|
/// 后台发送循环:以 Stopwatch + SpinWait 实现高精度 8ms 周期发送当前命令。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task SendLoopAsync(CancellationToken cancellationToken)
|
private async Task SendLoopAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -234,13 +249,17 @@ public sealed class FanucJ519Client : IDisposable
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用 8ms 周期近似 125Hz 伺服频率。
|
// 8ms 伺服周期,对应 125Hz。
|
||||||
using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(8));
|
var periodTicks = (long)(0.008 * Stopwatch.Frequency);
|
||||||
|
var stopwatch = Stopwatch.StartNew();
|
||||||
|
long nextTick = stopwatch.ElapsedTicks;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false))
|
while (!cancellationToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
|
nextTick += periodTicks;
|
||||||
|
|
||||||
FanucJ519Command? command;
|
FanucJ519Command? command;
|
||||||
lock (_commandLock)
|
lock (_commandLock)
|
||||||
{
|
{
|
||||||
@@ -252,6 +271,12 @@ public sealed class FanucJ519Client : IDisposable
|
|||||||
var packet = FanucJ519Protocol.PackCommandPacket(command);
|
var packet = FanucJ519Protocol.PackCommandPacket(command);
|
||||||
await _udpClient.SendAsync(packet, cancellationToken).ConfigureAwait(false);
|
await _udpClient.SendAsync(packet, cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 高精度忙等待直到下一周期,避免 PeriodicTimer 的 ±15ms 抖动。
|
||||||
|
while (stopwatch.ElapsedTicks < nextTick)
|
||||||
|
{
|
||||||
|
Thread.SpinWait(1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
|
|||||||
@@ -26,12 +26,12 @@ public sealed class ConfigCompatibilityTests
|
|||||||
Assert.Equal(1.0, loaded.Robot.JerkLimitScale);
|
Assert.Equal(1.0, loaded.Robot.JerkLimitScale);
|
||||||
Assert.Equal(5, loaded.Robot.AdaptIcspTryNum);
|
Assert.Equal(5, loaded.Robot.AdaptIcspTryNum);
|
||||||
|
|
||||||
var program = Assert.Contains("001", loaded.Programs);
|
var program = Assert.Contains("EOL10_EAU_0", loaded.Programs);
|
||||||
Assert.Equal("001", program.Name);
|
Assert.Equal("EOL10_EAU_0", program.Name);
|
||||||
Assert.Equal(5, program.Waypoints.Count);
|
Assert.Equal(45, program.Waypoints.Count);
|
||||||
Assert.Equal(3, program.ShotWaypointCount);
|
Assert.Equal(42, program.ShotWaypointCount);
|
||||||
Assert.Empty(program.AddressGroups[0].Addresses);
|
Assert.Empty(program.AddressGroups[0].Addresses);
|
||||||
Assert.Equal([8, 7], program.AddressGroups[1].Addresses);
|
Assert.Equal([4, 3], program.AddressGroups[1].Addresses);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -130,6 +130,27 @@ public sealed class FanucCommandClientTests : IDisposable
|
|||||||
await handlerTask.WaitAsync(TimeSpan.FromSeconds(2), _cts.Token);
|
await handlerTask.WaitAsync(TimeSpan.FromSeconds(2), _cts.Token);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 验证命令响应 result_code 非零时,客户端会抛出可诊断异常而不是让上层误判成功。
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task StopProgramAsync_NonZeroResultCode_ThrowsDiagnosticException()
|
||||||
|
{
|
||||||
|
using var client = new FanucCommandClient();
|
||||||
|
var handlerTask = RunSingleResponseControllerAsync(
|
||||||
|
FanucCommandProtocol.PackProgramCommand(FanucCommandMessageIds.StopProgram, "RVBUSTSM"),
|
||||||
|
Convert.FromHexString("646f7a00000012000021030000002a7a6f64"),
|
||||||
|
_cts.Token);
|
||||||
|
|
||||||
|
await client.ConnectAsync("127.0.0.1", Port, _cts.Token);
|
||||||
|
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
() => client.StopProgramAsync("RVBUSTSM", _cts.Token));
|
||||||
|
|
||||||
|
Assert.Contains("0x2103", exception.Message);
|
||||||
|
Assert.Contains("42", exception.Message);
|
||||||
|
await handlerTask.WaitAsync(TimeSpan.FromSeconds(2), _cts.Token);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 验证在连接前调用命令会抛出 InvalidOperationException。
|
/// 验证在连接前调用命令会抛出 InvalidOperationException。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
96
tests/Flyshot.Core.Tests/FanucControllerRuntimeDenseTests.cs
Normal file
96
tests/Flyshot.Core.Tests/FanucControllerRuntimeDenseTests.cs
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
using Flyshot.Core.Domain;
|
||||||
|
using Flyshot.Runtime.Fanuc;
|
||||||
|
|
||||||
|
namespace Flyshot.Core.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 验证 FANUC 控制器运行在稠密轨迹流式执行与 IO 触发上的行为。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FanucControllerRuntimeDenseTests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 验证仿真模式下即使传入稠密路点,也会回退到单点同步更新。
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void ExecuteTrajectory_WithDenseWaypoints_SimulationMode_FallsBackToSinglePoint()
|
||||||
|
{
|
||||||
|
var runtime = new FanucControllerRuntime();
|
||||||
|
var robot = TestRobotFactory.CreateRobotProfile();
|
||||||
|
runtime.ResetRobot(robot, "FANUC_LR_Mate_200iD");
|
||||||
|
runtime.SetActiveController(sim: true);
|
||||||
|
runtime.Connect("192.168.10.101");
|
||||||
|
runtime.EnableRobot(bufferSize: 2);
|
||||||
|
|
||||||
|
var denseTrajectory = new[]
|
||||||
|
{
|
||||||
|
new[] { 0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6 },
|
||||||
|
new[] { 0.008, 0.11, 0.21, 0.31, 0.41, 0.51, 0.61 },
|
||||||
|
new[] { 0.016, 0.12, 0.22, 0.32, 0.42, 0.52, 0.62 }
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = new TrajectoryResult(
|
||||||
|
programName: "demo",
|
||||||
|
method: PlanningMethod.Icsp,
|
||||||
|
isValid: true,
|
||||||
|
duration: TimeSpan.FromSeconds(0.016),
|
||||||
|
shotEvents: Array.Empty<ShotEvent>(),
|
||||||
|
triggerTimeline: Array.Empty<TrajectoryDoEvent>(),
|
||||||
|
artifacts: Array.Empty<TrajectoryArtifact>(),
|
||||||
|
failureReason: null,
|
||||||
|
usedCache: false,
|
||||||
|
originalWaypointCount: 4,
|
||||||
|
plannedWaypointCount: 4,
|
||||||
|
denseJointTrajectory: denseTrajectory);
|
||||||
|
|
||||||
|
runtime.ExecuteTrajectory(result, [0.12, 0.22, 0.32, 0.42, 0.52, 0.62]);
|
||||||
|
|
||||||
|
var snapshot = runtime.GetSnapshot();
|
||||||
|
Assert.False(snapshot.IsInMotion);
|
||||||
|
Assert.Equal([0.12, 0.22, 0.32, 0.42, 0.52, 0.62], snapshot.JointPositions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 验证 StopMove 在没有任何后台发送任务运行时不会抛出异常。
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void StopMove_DoesNotThrowWhenNoSendTaskRunning()
|
||||||
|
{
|
||||||
|
var runtime = new FanucControllerRuntime();
|
||||||
|
var robot = TestRobotFactory.CreateRobotProfile();
|
||||||
|
runtime.ResetRobot(robot, "FANUC_LR_Mate_200iD");
|
||||||
|
runtime.SetActiveController(sim: true);
|
||||||
|
runtime.Connect("192.168.10.101");
|
||||||
|
runtime.EnableRobot(bufferSize: 2);
|
||||||
|
|
||||||
|
var exception = Record.Exception(() => runtime.StopMove());
|
||||||
|
Assert.Null(exception);
|
||||||
|
Assert.False(runtime.GetSnapshot().IsInMotion);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 验证 IO 地址组中的地址号被正确映射为 writeIoValue 的位掩码。
|
||||||
|
/// </summary>
|
||||||
|
[Theory]
|
||||||
|
[InlineData(new[] { 0 }, (ushort)1)]
|
||||||
|
[InlineData(new[] { 7 }, (ushort)128)]
|
||||||
|
[InlineData(new[] { 7, 8 }, (ushort)384)] // 128 + 256
|
||||||
|
[InlineData(new[] { 15 }, (ushort)32768)]
|
||||||
|
[InlineData(new int[] { }, (ushort)0)]
|
||||||
|
public void ComputeIoValue_MapsAddressesToBitMask(int[] addresses, ushort expected)
|
||||||
|
{
|
||||||
|
var group = new IoAddressGroup(addresses);
|
||||||
|
var actual = FanucControllerRuntime.ComputeIoValue(group);
|
||||||
|
Assert.Equal(expected, actual);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 验证超过 15 的地址号会被安全忽略,不会溢出位掩码。
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void ComputeIoValue_IgnoresOutOfRangeAddresses()
|
||||||
|
{
|
||||||
|
var group = new IoAddressGroup([0, 16, 7]);
|
||||||
|
var actual = FanucControllerRuntime.ComputeIoValue(group);
|
||||||
|
Assert.Equal((ushort)(1 | 128), actual);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -177,4 +177,43 @@ public sealed class FanucJ519ClientTests : IDisposable
|
|||||||
using var client = new FanucJ519Client();
|
using var client = new FanucJ519Client();
|
||||||
Assert.Throws<InvalidOperationException>(() => client.StartMotion());
|
Assert.Throws<InvalidOperationException>(() => client.StartMotion());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 验证 Stopwatch + SpinWait 发送循环能保持约 8ms 周期抖动在亚毫秒级。
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task StartMotion_MaintainsSubMillisecondPeriod()
|
||||||
|
{
|
||||||
|
using var client = new FanucJ519Client();
|
||||||
|
await client.ConnectAsync("127.0.0.1", Port, _cts.Token);
|
||||||
|
await _server.ReceiveAsync(_cts.Token); // init
|
||||||
|
|
||||||
|
var command = new FanucJ519Command(sequence: 1, targetJoints: [1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
|
||||||
|
client.UpdateCommand(command);
|
||||||
|
client.StartMotion();
|
||||||
|
|
||||||
|
// 收集 5 个命令包到达时间戳。
|
||||||
|
var timestamps = new List<DateTimeOffset>();
|
||||||
|
for (var i = 0; i < 5; i++)
|
||||||
|
{
|
||||||
|
var result = await _server.ReceiveAsync(_cts.Token);
|
||||||
|
timestamps.Add(DateTimeOffset.UtcNow);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.StopMotionAsync(_cts.Token);
|
||||||
|
|
||||||
|
// 计算相邻包间隔并断言最大抖动。
|
||||||
|
var intervals = new List<TimeSpan>();
|
||||||
|
for (var i = 1; i < timestamps.Count; i++)
|
||||||
|
{
|
||||||
|
intervals.Add(timestamps[i] - timestamps[i - 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 允许 ±2ms 的测量误差(含 UDP 传输和调度延迟)。
|
||||||
|
Assert.All(intervals, interval =>
|
||||||
|
{
|
||||||
|
Assert.True(interval >= TimeSpan.FromMilliseconds(6), $"间隔 {interval.TotalMilliseconds:F2}ms 过短。");
|
||||||
|
Assert.True(interval <= TimeSpan.FromMilliseconds(10), $"间隔 {interval.TotalMilliseconds:F2}ms 过长。");
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using Flyshot.ControllerClientCompat;
|
using Flyshot.ControllerClientCompat;
|
||||||
using Flyshot.Core.Config;
|
using Flyshot.Core.Config;
|
||||||
using Flyshot.Core.Domain;
|
using Flyshot.Core.Domain;
|
||||||
|
using Flyshot.Runtime.Common;
|
||||||
using Flyshot.Runtime.Fanuc;
|
using Flyshot.Runtime.Fanuc;
|
||||||
|
|
||||||
namespace Flyshot.Core.Tests;
|
namespace Flyshot.Core.Tests;
|
||||||
@@ -82,6 +83,104 @@ public sealed class RuntimeOrchestrationTests
|
|||||||
Assert.Single(bundle.Result.TriggerTimeline);
|
Assert.Single(bundle.Result.TriggerTimeline);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 验证飞拍编排会使用 RobotConfig.json 中的 IO 保持周期。
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void ControllerClientTrajectoryOrchestrator_PlanUploadedFlyshot_UsesRobotSettingsForHoldCycles()
|
||||||
|
{
|
||||||
|
var orchestrator = new ControllerClientTrajectoryOrchestrator();
|
||||||
|
var robot = TestRobotFactory.CreateRobotProfile();
|
||||||
|
var uploaded = TestRobotFactory.CreateUploadedTrajectoryWithSingleShot();
|
||||||
|
var settings = new CompatibilityRobotSettings(
|
||||||
|
useDo: true,
|
||||||
|
ioAddresses: [7, 8],
|
||||||
|
ioKeepCycles: 4,
|
||||||
|
accLimitScale: 1.0,
|
||||||
|
jerkLimitScale: 1.0,
|
||||||
|
adaptIcspTryNum: 5);
|
||||||
|
|
||||||
|
var bundle = orchestrator.PlanUploadedFlyshot(robot, uploaded, settings: settings);
|
||||||
|
|
||||||
|
var doEvent = Assert.Single(bundle.Result.TriggerTimeline);
|
||||||
|
Assert.Equal(4, doEvent.HoldCycles);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 验证 RobotConfig.json 关闭 use_do 时仍保留 ShotEvent 诊断信息,但不生成伺服 DO 事件。
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void ControllerClientTrajectoryOrchestrator_PlanUploadedFlyshot_SuppressesDoTimeline_WhenUseDoIsFalse()
|
||||||
|
{
|
||||||
|
var orchestrator = new ControllerClientTrajectoryOrchestrator();
|
||||||
|
var robot = TestRobotFactory.CreateRobotProfile();
|
||||||
|
var uploaded = TestRobotFactory.CreateUploadedTrajectoryWithSingleShot();
|
||||||
|
var settings = new CompatibilityRobotSettings(
|
||||||
|
useDo: false,
|
||||||
|
ioAddresses: [7, 8],
|
||||||
|
ioKeepCycles: 4,
|
||||||
|
accLimitScale: 1.0,
|
||||||
|
jerkLimitScale: 1.0,
|
||||||
|
adaptIcspTryNum: 5);
|
||||||
|
|
||||||
|
var bundle = orchestrator.PlanUploadedFlyshot(robot, uploaded, settings: settings);
|
||||||
|
|
||||||
|
Assert.Single(bundle.Result.ShotEvents);
|
||||||
|
Assert.Empty(bundle.Result.TriggerTimeline);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 验证普通轨迹规划后会生成稠密关节采样序列。
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void ControllerClientTrajectoryOrchestrator_PlanOrdinaryTrajectory_ReturnsDenseJointTrajectory()
|
||||||
|
{
|
||||||
|
var orchestrator = new ControllerClientTrajectoryOrchestrator();
|
||||||
|
var robot = TestRobotFactory.CreateRobotProfile();
|
||||||
|
|
||||||
|
var bundle = orchestrator.PlanOrdinaryTrajectory(
|
||||||
|
robot,
|
||||||
|
[
|
||||||
|
[0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
|
||||||
|
[0.1, 0.0, 0.0, 0.0, 0.0, 0.0],
|
||||||
|
[0.2, 0.0, 0.0, 0.0, 0.0, 0.0],
|
||||||
|
[0.3, 0.0, 0.0, 0.0, 0.0, 0.0]
|
||||||
|
]);
|
||||||
|
|
||||||
|
Assert.NotNull(bundle.Result.DenseJointTrajectory);
|
||||||
|
Assert.NotEmpty(bundle.Result.DenseJointTrajectory);
|
||||||
|
|
||||||
|
// 验证时间单调递增。
|
||||||
|
var times = bundle.Result.DenseJointTrajectory.Select(static row => row[0]).ToArray();
|
||||||
|
for (var i = 1; i < times.Length; i++)
|
||||||
|
{
|
||||||
|
Assert.True(times[i] > times[i - 1], $"采样时间点应在索引 {i} 处单调递增。");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证每行包含时间 + 6 个关节值。
|
||||||
|
Assert.All(bundle.Result.DenseJointTrajectory, row => Assert.Equal(7, row.Count));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 验证飞拍轨迹规划后的稠密采样时间轴与伺服周期一致。
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void ControllerClientTrajectoryOrchestrator_PlanUploadedFlyshot_DenseTrajectoryUsesServoPeriod()
|
||||||
|
{
|
||||||
|
var orchestrator = new ControllerClientTrajectoryOrchestrator();
|
||||||
|
var robot = TestRobotFactory.CreateRobotProfile();
|
||||||
|
var uploaded = TestRobotFactory.CreateUploadedTrajectoryWithSingleShot();
|
||||||
|
|
||||||
|
var bundle = orchestrator.PlanUploadedFlyshot(robot, uploaded);
|
||||||
|
|
||||||
|
Assert.NotNull(bundle.Result.DenseJointTrajectory);
|
||||||
|
Assert.True(bundle.Result.DenseJointTrajectory.Count > 1);
|
||||||
|
|
||||||
|
// 采样周期应为 8ms(伺服周期)。
|
||||||
|
var firstDt = bundle.Result.DenseJointTrajectory[1][0] - bundle.Result.DenseJointTrajectory[0][0];
|
||||||
|
Assert.Equal(0.008, firstDt, precision: 3);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 验证兼容服务执行普通轨迹时会进入规划链路,而不是直接把最后一个路点写入状态。
|
/// 验证兼容服务执行普通轨迹时会进入规划链路,而不是直接把最后一个路点写入状态。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -104,6 +203,73 @@ public sealed class RuntimeOrchestrationTests
|
|||||||
|
|
||||||
Assert.Throws<ArgumentException>(Act);
|
Assert.Throws<ArgumentException>(Act);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 验证兼容服务初始化机器人时会把 RobotConfig.json 中的 acc_limit / jerk_limit 传给模型加载器。
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void ControllerClientCompatService_SetUpRobot_AppliesRobotConfigLimitScales()
|
||||||
|
{
|
||||||
|
var tempRoot = CreateTempWorkspaceRoot();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.WriteAllText(
|
||||||
|
Path.Combine(tempRoot, "RobotConfig.json"),
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"robot": {
|
||||||
|
"use_do": true,
|
||||||
|
"io_addr": [7, 8],
|
||||||
|
"io_keep_cycles": 4,
|
||||||
|
"acc_limit": 0.5,
|
||||||
|
"jerk_limit": 0.25,
|
||||||
|
"adapt_icsp_try_num": 3
|
||||||
|
},
|
||||||
|
"flying_shots": {}
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
|
||||||
|
var options = new ControllerClientCompatOptions { WorkspaceRoot = tempRoot };
|
||||||
|
var runtime = new RecordingControllerRuntime();
|
||||||
|
var service = new ControllerClientCompatService(
|
||||||
|
options,
|
||||||
|
new ControllerClientCompatRobotCatalog(options, new RobotModelLoader()),
|
||||||
|
runtime,
|
||||||
|
new ControllerClientTrajectoryOrchestrator(),
|
||||||
|
new RobotConfigLoader(),
|
||||||
|
new InMemoryFlyshotTrajectoryStore());
|
||||||
|
|
||||||
|
service.SetUpRobot("FANUC_LR_Mate_200iD");
|
||||||
|
|
||||||
|
var profile = Assert.IsType<RobotProfile>(runtime.LastRobotProfile);
|
||||||
|
Assert.Equal(14.905, profile.JointLimits[2].AccelerationLimit, precision: 3);
|
||||||
|
Assert.Equal(62.115, profile.JointLimits[2].JerkLimit, precision: 3);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Directory.Delete(tempRoot, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 创建只包含当前支持机器人模型和 RobotConfig.json 的临时工作区。
|
||||||
|
/// </summary>
|
||||||
|
private static string CreateTempWorkspaceRoot()
|
||||||
|
{
|
||||||
|
var tempRoot = Path.Combine(Path.GetTempPath(), "flyshot-runtime-tests", Guid.NewGuid().ToString("N"));
|
||||||
|
var modelDir = Path.Combine(tempRoot, "FlyingShot", "FlyingShot", "Models");
|
||||||
|
Directory.CreateDirectory(modelDir);
|
||||||
|
|
||||||
|
var sourceModel = Path.Combine(
|
||||||
|
TestRobotFactory.GetWorkspaceRoot(),
|
||||||
|
"FlyingShot",
|
||||||
|
"FlyingShot",
|
||||||
|
"Models",
|
||||||
|
"LR_Mate_200iD_7L.robot");
|
||||||
|
File.Copy(sourceModel, Path.Combine(modelDir, "LR_Mate_200iD_7L.robot"));
|
||||||
|
|
||||||
|
return tempRoot;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -170,14 +336,16 @@ internal static class TestRobotFactory
|
|||||||
options,
|
options,
|
||||||
new ControllerClientCompatRobotCatalog(options, new RobotModelLoader()),
|
new ControllerClientCompatRobotCatalog(options, new RobotModelLoader()),
|
||||||
new FanucControllerRuntime(),
|
new FanucControllerRuntime(),
|
||||||
new ControllerClientTrajectoryOrchestrator());
|
new ControllerClientTrajectoryOrchestrator(),
|
||||||
|
new RobotConfigLoader(),
|
||||||
|
new InMemoryFlyshotTrajectoryStore());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 定位父工作区根目录,供兼容服务加载真实机器人模型。
|
/// 定位父工作区根目录,供兼容服务加载真实机器人模型。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>父工作区根目录。</returns>
|
/// <returns>父工作区根目录。</returns>
|
||||||
private static string GetWorkspaceRoot()
|
public static string GetWorkspaceRoot()
|
||||||
{
|
{
|
||||||
var current = new DirectoryInfo(AppContext.BaseDirectory);
|
var current = new DirectoryInfo(AppContext.BaseDirectory);
|
||||||
while (current is not null)
|
while (current is not null)
|
||||||
@@ -193,3 +361,126 @@ internal static class TestRobotFactory
|
|||||||
throw new DirectoryNotFoundException("Unable to locate the flyshot workspace root.");
|
throw new DirectoryNotFoundException("Unable to locate the flyshot workspace root.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 内存中的轨迹存储实现,用于避免单元测试污染真实文件系统。
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class InMemoryFlyshotTrajectoryStore : IFlyshotTrajectoryStore
|
||||||
|
{
|
||||||
|
private readonly Dictionary<string, ControllerClientCompatUploadedTrajectory> _store = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Save(string robotName, CompatibilityRobotSettings settings, ControllerClientCompatUploadedTrajectory trajectory)
|
||||||
|
{
|
||||||
|
_store[trajectory.Name] = trajectory;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Delete(string robotName, string trajectoryName)
|
||||||
|
{
|
||||||
|
_store.Remove(trajectoryName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IReadOnlyDictionary<string, ControllerClientCompatUploadedTrajectory> LoadAll(string robotName, out CompatibilityRobotSettings? settings)
|
||||||
|
{
|
||||||
|
settings = null;
|
||||||
|
return _store;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 记录 ResetRobot 入参的测试运行时,用于验证兼容服务传递的机器人配置。
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class RecordingControllerRuntime : IControllerRuntime
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 获取最近一次 ResetRobot 收到的机器人配置。
|
||||||
|
/// </summary>
|
||||||
|
public RobotProfile? LastRobotProfile { get; private set; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void ResetRobot(RobotProfile robot, string robotName)
|
||||||
|
{
|
||||||
|
LastRobotProfile = robot;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void SetActiveController(bool sim)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Connect(string robotIp)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Disconnect()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void EnableRobot(int bufferSize)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void DisableRobot()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void StopMove()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public double GetSpeedRatio() => 1.0;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void SetSpeedRatio(double ratio)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IReadOnlyList<double> GetTcp() => [0.0, 0.0, 0.0];
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void SetTcp(double x, double y, double z)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool GetIo(int port, string ioType) => false;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void SetIo(int port, bool value, string ioType)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IReadOnlyList<double> GetJointPositions() => Array.Empty<double>();
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IReadOnlyList<double> GetPose() => Array.Empty<double>();
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public ControllerStateSnapshot GetSnapshot()
|
||||||
|
{
|
||||||
|
return new ControllerStateSnapshot(
|
||||||
|
capturedAt: DateTimeOffset.UtcNow,
|
||||||
|
connectionState: "Connected",
|
||||||
|
isEnabled: true,
|
||||||
|
isInMotion: false,
|
||||||
|
speedRatio: 1.0,
|
||||||
|
jointPositions: Array.Empty<double>(),
|
||||||
|
cartesianPose: Array.Empty<double>(),
|
||||||
|
activeAlarms: Array.Empty<RuntimeAlarm>());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void ExecuteTrajectory(TrajectoryResult result, IReadOnlyList<double> finalJointPositions)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user