feat(runtime): 添加轨迹持久化与密集执行链路

* 新增飞拍轨迹文件存储,支持上传、加载与删除
* 接通 ControllerClientCompat 到运行时的轨迹编排
* 完善 FANUC 命令与 J519 客户端发送链路
* 补充密集轨迹执行、运行时编排和协议客户端测试
* 更新 README 与 AGENTS 中的当前实现状态
This commit is contained in:
2026-04-26 17:14:17 +08:00
parent a78e6761cb
commit 390d066ece
19 changed files with 1172 additions and 57 deletions

View File

@@ -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`,不再只是兼容层内存赋值。

View File

@@ -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 状态位和最近报警显示。

View 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": {}
}

View File

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

View File

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

View File

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

View File

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

View 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.");
}
}

View File

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

View File

@@ -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,6 +70,9 @@ public sealed class ShotTimelineBuilder
sampleTime: sampleTime, sampleTime: sampleTime,
addressGroup: addressGroup)); addressGroup: addressGroup));
if (useDo)
{
// use_do=false 时保留 ShotEvent 诊断信息,但不向运行时下发 IO 脉冲。
triggerTimeline.Add(new TrajectoryDoEvent( triggerTimeline.Add(new TrajectoryDoEvent(
waypointIndex: i, waypointIndex: i,
triggerTime: triggerTime, triggerTime: triggerTime,
@@ -76,6 +80,7 @@ public sealed class ShotTimelineBuilder
holdCycles: holdCycles, holdCycles: holdCycles,
addressGroup: addressGroup)); addressGroup: addressGroup));
} }
}
return new ShotTimeline(shotEvents, triggerTimeline); return new ShotTimeline(shotEvents, triggerTimeline);
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -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 过长。");
});
}
} }

View File

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