diff --git a/AGENTS.md b/AGENTS.md index 1fe51ff..3b6240e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ - 使用 `C# + .NET 8` - 提供跨平台独立服务端 -- 兼容现有 `50001/TCP+JSON` 上层接入语义 +- 以新的 ASP.NET Core HTTP API 作为唯一上层接口 - 重写轨迹生成、触发时序、FANUC 控制链路和状态监控 - Windows / Linux 都能运行完整服务端 - 只支持当前现场这套组合 @@ -18,6 +18,7 @@ - GUI 桌面程序 - 多机器人同时控制 - 面向多控制柜的通用平台化框架 +- 恢复旧 `50001/TCP+JSON` 网关 ## 2. 代码与资料边界 @@ -91,6 +92,7 @@ flyshot-replacement/ ### 4.2 实现约束 +- 旧 `ControllerClient` 资料只作为接口语义参考;运行时入口以新 HTTP API 为准,不恢复旧 `50001/TCP+JSON` 网关。 - 旧协议兼容以“语义兼容”为主,不追求二进制逐字节一致。 - 轨迹规划必须与底层 Socket / HTTP / Web UI 解耦。 - 领域层不允许引用 ASP.NET Core、Socket、文件系统 API。 @@ -169,8 +171,9 @@ flyshot-replacement/ - `Flyshot.Server.Host` 已提供最小 `/healthz`。 - 最小集成测试已通过。 - 解决方案构建已通过。 -- HTTP-only `ControllerClientCompat` 已覆盖旧 HTTP 控制器后端的主要兼容语义。 -- `Flyshot.Core.Planning` 已落地 `icsp` 与 `self-adapt-icsp` 的最小规划链路。 +- 新 HTTP API / HTTP-only `ControllerClientCompat` 已覆盖旧 HTTP 控制器后端的主要兼容语义。 +- `Flyshot.Core.Planning` 已落地 `icsp` 与 `self-adapt-icsp`,并已完成旧系统导出轨迹对齐。 - `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`,不再只是兼容层内存赋值。 diff --git a/README.md b/README.md index 066d2d4..4edd75e 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ 当前目标: -- 兼容现有 `50001/TCP+JSON` 上层接入语义 +- 以新的 ASP.NET Core HTTP API 作为唯一上层接口 - 重写轨迹生成、触发时序和 FANUC 实时控制链路 - 提供 Web 状态监控页面 - 在 Windows 和 Linux 上运行完整后台服务 @@ -13,9 +13,12 @@ - 这是长期运行的无头后台服务,不是 GUI 桌面程序。 - 第一版仅面向当前现场组合,后续再扩展机型与控制柜适配。 -- 当前仓库内已经移除宿主中的 `50001/TCP+JSON` 监听实现;现阶段只保留 ASP.NET Core HTTP 控制器层,以及其后端 `Flyshot.ControllerClientCompat` 兼容服务。 -- `ExecuteTrajectory` 与 `ExecuteFlyShotTraj` 已经接入 `Planning + Triggering + Runtime` 最小链路;Web 状态页已通过 `/status` 和 `/api/status/snapshot` 暴露当前兼容层与运行时状态;`Flyshot.Runtime.Fanuc` 已固化 `10010 / 10012 / 60015` 基础协议帧编解码,但运行时仍是状态型骨架,尚未完成真机 Socket 联调。 -- `50001/TCP+JSON` 的真实兼容入口如果后续需要恢复,必须基于 `docs/controller-client-api-compatibility-requirements.md` 与 `docs/controller-client-api-reverse-engineering.md` 重新评估,而不是直接把旧的 TCP 网关方向接回宿主。 +- 当前仓库不再恢复旧 `50001/TCP+JSON` 监听入口;旧 `ControllerClient` 逆向资料只作为接口语义参考,不作为运行时目标。 +- 宿主只保留 ASP.NET Core HTTP 控制器层,以及其后端 `Flyshot.ControllerClientCompat` 兼容服务。 +- `ExecuteTrajectory` 与 `ExecuteFlyShotTraj` 已经接入 `Planning + Triggering + Runtime` 链路;Web 状态页已通过 `/status` 和 `/api/status/snapshot` 暴露当前兼容层与运行时状态。 +- `Flyshot.Core.Planning` 的 ICSP / self-adapt-icsp 轨迹已经完成旧系统导出轨迹对齐;`doubles` 仍未实现。 +- `Flyshot.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/`、`FlyingShot/`、`RobotController/`、`RPS/` 主要作为逆向参考资料和样本来源,新实现默认只落地在当前仓库。 -当前 Todo: +当前已完成: - [x] 初始化独立仓库 - [x] 创建 `dotnet 8` 解决方案骨架 - [x] 打通最小宿主与 `/healthz` - [x] 建立领域模型与模块边界 - [x] 落地配置兼容与机器人模型解析 -- [x] 落地轨迹规划与飞拍触发时间轴 -- [x] 将 `ExecuteTrajectory` / `ExecuteFlyShotTraj` 接入最小 FANUC 运行时骨架 +- [x] 落地 ICSP / self-adapt-icsp 轨迹规划与飞拍触发时间轴 +- [x] 完成 ICSP 轨迹导出结果与旧系统对齐 +- [x] 将 `ExecuteTrajectory` / `ExecuteFlyShotTraj` 接入 FANUC 运行时链路 - [x] 落地 Web 状态页 -- [x] 固化 `10010 / 10012 / 60015` FANUC 基础协议帧编解码 -- [ ] 落地真实 `10010 / 10012 / 60015` FANUC Socket 通讯与现场联调 +- [x] 固化 `10010 / 10012 / 60015` FANUC 基础协议帧编解码,确认 `10010` 状态帧为 90B +- [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 状态位和最近报警显示。 diff --git a/TrajectoryStore/FANUC_LR_Mate_200iD_trajectories.json b/TrajectoryStore/FANUC_LR_Mate_200iD_trajectories.json new file mode 100644 index 0000000..998e6ab --- /dev/null +++ b/TrajectoryStore/FANUC_LR_Mate_200iD_trajectories.json @@ -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": {} +} \ No newline at end of file diff --git a/src/Flyshot.ControllerClientCompat/ControllerClientCompatRobotCatalog.cs b/src/Flyshot.ControllerClientCompat/ControllerClientCompatRobotCatalog.cs index e99a69c..6ab5e35 100644 --- a/src/Flyshot.ControllerClientCompat/ControllerClientCompatRobotCatalog.cs +++ b/src/Flyshot.ControllerClientCompat/ControllerClientCompatRobotCatalog.cs @@ -37,8 +37,10 @@ public sealed class ControllerClientCompatRobotCatalog /// 根据旧客户端的机器人名称加载对应模型。 /// /// 旧客户端传入的机器人名称。 + /// RobotConfig.json 中的加速度倍率。 + /// RobotConfig.json 中的 jerk 倍率。 /// 兼容层加载出的机器人模型。 - public RobotProfile LoadProfile(string robotName) + public RobotProfile LoadProfile(string robotName, double accLimitScale = 1.0, double jerkLimitScale = 1.0) { if (string.IsNullOrWhiteSpace(robotName)) { @@ -52,7 +54,7 @@ public sealed class ControllerClientCompatRobotCatalog var workspaceRoot = ResolveWorkspaceRoot(); var modelPath = Path.Combine(workspaceRoot, modelRelativePath); - return _robotModelLoader.LoadProfile(modelPath); + return _robotModelLoader.LoadProfile(modelPath, accLimitScale, jerkLimitScale); } /// diff --git a/src/Flyshot.ControllerClientCompat/ControllerClientCompatService.cs b/src/Flyshot.ControllerClientCompat/ControllerClientCompatService.cs index ef55b1c..4ef93af 100644 --- a/src/Flyshot.ControllerClientCompat/ControllerClientCompatService.cs +++ b/src/Flyshot.ControllerClientCompat/ControllerClientCompatService.cs @@ -1,3 +1,4 @@ +using Flyshot.Core.Config; using Flyshot.Core.Domain; using Flyshot.Runtime.Common; @@ -14,8 +15,11 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi private readonly ControllerClientCompatRobotCatalog _robotCatalog; private readonly IControllerRuntime _runtime; private readonly ControllerClientTrajectoryOrchestrator _trajectoryOrchestrator; + private readonly RobotConfigLoader _configLoader; + private readonly IFlyshotTrajectoryStore _trajectoryStore; private RobotProfile? _activeRobotProfile; private string? _configuredRobotName; + private CompatibilityRobotSettings? _robotSettings; private string? _connectedServerIp; private int _connectedServerPort; private bool _showTcp = true; @@ -29,16 +33,22 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi /// 机器人模型目录。 /// 控制器运行时。 /// 轨迹规划与触发编排器。 + /// 旧版 RobotConfig.json 加载器。 + /// 已上传轨迹持久化存储。 public ControllerClientCompatService( ControllerClientCompatOptions options, ControllerClientCompatRobotCatalog robotCatalog, IControllerRuntime runtime, - ControllerClientTrajectoryOrchestrator trajectoryOrchestrator) + ControllerClientTrajectoryOrchestrator trajectoryOrchestrator, + RobotConfigLoader configLoader, + IFlyshotTrajectoryStore trajectoryStore) { _options = options ?? throw new ArgumentNullException(nameof(options)); _robotCatalog = robotCatalog ?? throw new ArgumentNullException(nameof(robotCatalog)); _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); _trajectoryOrchestrator = trajectoryOrchestrator ?? throw new ArgumentNullException(nameof(trajectoryOrchestrator)); + _configLoader = configLoader ?? throw new ArgumentNullException(nameof(configLoader)); + _trajectoryStore = trajectoryStore ?? throw new ArgumentNullException(nameof(trajectoryStore)); } /// @@ -97,7 +107,11 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi /// 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) { @@ -106,6 +120,14 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi _activeRobotProfile = robotProfile; _uploadedTrajectories.Clear(); _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(); _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 后再交给运行时。 - var bundle = _trajectoryOrchestrator.PlanUploadedFlyshot(robot, trajectory, options); + var bundle = _trajectoryOrchestrator.PlanUploadedFlyshot(robot, trajectory, options, RequireRobotSettings()); if (options.MoveToStart) { _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."); } - // 当前阶段没有落地文件导出,先通过 saveTrajectory=true 走规划校验,避免静默接受非法参数。 + // 先通过规划校验避免静默接受非法参数,同时把轨迹信息强制刷写到本地 JSON。 _ = _trajectoryOrchestrator.PlanUploadedFlyshot( robot, 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( robot, trajectory, - new FlyshotExecutionOptions(method: method, saveTrajectory: saveTrajectory)); + new FlyshotExecutionOptions(method: method, saveTrajectory: saveTrajectory), + RequireRobotSettings()); duration = bundle.Result.Duration; return bundle.Result.IsValid; @@ -473,6 +505,9 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi { 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."); } + /// + /// 获取当前机器人兼容配置;未加载旧配置时回退到现场默认值。 + /// + /// 当前机器人配置。 + private CompatibilityRobotSettings RequireRobotSettings() + { + return _robotSettings ?? CreateDefaultRobotSettings(); + } + /// /// 校验机器人已经完成初始化。 /// @@ -542,4 +586,61 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi originalWaypointCount: 1, plannedWaypointCount: 1); } + + /// + /// 尝试从工作区加载旧版 RobotConfig.json 获取机器人配置;失败时返回 null。 + /// + /// 加载到的机器人配置,或 null。 + 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; + } + } + + /// + /// 构造与旧现场默认行为一致的机器人兼容配置。 + /// + /// 默认机器人配置。 + private static CompatibilityRobotSettings CreateDefaultRobotSettings() + { + return new CompatibilityRobotSettings( + useDo: false, + ioAddresses: Array.Empty(), + ioKeepCycles: 2, + accLimitScale: 1.0, + jerkLimitScale: 1.0, + adaptIcspTryNum: 5); + } + + /// + /// 从当前程序基目录向上搜索 FlyshotReplacement.sln 以推断工作区根目录。 + /// + /// 父工作区根目录。 + 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."); + } } diff --git a/src/Flyshot.ControllerClientCompat/ControllerClientCompatServiceCollectionExtensions.cs b/src/Flyshot.ControllerClientCompat/ControllerClientCompatServiceCollectionExtensions.cs index 5e7a44a..0e2945f 100644 --- a/src/Flyshot.ControllerClientCompat/ControllerClientCompatServiceCollectionExtensions.cs +++ b/src/Flyshot.ControllerClientCompat/ControllerClientCompatServiceCollectionExtensions.cs @@ -29,8 +29,10 @@ public static class ControllerClientCompatServiceCollectionExtensions services.AddSingleton(static serviceProvider => serviceProvider.GetRequiredService>().Value); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Flyshot.ControllerClientCompat/ControllerClientTrajectoryOrchestrator.cs b/src/Flyshot.ControllerClientCompat/ControllerClientTrajectoryOrchestrator.cs index 793f9af..a7013ec 100644 --- a/src/Flyshot.ControllerClientCompat/ControllerClientTrajectoryOrchestrator.cs +++ b/src/Flyshot.ControllerClientCompat/ControllerClientTrajectoryOrchestrator.cs @@ -1,5 +1,7 @@ +using Flyshot.Core.Config; using Flyshot.Core.Domain; using Flyshot.Core.Planning; +using Flyshot.Core.Planning.Sampling; using Flyshot.Core.Triggering; namespace Flyshot.ControllerClientCompat; @@ -59,11 +61,13 @@ public sealed class ControllerClientTrajectoryOrchestrator public PlannedExecutionBundle PlanUploadedFlyshot( RobotProfile robot, ControllerClientCompatUploadedTrajectory uploaded, - FlyshotExecutionOptions? options = null) + FlyshotExecutionOptions? options = null, + CompatibilityRobotSettings? settings = null) { ArgumentNullException.ThrowIfNull(robot); ArgumentNullException.ThrowIfNull(uploaded); options ??= new FlyshotExecutionOptions(); + settings ??= CreateDefaultRobotSettings(); var program = CreateProgram( name: uploaded.Name, @@ -73,7 +77,7 @@ public sealed class ControllerClientTrajectoryOrchestrator addressGroups: uploaded.AddressGroups); 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)) { // 命中缓存时只替换 TrajectoryResult 的 usedCache 标志,规划轨迹和触发时间轴保持不可变复用。 @@ -91,11 +95,12 @@ public sealed class ControllerClientTrajectoryOrchestrator saveTrajectoryArtifacts: options.SaveTrajectory, useCache: options.UseCache); - var plannedTrajectory = PlanByMethod(request, method); + var plannedTrajectory = PlanByMethod(request, method, settings); var shotTimeline = _shotTimelineBuilder.Build( plannedTrajectory, - holdCycles: 0, - samplePeriod: robot.ServoPeriod); + holdCycles: settings.IoKeepCycles, + samplePeriod: robot.ServoPeriod, + useDo: settings.UseDo); var result = CreateResult(plannedTrajectory, shotTimeline, usedCache: false); var bundle = new PlannedExecutionBundle(plannedTrajectory, shotTimeline, result); @@ -146,12 +151,12 @@ public sealed class ControllerClientTrajectoryOrchestrator /// 规划请求。 /// 规划方法。 /// 规划轨迹。 - private PlannedTrajectory PlanByMethod(TrajectoryRequest request, PlanningMethod method) + private PlannedTrajectory PlanByMethod(TrajectoryRequest request, PlanningMethod method, CompatibilityRobotSettings? settings = null) { return method switch { PlanningMethod.Icsp => _icspPlanner.Plan(request), - PlanningMethod.SelfAdaptIcsp => _selfAdaptIcspPlanner.Plan(request), + PlanningMethod.SelfAdaptIcsp => _selfAdaptIcspPlanner.Plan(request, settings?.AdaptIcspTryNum ?? 5), PlanningMethod.Doubles => throw new NotSupportedException("doubles 轨迹规划尚未落地。"), _ => throw new ArgumentOutOfRangeException(nameof(method), method, "未知轨迹规划方法。") }; @@ -182,7 +187,8 @@ public sealed class ControllerClientTrajectoryOrchestrator private static string CreateFlyshotCacheKey( RobotProfile robot, ControllerClientCompatUploadedTrajectory uploaded, - FlyshotExecutionOptions options) + FlyshotExecutionOptions options, + CompatibilityRobotSettings settings) { var hash = new HashCode(); hash.Add(robot.Name, StringComparer.Ordinal); @@ -190,6 +196,9 @@ public sealed class ControllerClientTrajectoryOrchestrator hash.Add(NormalizeMethod(options.Method), StringComparer.Ordinal); hash.Add(options.MoveToStart); hash.Add(options.SaveTrajectory); + hash.Add(settings.UseDo); + hash.Add(settings.IoKeepCycles); + hash.Add(settings.AdaptIcspTryNum); foreach (var waypoint in uploaded.Waypoints) { @@ -220,6 +229,21 @@ public sealed class ControllerClientTrajectoryOrchestrator return hash.ToHashCode().ToString("X8"); } + /// + /// 构造编排器直接调用时的默认兼容配置,保持既有单元测试中的 DO 生成行为。 + /// + /// 默认机器人兼容配置。 + private static CompatibilityRobotSettings CreateDefaultRobotSettings() + { + return new CompatibilityRobotSettings( + useDo: true, + ioAddresses: Array.Empty(), + ioKeepCycles: 0, + accLimitScale: 1.0, + jerkLimitScale: 1.0, + adaptIcspTryNum: 5); + } + /// /// 把兼容层输入数组转换成领域层 FlyshotProgram。 /// @@ -252,6 +276,10 @@ public sealed class ControllerClientTrajectoryOrchestrator /// 运行时执行结果描述。 private static TrajectoryResult CreateResult(PlannedTrajectory plannedTrajectory, ShotTimeline shotTimeline, bool usedCache) { + var denseJointTrajectory = TrajectorySampler.SampleJointTrajectory( + plannedTrajectory, + samplePeriod: plannedTrajectory.Robot.ServoPeriod.TotalSeconds); + return new TrajectoryResult( programName: plannedTrajectory.OriginalProgram.Name, method: plannedTrajectory.Method, @@ -263,6 +291,7 @@ public sealed class ControllerClientTrajectoryOrchestrator failureReason: null, usedCache: usedCache, originalWaypointCount: plannedTrajectory.OriginalWaypointCount, - plannedWaypointCount: plannedTrajectory.PlannedWaypointCount); + plannedWaypointCount: plannedTrajectory.PlannedWaypointCount, + denseJointTrajectory: denseJointTrajectory); } } diff --git a/src/Flyshot.ControllerClientCompat/FlyshotTrajectoryStore.cs b/src/Flyshot.ControllerClientCompat/FlyshotTrajectoryStore.cs new file mode 100644 index 0000000..1cbcce6 --- /dev/null +++ b/src/Flyshot.ControllerClientCompat/FlyshotTrajectoryStore.cs @@ -0,0 +1,231 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Flyshot.Core.Config; +using Flyshot.Core.Domain; + +namespace Flyshot.ControllerClientCompat; + +/// +/// 定义已上传飞拍轨迹的持久化存储契约。 +/// +public interface IFlyshotTrajectoryStore +{ + /// + /// 将单条轨迹持久化到本地 JSON,同时更新所属机器人配置段。 + /// + /// 当前已初始化的机器人名称。 + /// 当前机器人级兼容配置。 + /// 要保存的已上传轨迹。 + void Save(string robotName, CompatibilityRobotSettings settings, ControllerClientCompatUploadedTrajectory trajectory); + + /// + /// 从本地 JSON 删除指定名称的轨迹。 + /// + /// 当前已初始化的机器人名称。 + /// 要删除的轨迹名称。 + void Delete(string robotName, string trajectoryName); + + /// + /// 加载指定机器人名下所有已持久化的轨迹,并回传保存时的机器人配置。 + /// + /// 当前已初始化的机器人名称。 + /// 输出保存时的机器人配置;若文件不存在或解析失败则为 null。 + /// 按轨迹名称索引的已上传轨迹集合。 + IReadOnlyDictionary LoadAll(string robotName, out CompatibilityRobotSettings? settings); +} + +/// +/// 使用与旧版 RobotConfig.json 一致的 JSON 格式持久化飞拍轨迹和机器人配置。 +/// +public sealed class JsonFlyshotTrajectoryStore : IFlyshotTrajectoryStore +{ + private readonly ControllerClientCompatOptions _options; + private readonly RobotConfigLoader _configLoader; + + /// + /// 初始化基于 JSON 文件的轨迹存储。 + /// + /// 兼容层基础配置,用于定位工作区根目录。 + /// 旧版 RobotConfig.json 加载器,用于反序列化已保存的轨迹。 + public JsonFlyshotTrajectoryStore(ControllerClientCompatOptions options, RobotConfigLoader configLoader) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + _configLoader = configLoader ?? throw new ArgumentNullException(nameof(configLoader)); + } + + /// + 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)); + } + + /// + 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)); + } + } + + /// + public IReadOnlyDictionary LoadAll(string robotName, out CompatibilityRobotSettings? settings) + { + var path = ResolveStorePath(robotName); + if (!File.Exists(path)) + { + settings = null; + return new Dictionary(StringComparer.Ordinal); + } + + try + { + var workspaceRoot = ResolveWorkspaceRoot(); + var loaded = _configLoader.Load(path, workspaceRoot); + settings = loaded.Robot; + + var dict = new Dictionary(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(StringComparer.Ordinal); + } + } + + /// + /// 把机器人兼容配置序列化为 JSON 对象,字段名与旧版 RobotConfig.json 一致。 + /// + 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) + }; + } + + /// + /// 把已上传轨迹序列化为 JSON 对象,字段名与旧版 RobotConfig.json 的 flying_shots 节点一致。 + /// + 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) + }; + } + + /// + /// 解析当前机器人对应的持久化文件路径。 + /// + private string ResolveStorePath(string robotName) + { + var workspaceRoot = ResolveWorkspaceRoot(); + var storeDir = Path.Combine(workspaceRoot, "flyshot-replacement", "TrajectoryStore"); + return Path.Combine(storeDir, $"{robotName}_trajectories.json"); + } + + /// + /// 解析父工作区根目录,优先使用显式配置。 + /// + 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."); + } +} diff --git a/src/Flyshot.Core.Domain/TrajectoryResult.cs b/src/Flyshot.Core.Domain/TrajectoryResult.cs index e0ada1c..7c55e6c 100644 --- a/src/Flyshot.Core.Domain/TrajectoryResult.cs +++ b/src/Flyshot.Core.Domain/TrajectoryResult.cs @@ -21,7 +21,8 @@ public sealed class TrajectoryResult string? failureReason, bool usedCache, int originalWaypointCount, - int plannedWaypointCount) + int plannedWaypointCount, + IEnumerable>? denseJointTrajectory = null) { if (string.IsNullOrWhiteSpace(programName)) { @@ -51,6 +52,7 @@ public sealed class TrajectoryResult var copiedShotEvents = shotEvents.ToArray(); var copiedTriggerTimeline = triggerTimeline.ToArray(); var copiedArtifacts = artifacts.ToArray(); + var copiedDenseJointTrajectory = denseJointTrajectory?.Select(static row => row.ToArray()).ToArray(); ProgramName = programName; Method = method; @@ -63,6 +65,7 @@ public sealed class TrajectoryResult UsedCache = usedCache; OriginalWaypointCount = originalWaypointCount; PlannedWaypointCount = plannedWaypointCount; + DenseJointTrajectory = copiedDenseJointTrajectory; } /// @@ -130,6 +133,13 @@ public sealed class TrajectoryResult /// [JsonPropertyName("plannedWaypointCount")] public int PlannedWaypointCount { get; } + + /// + /// Gets the dense joint trajectory samples where each row is [time, j1, j2, ...]. + /// Null when dense sampling was not performed (e.g. simulation fallback). + /// + [JsonPropertyName("denseJointTrajectory")] + public IReadOnlyList>? DenseJointTrajectory { get; } } /// diff --git a/src/Flyshot.Core.Triggering/ShotTimelineBuilder.cs b/src/Flyshot.Core.Triggering/ShotTimelineBuilder.cs index f95778e..7d91b67 100644 --- a/src/Flyshot.Core.Triggering/ShotTimelineBuilder.cs +++ b/src/Flyshot.Core.Triggering/ShotTimelineBuilder.cs @@ -25,8 +25,9 @@ public sealed class ShotTimelineBuilder /// 规划后的轨迹(含补中点信息和机器人配置)。 /// IO 保持周期数(对应原系统的 io_keep_cycles)。 /// 稠密采样周期,用于离散化 sample_index 和 sample_time。 + /// 是否生成可注入伺服流的 DO 事件。 /// 包含 ShotEvent 和 TrajectoryDoEvent 的触发时间轴。 - public ShotTimeline Build(PlannedTrajectory trajectory, int holdCycles, TimeSpan samplePeriod) + public ShotTimeline Build(PlannedTrajectory trajectory, int holdCycles, TimeSpan samplePeriod, bool useDo = true) { ArgumentNullException.ThrowIfNull(trajectory); @@ -69,12 +70,16 @@ public sealed class ShotTimelineBuilder sampleTime: sampleTime, addressGroup: addressGroup)); - triggerTimeline.Add(new TrajectoryDoEvent( - waypointIndex: i, - triggerTime: triggerTime, - offsetCycles: program.OffsetValues[i], - holdCycles: holdCycles, - addressGroup: addressGroup)); + if (useDo) + { + // use_do=false 时保留 ShotEvent 诊断信息,但不向运行时下发 IO 脉冲。 + triggerTimeline.Add(new TrajectoryDoEvent( + waypointIndex: i, + triggerTime: triggerTime, + offsetCycles: program.OffsetValues[i], + holdCycles: holdCycles, + addressGroup: addressGroup)); + } } return new ShotTimeline(shotEvents, triggerTimeline); diff --git a/src/Flyshot.Runtime.Fanuc/FanucControllerRuntime.cs b/src/Flyshot.Runtime.Fanuc/FanucControllerRuntime.cs index 782331b..5b45b10 100644 --- a/src/Flyshot.Runtime.Fanuc/FanucControllerRuntime.cs +++ b/src/Flyshot.Runtime.Fanuc/FanucControllerRuntime.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using Flyshot.Core.Domain; using Flyshot.Runtime.Common; using Flyshot.Runtime.Fanuc.Protocol; @@ -12,9 +13,9 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable { private readonly object _stateLock = new(); private readonly Dictionary<(string IoType, int Port), bool> _ioValues = new(); - private readonly FanucCommandClient _commandClient = new(); - private readonly FanucStateClient _stateClient = new(); - private readonly FanucJ519Client _j519Client = new(); + private readonly FanucCommandClient _commandClient; + private readonly FanucStateClient _stateClient; + private readonly FanucJ519Client _j519Client; private RobotProfile? _robot; private string? _robotName; @@ -28,6 +29,28 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable private double[] _jointPositions = Array.Empty(); private double[] _pose = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0]; private bool _disposed; + private CancellationTokenSource? _sendCts; + private Task? _sendTask; + + /// + /// 初始化 FANUC 控制器运行时。 + /// + public FanucControllerRuntime() + { + _commandClient = new FanucCommandClient(); + _stateClient = new FanucStateClient(); + _j519Client = new FanucJ519Client(); + } + + /// + /// 供测试注入 mock 客户端的内部构造函数。 + /// + internal FanucControllerRuntime(FanucCommandClient commandClient, FanucStateClient stateClient, FanucJ519Client j519Client) + { + _commandClient = commandClient; + _stateClient = stateClient; + _j519Client = j519Client; + } /// public void ResetRobot(RobotProfile robot, string robotName) @@ -106,6 +129,7 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable lock (_stateLock) { EnsureRobotSetup(); + CancelSendTaskLocked(); DisconnectClients(); _connectedRobotIp = null; _isEnabled = false; @@ -149,6 +173,7 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable lock (_stateLock) { EnsureRobotSetup(); + CancelSendTaskLocked(); if (!IsSimulationMode) { _j519Client.StopMotionAsync().GetAwaiter().GetResult(); @@ -166,6 +191,7 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable lock (_stateLock) { EnsureRobotSetup(); + CancelSendTaskLocked(); if (!IsSimulationMode) { _j519Client.StopMotionAsync().GetAwaiter().GetResult(); @@ -347,11 +373,21 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable EnsureEnabled(); EnsureValidTrajectory(result); 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) { - // 真机模式:通过 J519 发送最终关节目标。 - // TODO: 后续接入稠密路点流,当前先发送单点收敛。 + // 真机模式无稠密路点:回退到单点收敛。 var command = new FanucJ519Command( sequence: 0, targetJoints: finalJointPositions.Select(j => (double)j).ToArray()); @@ -375,12 +411,138 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable } _disposed = true; + CancelSendTaskLocked(); DisconnectClients(); _commandClient.Dispose(); _stateClient.Dispose(); _j519Client.Dispose(); } + /// + /// 后台高精度发送任务:按伺服周期遍历稠密路点并注入 IO 触发。 + /// + private void SendDenseTrajectory(TrajectoryResult result, IReadOnlyList 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(); + } + } + } + + /// + /// 取消并等待当前后台发送任务,避免旧任务与新轨迹并发。 + /// + private void CancelSendTaskLocked() + { + _sendCts?.Cancel(); + + if (_sendTask is not null) + { + try + { + _sendTask.Wait(TimeSpan.FromSeconds(2)); + } + catch (AggregateException) + { + // 忽略取消异常。 + } + + _sendTask = null; + } + + _sendCts?.Dispose(); + _sendCts = null; + } + + /// + /// 把 IO 地址组中的地址号映射为 writeIoValue 的位掩码。 + /// + 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; + } + /// /// 判断当前是否处于仿真模式;若尚未选择控制器则抛出异常。 /// diff --git a/src/Flyshot.Runtime.Fanuc/Flyshot.Runtime.Fanuc.csproj b/src/Flyshot.Runtime.Fanuc/Flyshot.Runtime.Fanuc.csproj index e637074..707afe4 100644 --- a/src/Flyshot.Runtime.Fanuc/Flyshot.Runtime.Fanuc.csproj +++ b/src/Flyshot.Runtime.Fanuc/Flyshot.Runtime.Fanuc.csproj @@ -6,6 +6,12 @@ enable + + + <_Parameter1>Flyshot.Core.Tests + + + diff --git a/src/Flyshot.Runtime.Fanuc/Protocol/FanucCommandClient.cs b/src/Flyshot.Runtime.Fanuc/Protocol/FanucCommandClient.cs index d4e0523..ad44b79 100644 --- a/src/Flyshot.Runtime.Fanuc/Protocol/FanucCommandClient.cs +++ b/src/Flyshot.Runtime.Fanuc/Protocol/FanucCommandClient.cs @@ -96,7 +96,7 @@ public sealed class FanucCommandClient : IDisposable { var frame = FanucCommandProtocol.PackProgramCommand(messageId, programName); var response = await SendRawFrameAsync(frame, cancellationToken).ConfigureAwait(false); - return FanucCommandProtocol.ParseResultResponse(response); + return EnsureSuccess(FanucCommandProtocol.ParseResultResponse(response)); } /// @@ -119,7 +119,7 @@ public sealed class FanucCommandClient : IDisposable { var frame = FanucCommandProtocol.PackEmptyCommand(FanucCommandMessageIds.ResetRobot); var response = await SendRawFrameAsync(frame, cancellationToken).ConfigureAwait(false); - return FanucCommandProtocol.ParseResultResponse(response); + return EnsureSuccess(FanucCommandProtocol.ParseResultResponse(response)); } /// @@ -132,7 +132,7 @@ public sealed class FanucCommandClient : IDisposable { var frame = FanucCommandProtocol.PackProgramCommand(FanucCommandMessageIds.GetProgramStatus, programName); var response = await SendRawFrameAsync(frame, cancellationToken).ConfigureAwait(false); - return FanucCommandProtocol.ParseProgramStatusResponse(response); + return EnsureSuccess(FanucCommandProtocol.ParseProgramStatusResponse(response)); } /// @@ -186,6 +186,41 @@ public sealed class FanucCommandClient : IDisposable } } + /// + /// 校验普通命令响应结果码,失败时抛出包含消息号和结果码的诊断异常。 + /// + private static FanucCommandResultResponse EnsureSuccess(FanucCommandResultResponse response) + { + if (!response.IsSuccess) + { + throw CreateCommandFailureException(response.MessageId, response.ResultCode); + } + + return response; + } + + /// + /// 校验程序状态响应结果码,失败时抛出包含消息号和结果码的诊断异常。 + /// + private static FanucProgramStatusResponse EnsureSuccess(FanucProgramStatusResponse response) + { + if (!response.IsSuccess) + { + throw CreateCommandFailureException(response.MessageId, response.ResultCode); + } + + return response; + } + + /// + /// 构造包含 FANUC 命令上下文的失败异常。 + /// + private static InvalidOperationException CreateCommandFailureException(uint messageId, uint resultCode) + { + return new InvalidOperationException( + $"FANUC command 0x{messageId:X4} failed with result_code {resultCode}."); + } + /// /// 从流中读取一条完整的 doz/zod 响应帧。 /// diff --git a/src/Flyshot.Runtime.Fanuc/Protocol/FanucJ519Client.cs b/src/Flyshot.Runtime.Fanuc/Protocol/FanucJ519Client.cs index 81da460..0a10926 100644 --- a/src/Flyshot.Runtime.Fanuc/Protocol/FanucJ519Client.cs +++ b/src/Flyshot.Runtime.Fanuc/Protocol/FanucJ519Client.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.Net.Sockets; namespace Flyshot.Runtime.Fanuc.Protocol; @@ -123,6 +124,20 @@ public sealed class FanucJ519Client : IDisposable } } + /// + /// 获取最近一次通过 UpdateCommand 设置的 J519 命令;供测试断言使用。 + /// + /// 当前 J519 命令或 null。 + internal FanucJ519Command? GetCurrentCommand() + { + ObjectDisposedException.ThrowIf(_disposed, this); + + lock (_commandLock) + { + return _currentCommand; + } + } + /// /// 获取最近一次解析的 J519 响应;若尚未收到任何响应则返回 null。 /// @@ -225,7 +240,7 @@ public sealed class FanucJ519Client : IDisposable } /// - /// 后台发送循环:约 8ms 周期发送当前命令。 + /// 后台发送循环:以 Stopwatch + SpinWait 实现高精度 8ms 周期发送当前命令。 /// private async Task SendLoopAsync(CancellationToken cancellationToken) { @@ -234,13 +249,17 @@ public sealed class FanucJ519Client : IDisposable return; } - // 使用 8ms 周期近似 125Hz 伺服频率。 - using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(8)); + // 8ms 伺服周期,对应 125Hz。 + var periodTicks = (long)(0.008 * Stopwatch.Frequency); + var stopwatch = Stopwatch.StartNew(); + long nextTick = stopwatch.ElapsedTicks; try { - while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false)) + while (!cancellationToken.IsCancellationRequested) { + nextTick += periodTicks; + FanucJ519Command? command; lock (_commandLock) { @@ -252,6 +271,12 @@ public sealed class FanucJ519Client : IDisposable var packet = FanucJ519Protocol.PackCommandPacket(command); await _udpClient.SendAsync(packet, cancellationToken).ConfigureAwait(false); } + + // 高精度忙等待直到下一周期,避免 PeriodicTimer 的 ±15ms 抖动。 + while (stopwatch.ElapsedTicks < nextTick) + { + Thread.SpinWait(1); + } } } catch (OperationCanceledException) diff --git a/tests/Flyshot.Core.Tests/ConfigCompatibilityTests.cs b/tests/Flyshot.Core.Tests/ConfigCompatibilityTests.cs index ac3b989..083be66 100644 --- a/tests/Flyshot.Core.Tests/ConfigCompatibilityTests.cs +++ b/tests/Flyshot.Core.Tests/ConfigCompatibilityTests.cs @@ -26,12 +26,12 @@ public sealed class ConfigCompatibilityTests Assert.Equal(1.0, loaded.Robot.JerkLimitScale); Assert.Equal(5, loaded.Robot.AdaptIcspTryNum); - var program = Assert.Contains("001", loaded.Programs); - Assert.Equal("001", program.Name); - Assert.Equal(5, program.Waypoints.Count); - Assert.Equal(3, program.ShotWaypointCount); + var program = Assert.Contains("EOL10_EAU_0", loaded.Programs); + Assert.Equal("EOL10_EAU_0", program.Name); + Assert.Equal(45, program.Waypoints.Count); + Assert.Equal(42, program.ShotWaypointCount); Assert.Empty(program.AddressGroups[0].Addresses); - Assert.Equal([8, 7], program.AddressGroups[1].Addresses); + Assert.Equal([4, 3], program.AddressGroups[1].Addresses); } /// diff --git a/tests/Flyshot.Core.Tests/FanucCommandClientTests.cs b/tests/Flyshot.Core.Tests/FanucCommandClientTests.cs index c1c0ce7..fd98477 100644 --- a/tests/Flyshot.Core.Tests/FanucCommandClientTests.cs +++ b/tests/Flyshot.Core.Tests/FanucCommandClientTests.cs @@ -130,6 +130,27 @@ public sealed class FanucCommandClientTests : IDisposable await handlerTask.WaitAsync(TimeSpan.FromSeconds(2), _cts.Token); } + /// + /// 验证命令响应 result_code 非零时,客户端会抛出可诊断异常而不是让上层误判成功。 + /// + [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( + () => client.StopProgramAsync("RVBUSTSM", _cts.Token)); + + Assert.Contains("0x2103", exception.Message); + Assert.Contains("42", exception.Message); + await handlerTask.WaitAsync(TimeSpan.FromSeconds(2), _cts.Token); + } + /// /// 验证在连接前调用命令会抛出 InvalidOperationException。 /// diff --git a/tests/Flyshot.Core.Tests/FanucControllerRuntimeDenseTests.cs b/tests/Flyshot.Core.Tests/FanucControllerRuntimeDenseTests.cs new file mode 100644 index 0000000..73af296 --- /dev/null +++ b/tests/Flyshot.Core.Tests/FanucControllerRuntimeDenseTests.cs @@ -0,0 +1,96 @@ +using Flyshot.Core.Domain; +using Flyshot.Runtime.Fanuc; + +namespace Flyshot.Core.Tests; + +/// +/// 验证 FANUC 控制器运行在稠密轨迹流式执行与 IO 触发上的行为。 +/// +public sealed class FanucControllerRuntimeDenseTests +{ + /// + /// 验证仿真模式下即使传入稠密路点,也会回退到单点同步更新。 + /// + [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(), + triggerTimeline: Array.Empty(), + artifacts: Array.Empty(), + 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); + } + + /// + /// 验证 StopMove 在没有任何后台发送任务运行时不会抛出异常。 + /// + [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); + } + + /// + /// 验证 IO 地址组中的地址号被正确映射为 writeIoValue 的位掩码。 + /// + [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); + } + + /// + /// 验证超过 15 的地址号会被安全忽略,不会溢出位掩码。 + /// + [Fact] + public void ComputeIoValue_IgnoresOutOfRangeAddresses() + { + var group = new IoAddressGroup([0, 16, 7]); + var actual = FanucControllerRuntime.ComputeIoValue(group); + Assert.Equal((ushort)(1 | 128), actual); + } +} diff --git a/tests/Flyshot.Core.Tests/FanucJ519ClientTests.cs b/tests/Flyshot.Core.Tests/FanucJ519ClientTests.cs index d453581..c98788e 100644 --- a/tests/Flyshot.Core.Tests/FanucJ519ClientTests.cs +++ b/tests/Flyshot.Core.Tests/FanucJ519ClientTests.cs @@ -177,4 +177,43 @@ public sealed class FanucJ519ClientTests : IDisposable using var client = new FanucJ519Client(); Assert.Throws(() => client.StartMotion()); } + + /// + /// 验证 Stopwatch + SpinWait 发送循环能保持约 8ms 周期抖动在亚毫秒级。 + /// + [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(); + 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(); + 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 过长。"); + }); + } } diff --git a/tests/Flyshot.Core.Tests/RuntimeOrchestrationTests.cs b/tests/Flyshot.Core.Tests/RuntimeOrchestrationTests.cs index f82519d..b169955 100644 --- a/tests/Flyshot.Core.Tests/RuntimeOrchestrationTests.cs +++ b/tests/Flyshot.Core.Tests/RuntimeOrchestrationTests.cs @@ -1,6 +1,7 @@ using Flyshot.ControllerClientCompat; using Flyshot.Core.Config; using Flyshot.Core.Domain; +using Flyshot.Runtime.Common; using Flyshot.Runtime.Fanuc; namespace Flyshot.Core.Tests; @@ -82,6 +83,104 @@ public sealed class RuntimeOrchestrationTests Assert.Single(bundle.Result.TriggerTimeline); } + /// + /// 验证飞拍编排会使用 RobotConfig.json 中的 IO 保持周期。 + /// + [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); + } + + /// + /// 验证 RobotConfig.json 关闭 use_do 时仍保留 ShotEvent 诊断信息,但不生成伺服 DO 事件。 + /// + [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); + } + + /// + /// 验证普通轨迹规划后会生成稠密关节采样序列。 + /// + [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)); + } + + /// + /// 验证飞拍轨迹规划后的稠密采样时间轴与伺服周期一致。 + /// + [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); + } + /// /// 验证兼容服务执行普通轨迹时会进入规划链路,而不是直接把最后一个路点写入状态。 /// @@ -104,6 +203,73 @@ public sealed class RuntimeOrchestrationTests Assert.Throws(Act); } + + /// + /// 验证兼容服务初始化机器人时会把 RobotConfig.json 中的 acc_limit / jerk_limit 传给模型加载器。 + /// + [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(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); + } + } + + /// + /// 创建只包含当前支持机器人模型和 RobotConfig.json 的临时工作区。 + /// + 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; + } } /// @@ -170,14 +336,16 @@ internal static class TestRobotFactory options, new ControllerClientCompatRobotCatalog(options, new RobotModelLoader()), new FanucControllerRuntime(), - new ControllerClientTrajectoryOrchestrator()); + new ControllerClientTrajectoryOrchestrator(), + new RobotConfigLoader(), + new InMemoryFlyshotTrajectoryStore()); } /// /// 定位父工作区根目录,供兼容服务加载真实机器人模型。 /// /// 父工作区根目录。 - private static string GetWorkspaceRoot() + public static string GetWorkspaceRoot() { var current = new DirectoryInfo(AppContext.BaseDirectory); while (current is not null) @@ -193,3 +361,126 @@ internal static class TestRobotFactory throw new DirectoryNotFoundException("Unable to locate the flyshot workspace root."); } } + +/// +/// 内存中的轨迹存储实现,用于避免单元测试污染真实文件系统。 +/// +internal sealed class InMemoryFlyshotTrajectoryStore : IFlyshotTrajectoryStore +{ + private readonly Dictionary _store = new(StringComparer.Ordinal); + + /// + public void Save(string robotName, CompatibilityRobotSettings settings, ControllerClientCompatUploadedTrajectory trajectory) + { + _store[trajectory.Name] = trajectory; + } + + /// + public void Delete(string robotName, string trajectoryName) + { + _store.Remove(trajectoryName); + } + + /// + public IReadOnlyDictionary LoadAll(string robotName, out CompatibilityRobotSettings? settings) + { + settings = null; + return _store; + } +} + +/// +/// 记录 ResetRobot 入参的测试运行时,用于验证兼容服务传递的机器人配置。 +/// +internal sealed class RecordingControllerRuntime : IControllerRuntime +{ + /// + /// 获取最近一次 ResetRobot 收到的机器人配置。 + /// + public RobotProfile? LastRobotProfile { get; private set; } + + /// + public void ResetRobot(RobotProfile robot, string robotName) + { + LastRobotProfile = robot; + } + + /// + public void SetActiveController(bool sim) + { + } + + /// + public void Connect(string robotIp) + { + } + + /// + public void Disconnect() + { + } + + /// + public void EnableRobot(int bufferSize) + { + } + + /// + public void DisableRobot() + { + } + + /// + public void StopMove() + { + } + + /// + public double GetSpeedRatio() => 1.0; + + /// + public void SetSpeedRatio(double ratio) + { + } + + /// + public IReadOnlyList GetTcp() => [0.0, 0.0, 0.0]; + + /// + public void SetTcp(double x, double y, double z) + { + } + + /// + public bool GetIo(int port, string ioType) => false; + + /// + public void SetIo(int port, bool value, string ioType) + { + } + + /// + public IReadOnlyList GetJointPositions() => Array.Empty(); + + /// + public IReadOnlyList GetPose() => Array.Empty(); + + /// + public ControllerStateSnapshot GetSnapshot() + { + return new ControllerStateSnapshot( + capturedAt: DateTimeOffset.UtcNow, + connectionState: "Connected", + isEnabled: true, + isInMotion: false, + speedRatio: 1.0, + jointPositions: Array.Empty(), + cartesianPose: Array.Empty(), + activeAlarms: Array.Empty()); + } + + /// + public void ExecuteTrajectory(TrajectoryResult result, IReadOnlyList finalJointPositions) + { + } +}