From 8a20d9f507d678ad711e23dcd5d483875ce38fc3 Mon Sep 17 00:00:00 2001 From: "yunxiao.zhu" Date: Fri, 24 Apr 2026 16:55:25 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20=E5=AE=9E=E7=8E=B0=20Contro?= =?UTF-8?q?llerClient=20HTTP=20=E5=85=BC=E5=AE=B9=E5=B1=82=E5=8F=8A=20FANU?= =?UTF-8?q?C=20=E8=BF=90=E8=A1=8C=E6=97=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 Flyshot.ControllerClientCompat 兼容层模块 - 新增 Flyshot.Runtime.Fanuc 运行时模块 - 新增 LegacyHttpApiController 暴露 HTTP 兼容 API - 补充 RuntimeOrchestrationTests 等测试覆盖 - 补充 docs/ 兼容性需求与逆向工程文档 - 更新 Host 注册、配置及解决方案引用 变更概览: - Flyshot.ControllerClientCompat — 旧 ControllerClient 语义的 HTTP 适配 - Flyshot.Runtime.Fanuc — IControllerRuntime 的 FANUC 真机实现 - LegacyHttpApiController — HTTP API 兼容旧 SDK - docs/ — 兼容性需求与逆向工程分析文档 - 测试:RuntimeOrchestrationTests、LegacyHttpApiCompatibilityTests --- .codex | 0 AGENTS.md | 30 +- CLAUDE.md | 6 +- FlyshotReplacement.sln | 30 + README.md | 14 +- ...r-client-api-compatibility-requirements.md | 135 ++++ ...ntroller-client-api-reverse-engineering.md | 561 ++++++++++++++++ ...026-04-24-minimal-runtime-orchestration.md | 271 ++++++++ .../ControllerClientCompatOptions.cs | 17 + .../ControllerClientCompatRobotCatalog.cs | 83 +++ .../ControllerClientCompatService.cs | 409 ++++++++++++ ...ClientCompatServiceCollectionExtensions.cs | 39 ++ ...ontrollerClientCompatUploadedTrajectory.cs | 64 ++ .../ControllerClientTrajectoryOrchestrator.cs | 126 ++++ .../Flyshot.ControllerClientCompat.csproj | 16 + .../IControllerClientCompatService.cs | 165 +++++ .../PlannedExecutionBundle.cs | 39 ++ .../Flyshot.Runtime.Common.csproj | 4 + .../IControllerRuntime.cs | 116 ++++ .../FanucControllerRuntime.cs | 366 +++++++++++ .../Flyshot.Runtime.Fanuc.csproj | 14 + .../Controllers/HealthController.cs | 24 + .../Controllers/LegacyHttpApiController.cs | 614 ++++++++++++++++++ .../Flyshot.Server.Host.csproj | 5 + src/Flyshot.Server.Host/HostCorsOptions.cs | 27 + src/Flyshot.Server.Host/HostSwaggerOptions.cs | 37 ++ src/Flyshot.Server.Host/Program.cs | 83 ++- .../appsettings.Development.json | 21 + src/Flyshot.Server.Host/appsettings.json | 25 + .../Flyshot.Core.Tests.csproj | 2 + .../RuntimeOrchestrationTests.cs | 195 ++++++ ...ControllerClientCompatRegistrationTests.cs | 21 + .../Flyshot.Server.IntegrationTests.csproj | 1 + .../HostMvcConfigurationTests.cs | 84 +++ .../LegacyHttpApiCompatibilityTests.cs | 235 +++++++ 35 files changed, 3869 insertions(+), 10 deletions(-) create mode 100644 .codex create mode 100644 docs/controller-client-api-compatibility-requirements.md create mode 100644 docs/controller-client-api-reverse-engineering.md create mode 100644 docs/superpowers/plans/2026-04-24-minimal-runtime-orchestration.md create mode 100644 src/Flyshot.ControllerClientCompat/ControllerClientCompatOptions.cs create mode 100644 src/Flyshot.ControllerClientCompat/ControllerClientCompatRobotCatalog.cs create mode 100644 src/Flyshot.ControllerClientCompat/ControllerClientCompatService.cs create mode 100644 src/Flyshot.ControllerClientCompat/ControllerClientCompatServiceCollectionExtensions.cs create mode 100644 src/Flyshot.ControllerClientCompat/ControllerClientCompatUploadedTrajectory.cs create mode 100644 src/Flyshot.ControllerClientCompat/ControllerClientTrajectoryOrchestrator.cs create mode 100644 src/Flyshot.ControllerClientCompat/Flyshot.ControllerClientCompat.csproj create mode 100644 src/Flyshot.ControllerClientCompat/IControllerClientCompatService.cs create mode 100644 src/Flyshot.ControllerClientCompat/PlannedExecutionBundle.cs create mode 100644 src/Flyshot.Runtime.Common/IControllerRuntime.cs create mode 100644 src/Flyshot.Runtime.Fanuc/FanucControllerRuntime.cs create mode 100644 src/Flyshot.Runtime.Fanuc/Flyshot.Runtime.Fanuc.csproj create mode 100644 src/Flyshot.Server.Host/Controllers/HealthController.cs create mode 100644 src/Flyshot.Server.Host/Controllers/LegacyHttpApiController.cs create mode 100644 src/Flyshot.Server.Host/HostCorsOptions.cs create mode 100644 src/Flyshot.Server.Host/HostSwaggerOptions.cs create mode 100644 tests/Flyshot.Core.Tests/RuntimeOrchestrationTests.cs create mode 100644 tests/Flyshot.Server.IntegrationTests/ControllerClientCompatRegistrationTests.cs create mode 100644 tests/Flyshot.Server.IntegrationTests/HostMvcConfigurationTests.cs create mode 100644 tests/Flyshot.Server.IntegrationTests/LegacyHttpApiCompatibilityTests.cs diff --git a/.codex b/.codex new file mode 100644 index 0000000..e69de29 diff --git a/AGENTS.md b/AGENTS.md index 16cbc09..1fe51ff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,7 +37,12 @@ flyshot-replacement/ ├─ src/ │ ├─ Flyshot.Server.Host/ + │ ├─ Flyshot.ControllerClientCompat/ + │ ├─ Flyshot.Core.Config/ │ ├─ Flyshot.Core.Domain/ + │ ├─ Flyshot.Core.Planning/ + │ ├─ Flyshot.Core.Triggering/ + │ ├─ Flyshot.Runtime.Fanuc/ │ └─ Flyshot.Runtime.Common/ ├─ tests/ │ ├─ Flyshot.Server.IntegrationTests/ @@ -64,8 +69,10 @@ flyshot-replacement/ - `Flyshot.Core.Triggering` - `TrajectoryDO` 等价时间轴 - `shot_flags / offset_values / addr` 解析 -- `Flyshot.LegacyGateway` - - `50001/TCP+JSON` 兼容接入 +- `Flyshot.ControllerClientCompat` + - HTTP 控制器后端兼容服务 + - 旧 `ControllerClient` 语义适配 + - 不启动 `50001/TCP+JSON` 监听 - `Flyshot.Runtime.Fanuc` - `10010 / 10012 / 60015` - `Flyshot.Web.Status` @@ -135,6 +142,20 @@ flyshot-replacement/ - `../analysis/FANUC_realtime_comm_analysis.md` - `../FlyingShot/FlyingShot/Include/ControllerClient/ControllerClient.h` +### 6.1 父目录资料引用约定 + +- 日常开发、测试和 Codex 会话默认从 `flyshot-replacement/` 根目录启动。 +- 当前仓库内的 `@` 引用默认只覆盖本仓库文件,不要假设它能索引父目录资料。 +- 引用父目录资料时,统一直接写明确路径,优先使用相对路径,例如: + - `../analysis/ICSP_algorithm_reverse_analysis.md` + - `../analysis/ControllerServer_analysis.md` + - `../FlyingShot/FlyingShot/Include/ControllerClient/ControllerClient.h` +- 当路径较长或跨工具复制时,可以使用绝对路径,但在文档和注释中优先保留相对路径写法,便于仓库整体搬迁。 +- 父目录中的 `analysis/`、`FlyingShot/`、`RobotController/`、`RPS/` 默认视为参考资料区,不在这些目录中继续落地新实现。 +- 新实现、测试、兼容层代码、设计文档和运行说明,都应优先写入 `flyshot-replacement/` 内部。 +- 如果父目录资料中的某段结论会长期影响本仓库实现,应在本仓库 `docs/` 中补充归纳说明,并标明来源路径,而不是要求后续开发反复回看聊天记录。 +- 如果需要引用父目录样本文件做测试输入,优先通过只读方式加载;只有在测试需要固化样本且样本已明确收敛时,才复制到本仓库测试数据目录。 + ## 7. 任务推进方式 - `README.md` 中的 Todo 需要随着阶段推进同步更新。 @@ -148,3 +169,8 @@ flyshot-replacement/ - `Flyshot.Server.Host` 已提供最小 `/healthz`。 - 最小集成测试已通过。 - 解决方案构建已通过。 +- HTTP-only `ControllerClientCompat` 已覆盖旧 HTTP 控制器后端的主要兼容语义。 +- `Flyshot.Core.Planning` 已落地 `icsp` 与 `self-adapt-icsp` 的最小规划链路。 +- `Flyshot.Core.Triggering` 已能从 `shot_flags / offset_values / addr` 生成触发时间轴。 +- `Flyshot.Runtime.Fanuc` 已提供状态型最小运行时骨架,供兼容服务执行规划结果。 +- `ExecuteTrajectory` / `ExecuteFlyShotTraj` 已接入 `Planning + Triggering + Runtime`,不再只是兼容层内存赋值。 diff --git a/CLAUDE.md b/CLAUDE.md index 16cbc09..6253174 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,8 +64,10 @@ flyshot-replacement/ - `Flyshot.Core.Triggering` - `TrajectoryDO` 等价时间轴 - `shot_flags / offset_values / addr` 解析 -- `Flyshot.LegacyGateway` - - `50001/TCP+JSON` 兼容接入 +- `Flyshot.ControllerClientCompat` + - HTTP 控制器后端兼容服务 + - 旧 `ControllerClient` 语义适配 + - 不启动 `50001/TCP+JSON` 监听 - `Flyshot.Runtime.Fanuc` - `10010 / 10012 / 60015` - `Flyshot.Web.Status` diff --git a/FlyshotReplacement.sln b/FlyshotReplacement.sln index 0e0216d..c4901e0 100644 --- a/FlyshotReplacement.sln +++ b/FlyshotReplacement.sln @@ -23,6 +23,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Flyshot.Core.Planning", "sr EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Flyshot.Core.Triggering", "src\Flyshot.Core.Triggering\Flyshot.Core.Triggering.csproj", "{E4DDC34C-9AB6-4050-A927-3DF69804708A}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Flyshot.ControllerClientCompat", "src\Flyshot.ControllerClientCompat\Flyshot.ControllerClientCompat.csproj", "{5B45CC23-3551-4D0F-B3CC-22659C2A8BA3}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Flyshot.Runtime.Fanuc", "src\Flyshot.Runtime.Fanuc\Flyshot.Runtime.Fanuc.csproj", "{B705FA6C-19CA-44A8-882C-6CE26A5379C9}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -129,6 +133,30 @@ Global {E4DDC34C-9AB6-4050-A927-3DF69804708A}.Release|x64.Build.0 = Release|Any CPU {E4DDC34C-9AB6-4050-A927-3DF69804708A}.Release|x86.ActiveCfg = Release|Any CPU {E4DDC34C-9AB6-4050-A927-3DF69804708A}.Release|x86.Build.0 = Release|Any CPU + {5B45CC23-3551-4D0F-B3CC-22659C2A8BA3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5B45CC23-3551-4D0F-B3CC-22659C2A8BA3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5B45CC23-3551-4D0F-B3CC-22659C2A8BA3}.Debug|x64.ActiveCfg = Debug|Any CPU + {5B45CC23-3551-4D0F-B3CC-22659C2A8BA3}.Debug|x64.Build.0 = Debug|Any CPU + {5B45CC23-3551-4D0F-B3CC-22659C2A8BA3}.Debug|x86.ActiveCfg = Debug|Any CPU + {5B45CC23-3551-4D0F-B3CC-22659C2A8BA3}.Debug|x86.Build.0 = Debug|Any CPU + {5B45CC23-3551-4D0F-B3CC-22659C2A8BA3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5B45CC23-3551-4D0F-B3CC-22659C2A8BA3}.Release|Any CPU.Build.0 = Release|Any CPU + {5B45CC23-3551-4D0F-B3CC-22659C2A8BA3}.Release|x64.ActiveCfg = Release|Any CPU + {5B45CC23-3551-4D0F-B3CC-22659C2A8BA3}.Release|x64.Build.0 = Release|Any CPU + {5B45CC23-3551-4D0F-B3CC-22659C2A8BA3}.Release|x86.ActiveCfg = Release|Any CPU + {5B45CC23-3551-4D0F-B3CC-22659C2A8BA3}.Release|x86.Build.0 = Release|Any CPU + {B705FA6C-19CA-44A8-882C-6CE26A5379C9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B705FA6C-19CA-44A8-882C-6CE26A5379C9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B705FA6C-19CA-44A8-882C-6CE26A5379C9}.Debug|x64.ActiveCfg = Debug|Any CPU + {B705FA6C-19CA-44A8-882C-6CE26A5379C9}.Debug|x64.Build.0 = Debug|Any CPU + {B705FA6C-19CA-44A8-882C-6CE26A5379C9}.Debug|x86.ActiveCfg = Debug|Any CPU + {B705FA6C-19CA-44A8-882C-6CE26A5379C9}.Debug|x86.Build.0 = Debug|Any CPU + {B705FA6C-19CA-44A8-882C-6CE26A5379C9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B705FA6C-19CA-44A8-882C-6CE26A5379C9}.Release|Any CPU.Build.0 = Release|Any CPU + {B705FA6C-19CA-44A8-882C-6CE26A5379C9}.Release|x64.ActiveCfg = Release|Any CPU + {B705FA6C-19CA-44A8-882C-6CE26A5379C9}.Release|x64.Build.0 = Release|Any CPU + {B705FA6C-19CA-44A8-882C-6CE26A5379C9}.Release|x86.ActiveCfg = Release|Any CPU + {B705FA6C-19CA-44A8-882C-6CE26A5379C9}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -142,5 +170,7 @@ Global {6CC8418D-2A13-4D70-8F94-585CD71F0B74} = {CB517CF5-2EF6-43A8-B335-ABD3A6FCE3BE} {154CA299-80D8-4BE2-B1C9-4BC133FA8B28} = {64EABE09-B1E0-4476-A213-32C93E46E7C3} {E4DDC34C-9AB6-4050-A927-3DF69804708A} = {64EABE09-B1E0-4476-A213-32C93E46E7C3} + {5B45CC23-3551-4D0F-B3CC-22659C2A8BA3} = {64EABE09-B1E0-4476-A213-32C93E46E7C3} + {B705FA6C-19CA-44A8-882C-6CE26A5379C9} = {64EABE09-B1E0-4476-A213-32C93E46E7C3} EndGlobalSection EndGlobal diff --git a/README.md b/README.md index 98be7c4..bf1b1df 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,15 @@ - 这是长期运行的无头后台服务,不是 GUI 桌面程序。 - 第一版仅面向当前现场组合,后续再扩展机型与控制柜适配。 +- 当前仓库内已经移除宿主中的 `50001/TCP+JSON` 监听实现;现阶段只保留 ASP.NET Core HTTP 控制器层,以及其后端 `Flyshot.ControllerClientCompat` 兼容服务。 +- `ExecuteTrajectory` 与 `ExecuteFlyShotTraj` 已经接入 `Planning + Triggering + Runtime` 最小链路;当前 `Flyshot.Runtime.Fanuc` 仍是状态型骨架,尚未接通真实 `10010 / 10012 / 60015` 通讯。 +- `50001/TCP+JSON` 的真实兼容入口如果后续需要恢复,必须基于 `docs/controller-client-api-compatibility-requirements.md` 与 `docs/controller-client-api-reverse-engineering.md` 重新评估,而不是直接把旧的 TCP 网关方向接回宿主。 + +开发约定: + +- 建议从 `flyshot-replacement/` 根目录启动 IDE、终端和 Codex 会话。 +- 当前仓库内的 `@` 引用主要覆盖本仓库文件;引用父目录资料时,请直接写相对路径,如 `../analysis/ICSP_algorithm_reverse_analysis.md`。 +- 父目录中的 `analysis/`、`FlyingShot/`、`RobotController/`、`RPS/` 主要作为逆向参考资料和样本来源,新实现默认只落地在当前仓库。 当前 Todo: @@ -21,4 +30,7 @@ - [x] 打通最小宿主与 `/healthz` - [x] 建立领域模型与模块边界 - [x] 落地配置兼容与机器人模型解析 -- [ ] 落地轨迹规划、实时控制和 Web 状态页 +- [x] 落地轨迹规划与飞拍触发时间轴 +- [x] 将 `ExecuteTrajectory` / `ExecuteFlyShotTraj` 接入最小 FANUC 运行时骨架 +- [ ] 落地真实 `10010 / 10012 / 60015` FANUC 通讯 +- [ ] 落地 Web 状态页 diff --git a/docs/controller-client-api-compatibility-requirements.md b/docs/controller-client-api-compatibility-requirements.md new file mode 100644 index 0000000..015cda2 --- /dev/null +++ b/docs/controller-client-api-compatibility-requirements.md @@ -0,0 +1,135 @@ +# ControllerClient API 兼容逆向约束 + +> 记录时间:2026-04-24 +> 适用仓库:`flyshot-replacement` +> 当前阶段:已落地 HTTP-only `ControllerClientCompat` 服务,并已将轨迹执行接入规划、触发时间轴和最小运行时骨架;不实现 `50001/TCP+JSON` 监听 + +## 1. 当前目标 + +本轮目标不是直接实现 `50001/TCP+JSON` 兼容网关,而是先把旧 `ControllerClient` 暴露的公开 API 做成可执行的逆向合同。 + +本轮交付物固定为两份文档: + +- `docs/controller-client-api-compatibility-requirements.md` +- `docs/controller-client-api-reverse-engineering.md` + +后续继续扩展 `Flyshot.ControllerClientCompat` 的方法覆盖、兼容测试矩阵或真实控制器联动时,必须以这两份文档为准,不再重新口头约定接口语义。 + +## 2. 范围边界 + +本轮只覆盖 `../FlyingShot/FlyingShot/Include/ControllerClient/ControllerClient.h` 中的 32 个公开方法。 + +分组如下: + +- 传输与版本:`ConnectServer`、`GetServerVersion`、`GetClientVersion` +- 机器人初始化:`SetUpRobot`、`SetUpRobotFromEnv`、`IsSetUp`、`SetShowTCP`、`GetName`、`GetDoF` +- 控制器状态:`SetActiveController`、`Connect`、`Disconnect`、`EnableRobot`、`DisableRobot`、`StopMove` +- 参数与 IO:`GetSpeedRatio`、`SetSpeedRatio`、`GetTCP`、`SetTCP`、`GetIO`、`SetIO` +- 运动与求解:`GetJointPosition`、`GetPose`、`GetNearestIK`、`MoveJoint`、`ExecuteTrajectory` +- 飞拍轨迹:`UploadFlyShotTraj`、`DeleteFlyShotTraj`、`ListFlyShotTraj`、`ExecuteFlyShotTraj`、`SaveTrajInfo`、`IsFlyShotTrajValid` + +明确不在本轮范围内: + +- `ControllerServer` 内部所有未公开 `_Xxx` 方法的完整实现复原 +- `50001` 网关代码、TCP server、JSON parser、命令路由实现 +- 真机 `10010 / 10012 / 60015` 联调 +- 抓包、hook、反汇编级协议完全坐实 +- 把当前 replacement 仓库的实现约束误写成旧系统事实 + +## 3. 证据源优先级 + +逆向结论必须按以下优先级交叉确认: + +1. `../FlyingShot/FlyingShot/Include/ControllerClient/ControllerClient.h` +2. `../FlyingShot/FlyingShot/Example/UseControllerClient.cpp` +3. `../FlyingShot/FlyingShot/Example/UseControllerClient.py` +4. `../FlyingShot/FlyingShot/Example/UseRealRobot.py` +5. `../FlyingShot/FlyingShot/Docs/用户手册/FANUC飞拍软件及SDK用户手册.md` +6. `../analysis/ControllerServer_analysis.md` +7. `../analysis/CommonMsg_protocol_analysis.md` +8. `../analysis/Trajectory_generation_algorithm_analysis.md` +9. `../FlyingShot/FlyingShot/Lib/libControllerClient.so` 与 `../FlyingShot/FlyingShot/Python/ControllerServer/ControllerServer.cpython-37m-x86_64-linux-gnu.so` 的字符串证据 + +使用规则: + +- 头文件负责定义“公开合同”:方法名、参数名、默认值、返回类型。 +- 示例和手册负责定义“典型调用方式”:调用顺序、Python 包装形态、用户侧常见用法。 +- 逆向分析文档和二进制字符串负责定义“服务端映射”和“协议线索”:`_Xxx` 方法名、错误文本、命令名、部分字段顺序。 +- 不能从当前 replacement 仓库的未来设计反推旧系统事实。 + +## 4. 文档填写规则 + +主归档文档 `docs/controller-client-api-reverse-engineering.md` 对每个 API 必须固定填写以下项目: + +- C++ 公开签名 +- Python 包装形态 +- 服务端归属 +- 协议 / 命令线索 +- 返回值与默认值 +- 典型工作流位置 +- 证据来源 +- 置信度 +- 待确认点 + +填写约束: + +- 只要精确 JSON 包结构还未恢复,就明确写成“命令名已知,JSON envelope 未恢复”。 +- 只要 Python 失败路径没有样例,就不能假装已经确认失败时的返回形态。 +- `GetServerVersion` 这类没有显式 `_GetServerVersion` 的接口,必须标成“协议分发层行为”,不能伪造一个服务端实现名。 +- `ConnectServer` 与 `GetClientVersion` 必须明确标成客户端侧行为,不写进服务端命令表。 +- 对 `GetJointPosition -> _GetJointPositions` 这种命名不一致,要单独注明。 +- 当同一结论同时来自头文件、示例和字符串时,置信度可标为“高”;只有示例间接体现时,最多“中”。 + +## 5. 验收条件 + +本轮逆向归档完成时,至少满足: + +- 32 个公开方法全部覆盖,且每个方法只出现一次 +- 29 个服务端相关 API 有明确映射或明确写成协议分发层行为 +- `ConnectServer`、`GetClientVersion` 两个客户端侧行为被明确排除在服务端命令表外 +- 四条工作流附录完整: + - 初始化工作流 + - 控制器状态工作流 + - 普通轨迹工作流 + - 飞拍轨迹工作流 +- 已知高置信协议字段至少记录: + - `GetSpeedRatio` + - `SetSpeedRatio` + - `GetIO` + - `SetIO` +- 仍未恢复的部分必须进入“待确认问题”清单,不能被静默略过 + +## 6. 当前已确认摘要 + +当前已确认的高价值结论如下: + +- `ControllerClient.h` 中共有 32 个公开方法。 +- 其中 29 个方法可与 `ControllerServer` 的公开 `_Xxx` 方法一一对齐。 +- `GetServerVersion` 能看到明确字符串证据,但未恢复到显式 `_GetServerVersion` 实现,更接近 `_ClientCB` / `_IsJsonValid` 所在的协议分发层。 +- `ConnectServer` 是客户端建立到 `127.0.0.1:50001` 的传输层动作。 +- `GetClientVersion` 更像客户端库自身版本查询,不进入服务端命令表。 +- `GetSpeedRatio` / `SetSpeedRatio`、`GetIO` / `SetIO` 已有较高置信度的底层字段顺序与 `MsgID` 证据。 +- `UploadFlyShotTraj`、`ListFlyShotTraj` 存在额外字符串线索: + - `StartUploadFlyShotTraj` + - `EndUploadFlyShotTraj` + - `GetNextListFlyShotTraj` +- `ExecuteFlyShotTraj`、`IsFlyShotTrajValid`、`SaveTrajInfo` 在工作流上有清晰分工,不能混成一个“执行轨迹”接口。 + +## 7. 下轮实现约束 + +当前 HTTP-only 兼容层已经可以承接公开 API 的主要服务端语义,并且 `ExecuteTrajectory` / `ExecuteFlyShotTraj` 已经进入 replacement 自身的规划与运行时链路。后续扩展必须遵守: + +- 先以逆向合同建命令表,再写 `TCP + JSON` 入口 +- 先做兼容测试矩阵,再补最小命令桩 +- 区分“旧系统事实”和“replacement 当前策略” +- 真机未接通前,允许实现层返回稳定错误或模拟状态,但不能反过来污染逆向文档 + +## 8. 当前 replacement 实现状态 + +以下内容是当前新实现的状态,不反推为旧系统事实: + +- `Flyshot.ControllerClientCompat` 继续作为 HTTP 控制器后端兼容服务,不启动 `50001/TCP+JSON` 监听。 +- `ExecuteTrajectory` 会先通过 `ICspPlanner` 规划普通轨迹,再把 `TrajectoryResult` 和最终关节位置交给 `IControllerRuntime`。 +- `ExecuteFlyShotTraj` 会从上传轨迹目录取出轨迹,通过 `SelfAdaptIcspPlanner` 规划并用 `ShotTimelineBuilder` 生成 `ShotEvent` / `TrajectoryDoEvent`。 +- `Flyshot.Runtime.Fanuc` 当前只保存连接、使能、速度、IO、TCP、关节位置和执行结果状态;真实 `10010 / 10012 / 60015` Socket 通讯尚未落地。 +- `MoveJoint` 仍保持旧兼容语义中的直接运动接口,但状态写入已经统一经过运行时,而不是由兼容服务自己维护关节数组。 diff --git a/docs/controller-client-api-reverse-engineering.md b/docs/controller-client-api-reverse-engineering.md new file mode 100644 index 0000000..91e22dd --- /dev/null +++ b/docs/controller-client-api-reverse-engineering.md @@ -0,0 +1,561 @@ +# ControllerClient API 全量逆向归档 + +> 记录时间:2026-04-24 +> 适用仓库:`flyshot-replacement` +> 目标:为后续 `50001/TCP+JSON` 兼容网关实现提供只读合同 + +## 1. 总览 + +本次归档覆盖 `../FlyingShot/FlyingShot/Include/ControllerClient/ControllerClient.h` 中全部 32 个公开方法。 + +当前分类结果: + +- 29 个 API 可与 `ControllerServer` 公开 `_Xxx` 方法一一映射 +- 1 个 API 属于协议分发层行为:`GetServerVersion` +- 2 个 API 属于客户端侧行为:`ConnectServer`、`GetClientVersion` + +本文档中的“协议 / 命令线索”有两个层次: + +- 命令名:已从头文件、字符串或逆向文档坐实,例如 `SetUpRobot`、`GetIO` +- JSON envelope:请求 JSON 的精确键名、必填规则、整体结构;当前大多仍未完全恢复 + +因此,除 `GetSpeedRatio` / `SetSpeedRatio` / `GetIO` / `SetIO` 等已在命令通道层坐实字段顺序的接口外,其余接口默认只写“命令名已知,精确 JSON 包结构待确认”。 + +置信度定义: + +- 高:头文件 + 示例/手册 + 字符串/逆向文档三方交叉确认 +- 中:头文件 + 示例,或头文件 + 字符串,缺少第三方交叉 +- 低:仅有间接线索,未达到本轮归档主结论标准 + +## 2. 传输与版本 + +### `ConnectServer` + +- C++ 公开签名:`bool ConnectServer(const std::string &server_ip = "127.0.0.1", unsigned port = 50001);` +- Python 包装形态:`c.ConnectServer(server_ip="127.0.0.1", port=50001) -> bool` +- 服务端归属:客户端传输层行为,不进入 `ControllerServer._Xxx` 命令表 +- 协议 / 命令线索:建立到 `127.0.0.1:50001` 的 TCP 连接;JSON 负载只发生在连接建立之后 +- 返回值与默认值:默认 `server_ip="127.0.0.1"`、`port=50001`;成功返回 `true` +- 典型工作流位置:所有远程 API 的前置步骤 +- 证据来源:`ControllerClient.h`、`UseControllerClient.cpp`、`UseControllerClient.py`、`UseRealRobot.py` +- 置信度:高 +- 待确认点:重连、超时、握手细节未恢复 + +### `GetServerVersion` + +- C++ 公开签名:`bool GetServerVersion(std::string &version);` +- Python 包装形态:样例中表现为 `controller.GetServerVersion() -> str` +- 服务端归属:协议分发层行为;当前未恢复到显式 `_GetServerVersion` +- 协议 / 命令线索:命令名字符串 `GetServerVersion` 已坐实;服务端存在 `GetServerVersion success: {}.` 日志;精确 JSON envelope 未恢复 +- 返回值与默认值:C++ 为 `bool + out string`;Python 样例把结果当作直接字符串使用 +- 典型工作流位置:`ConnectServer` 之后、机器人初始化前后均可读取的元信息接口 +- 证据来源:`ControllerClient.h`、`UseControllerClient.py`、SDK 手册、`ControllerServer_analysis.md`、`libControllerClient.so`、`ControllerServer.cpython-37m-x86_64-linux-gnu.so` +- 置信度:高 +- 待确认点:Python 失败路径的返回形态未看到样例;请求 JSON 的精确字段未恢复 + +### `GetClientVersion` + +- C++ 公开签名:`bool GetClientVersion(std::string &version);` +- Python 包装形态:未见公开样例;从包装库字符串看接口存在 +- 服务端归属:客户端本地行为,不进入服务端命令表 +- 协议 / 命令线索:`libControllerClient.so` 与 `PyControllerClient` 都有 `GetClientVersion` 符号;未见 `ControllerServer` 对应 `_Xxx` +- 返回值与默认值:C++ 为 `bool + out string`;Python 侧返回形态待确认 +- 典型工作流位置:客户端自检或 SDK 版本上报,不依赖服务端状态 +- 证据来源:`ControllerClient.h`、`libControllerClient.so`、`PyControllerClient.cpython-37m-x86_64-linux-gnu.so` +- 置信度:高 +- 待确认点:实际版本字符串来源与 Python 包装失败行为未恢复 + +## 3. 机器人初始化 + +### `SetUpRobot` + +- C++ 公开签名:`bool SetUpRobot(const std::string &robot_name);` +- Python 包装形态:`c.SetUpRobot("FANUC_LR_Mate_200iD") -> bool` +- 服务端归属:`ControllerServer._SetUpRobot` +- 协议 / 命令线索:命令名 `SetUpRobot` 已坐实;参数高概率是 `robot_name`;精确 JSON envelope 未恢复 +- 返回值与默认值:成功返回 `true` +- 典型工作流位置:创建客户端并连上 `50001` 后首先调用;旧说明明确它是最先执行的服务端初始化动作 +- 证据来源:`ControllerClient.h`、`UseControllerClient.cpp`、`UseControllerClient.py`、SDK 手册、`ControllerServer_analysis.md`、二进制字符串 +- 置信度:高 +- 待确认点:请求字段键名是否为 `robot_name` 仍需抓包或 hook 坐实 + +### `SetUpRobotFromEnv` + +- C++ 公开签名:`bool SetUpRobotFromEnv(const std::string &env_file);` +- Python 包装形态:未见公开样例;服务端与客户端字符串均存在 +- 服务端归属:`ControllerServer._SetUpRobotFromEnv` +- 协议 / 命令线索:命令名 `SetUpRobotFromEnv` 已坐实;参数高概率是环境文件绝对路径;精确 JSON envelope 未恢复 +- 返回值与默认值:成功返回 `true` +- 典型工作流位置:与 `SetUpRobot` 二选一;当现场通过环境文件而不是型号名初始化时使用 +- 证据来源:`ControllerClient.h`、`ControllerServer_analysis.md`、`ControllerServer` 字符串、`libControllerClient.so` +- 置信度:高 +- 待确认点:环境文件路径是否必须为绝对路径由注释可见,但服务端是否做额外归一化未恢复 + +### `IsSetUp` + +- C++ 公开签名:`bool IsSetUp();` +- Python 包装形态:`c.IsSetUp() -> bool` +- 服务端归属:`ControllerServer._IsSetUp` +- 协议 / 命令线索:命令名 `IsSetUp` 已坐实;精确 JSON envelope 未恢复 +- 返回值与默认值:直接返回布尔值 +- 典型工作流位置:`SetUpRobot` 或 `SetUpRobotFromEnv` 之后的状态确认 +- 证据来源:`ControllerClient.h`、`UseControllerClient.cpp`、`UseControllerClient.py`、`ControllerServer_analysis.md`、二进制字符串 +- 置信度:高 +- 待确认点:服务端返回是否仅受机器人对象存在性控制,还是还依赖模型/控制器更深层状态,当前未完全恢复 + +### `SetShowTCP` + +- C++ 公开签名:`bool SetShowTCP(bool is_show = true, double axis_length = 0.1, size_t axis_size = 2);` +- Python 包装形态:`c.SetShowTCP(is_show=True, axis_length=0.1, axis_size=2) -> bool` +- 服务端归属:`ControllerServer._SetShowTCP` +- 协议 / 命令线索:命令名 `SetShowTCP` 已坐实;字符串中可见 `SetShowTCP is_show success/failed`;精确 JSON envelope 未恢复 +- 返回值与默认值:默认 `is_show=true`、`axis_length=0.1`、`axis_size=2` +- 典型工作流位置:初始化完成后、切换控制器前的仿真显示配置 +- 证据来源:`ControllerClient.h`、`UseControllerClient.cpp`、`UseControllerClient.py`、`ControllerServer_analysis.md`、`ControllerServer` 字符串 +- 置信度:高 +- 待确认点:仅仿真控制器生效还是真实控制器也接受该命令,当前未见明确负例 + +### `GetName` + +- C++ 公开签名:`std::string GetName();` +- Python 包装形态:`c.GetName() -> str` +- 服务端归属:`ControllerServer._GetName` +- 协议 / 命令线索:命令名 `GetName` 已坐实;精确 JSON envelope 未恢复 +- 返回值与默认值:直接返回机器人名称字符串 +- 典型工作流位置:完成机器人初始化后读取名称确认 +- 证据来源:`ControllerClient.h`、`UseControllerClient.py`、SDK 手册、`ControllerServer_analysis.md`、二进制字符串 +- 置信度:高 +- 待确认点:失败时是返回空串还是异常/默认值,Python 样例未覆盖 + +### `GetDoF` + +- C++ 公开签名:`int GetDoF();` +- Python 包装形态:`c.GetDoF() -> int` +- 服务端归属:`ControllerServer._GetDoF` +- 协议 / 命令线索:命令名 `GetDoF` 已坐实;精确 JSON envelope 未恢复 +- 返回值与默认值:直接返回自由度整数 +- 典型工作流位置:完成机器人初始化后读取自由度 +- 证据来源:`ControllerClient.h`、`UseControllerClient.py`、SDK 手册、`ControllerServer_analysis.md`、二进制字符串 +- 置信度:高 +- 待确认点:失败时的客户端行为未恢复 + +## 4. 控制器状态 + +### `SetActiveController` + +- C++ 公开签名:`bool SetActiveController(bool sim = true);` +- Python 包装形态:`c.SetActiveController(sim=True) -> bool`;真机样例使用 `sim=False` +- 服务端归属:`ControllerServer._SetActiveController` +- 协议 / 命令线索:命令名 `SetActiveController` 已坐实;字符串中可见 `SetActiveController sim success/failed` +- 返回值与默认值:默认 `sim=true` +- 典型工作流位置:机器人初始化后、`Connect` 前,用于在仿真控制器和真实控制器之间切换 +- 证据来源:`ControllerClient.h`、`UseControllerClient.py`、`UseRealRobot.py`、SDK 手册、`ControllerServer_analysis.md`、二进制字符串 +- 置信度:高 +- 待确认点:切换控制器时是否会隐式断开旧控制器,当前只有 `_DisconnectAll` 的间接线索 + +### `Connect` + +- C++ 公开签名:`bool Connect(const std::string &robot_ip);` +- Python 包装形态:`c.Connect("192.168.10.101") -> bool` +- 服务端归属:`ControllerServer._Connect` +- 协议 / 命令线索:命令名 `Connect` 已坐实;字符串中可见 `Connect ip success/failed` +- 返回值与默认值:成功返回 `true` +- 典型工作流位置:选定活动控制器后,通知服务端连接真实机器人 IP +- 证据来源:`ControllerClient.h`、`UseControllerClient.cpp`、`UseControllerClient.py`、`UseRealRobot.py`、SDK 手册、`ControllerServer_analysis.md`、`FANUC_realtime_comm_analysis.md`、二进制字符串 +- 置信度:高 +- 待确认点:JSON 键名是否为 `ip` 或 `robot_ip` 未恢复;仿真模式下是否接受同一命令仍需实现侧验证 + +### `Disconnect` + +- C++ 公开签名:`bool Disconnect();` +- Python 包装形态:`c.Disconnect() -> bool` +- 服务端归属:`ControllerServer._Disconnect` +- 协议 / 命令线索:命令名 `Disconnect` 已坐实;字符串中可见 `Disconnect success/failed` +- 返回值与默认值:直接返回布尔值 +- 典型工作流位置:真实控制器断开或工作流结束时调用 +- 证据来源:`ControllerClient.h`、`ControllerServer_analysis.md`、`ControllerServer` 字符串、`libControllerClient.so` +- 置信度:高 +- 待确认点:是否同步清理状态通道 / 命令通道 / 伺服通道,当前只在 FANUC 分析文档看到对象链,并未完全坐实释放顺序 + +### `EnableRobot` + +- C++ 公开签名:`bool EnableRobot(unsigned buffer_size = 2);` +- Python 包装形态:`c.EnableRobot() -> bool` +- 服务端归属:`ControllerServer._EnableRobot` +- 协议 / 命令线索:命令名 `EnableRobot` 已坐实;字符串中可见 `EnableRobot success/failed` +- 返回值与默认值:默认 `buffer_size=2` +- 典型工作流位置:`Connect` 后使能机器人;多数运动 API 前置条件 +- 证据来源:`ControllerClient.h`、`UseControllerClient.cpp`、`UseControllerClient.py`、`UseRealRobot.py`、`ControllerServer_analysis.md`、`FANUC_realtime_comm_analysis.md`、二进制字符串 +- 置信度:高 +- 待确认点:`buffer_size` 在旧服务端具体如何映射到伺服缓冲区参数,当前未完全恢复 + +### `DisableRobot` + +- C++ 公开签名:`bool DisableRobot();` +- Python 包装形态:`c.DisableRobot() -> bool` +- 服务端归属:`ControllerServer._DisableRobot` +- 协议 / 命令线索:命令名 `DisableRobot` 已坐实;字符串中可见 `DisableRobot success/failed` +- 返回值与默认值:直接返回布尔值 +- 典型工作流位置:停机或退出前关闭机器人使能 +- 证据来源:`ControllerClient.h`、`ControllerServer_analysis.md`、`FANUC_realtime_comm_analysis.md`、二进制字符串 +- 置信度:高 +- 待确认点:与 `StopMove` 的先后顺序约束未恢复 + +### `StopMove` + +- C++ 公开签名:`bool StopMove();` +- Python 包装形态:`c.StopMove() -> bool` +- 服务端归属:`ControllerServer._StopMove` +- 协议 / 命令线索:命令名 `StopMove` 已坐实;字符串中可见 `StopMove success/failed` +- 返回值与默认值:直接返回布尔值 +- 典型工作流位置:当前运动中止;Python 示例中调用后会再次 `EnableRobot()` +- 证据来源:`ControllerClient.h`、`UseControllerClient.py`、`ControllerServer_analysis.md`、`FANUC_realtime_comm_analysis.md`、二进制字符串 +- 置信度:高 +- 待确认点:是否会清空控制器执行队列、是否必须重新使能,当前只从示例看到倾向性而非硬约束 + +## 5. 参数与 IO + +### `GetSpeedRatio` + +- C++ 公开签名:`double GetSpeedRatio();` +- Python 包装形态:`c.GetSpeedRatio() -> float` +- 服务端归属:`ControllerServer._GetSpeedRatio` +- 协议 / 命令线索:命令通道 `MsgID = 0x2206`;响应字段为 `ratio_int` 与 `result_code`;成功后客户端将 `ratio_int / 100.0` +- 返回值与默认值:直接返回 `0~1` 之间的倍率 +- 典型工作流位置:连机并使能后读取当前速度倍率 +- 证据来源:`ControllerClient.h`、`UseControllerClient.cpp`、`UseControllerClient.py`、SDK 手册、`ControllerServer_analysis.md`、`CommonMsg_protocol_analysis.md` +- 置信度:高 +- 待确认点:服务端 JSON 层到命令通道层的参数桥接结构未恢复 + +### `SetSpeedRatio` + +- C++ 公开签名:`bool SetSpeedRatio(double ratio);` +- Python 包装形态:`c.SetSpeedRatio(0.8) -> bool` +- 服务端归属:`ControllerServer._SetSpeedRatio` +- 协议 / 命令线索:命令通道 `MsgID = 0x2207`;请求字段为 `ratio_int_0_100`;输入 `double` 先乘 `100` 并夹到 `[0,100]` +- 返回值与默认值:成功返回 `true` +- 典型工作流位置:连机并使能后修改控制器速度倍率 +- 证据来源:`ControllerClient.h`、`UseControllerClient.py`、`UseRealRobot.py`、SDK 手册、`ControllerServer_analysis.md`、`CommonMsg_protocol_analysis.md`、二进制字符串 +- 置信度:高 +- 待确认点:ratio 越界时客户端是裁剪还是直接失败,当前更偏向裁剪,但缺少公开负例 + +### `GetTCP` + +- C++ 公开签名:`bool GetTCP(Pose &tcp);` +- Python 包装形态:`res, tcp = c.GetTCP()` +- 服务端归属:`ControllerServer._GetTCP` +- 协议 / 命令线索:控制器命令通道底层接口 `GetTCP` 已有 `MsgID = 0x2200` 证据,但 `ControllerClient` JSON 层请求结构未完全恢复 +- 返回值与默认值:C++ 为 `bool + out Pose`;Python 为 `(bool, Pose)` +- 典型工作流位置:连机后读取当前控制器 TCP +- 证据来源:`ControllerClient.h`、`UseControllerClient.py`、`ControllerServer_analysis.md`、`CommonMsg_protocol_analysis.md`、二进制字符串 +- 置信度:高 +- 待确认点:JSON 层是否支持多个 TCP ID,目前只看到底层命令通道有 `tcp_id` + +### `SetTCP` + +- C++ 公开签名:`bool SetTCP(const Pose &tcp);` +- Python 包装形态:`c.SetTCP(tcp) -> bool` +- 服务端归属:`ControllerServer._SetTCP` +- 协议 / 命令线索:控制器命令通道底层接口 `SetTCP` 已有 `MsgID = 0x2201` 证据;JSON 层精确字段未恢复 +- 返回值与默认值:成功返回 `true` +- 典型工作流位置:连机后修改控制器 TCP +- 证据来源:`ControllerClient.h`、`UseControllerClient.py`、`ControllerServer_analysis.md`、`CommonMsg_protocol_analysis.md`、`FANUC_realtime_comm_analysis.md`、二进制字符串 +- 置信度:高 +- 待确认点:JSON 层是否也暴露 `tcp_id` 概念尚未恢复 + +### `GetIO` + +- C++ 公开签名:`bool GetIO(unsigned port, bool &value, IOType type = kIOTypeDI);` +- Python 包装形态:`res, value = c.GetIO(port=1, io_type=IOType.kIOTypeDI)` +- 服务端归属:`ControllerServer._GetIO` +- 协议 / 命令线索:命令通道 `MsgID = 0x2208`;请求字段顺序是 `io_type`、`io_index`;响应字段是 `result_code`、`io_value` +- 返回值与默认值:默认 `type = kIOTypeDI`;C++ 为 `bool + out bool`;Python 为 `(bool, bool)` +- 典型工作流位置:连机后读取 DI/DO/RI/RO 等 IO 值 +- 证据来源:`ControllerClient.h`、`UseControllerClient.py`、SDK 手册、`ControllerServer_analysis.md`、`CommonMsg_protocol_analysis.md`、二进制字符串 +- 置信度:高 +- 待确认点:高层 JSON 是否把字段命名为 `port` / `value` / `type`,还是 `io_index` / `io_type`,当前未抓到完整包 + +### `SetIO` + +- C++ 公开签名:`bool SetIO(unsigned port, bool value, IOType type = kIOTypeDO);` +- Python 包装形态:`c.SetIO(port=1, value=True, io_type=IOType.kIOTypeDO) -> bool` +- 服务端归属:`ControllerServer._SetIO` +- 协议 / 命令线索:命令通道 `MsgID = 0x2209`;请求字段顺序是 `io_type`、`io_index`、`io_value` +- 返回值与默认值:默认 `type = kIOTypeDO` +- 典型工作流位置:连机后设置数字输出;飞拍链路之外的普通 IO 调试也会用到 +- 证据来源:`ControllerClient.h`、`UseControllerClient.py`、SDK 手册、`ControllerServer_analysis.md`、`CommonMsg_protocol_analysis.md`、`FANUC_realtime_comm_analysis.md`、二进制字符串 +- 置信度:高 +- 待确认点:布尔值在高层 JSON 中是否以 `true/false` 传输、在命令通道中以 `float` 传输,当前只坐实了命令通道层 + +## 6. 运动与求解 + +### `GetJointPosition` + +- C++ 公开签名:`bool GetJointPosition(JointPositions &joint_position);` +- Python 包装形态:`res, joints = c.GetJointPosition()` +- 服务端归属:`ControllerServer._GetJointPositions` +- 协议 / 命令线索:命令名 `GetJointPositions` 已坐实;客户端公开 API 名与服务端方法名存在单复数差异 +- 返回值与默认值:C++ 为 `bool + out JointPositions`;Python 为 `(bool, JointPositions)` +- 典型工作流位置:读取当前关节角,常作为 `GetNearestIK` 的 seed 或 `MoveJoint` 的基准 +- 证据来源:`ControllerClient.h`、`UseControllerClient.cpp`、`UseControllerClient.py`、SDK 手册、`ControllerServer_analysis.md`、二进制字符串 +- 置信度:高 +- 待确认点:JSON 层返回数组字段键名未恢复 + +### `GetPose` + +- C++ 公开签名:`bool GetPose(Pose &pose);` +- Python 包装形态:`res, pose = c.GetPose()` +- 服务端归属:`ControllerServer._GetPose` +- 协议 / 命令线索:命令名 `GetPose` 已坐实;字符串中可见 `GetPose success/failed` +- 返回值与默认值:C++ 为 `bool + out Pose`;Python 为 `(bool, Pose)` +- 典型工作流位置:读取当前 TCP 位姿,常与 `GetNearestIK` 配套使用 +- 证据来源:`ControllerClient.h`、`UseControllerClient.py`、SDK 手册、`ControllerServer_analysis.md`、二进制字符串 +- 置信度:高 +- 待确认点:位姿坐标系定义虽然在 `Types.h` 中体现为 7 元数组,但 JSON 层字段结构未恢复 + +### `GetNearestIK` + +- C++ 公开签名:`bool GetNearestIK(const Pose &pose, const JointPositions &seed, JointPositions &ik);` +- Python 包装形态:`res, ik = c.GetNearestIK(pose, joint_seed=joints)` +- 服务端归属:`ControllerServer._GetNearestIK` +- 协议 / 命令线索:命令名 `GetNearestIK` 已坐实;字符串中可见 `GetNearestIK success/failed` +- 返回值与默认值:C++ 为 `bool + out JointPositions`;Python 为 `(bool, JointPositions)` +- 典型工作流位置:先 `GetPose` 得到当前位姿,再构造目标位姿,并使用当前关节或邻近关节作为 seed 求最近 IK +- 证据来源:`ControllerClient.h`、`UseControllerClient.py`、`ControllerServer_analysis.md`、二进制字符串 +- 置信度:高 +- 待确认点:JSON 层中 seed 参数键名在 Python 里表现为 `joint_seed`,但 C++ 注释写的是 `seed`;高层字段命名仍待确认 + +### `MoveJoint` + +- C++ 公开签名:`bool MoveJoint(const JointPositions &joint_position);` +- Python 包装形态:`c.MoveJoint(home_joint) -> bool` +- 服务端归属:`ControllerServer._MoveJoint` +- 协议 / 命令线索:命令名 `MoveJoint` 已坐实;字符串中可见 `MoveJoint waypoint success/failed` +- 返回值与默认值:成功返回 `true` +- 典型工作流位置:点到点回零或移动到飞拍轨迹起点 +- 证据来源:`ControllerClient.h`、`UseControllerClient.cpp`、`UseControllerClient.py`、`ControllerServer_analysis.md`、二进制字符串 +- 置信度:高 +- 待确认点:移动速度/平滑参数是否固定在服务端内部,公开 API 未暴露 + +### `ExecuteTrajectory` + +- C++ 公开签名:`bool ExecuteTrajectory(const std::vector &waypoints, const std::string &method = "icsp", bool save_traj = false);` +- Python 包装形态:`c.ExecuteTrajectory(waypoints=[...], method="icsp", save_traj=True) -> bool` +- 服务端归属:`ControllerServer._ExecuteTrajectory` +- 协议 / 命令线索:命令名 `ExecuteTrajectory` 已坐实;`method` 至少支持 `icsp` 与 `doubles` +- 返回值与默认值:默认 `method="icsp"`、`save_traj=false` +- 典型工作流位置:普通多点轨迹执行,不带飞拍 IO;输入是关节空间稀疏 waypoint,而不是笛卡尔点 +- 证据来源:`ControllerClient.h`、`UseControllerClient.py`、SDK 手册、`ControllerServer_analysis.md`、`Trajectory_generation_algorithm_analysis.md`、二进制字符串 +- 置信度:高 +- 待确认点:JSON 中 waypoint 列表的键名和序列化结构未恢复;`doubles` 的失败语义未在公开样例中体现 + +## 7. 飞拍轨迹 + +### `UploadFlyShotTraj` + +- C++ 公开签名:`bool UploadFlyShotTraj(const std::string &name, const std::vector &waypoints, const std::vector &shot_flags, const std::vector &offset_values, const std::vector> &addrs);` +- Python 包装形态:`c.UploadFlyShotTraj(name="test_traj", waypoints=..., shot_flags=..., offset_values=..., addrs=...) -> bool` +- 服务端归属:`ControllerServer._UploadFlyShotTraj` +- 协议 / 命令线索:命令名 `UploadFlyShotTraj` 已坐实;客户端字符串中可见 `StartUploadFlyShotTraj` 与 `EndUploadFlyShotTraj`,说明上传阶段很可能分为开始 / 传输 / 结束三个子步骤 +- 返回值与默认值:成功返回 `true`;无默认参数 +- 典型工作流位置:运行时动态构造飞拍轨迹时,先把轨迹定义登记到服务端 +- 证据来源:`ControllerClient.h`、`UseControllerClient.py`、SDK 手册、`ControllerServer_analysis.md`、`Trajectory_generation_algorithm_analysis.md`、`libControllerClient.so`、`ControllerServer` 字符串 +- 置信度:高 +- 待确认点:JSON 上传是否一次性发送完整 payload,还是像字符串暗示的那样分片上传,当前未完全恢复 + +### `DeleteFlyShotTraj` + +- C++ 公开签名:`bool DeleteFlyShotTraj(const std::string &name);` +- Python 包装形态:`c.DeleteFlyShotTraj(name="test_traj") -> bool` +- 服务端归属:`ControllerServer._DeleteFlyShotTraj` +- 协议 / 命令线索:命令名 `DeleteFlyShotTraj` 已坐实;字符串中可见 `DeleteFlyShotTraj {} success/failed` +- 返回值与默认值:成功返回 `true` +- 典型工作流位置:删除已上传的临时飞拍轨迹定义 +- 证据来源:`ControllerClient.h`、`UseControllerClient.py`、SDK 手册、`ControllerServer_analysis.md`、`PyControllerClient` 字符串、`ControllerServer` 字符串 +- 置信度:高 +- 待确认点:删除配置内固有轨迹名与删除运行时上传轨迹时是否走同一条路径,当前未恢复 + +### `ListFlyShotTraj` + +- C++ 公开签名:`std::vector ListFlyShotTraj();` +- Python 包装形态:`c.ListFlyShotTraj() -> list[str]` +- 服务端归属:`ControllerServer._ListFlyShotTraj` +- 协议 / 命令线索:命令名 `ListFlyShotTraj` 已坐实;客户端与服务端字符串都出现 `GetNextListFlyShotTraj`,说明底层列举很可能是迭代式获取,而不是一次性返回整个数组 +- 返回值与默认值:直接返回轨迹名称列表 +- 典型工作流位置:查看当前服务端已登记的飞拍轨迹 +- 证据来源:`ControllerClient.h`、`UseControllerClient.py`、`ControllerServer_analysis.md`、`libControllerClient.so`、`ControllerServer` 字符串 +- 置信度:高 +- 待确认点:高层 JSON 是否真的直接返回数组,还是客户端内部循环拉取后再拼成数组,当前未完全恢复 + +### `ExecuteFlyShotTraj` + +- C++ 公开签名:`bool ExecuteFlyShotTraj(const std::string &name, const bool move_to_start = false, const std::string &method = "icsp", bool save_traj = false, bool use_cache = false);` +- Python 包装形态:`c.ExecuteFlyShotTraj(name="002", move_to_start=True, method="icsp", save_traj=True, use_cache=False) -> bool` +- 服务端归属:`ControllerServer._ExecuteFlyShotTraj` +- 协议 / 命令线索:命令名 `ExecuteFlyShotTraj` 已坐实;字符串中出现 `move_to_start`、`use_cache`;伪代码级逆向已恢复其主流程 +- 返回值与默认值:默认 `move_to_start=false`、`method="icsp"`、`save_traj=false`、`use_cache=false` +- 典型工作流位置:对已存在的飞拍轨迹定义执行“生成 + 挂接 DO 时间轴 + 执行” +- 证据来源:`ControllerClient.h`、`UseControllerClient.cpp`、`UseControllerClient.py`、`UseRealRobot.py`、SDK 手册、`ControllerServer_analysis.md`、`Trajectory_generation_algorithm_analysis.md`、二进制字符串 +- 置信度:高 +- 待确认点:JSON 包结构未恢复;`use_cache` 的缓存键是否只按 `name`,当前从伪代码判断是,但未见更底层证据 + +### `SaveTrajInfo` + +- C++ 公开签名:`bool SaveTrajInfo(const std::string &name, const std::string &method = "icsp");` +- Python 包装形态:未见运行样例,但包装库和服务端都有明确字符串;语义可由手册和分析文档确认 +- 服务端归属:`ControllerServer._SaveTrajInfo` +- 协议 / 命令线索:命令名 `SaveTrajInfo` 已坐实;字符串中可见 `SaveTrajInfo {} success/failed` +- 返回值与默认值:默认 `method="icsp"`;`self-adapt-icsp` 在执行时保存分析文件会回落成 `icsp` 导出语义 +- 典型工作流位置:按给定 `name + method` 生成并导出轨迹分析文件,例如 `JointTraj.txt`、`CartDetialTraj.txt` +- 证据来源:`ControllerClient.h`、`ControllerServer_analysis.md`、`Trajectory_generation_algorithm_analysis.md`、二进制字符串 +- 置信度:高 +- 待确认点:Python 包装是否直接暴露该方法并返回 `bool`,虽然字符串存在,但公开示例未调用 + +### `IsFlyShotTrajValid` + +- C++ 公开签名:`bool IsFlyShotTrajValid(double &time, const std::string &name, const std::string &method = "icsp", bool save_traj = false);` +- Python 包装形态:`valid, time_sec = c.IsFlyShotTrajValid("EOL9_EAU_90", "icsp", save_traj=True)` +- 服务端归属:`ControllerServer._IsFlyShotTrajValid` +- 协议 / 命令线索:命令名 `IsFlyShotTrajValid` 已坐实;作用是“生成 + 合法性检查 + 返回轨迹总时长” +- 返回值与默认值:默认 `method="icsp"`、`save_traj=false`;C++ 为 `bool + out double`;Python 为 `(bool, float)` +- 典型工作流位置:在执行飞拍轨迹前预校验;也可与 `save_traj=True` 结合导出分析文件 +- 证据来源:`ControllerClient.h`、`UseControllerClient.py` 中的注释样例、`ControllerServer_analysis.md`、`Trajectory_generation_algorithm_analysis.md`、二进制字符串 +- 置信度:高 +- 待确认点:`method="self-adapt-icsp"` 是否被该接口完整支持,分析文档显示公开手册更强调 `icsp` / `doubles`,而示例注释里又出现 `self-adapt-icsp`,这里仍需后续抓包或真环境验证 + +## 8. 四条旧 SDK 工作流 + +### 8.1 初始化工作流 + +```text +ControllerClient() + -> ConnectServer("127.0.0.1", 50001) + -> SetUpRobot("FANUC_LR_Mate_200iD") 或 SetUpRobotFromEnv(...) + -> IsSetUp() + -> GetName() + -> GetDoF() + -> SetShowTCP(...) +``` + +说明: + +- 这是最常见的仿真或真机前置流程。 +- `SetUpRobot` 是旧说明里明确要求首先执行的服务端初始化动作。 + +### 8.2 控制器状态工作流 + +```text +SetActiveController(sim=True/False) + -> Connect(robot_ip) + -> EnableRobot(buffer_size=2) + -> GetSpeedRatio() / SetSpeedRatio(...) + -> GetTCP() / SetTCP(...) + -> GetIO() / SetIO(...) + -> StopMove() + -> DisableRobot() + -> Disconnect() +``` + +说明: + +- 真机样例会使用 `sim=False`。 +- `StopMove()` 之后,Python 示例里会再次 `EnableRobot()`,说明停止运动后常伴随重新使能。 + +### 8.3 普通轨迹工作流 + +```text +GetJointPosition() + -> GetPose() + -> GetNearestIK(pose, seed) + -> MoveJoint(home_joint) + -> ExecuteTrajectory(waypoints, method="icsp", save_traj=True) +``` + +说明: + +- 输入是关节空间稀疏 waypoint。 +- `ExecuteTrajectory` 至少支持 `icsp` 与 `doubles`。 + +### 8.4 飞拍轨迹工作流 + +#### 方案 A:轨迹名已在配置中存在 + +```text +name + -> IsFlyShotTrajValid(name, "icsp", save_traj=True) + -> ExecuteFlyShotTraj(name, move_to_start=True, method="icsp", save_traj=True, use_cache=False) + -> SaveTrajInfo(name, "icsp") +``` + +#### 方案 B:客户端动态上传飞拍轨迹 + +```text +UploadFlyShotTraj(name, waypoints, shot_flags, offset_values, addrs) + -> ListFlyShotTraj() + -> IsFlyShotTrajValid(name, "icsp", save_traj=True) + -> ExecuteFlyShotTraj(name, move_to_start=True, method="self-adapt-icsp", save_traj=True, use_cache=True) + -> DeleteFlyShotTraj(name) +``` + +说明: + +- `UploadFlyShotTraj` 只负责登记轨迹定义。 +- `IsFlyShotTrajValid` 负责“生成 + 合法性检查”。 +- `ExecuteFlyShotTraj` 负责“生成 + 挂接 `TrajectoryDO` + 执行”。 +- `SaveTrajInfo` 负责导出分析文件。 + +## 9. 已知高置信协议线索 + +当前能直接用于后续实现的高置信协议结论如下: + +- 传输层为 `TCP + JSON` 风格协议,不是 HTTP/gRPC。 +- JSON 层至少存在 `method` 字段,服务端存在 `_ClientCB` 与 `_IsJsonValid`。 +- 命令通道字段已确认: + - `GetSpeedRatio`:`MsgID = 0x2206` + - `SetSpeedRatio`:`MsgID = 0x2207` + - `GetIO`:`MsgID = 0x2208` + - `SetIO`:`MsgID = 0x2209` +- 飞拍轨迹相关额外字符串线索: + - `StartUploadFlyShotTraj` + - `EndUploadFlyShotTraj` + - `GetNextListFlyShotTraj` + - `move_to_start` + - `use_cache` + - `shot_flags` + - `offset_values` + - `addr` / `addrs` + +## 10. 待确认问题 + +以下问题本轮故意保留,不冒充已确认结论: + +1. `50001/TCP+JSON` 请求 JSON 的精确 envelope 结构、字段必填规则、响应包统一格式。 +2. `GetServerVersion` 在高层 JSON 中的完整请求 / 响应字段。 +3. `GetClientVersion` 的实际版本字符串来源,以及 Python 包装失败路径。 +4. `ListFlyShotTraj` 是高层一次性返回数组,还是客户端内部循环 `GetNextListFlyShotTraj` 后再拼装列表。 +5. `UploadFlyShotTraj` 是否采用开始 / 数据 / 结束的多阶段上传协议。 +6. `IsFlyShotTrajValid` 对 `self-adapt-icsp` 的真实支持边界。 +7. `SetTCP` / `GetTCP` 在高层 JSON 中是否暴露 `tcp_id` 概念。 +8. `SetActiveController` 切换控制器时是否会隐式触发 `_DisconnectAll`。 + +## 11. 后续实现使用方式 + +等继续扩展 `Flyshot.ControllerClientCompat` 时,建议按以下顺序使用本文档: + +1. 先把 32 个 API 按本文档拆成命令表。 +2. 先实现高置信、状态简单的接口: + - `GetServerVersion` + - `SetUpRobot` + - `IsSetUp` + - `GetName` + - `GetDoF` + - `GetSpeedRatio` + - `SetSpeedRatio` + - `GetIO` + - `SetIO` +3. 再实现返回复杂结构的接口: + - `GetTCP` + - `GetJointPosition` + - `GetPose` + - `GetNearestIK` +4. 最后实现飞拍轨迹相关接口,并把本文档中的“待确认问题”逐项收敛成兼容测试。 diff --git a/docs/superpowers/plans/2026-04-24-minimal-runtime-orchestration.md b/docs/superpowers/plans/2026-04-24-minimal-runtime-orchestration.md new file mode 100644 index 0000000..8aa9204 --- /dev/null +++ b/docs/superpowers/plans/2026-04-24-minimal-runtime-orchestration.md @@ -0,0 +1,271 @@ +# Minimal Runtime Orchestration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the first real execution slice after the HTTP-only refactor by routing `ExecuteTrajectory` and `ExecuteFlyShotTraj` through planning, triggering, and a new minimal FANUC runtime project instead of in-memory last-point assignment. + +**Architecture:** Keep `Flyshot.Server.Host` as a pure HTTP adapter and keep uploaded program state in `Flyshot.ControllerClientCompat`, but move controller runtime state into a new `Flyshot.Runtime.Fanuc` project and add a focused planning/orchestration helper in `Flyshot.ControllerClientCompat`. Ordinary trajectory execution will use `ICspPlanner`; uploaded flyshot execution will use `SelfAdaptIcspPlanner` plus `ShotTimelineBuilder`, then hand the resulting `TrajectoryResult` to the runtime. + +**Tech Stack:** C#, .NET 8, xUnit, existing `Flyshot.Core.Domain`, `Flyshot.Core.Planning`, `Flyshot.Core.Triggering`, ASP.NET Core DI. + +--- + +### Task 1: Add Runtime Contracts And Minimal FANUC Runtime + +**Files:** +- Create: `src/Flyshot.Runtime.Common/IControllerRuntime.cs` +- Create: `src/Flyshot.Runtime.Fanuc/Flyshot.Runtime.Fanuc.csproj` +- Create: `src/Flyshot.Runtime.Fanuc/FanucControllerRuntime.cs` +- Modify: `FlyshotReplacement.sln` +- Modify: `src/Flyshot.Server.Host/Flyshot.Server.Host.csproj` +- Modify: `src/Flyshot.Server.Host/Program.cs` +- Test: `tests/Flyshot.Core.Tests/RuntimeOrchestrationTests.cs` + +- [x] **Step 1: Write the failing runtime test** + +```csharp +[Fact] +public void FanucControllerRuntime_ExecuteTrajectory_UpdatesSnapshotAndFinalJointPositions() +{ + var runtime = new FanucControllerRuntime(); + var robot = TestRobotFactory.CreateRobotProfile(); + runtime.ResetRobot(robot, "FANUC_LR_Mate_200iD"); + runtime.SetActiveController(sim: false); + runtime.Connect("192.168.10.101"); + runtime.EnableRobot(bufferSize: 2); + + var result = new TrajectoryResult( + programName: "demo", + method: PlanningMethod.Icsp, + isValid: true, + duration: TimeSpan.FromSeconds(1.2), + shotEvents: Array.Empty(), + triggerTimeline: Array.Empty(), + artifacts: Array.Empty(), + failureReason: null, + usedCache: false, + originalWaypointCount: 4, + plannedWaypointCount: 4); + + runtime.ExecuteTrajectory(result, [1.0, 2.0, 3.0, 4.0, 5.0, 6.0]); + + var snapshot = runtime.GetSnapshot(); + Assert.Equal("Connected", snapshot.ConnectionState); + Assert.False(snapshot.IsInMotion); + Assert.Equal([1.0, 2.0, 3.0, 4.0, 5.0, 6.0], snapshot.JointPositions); +} +``` + +- [x] **Step 2: Run test to verify it fails** + +Run: `dotnet test tests/Flyshot.Core.Tests/Flyshot.Core.Tests.csproj --filter FanucControllerRuntime_ExecuteTrajectory_UpdatesSnapshotAndFinalJointPositions -v minimal -m:1 -nodeReuse:false` +Expected: FAIL because `FanucControllerRuntime` and `IControllerRuntime` do not exist. + +- [x] **Step 3: Write the minimal runtime contracts and implementation** + +```csharp +public interface IControllerRuntime +{ + void ResetRobot(RobotProfile robot, string robotName); + void SetActiveController(bool sim); + void Connect(string robotIp); + void Disconnect(); + void EnableRobot(int bufferSize); + void DisableRobot(); + void StopMove(); + double GetSpeedRatio(); + void SetSpeedRatio(double ratio); + IReadOnlyList GetTcp(); + void SetTcp(double x, double y, double z); + bool GetIo(int port, string ioType); + void SetIo(int port, bool value, string ioType); + IReadOnlyList GetJointPositions(); + IReadOnlyList GetPose(); + ControllerStateSnapshot GetSnapshot(); + void ExecuteTrajectory(TrajectoryResult result, IReadOnlyList finalJointPositions); +} +``` + +```csharp +public sealed class FanucControllerRuntime : IControllerRuntime +{ + // Stage-1 runtime: owns controller state in one place so later sockets can replace internals without rewriting compat service. +} +``` + +- [x] **Step 4: Run test to verify it passes** + +Run: `dotnet test tests/Flyshot.Core.Tests/Flyshot.Core.Tests.csproj --filter FanucControllerRuntime_ExecuteTrajectory_UpdatesSnapshotAndFinalJointPositions -v minimal -m:1 -nodeReuse:false` +Expected: PASS. + +### Task 2: Add Planning And Triggering Orchestration For Execution + +**Files:** +- Create: `src/Flyshot.ControllerClientCompat/PlannedExecutionBundle.cs` +- Create: `src/Flyshot.ControllerClientCompat/ControllerClientTrajectoryOrchestrator.cs` +- Modify: `src/Flyshot.ControllerClientCompat/Flyshot.ControllerClientCompat.csproj` +- Modify: `tests/Flyshot.Core.Tests/Flyshot.Core.Tests.csproj` +- Test: `tests/Flyshot.Core.Tests/RuntimeOrchestrationTests.cs` + +- [x] **Step 1: Write the failing orchestration tests** + +```csharp +[Fact] +public void ControllerClientTrajectoryOrchestrator_PlanOrdinaryTrajectory_RejectsThreeTeachPoints() +{ + var orchestrator = new ControllerClientTrajectoryOrchestrator(); + var robot = TestRobotFactory.CreateRobotProfile(); + + Assert.Throws(() => + orchestrator.PlanOrdinaryTrajectory(robot, + [ + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [0.5, 0.0, 0.0, 0.0, 0.0, 0.0], + [1.0, 0.0, 0.0, 0.0, 0.0, 0.0] + ])); +} + +[Fact] +public void ControllerClientTrajectoryOrchestrator_PlanUploadedFlyshot_BuildsShotTimeline() +{ + var orchestrator = new ControllerClientTrajectoryOrchestrator(); + var robot = TestRobotFactory.CreateRobotProfile(); + var uploaded = TestRobotFactory.CreateUploadedTrajectoryWithSingleShot(); + + var bundle = orchestrator.PlanUploadedFlyshot(robot, uploaded); + + Assert.True(bundle.Result.IsValid); + Assert.Single(bundle.Result.ShotEvents); + Assert.Single(bundle.Result.TriggerTimeline); +} +``` + +- [x] **Step 2: Run tests to verify they fail** + +Run: `dotnet test tests/Flyshot.Core.Tests/Flyshot.Core.Tests.csproj --filter ControllerClientTrajectoryOrchestrator -v minimal -m:1 -nodeReuse:false` +Expected: FAIL because the orchestrator types do not exist. + +- [x] **Step 3: Write the minimal orchestration layer** + +```csharp +public sealed class PlannedExecutionBundle +{ + public PlannedExecutionBundle(PlannedTrajectory plannedTrajectory, ShotTimeline shotTimeline, TrajectoryResult result) + { + PlannedTrajectory = plannedTrajectory; + ShotTimeline = shotTimeline; + Result = result; + } + + public PlannedTrajectory PlannedTrajectory { get; } + public ShotTimeline ShotTimeline { get; } + public TrajectoryResult Result { get; } +} +``` + +```csharp +public sealed class ControllerClientTrajectoryOrchestrator +{ + public PlannedExecutionBundle PlanOrdinaryTrajectory(RobotProfile robot, IReadOnlyList> waypoints) { ... } + public PlannedExecutionBundle PlanUploadedFlyshot(RobotProfile robot, ControllerClientCompatUploadedTrajectory uploaded) { ... } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `dotnet test tests/Flyshot.Core.Tests/Flyshot.Core.Tests.csproj --filter ControllerClientTrajectoryOrchestrator -v minimal -m:1 -nodeReuse:false` +Expected: PASS. + +### Task 3: Rewire ControllerClientCompatService To Runtime + Orchestrator + +**Files:** +- Modify: `src/Flyshot.ControllerClientCompat/ControllerClientCompatService.cs` +- Modify: `src/Flyshot.ControllerClientCompat/ControllerClientCompatServiceCollectionExtensions.cs` +- Modify: `src/Flyshot.ControllerClientCompat/IControllerClientCompatService.cs` +- Modify: `tests/Flyshot.Server.IntegrationTests/LegacyHttpApiCompatibilityTests.cs` +- Modify: `tests/Flyshot.Server.IntegrationTests/ControllerClientCompatRegistrationTests.cs` +- Test: `tests/Flyshot.Core.Tests/RuntimeOrchestrationTests.cs` +- Test: `tests/Flyshot.Server.IntegrationTests/LegacyHttpApiCompatibilityTests.cs` + +- [x] **Step 1: Write the failing compat-service test** + +```csharp +[Fact] +public void ControllerClientCompatService_ExecuteTrajectory_RejectsThreeTeachPointsAfterPlanningIsIntroduced() +{ + var service = TestRobotFactory.CreateCompatService(); + service.SetUpRobot("FANUC_LR_Mate_200iD"); + service.SetActiveController(sim: false); + service.Connect("192.168.10.101"); + service.EnableRobot(2); + + Assert.Throws(() => + service.ExecuteTrajectory( + [ + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [0.5, 0.0, 0.0, 0.0, 0.0, 0.0], + [1.0, 0.0, 0.0, 0.0, 0.0, 0.0] + ])); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test tests/Flyshot.Core.Tests/Flyshot.Core.Tests.csproj --filter ControllerClientCompatService_ExecuteTrajectory_RejectsThreeTeachPointsAfterPlanningIsIntroduced -v minimal -m:1 -nodeReuse:false` +Expected: FAIL because current service still treats ordinary execution as "move to last waypoint". + +- [x] **Step 3: Rewire service to the runtime and orchestrator** + +```csharp +public sealed class ControllerClientCompatService : IControllerClientCompatService +{ + private readonly IControllerRuntime _runtime; + private readonly ControllerClientTrajectoryOrchestrator _trajectoryOrchestrator; + + public void ExecuteTrajectory(IReadOnlyList> waypoints) + { + var robot = RequireActiveRobot(); + var bundle = _trajectoryOrchestrator.PlanOrdinaryTrajectory(robot, waypoints); + _runtime.ExecuteTrajectory(bundle.Result, bundle.PlannedTrajectory.PlannedWaypoints[^1].Positions); + } + + public void ExecuteTrajectoryByName(string name) + { + var robot = RequireActiveRobot(); + var uploaded = RequireUploadedTrajectory(name); + var bundle = _trajectoryOrchestrator.PlanUploadedFlyshot(robot, uploaded); + _runtime.ExecuteTrajectory(bundle.Result, bundle.PlannedTrajectory.PlannedWaypoints[^1].Positions); + } +} +``` + +- [ ] **Step 4: Run focused tests to verify green** + +Run: `dotnet test tests/Flyshot.Core.Tests/Flyshot.Core.Tests.csproj --filter "ControllerClientCompatService|ControllerClientTrajectoryOrchestrator|FanucControllerRuntime" -v minimal -m:1 -nodeReuse:false` +Expected: PASS. + +- [ ] **Step 5: Run integration verification** + +Run: `dotnet test tests/Flyshot.Server.IntegrationTests/Flyshot.Server.IntegrationTests.csproj -v minimal -m:1 -nodeReuse:false` +Expected: PASS, with existing HTTP compatibility tests still green. + +### Task 4: Verify Solution Build And Update Progress Docs + +**Files:** +- Modify: `README.md` +- Modify: `AGENTS.md` +- Modify: `docs/controller-client-api-compatibility-requirements.md` + +- [x] **Step 1: Update docs to reflect the new stage** + +```markdown +- [x] 落地最小 FANUC 运行时骨架 +- [x] 将 ExecuteTrajectory / ExecuteFlyShotTraj 接入 Planning + Triggering + Runtime +- [ ] 落地真实 10010 / 10012 / 60015 通讯 +- [ ] 落地 Web 状态页 +``` + +- [x] **Step 2: Run final build** + +Run: `dotnet build FlyshotReplacement.sln --no-restore -v minimal -m:1 -nodeReuse:false` +Expected: PASS with 0 errors. diff --git a/src/Flyshot.ControllerClientCompat/ControllerClientCompatOptions.cs b/src/Flyshot.ControllerClientCompat/ControllerClientCompatOptions.cs new file mode 100644 index 0000000..5a656b3 --- /dev/null +++ b/src/Flyshot.ControllerClientCompat/ControllerClientCompatOptions.cs @@ -0,0 +1,17 @@ +namespace Flyshot.ControllerClientCompat; + +/// +/// 表示 HTTP-only ControllerClient 兼容层的基础配置。 +/// +public sealed class ControllerClientCompatOptions +{ + /// + /// 获取或设置对外暴露的服务端版本号。 + /// + public string ServerVersion { get; set; } = "flyshot-replacement-controller-client-compat/0.1.0"; + + /// + /// 获取或设置父工作区根目录;为空时由运行时自动推断。 + /// + public string? WorkspaceRoot { get; set; } +} diff --git a/src/Flyshot.ControllerClientCompat/ControllerClientCompatRobotCatalog.cs b/src/Flyshot.ControllerClientCompat/ControllerClientCompatRobotCatalog.cs new file mode 100644 index 0000000..e99a69c --- /dev/null +++ b/src/Flyshot.ControllerClientCompat/ControllerClientCompatRobotCatalog.cs @@ -0,0 +1,83 @@ +using Flyshot.Core.Config; +using Flyshot.Core.Domain; + +namespace Flyshot.ControllerClientCompat; + +/// +/// 根据旧版 ControllerClient 的机器人名称,解析当前 replacement 仓库支持的真实模型文件。 +/// +public sealed class ControllerClientCompatRobotCatalog +{ + /// + /// 保存当前现场支持的机器人名称到模型相对路径映射。 + /// + private static readonly IReadOnlyDictionary SupportedRobotModelMap = new Dictionary(StringComparer.Ordinal) + { + ["FANUC_LR_Mate_200iD"] = Path.Combine("FlyingShot", "FlyingShot", "Models", "LR_Mate_200iD_7L.robot"), + ["FANUC_LR_Mate_200iD_7L"] = Path.Combine("FlyingShot", "FlyingShot", "Models", "LR_Mate_200iD_7L.robot") + }; + + private readonly ControllerClientCompatOptions _options; + private readonly RobotModelLoader _robotModelLoader; + + /// + /// 初始化机器人兼容目录解析器。 + /// + /// 兼容层基础配置。 + /// .robot 文件加载器。 + public ControllerClientCompatRobotCatalog( + ControllerClientCompatOptions options, + RobotModelLoader robotModelLoader) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + _robotModelLoader = robotModelLoader ?? throw new ArgumentNullException(nameof(robotModelLoader)); + } + + /// + /// 根据旧客户端的机器人名称加载对应模型。 + /// + /// 旧客户端传入的机器人名称。 + /// 兼容层加载出的机器人模型。 + public RobotProfile LoadProfile(string robotName) + { + if (string.IsNullOrWhiteSpace(robotName)) + { + throw new ArgumentException("机器人名称不能为空。", nameof(robotName)); + } + + if (!SupportedRobotModelMap.TryGetValue(robotName, out var modelRelativePath)) + { + throw new InvalidOperationException($"Unsupported robot name: {robotName}"); + } + + var workspaceRoot = ResolveWorkspaceRoot(); + var modelPath = Path.Combine(workspaceRoot, modelRelativePath); + return _robotModelLoader.LoadProfile(modelPath); + } + + /// + /// 解析父工作区根目录,优先使用显式配置。 + /// + /// 包含 `FlyingShot/` 与 `Rvbust/` 的父工作区根目录。 + private string ResolveWorkspaceRoot() + { + if (!string.IsNullOrWhiteSpace(_options.WorkspaceRoot)) + { + return Path.GetFullPath(_options.WorkspaceRoot); + } + + var current = new DirectoryInfo(AppContext.BaseDirectory); + while (current is not null) + { + // 宿主和测试都从 replacement 仓库内启动;找到 sln 后回退一级就是父工作区根目录。 + 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/ControllerClientCompatService.cs b/src/Flyshot.ControllerClientCompat/ControllerClientCompatService.cs new file mode 100644 index 0000000..ced717c --- /dev/null +++ b/src/Flyshot.ControllerClientCompat/ControllerClientCompatService.cs @@ -0,0 +1,409 @@ +using Flyshot.Core.Domain; +using Flyshot.Runtime.Common; + +namespace Flyshot.ControllerClientCompat; + +/// +/// 在宿主进程内实现 HTTP-only ControllerClient 兼容语义,并把控制器状态委托给运行时。 +/// +public sealed class ControllerClientCompatService : IControllerClientCompatService +{ + private readonly object _stateLock = new(); + private readonly Dictionary _uploadedTrajectories = new(StringComparer.Ordinal); + private readonly ControllerClientCompatOptions _options; + private readonly ControllerClientCompatRobotCatalog _robotCatalog; + private readonly IControllerRuntime _runtime; + private readonly ControllerClientTrajectoryOrchestrator _trajectoryOrchestrator; + private RobotProfile? _activeRobotProfile; + private string? _configuredRobotName; + private string? _connectedServerIp; + private int _connectedServerPort; + + /// + /// 初始化一份 HTTP-only 的 ControllerClient 兼容服务。 + /// + /// 兼容层基础配置。 + /// 机器人模型目录。 + /// 控制器运行时。 + /// 轨迹规划与触发编排器。 + public ControllerClientCompatService( + ControllerClientCompatOptions options, + ControllerClientCompatRobotCatalog robotCatalog, + IControllerRuntime runtime, + ControllerClientTrajectoryOrchestrator trajectoryOrchestrator) + { + _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)); + } + + /// + public string ServerVersion => _options.ServerVersion; + + /// + public bool IsSetUp + { + get + { + lock (_stateLock) + { + return _activeRobotProfile is not null; + } + } + } + + /// + /// 获取当前运行时是否处于运动态。 + /// + public bool IsInMotion => _runtime.GetSnapshot().IsInMotion; + + /// + public void ConnectServer(string serverIp, int port) + { + if (string.IsNullOrWhiteSpace(serverIp)) + { + throw new ArgumentException("服务端 IP 不能为空。", nameof(serverIp)); + } + + if (port <= 0) + { + throw new ArgumentOutOfRangeException(nameof(port), "端口必须大于 0。"); + } + + lock (_stateLock) + { + // HTTP-only 阶段仍记录旧客户端期望的 50001 地址,便于后续 TCP 入口恢复时复用状态。 + _connectedServerIp = serverIp; + _connectedServerPort = port; + } + } + + /// + public void SetUpRobot(string robotName) + { + var robotProfile = _robotCatalog.LoadProfile(robotName); + + lock (_stateLock) + { + // 机器人重新初始化时同步重置运行时和上传轨迹目录,保持旧服务初始化语义。 + _configuredRobotName = robotName; + _activeRobotProfile = robotProfile; + _uploadedTrajectories.Clear(); + _runtime.ResetRobot(robotProfile, robotName); + } + } + + /// + public void SetActiveController(bool sim) + { + lock (_stateLock) + { + EnsureRobotSetup(); + _runtime.SetActiveController(sim); + } + } + + /// + public void Connect(string robotIp) + { + if (string.IsNullOrWhiteSpace(robotIp)) + { + throw new ArgumentException("控制器 IP 不能为空。", nameof(robotIp)); + } + + lock (_stateLock) + { + EnsureRobotSetup(); + _runtime.Connect(robotIp); + } + } + + /// + public void Disconnect() + { + lock (_stateLock) + { + EnsureRobotSetup(); + _runtime.Disconnect(); + } + } + + /// + public void EnableRobot(int bufferSize) + { + lock (_stateLock) + { + EnsureRobotSetup(); + _runtime.EnableRobot(bufferSize); + } + } + + /// + public void DisableRobot() + { + lock (_stateLock) + { + EnsureRobotSetup(); + _runtime.DisableRobot(); + } + } + + /// + public void StopMove() + { + lock (_stateLock) + { + EnsureRobotSetup(); + _runtime.StopMove(); + } + } + + /// + public double GetSpeedRatio() + { + lock (_stateLock) + { + EnsureRobotSetup(); + return _runtime.GetSpeedRatio(); + } + } + + /// + public void SetSpeedRatio(double ratio) + { + lock (_stateLock) + { + EnsureRobotSetup(); + _runtime.SetSpeedRatio(ratio); + } + } + + /// + public void SetIo(int port, bool value, string ioType) + { + lock (_stateLock) + { + EnsureRobotSetup(); + _runtime.SetIo(port, value, ioType); + } + } + + /// + public bool GetIo(int port, string ioType) + { + lock (_stateLock) + { + EnsureRobotSetup(); + return _runtime.GetIo(port, ioType); + } + } + + /// + public void SetTcp(double x, double y, double z) + { + lock (_stateLock) + { + EnsureRobotSetup(); + _runtime.SetTcp(x, y, z); + } + } + + /// + public IReadOnlyList GetTcp() + { + lock (_stateLock) + { + EnsureRobotSetup(); + return _runtime.GetTcp(); + } + } + + /// + public IReadOnlyList GetJointPositions() + { + lock (_stateLock) + { + EnsureRobotSetup(); + return _runtime.GetJointPositions(); + } + } + + /// + public void MoveJoint(IReadOnlyList jointPositions) + { + ArgumentNullException.ThrowIfNull(jointPositions); + + lock (_stateLock) + { + EnsureRobotSetup(); + _runtime.ExecuteTrajectory(CreateImmediateMoveResult(), jointPositions); + } + } + + /// + public void ExecuteTrajectory(IReadOnlyList> waypoints) + { + ArgumentNullException.ThrowIfNull(waypoints); + if (waypoints.Count == 0) + { + throw new ArgumentException("轨迹路点不能为空。", nameof(waypoints)); + } + + lock (_stateLock) + { + var robot = RequireActiveRobot(); + EnsureRuntimeEnabled(); + + // 普通轨迹必须先通过 ICSP 规划,再把规划结果交给运行时执行。 + var bundle = _trajectoryOrchestrator.PlanOrdinaryTrajectory(robot, waypoints); + var finalJointPositions = bundle.PlannedTrajectory.PlannedWaypoints[^1].Positions; + _runtime.ExecuteTrajectory(bundle.Result, finalJointPositions); + } + } + + /// + public IReadOnlyList GetPose() + { + lock (_stateLock) + { + EnsureRobotSetup(); + return _runtime.GetPose(); + } + } + + /// + public void UploadTrajectory(ControllerClientCompatUploadedTrajectory trajectory) + { + ArgumentNullException.ThrowIfNull(trajectory); + + lock (_stateLock) + { + EnsureRuntimeEnabled(); + _uploadedTrajectories[trajectory.Name] = trajectory; + } + } + + /// + public IReadOnlyList ListTrajectoryNames() + { + lock (_stateLock) + { + return _uploadedTrajectories.Keys.ToArray(); + } + } + + /// + public void ExecuteTrajectoryByName(string name) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException("轨迹名称不能为空。", nameof(name)); + } + + lock (_stateLock) + { + var robot = RequireActiveRobot(); + EnsureRuntimeEnabled(); + + if (!_uploadedTrajectories.TryGetValue(name, out var trajectory)) + { + throw new InvalidOperationException("FlyShot trajectory does not exist."); + } + + if (trajectory.Waypoints.Count == 0) + { + throw new InvalidOperationException("FlyShot trajectory contains no waypoints."); + } + + // 已上传飞拍轨迹必须生成 shot timeline 后再交给运行时。 + var bundle = _trajectoryOrchestrator.PlanUploadedFlyshot(robot, trajectory); + var finalJointPositions = bundle.PlannedTrajectory.PlannedWaypoints[^1].Positions; + _runtime.ExecuteTrajectory(bundle.Result, finalJointPositions); + } + } + + /// + public void DeleteTrajectory(string name) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException("轨迹名称不能为空。", nameof(name)); + } + + lock (_stateLock) + { + if (!_uploadedTrajectories.Remove(name)) + { + throw new InvalidOperationException("DeleteFlyShotTraj failed"); + } + } + } + + /// + public string GetRobotName() + { + lock (_stateLock) + { + return _configuredRobotName ?? throw new InvalidOperationException("Robot has not been setup."); + } + } + + /// + public int GetDegreesOfFreedom() + { + lock (_stateLock) + { + return _activeRobotProfile?.DegreesOfFreedom ?? throw new InvalidOperationException("Robot has not been setup."); + } + } + + /// + /// 获取当前机器人配置,未初始化时抛出兼容错误。 + /// + /// 当前机器人配置。 + private RobotProfile RequireActiveRobot() + { + return _activeRobotProfile ?? throw new InvalidOperationException("Robot has not been setup."); + } + + /// + /// 校验机器人已经完成初始化。 + /// + private void EnsureRobotSetup() + { + _ = RequireActiveRobot(); + } + + /// + /// 校验运行时已经处于可执行状态。 + /// + private void EnsureRuntimeEnabled() + { + EnsureRobotSetup(); + if (!_runtime.GetSnapshot().IsEnabled) + { + throw new InvalidOperationException("Robot has not been enabled."); + } + } + + /// + /// 构造 MoveJoint 直达运行时所需的最小合法轨迹结果。 + /// + /// 可立即执行的轨迹结果。 + private static TrajectoryResult CreateImmediateMoveResult() + { + return new TrajectoryResult( + programName: "move-joint", + method: PlanningMethod.Icsp, + isValid: true, + duration: TimeSpan.Zero, + shotEvents: Array.Empty(), + triggerTimeline: Array.Empty(), + artifacts: Array.Empty(), + failureReason: null, + usedCache: false, + originalWaypointCount: 1, + plannedWaypointCount: 1); + } +} diff --git a/src/Flyshot.ControllerClientCompat/ControllerClientCompatServiceCollectionExtensions.cs b/src/Flyshot.ControllerClientCompat/ControllerClientCompatServiceCollectionExtensions.cs new file mode 100644 index 0000000..5e7a44a --- /dev/null +++ b/src/Flyshot.ControllerClientCompat/ControllerClientCompatServiceCollectionExtensions.cs @@ -0,0 +1,39 @@ +using Flyshot.Core.Config; +using Flyshot.Runtime.Common; +using Flyshot.Runtime.Fanuc; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Flyshot.ControllerClientCompat; + +/// +/// 提供 ControllerClient HTTP 兼容层的依赖注入注册入口。 +/// +public static class ControllerClientCompatServiceCollectionExtensions +{ + /// + /// 将 HTTP-only 的 ControllerClient 兼容服务注册到当前宿主。 + /// + /// 当前宿主服务集合。 + /// 宿主配置根。 + /// 同一服务集合,便于链式调用。 + public static IServiceCollection AddControllerClientCompat(this IServiceCollection services, IConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configuration); + + services + .AddOptions() + .Bind(configuration.GetSection("ControllerClientCompat")); + + services.AddSingleton(static serviceProvider => serviceProvider.GetRequiredService>().Value); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + return services; + } +} diff --git a/src/Flyshot.ControllerClientCompat/ControllerClientCompatUploadedTrajectory.cs b/src/Flyshot.ControllerClientCompat/ControllerClientCompatUploadedTrajectory.cs new file mode 100644 index 0000000..96783b9 --- /dev/null +++ b/src/Flyshot.ControllerClientCompat/ControllerClientCompatUploadedTrajectory.cs @@ -0,0 +1,64 @@ +namespace Flyshot.ControllerClientCompat; + +/// +/// 保存一条已上传到兼容层内存目录中的飞拍轨迹,供 HTTP API 层列出、执行和删除。 +/// +public sealed class ControllerClientCompatUploadedTrajectory +{ + /// + /// 初始化一条飞拍轨迹快照,并立即复制所有数组,避免调用方后续原地修改。 + /// + /// 轨迹名称。 + /// 关节路点集合。 + /// 拍摄标志集合。 + /// 偏移周期集合。 + /// 地址组集合。 + public ControllerClientCompatUploadedTrajectory( + string name, + IEnumerable> waypoints, + IEnumerable shotFlags, + IEnumerable offsetValues, + IEnumerable> addressGroups) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException("轨迹名称不能为空。", nameof(name)); + } + + ArgumentNullException.ThrowIfNull(waypoints); + ArgumentNullException.ThrowIfNull(shotFlags); + ArgumentNullException.ThrowIfNull(offsetValues); + ArgumentNullException.ThrowIfNull(addressGroups); + + Name = name; + Waypoints = waypoints.Select(static waypoint => waypoint.ToArray()).ToArray(); + ShotFlags = shotFlags.ToArray(); + OffsetValues = offsetValues.ToArray(); + AddressGroups = addressGroups.Select(static group => group.ToArray()).ToArray(); + } + + /// + /// 获取轨迹名称。 + /// + public string Name { get; } + + /// + /// 获取已复制的关节路点集合。 + /// + public IReadOnlyList Waypoints { get; } + + /// + /// 获取拍摄标志集合。 + /// + public IReadOnlyList ShotFlags { get; } + + /// + /// 获取偏移周期集合。 + /// + public IReadOnlyList OffsetValues { get; } + + /// + /// 获取地址组集合。 + /// + public IReadOnlyList AddressGroups { get; } +} diff --git a/src/Flyshot.ControllerClientCompat/ControllerClientTrajectoryOrchestrator.cs b/src/Flyshot.ControllerClientCompat/ControllerClientTrajectoryOrchestrator.cs new file mode 100644 index 0000000..1b19c2c --- /dev/null +++ b/src/Flyshot.ControllerClientCompat/ControllerClientTrajectoryOrchestrator.cs @@ -0,0 +1,126 @@ +using Flyshot.Core.Domain; +using Flyshot.Core.Planning; +using Flyshot.Core.Triggering; + +namespace Flyshot.ControllerClientCompat; + +/// +/// 负责把 ControllerClient 兼容层的轨迹输入转换为规划结果和触发时间轴。 +/// +public sealed class ControllerClientTrajectoryOrchestrator +{ + private readonly ICspPlanner _icspPlanner = new(); + private readonly SelfAdaptIcspPlanner _selfAdaptIcspPlanner = new(); + private readonly ShotTimelineBuilder _shotTimelineBuilder = new(new WaypointTimestampResolver()); + + /// + /// 对普通轨迹执行 ICSP 规划。 + /// + /// 当前机器人配置。 + /// 普通轨迹关节路点。 + /// 包含规划轨迹、空触发时间轴和执行结果的结果包。 + public PlannedExecutionBundle PlanOrdinaryTrajectory( + RobotProfile robot, + IReadOnlyList> waypoints) + { + ArgumentNullException.ThrowIfNull(robot); + ArgumentNullException.ThrowIfNull(waypoints); + + var program = CreateProgram( + name: "ordinary-trajectory", + waypoints: waypoints, + shotFlags: Enumerable.Repeat(false, waypoints.Count), + offsetValues: Enumerable.Repeat(0, waypoints.Count), + addressGroups: Enumerable.Range(0, waypoints.Count).Select(static _ => Array.Empty())); + + var request = new TrajectoryRequest( + robot: robot, + program: program, + method: PlanningMethod.Icsp); + + var plannedTrajectory = _icspPlanner.Plan(request); + var shotTimeline = new ShotTimeline(Array.Empty(), Array.Empty()); + var result = CreateResult(plannedTrajectory, shotTimeline); + + return new PlannedExecutionBundle(plannedTrajectory, shotTimeline, result); + } + + /// + /// 对已经上传的飞拍轨迹执行自适应 ICSP 规划并生成触发时间轴。 + /// + /// 当前机器人配置。 + /// 兼容层保存的上传轨迹。 + /// 包含规划轨迹、触发时间轴和执行结果的结果包。 + public PlannedExecutionBundle PlanUploadedFlyshot(RobotProfile robot, ControllerClientCompatUploadedTrajectory uploaded) + { + ArgumentNullException.ThrowIfNull(robot); + ArgumentNullException.ThrowIfNull(uploaded); + + var program = CreateProgram( + name: uploaded.Name, + waypoints: uploaded.Waypoints, + shotFlags: uploaded.ShotFlags, + offsetValues: uploaded.OffsetValues, + addressGroups: uploaded.AddressGroups); + + var request = new TrajectoryRequest( + robot: robot, + program: program, + method: PlanningMethod.SelfAdaptIcsp); + + var plannedTrajectory = _selfAdaptIcspPlanner.Plan(request); + var shotTimeline = _shotTimelineBuilder.Build( + plannedTrajectory, + holdCycles: 0, + samplePeriod: robot.ServoPeriod); + var result = CreateResult(plannedTrajectory, shotTimeline); + + return new PlannedExecutionBundle(plannedTrajectory, shotTimeline, result); + } + + /// + /// 把兼容层输入数组转换成领域层 FlyshotProgram。 + /// + /// 轨迹名称。 + /// 关节路点。 + /// 拍照标志。 + /// 偏移周期。 + /// IO 地址组。 + /// 领域层飞拍程序。 + private static FlyshotProgram CreateProgram( + string name, + IEnumerable> waypoints, + IEnumerable shotFlags, + IEnumerable offsetValues, + IEnumerable> addressGroups) + { + return new FlyshotProgram( + name: name, + waypoints: waypoints.Select(static waypoint => new JointWaypoint(waypoint)).ToArray(), + shotFlags: shotFlags.ToArray(), + offsetValues: offsetValues.ToArray(), + addressGroups: addressGroups.Select(static group => new IoAddressGroup(group)).ToArray()); + } + + /// + /// 从规划轨迹和触发时间轴构造运行时可消费的稳定结果对象。 + /// + /// 规划后的轨迹。 + /// 触发时间轴。 + /// 运行时执行结果描述。 + private static TrajectoryResult CreateResult(PlannedTrajectory plannedTrajectory, ShotTimeline shotTimeline) + { + return new TrajectoryResult( + programName: plannedTrajectory.OriginalProgram.Name, + method: plannedTrajectory.Method, + isValid: true, + duration: TimeSpan.FromSeconds(plannedTrajectory.WaypointTimes[^1]), + shotEvents: shotTimeline.ShotEvents, + triggerTimeline: shotTimeline.TriggerTimeline, + artifacts: Array.Empty(), + failureReason: null, + usedCache: false, + originalWaypointCount: plannedTrajectory.OriginalWaypointCount, + plannedWaypointCount: plannedTrajectory.PlannedWaypointCount); + } +} diff --git a/src/Flyshot.ControllerClientCompat/Flyshot.ControllerClientCompat.csproj b/src/Flyshot.ControllerClientCompat/Flyshot.ControllerClientCompat.csproj new file mode 100644 index 0000000..eba456d --- /dev/null +++ b/src/Flyshot.ControllerClientCompat/Flyshot.ControllerClientCompat.csproj @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src/Flyshot.ControllerClientCompat/IControllerClientCompatService.cs b/src/Flyshot.ControllerClientCompat/IControllerClientCompatService.cs new file mode 100644 index 0000000..02f7175 --- /dev/null +++ b/src/Flyshot.ControllerClientCompat/IControllerClientCompatService.cs @@ -0,0 +1,165 @@ +namespace Flyshot.ControllerClientCompat; + +/// +/// 定义 HTTP-only 兼容层对外暴露的 ControllerClient 语义服务接口。 +/// +public interface IControllerClientCompatService +{ + /// + /// 获取当前兼容层对外报告的服务端版本号。 + /// + string ServerVersion { get; } + + /// + /// 获取当前是否已经完成机器人初始化。 + /// + bool IsSetUp { get; } + + /// + /// 保存当前调用方期望连接的 replacement 服务端地址。 + /// + /// 客户端传入的服务端 IP。 + /// 客户端传入的服务端端口。 + void ConnectServer(string serverIp, int port); + + /// + /// 根据旧客户端使用的机器人名称完成机器人初始化。 + /// + /// 机器人名称。 + void SetUpRobot(string robotName); + + /// + /// 记录当前激活的控制器类型。 + /// + /// 是否为仿真控制器。 + void SetActiveController(bool sim); + + /// + /// 记录当前控制器已经建立连接。 + /// + /// 控制器 IP。 + void Connect(string robotIp); + + /// + /// 记录当前控制器已经断开。 + /// + void Disconnect(); + + /// + /// 记录当前机器人进入使能态。 + /// + /// 缓冲区大小。 + void EnableRobot(int bufferSize); + + /// + /// 记录当前机器人退出使能态。 + /// + void DisableRobot(); + + /// + /// 停止当前运动状态。 + /// + void StopMove(); + + /// + /// 获取当前速度倍率。 + /// + /// 当前速度倍率。 + double GetSpeedRatio(); + + /// + /// 更新当前速度倍率。 + /// + /// 目标速度倍率。 + void SetSpeedRatio(double ratio); + + /// + /// 写入兼容层缓存的 IO 数值。 + /// + /// IO 端口号。 + /// IO 值。 + /// IO 类型。 + void SetIo(int port, bool value, string ioType); + + /// + /// 读取兼容层缓存的 IO 数值。 + /// + /// IO 端口号。 + /// IO 类型。 + /// 缓存中的 IO 值。 + bool GetIo(int port, string ioType); + + /// + /// 设置当前 TCP 三维坐标。 + /// + /// TCP X。 + /// TCP Y。 + /// TCP Z。 + void SetTcp(double x, double y, double z); + + /// + /// 读取当前 TCP 三维坐标。 + /// + /// TCP 数组。 + IReadOnlyList GetTcp(); + + /// + /// 读取当前关节位置。 + /// + /// 关节位置数组。 + IReadOnlyList GetJointPositions(); + + /// + /// 更新当前关节位置。 + /// + /// 目标关节位置。 + void MoveJoint(IReadOnlyList jointPositions); + + /// + /// 执行普通轨迹。 + /// + /// 轨迹路点集合。 + void ExecuteTrajectory(IReadOnlyList> waypoints); + + /// + /// 读取当前末端位姿快照。 + /// + /// 位姿数组。 + IReadOnlyList GetPose(); + + /// + /// 上传一条飞拍轨迹。 + /// + /// 飞拍轨迹。 + void UploadTrajectory(ControllerClientCompatUploadedTrajectory trajectory); + + /// + /// 列出当前已上传的飞拍轨迹名称。 + /// + /// 轨迹名称列表。 + IReadOnlyList ListTrajectoryNames(); + + /// + /// 执行指定名称的飞拍轨迹。 + /// + /// 轨迹名称。 + void ExecuteTrajectoryByName(string name); + + /// + /// 删除指定名称的飞拍轨迹。 + /// + /// 轨迹名称。 + void DeleteTrajectory(string name); + + /// + /// 读取当前配置过的机器人名称。 + /// + /// 机器人名称。 + string GetRobotName(); + + /// + /// 读取当前机器人自由度。 + /// + /// 机器人自由度。 + int GetDegreesOfFreedom(); +} diff --git a/src/Flyshot.ControllerClientCompat/PlannedExecutionBundle.cs b/src/Flyshot.ControllerClientCompat/PlannedExecutionBundle.cs new file mode 100644 index 0000000..a806df5 --- /dev/null +++ b/src/Flyshot.ControllerClientCompat/PlannedExecutionBundle.cs @@ -0,0 +1,39 @@ +using Flyshot.Core.Domain; +using Flyshot.Core.Planning; +using Flyshot.Core.Triggering; + +namespace Flyshot.ControllerClientCompat; + +/// +/// 表示兼容层执行轨迹前生成的完整规划结果包。 +/// +public sealed class PlannedExecutionBundle +{ + /// + /// 初始化一份执行规划结果包。 + /// + /// 规划后的轨迹。 + /// 飞拍触发时间轴。 + /// 对运行时和监控层暴露的规划结果。 + public PlannedExecutionBundle(PlannedTrajectory plannedTrajectory, ShotTimeline shotTimeline, TrajectoryResult result) + { + PlannedTrajectory = plannedTrajectory ?? throw new ArgumentNullException(nameof(plannedTrajectory)); + ShotTimeline = shotTimeline ?? throw new ArgumentNullException(nameof(shotTimeline)); + Result = result ?? throw new ArgumentNullException(nameof(result)); + } + + /// + /// 获取规划后的轨迹。 + /// + public PlannedTrajectory PlannedTrajectory { get; } + + /// + /// 获取飞拍触发时间轴。 + /// + public ShotTimeline ShotTimeline { get; } + + /// + /// 获取运行时可消费的规划结果。 + /// + public TrajectoryResult Result { get; } +} diff --git a/src/Flyshot.Runtime.Common/Flyshot.Runtime.Common.csproj b/src/Flyshot.Runtime.Common/Flyshot.Runtime.Common.csproj index fa71b7a..82ae933 100644 --- a/src/Flyshot.Runtime.Common/Flyshot.Runtime.Common.csproj +++ b/src/Flyshot.Runtime.Common/Flyshot.Runtime.Common.csproj @@ -6,4 +6,8 @@ enable + + + + diff --git a/src/Flyshot.Runtime.Common/IControllerRuntime.cs b/src/Flyshot.Runtime.Common/IControllerRuntime.cs new file mode 100644 index 0000000..d71421b --- /dev/null +++ b/src/Flyshot.Runtime.Common/IControllerRuntime.cs @@ -0,0 +1,116 @@ +using Flyshot.Core.Domain; + +namespace Flyshot.Runtime.Common; + +/// +/// 定义控制器运行时的最小状态与执行契约,供兼容层在不关心底层 Socket 细节的情况下调度轨迹。 +/// +public interface IControllerRuntime +{ + /// + /// 重置当前机器人模型并清空控制器运行时状态。 + /// + /// 当前机器人配置。 + /// 兼容层传入的机器人名称。 + void ResetRobot(RobotProfile robot, string robotName); + + /// + /// 选择当前活动控制器类型。 + /// + /// 是否使用仿真控制器。 + void SetActiveController(bool sim); + + /// + /// 建立到控制器 IP 的连接。 + /// + /// 控制器 IP。 + void Connect(string robotIp); + + /// + /// 断开当前控制器连接。 + /// + void Disconnect(); + + /// + /// 使能机器人并记录底层缓冲区大小。 + /// + /// 运行时缓冲区大小。 + void EnableRobot(int bufferSize); + + /// + /// 关闭机器人使能。 + /// + void DisableRobot(); + + /// + /// 停止当前运动。 + /// + void StopMove(); + + /// + /// 获取当前速度倍率。 + /// + /// 速度倍率。 + double GetSpeedRatio(); + + /// + /// 设置当前速度倍率。 + /// + /// 目标速度倍率。 + void SetSpeedRatio(double ratio); + + /// + /// 获取当前 TCP 坐标。 + /// + /// TCP 三维坐标。 + IReadOnlyList GetTcp(); + + /// + /// 设置当前 TCP 坐标。 + /// + /// TCP X。 + /// TCP Y。 + /// TCP Z。 + void SetTcp(double x, double y, double z); + + /// + /// 读取指定 IO 端口。 + /// + /// IO 端口。 + /// IO 类型。 + /// IO 当前值。 + bool GetIo(int port, string ioType); + + /// + /// 写入指定 IO 端口。 + /// + /// IO 端口。 + /// 目标 IO 值。 + /// IO 类型。 + void SetIo(int port, bool value, string ioType); + + /// + /// 获取当前关节位置。 + /// + /// 当前关节位置。 + IReadOnlyList GetJointPositions(); + + /// + /// 获取当前末端位姿。 + /// + /// 当前末端位姿。 + IReadOnlyList GetPose(); + + /// + /// 获取当前运行时状态快照。 + /// + /// 控制器状态快照。 + ControllerStateSnapshot GetSnapshot(); + + /// + /// 执行一条已经完成规划的轨迹,并更新最终关节位置。 + /// + /// 规划结果。 + /// 轨迹执行结束后的关节位置。 + void ExecuteTrajectory(TrajectoryResult result, IReadOnlyList finalJointPositions); +} diff --git a/src/Flyshot.Runtime.Fanuc/FanucControllerRuntime.cs b/src/Flyshot.Runtime.Fanuc/FanucControllerRuntime.cs new file mode 100644 index 0000000..ab22653 --- /dev/null +++ b/src/Flyshot.Runtime.Fanuc/FanucControllerRuntime.cs @@ -0,0 +1,366 @@ +using Flyshot.Core.Domain; +using Flyshot.Runtime.Common; + +namespace Flyshot.Runtime.Fanuc; + +/// +/// 提供第一阶段 FANUC 控制器运行时骨架,集中保存连接、使能、IO 和运动结果状态。 +/// +public sealed class FanucControllerRuntime : IControllerRuntime +{ + private readonly object _stateLock = new(); + private readonly Dictionary<(string IoType, int Port), bool> _ioValues = new(); + private RobotProfile? _robot; + private string? _robotName; + private bool? _activeControllerIsSimulation; + private string? _connectedRobotIp; + private bool _isEnabled; + private bool _isInMotion; + private int _bufferSize; + private double _speedRatio = 1.0; + private double[] _tcp = [0.0, 0.0, 0.0]; + private double[] _jointPositions = Array.Empty(); + private double[] _pose = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0]; + + /// + public void ResetRobot(RobotProfile robot, string robotName) + { + ArgumentNullException.ThrowIfNull(robot); + if (string.IsNullOrWhiteSpace(robotName)) + { + throw new ArgumentException("机器人名称不能为空。", nameof(robotName)); + } + + lock (_stateLock) + { + // 重新初始化机器人时清空底层控制器状态,匹配旧 ControllerClient 的初始化顺序。 + _robot = robot; + _robotName = robotName; + _activeControllerIsSimulation = null; + _connectedRobotIp = null; + _isEnabled = false; + _isInMotion = false; + _bufferSize = 0; + _speedRatio = 1.0; + _tcp = [0.0, 0.0, 0.0]; + _jointPositions = new double[robot.DegreesOfFreedom]; + _pose = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0]; + _ioValues.Clear(); + } + } + + /// + public void SetActiveController(bool sim) + { + lock (_stateLock) + { + EnsureRobotSetup(); + _activeControllerIsSimulation = sim; + _connectedRobotIp = null; + _isEnabled = false; + _isInMotion = false; + } + } + + /// + public void Connect(string robotIp) + { + if (string.IsNullOrWhiteSpace(robotIp)) + { + throw new ArgumentException("控制器 IP 不能为空。", nameof(robotIp)); + } + + lock (_stateLock) + { + EnsureActiveControllerSelected(); + _connectedRobotIp = robotIp; + _isEnabled = false; + _isInMotion = false; + } + } + + /// + public void Disconnect() + { + lock (_stateLock) + { + EnsureRobotSetup(); + _connectedRobotIp = null; + _isEnabled = false; + _isInMotion = false; + } + } + + /// + public void EnableRobot(int bufferSize) + { + if (bufferSize <= 0) + { + throw new ArgumentOutOfRangeException(nameof(bufferSize), "buffer_size 必须大于 0。"); + } + + lock (_stateLock) + { + EnsureConnected(); + _bufferSize = bufferSize; + _isEnabled = true; + } + } + + /// + public void DisableRobot() + { + lock (_stateLock) + { + EnsureRobotSetup(); + _isEnabled = false; + _isInMotion = false; + } + } + + /// + public void StopMove() + { + lock (_stateLock) + { + EnsureRobotSetup(); + _isInMotion = false; + } + } + + /// + public double GetSpeedRatio() + { + lock (_stateLock) + { + EnsureConnected(); + return _speedRatio; + } + } + + /// + public void SetSpeedRatio(double ratio) + { + if (double.IsNaN(ratio) || double.IsInfinity(ratio)) + { + throw new ArgumentOutOfRangeException(nameof(ratio), "ratio 必须是有限数值。"); + } + + lock (_stateLock) + { + EnsureConnected(); + _speedRatio = Math.Clamp(ratio, 0.0, 1.0); + } + } + + /// + public IReadOnlyList GetTcp() + { + lock (_stateLock) + { + EnsureRobotSetup(); + return _tcp.ToArray(); + } + } + + /// + public void SetTcp(double x, double y, double z) + { + lock (_stateLock) + { + EnsureRobotSetup(); + _tcp = [x, y, z]; + } + } + + /// + public bool GetIo(int port, string ioType) + { + if (port < 0) + { + throw new ArgumentOutOfRangeException(nameof(port), "IO 端口不能为负数。"); + } + + var normalizedIoType = NormalizeIoType(ioType); + + lock (_stateLock) + { + EnsureConnected(); + return _ioValues.TryGetValue((normalizedIoType, port), out var value) && value; + } + } + + /// + public void SetIo(int port, bool value, string ioType) + { + if (port < 0) + { + throw new ArgumentOutOfRangeException(nameof(port), "IO 端口不能为负数。"); + } + + var normalizedIoType = NormalizeIoType(ioType); + + lock (_stateLock) + { + EnsureEnabled(); + _ioValues[(normalizedIoType, port)] = value; + } + } + + /// + public IReadOnlyList GetJointPositions() + { + lock (_stateLock) + { + EnsureRobotSetup(); + return _jointPositions.ToArray(); + } + } + + /// + public IReadOnlyList GetPose() + { + lock (_stateLock) + { + EnsureRobotSetup(); + return _pose.ToArray(); + } + } + + /// + public ControllerStateSnapshot GetSnapshot() + { + lock (_stateLock) + { + return new ControllerStateSnapshot( + capturedAt: DateTimeOffset.UtcNow, + connectionState: ResolveConnectionState(), + isEnabled: _isEnabled, + isInMotion: _isInMotion, + speedRatio: _speedRatio, + jointPositions: _jointPositions, + cartesianPose: _pose, + activeAlarms: Array.Empty()); + } + } + + /// + public void ExecuteTrajectory(TrajectoryResult result, IReadOnlyList finalJointPositions) + { + ArgumentNullException.ThrowIfNull(result); + ArgumentNullException.ThrowIfNull(finalJointPositions); + + lock (_stateLock) + { + EnsureEnabled(); + EnsureValidTrajectory(result); + EnsureJointCount(finalJointPositions.Count); + + // 第一阶段没有真机 Socket 流,先把执行结果收敛到统一运行时状态。 + _isInMotion = true; + _jointPositions = finalJointPositions.ToArray(); + _isInMotion = false; + } + } + + /// + /// 归一化 IO 类型字符串,避免调用方大小写差异影响缓存键。 + /// + /// 调用方传入的 IO 类型。 + /// 标准化后的 IO 类型。 + private static string NormalizeIoType(string ioType) + { + if (string.IsNullOrWhiteSpace(ioType)) + { + throw new ArgumentException("IO 类型不能为空。", nameof(ioType)); + } + + return ioType.Trim().ToUpperInvariant(); + } + + /// + /// 校验轨迹规划结果可执行。 + /// + /// 规划结果。 + private static void EnsureValidTrajectory(TrajectoryResult result) + { + if (!result.IsValid) + { + throw new InvalidOperationException(result.FailureReason ?? "Trajectory result is invalid."); + } + } + + /// + /// 根据当前内部状态生成连接状态标签。 + /// + /// 面向监控和测试的连接状态。 + private string ResolveConnectionState() + { + if (_robot is null) + { + return "NotConfigured"; + } + + return string.IsNullOrWhiteSpace(_connectedRobotIp) ? "Disconnected" : "Connected"; + } + + /// + /// 校验给定关节数组长度与当前机器人自由度一致。 + /// + /// 调用方传入的关节数。 + private void EnsureJointCount(int jointCount) + { + var expectedJointCount = _robot?.DegreesOfFreedom ?? throw new InvalidOperationException("Robot has not been setup."); + if (jointCount != expectedJointCount) + { + throw new InvalidOperationException($"Expected {expectedJointCount} joints but received {jointCount}."); + } + } + + /// + /// 校验机器人已经完成初始化。 + /// + private void EnsureRobotSetup() + { + if (_robot is null || string.IsNullOrWhiteSpace(_robotName)) + { + throw new InvalidOperationException("Robot has not been setup."); + } + } + + /// + /// 校验活动控制器已经被选择。 + /// + private void EnsureActiveControllerSelected() + { + EnsureRobotSetup(); + if (_activeControllerIsSimulation is null) + { + throw new InvalidOperationException("Active controller has not been selected."); + } + } + + /// + /// 校验控制器已经建立连接。 + /// + private void EnsureConnected() + { + EnsureActiveControllerSelected(); + if (string.IsNullOrWhiteSpace(_connectedRobotIp)) + { + throw new InvalidOperationException("Controller has not been connected."); + } + } + + /// + /// 校验机器人已经处于使能态。 + /// + private void EnsureEnabled() + { + EnsureConnected(); + if (!_isEnabled || _bufferSize <= 0) + { + throw new InvalidOperationException("Robot has not been enabled."); + } + } +} diff --git a/src/Flyshot.Runtime.Fanuc/Flyshot.Runtime.Fanuc.csproj b/src/Flyshot.Runtime.Fanuc/Flyshot.Runtime.Fanuc.csproj new file mode 100644 index 0000000..e637074 --- /dev/null +++ b/src/Flyshot.Runtime.Fanuc/Flyshot.Runtime.Fanuc.csproj @@ -0,0 +1,14 @@ + + + + net8.0 + enable + enable + + + + + + + + diff --git a/src/Flyshot.Server.Host/Controllers/HealthController.cs b/src/Flyshot.Server.Host/Controllers/HealthController.cs new file mode 100644 index 0000000..3da7d41 --- /dev/null +++ b/src/Flyshot.Server.Host/Controllers/HealthController.cs @@ -0,0 +1,24 @@ +using Microsoft.AspNetCore.Mvc; + +namespace Flyshot.Server.Host.Controllers; + +/// +/// 提供宿主基础探活与诊断接口。 +/// +[ApiController] +public sealed class HealthController : ControllerBase +{ + /// + /// 返回宿主健康状态。 + /// + /// 固定的健康检查 JSON。 + [HttpGet("/healthz")] + public IActionResult GetHealth() + { + return Ok(new + { + status = "ok", + service = "flyshot-server-host" + }); + } +} diff --git a/src/Flyshot.Server.Host/Controllers/LegacyHttpApiController.cs b/src/Flyshot.Server.Host/Controllers/LegacyHttpApiController.cs new file mode 100644 index 0000000..bdfd178 --- /dev/null +++ b/src/Flyshot.Server.Host/Controllers/LegacyHttpApiController.cs @@ -0,0 +1,614 @@ +using System.Text.Json; +using Flyshot.ControllerClientCompat; +using Microsoft.AspNetCore.Mvc; + +namespace Flyshot.Server.Host.Controllers; + +/// +/// 提供对 `flyshot-uaes-interface` 既有 FastAPI HTTP 路由层的一比一 MVC 兼容控制器。 +/// +[ApiController] +public sealed class LegacyHttpApiController : ControllerBase +{ + private readonly IControllerClientCompatService _compatService; + + /// + /// 初始化旧 HTTP 兼容控制器。 + /// + /// ControllerClient 兼容服务。 + public LegacyHttpApiController(IControllerClientCompatService compatService) + { + _compatService = compatService ?? throw new ArgumentNullException(nameof(compatService)); + } + + /// + /// 兼容旧根路径探活接口。 + /// + /// 旧 HTTP 服务约定的 Hello World 响应。 + [HttpGet("/")] + public IActionResult Root() + { + return Ok(new { message = "Hello World" }); + } + + /// + /// 兼容旧 `/connect_server/` 路由;在 replacement 宿主中仅记录调用方期望连接的地址。 + /// + /// 旧客户端传入的服务端 IP。 + /// 旧客户端传入的服务端端口。 + /// 与旧 FastAPI 层一致的状态响应。 + [HttpPost("/connect_server/")] + public IActionResult ConnectServer([FromQuery] string server_ip, [FromQuery] int port) + { + try + { + _compatService.ConnectServer(server_ip, port); + return Ok(new { status = "connected" }); + } + catch + { + return LegacyBadRequest("Connect Server failed"); + } + } + + /// + /// 兼容旧 `/setup_robot/` 路由。 + /// + /// 旧 HTTP 层使用的机器人名称。 + /// 旧 FastAPI 层风格的状态响应。 + [HttpPost("/setup_robot/")] + public IActionResult SetupRobot([FromQuery] string robot_name) + { + try + { + _compatService.SetUpRobot(robot_name); + return Ok(new { status = "robot setup" }); + } + catch + { + return LegacyBadRequest("SetUpRobot failed"); + } + } + + /// + /// 兼容旧 `/is_setup/` 路由。 + /// + /// 当前机器人是否完成初始化。 + [HttpGet("/is_setup/")] + public IActionResult IsSetup() + { + return Ok(new { is_setup = _compatService.IsSetUp }); + } + + /// + /// 兼容旧 `/enable_robot/` 路由;保持原 Python 服务固定传 `8` 的行为。 + /// + /// 旧 FastAPI 层风格的布尔状态响应。 + [HttpGet("/enable_robot/")] + public IActionResult EnableRobot() + { + try + { + _compatService.EnableRobot(8); + return Ok(new { enable_robot = true }); + } + catch + { + return LegacyBadRequest("EnableRobot failed"); + } + } + + /// + /// 兼容旧 `/disable_robot/` 路由。 + /// + /// 旧 FastAPI 层风格的布尔状态响应。 + [HttpGet("/disable_robot/")] + public IActionResult DisableRobot() + { + try + { + _compatService.DisableRobot(); + return Ok(new { disable_robot = true }); + } + catch + { + return LegacyBadRequest("DisableRobot failed"); + } + } + + /// + /// 兼容旧 `/set_active_controller/` 路由。 + /// + /// 是否切到仿真控制器。 + /// 旧 FastAPI 层风格的状态响应。 + [HttpPost("/set_active_controller/")] + public IActionResult SetActiveController([FromQuery] bool sim) + { + try + { + _compatService.SetActiveController(sim); + return Ok(new { status = "active controller set" }); + } + catch + { + return LegacyBadRequest("SetActiveController failed"); + } + } + + /// + /// 兼容旧 `/connect_robot/` 路由。 + /// + /// 控制器 IP。 + /// 旧 FastAPI 层风格的状态响应。 + [HttpPost("/connect_robot/")] + public IActionResult ConnectRobot([FromQuery] string ip) + { + try + { + _compatService.Connect(ip); + return Ok(new { status = "robot connected" }); + } + catch + { + return LegacyBadRequest("Connect failed"); + } + } + + /// + /// 兼容旧 `/robot_info/` 路由。 + /// + /// 旧 HTTP 层聚合的机器人元信息。 + [HttpGet("/robot_info/")] + public IActionResult GetRobotInfo() + { + try + { + return Ok(new + { + name = _compatService.GetRobotName(), + server_version = _compatService.ServerVersion, + dof = _compatService.GetDegreesOfFreedom(), + speed_ratio = _compatService.GetSpeedRatio() + }); + } + catch + { + return LegacyBadRequest("GetRobotInfo failed"); + } + } + + /// + /// 兼容旧 `/set_tcp/` 路由。 + /// + /// 三维 TCP 请求体。 + /// 旧 FastAPI 层风格的状态响应。 + [HttpPost("/set_tcp/")] + public IActionResult SetTcp([FromBody] LegacyTcpRequest tcp_data) + { + try + { + _compatService.SetTcp(tcp_data.x, tcp_data.y, tcp_data.z); + return Ok(new { status = "TCP set" }); + } + catch + { + return LegacyBadRequest("SetTCP failed"); + } + } + + /// + /// 兼容旧 `/get_tcp/` 路由。 + /// + /// 当前 TCP 三维坐标。 + [HttpGet("/get_tcp/")] + public IActionResult GetTcp() + { + try + { + return Ok(new { tcp = _compatService.GetTcp() }); + } + catch + { + return LegacyBadRequest("GetTCP failed"); + } + } + + /// + /// 兼容旧 `/set_io/` 路由。 + /// + /// IO 端口号。 + /// IO 值。 + /// IO 类型字符串。 + /// 旧 FastAPI 层风格的状态响应。 + [HttpPost("/set_io/")] + public IActionResult SetIo([FromQuery] int port, [FromQuery] bool value, [FromQuery] string io_type) + { + try + { + _compatService.SetIo(port, value, io_type); + return Ok(new { status = "IO set" }); + } + catch + { + return LegacyBadRequest("SetDigitalOutput failed"); + } + } + + /// + /// 兼容旧 `/get_joint_position/` 路由。 + /// + /// 旧 HTTP 层定义的关节位置 JSON 外形。 + [HttpGet("/get_joint_position/")] + public IActionResult GetJointPosition() + { + try + { + return Ok(new { success = true, points = _compatService.GetJointPositions() }); + } + catch + { + return LegacyBadRequest("GetJointPosition failed"); + } + } + + /// + /// 兼容旧 `/move_joint/` 路由。 + /// + /// 关节位置请求体。 + /// 旧 FastAPI 层风格的状态响应。 + [HttpPost("/move_joint/")] + public IActionResult MoveJoint([FromBody] LegacyJointPositionRequest joint_data) + { + try + { + _compatService.MoveJoint(joint_data.joints); + return Ok(new { status = "robot moved" }); + } + catch + { + return LegacyBadRequest("MoveJoint failed"); + } + } + + /// + /// 兼容旧 `/list_flyShotTraj/` 路由。 + /// + /// 已上传飞拍轨迹名称列表。 + [HttpGet("/list_flyShotTraj/")] + public IActionResult ListFlyshotTrajectories() + { + var names = _compatService.ListTrajectoryNames(); + if (names.Count == 0) + { + return LegacyBadRequest("ListFlyShotTraj failed"); + } + + return Ok(new { flyshot_trajs = names }); + } + + /// + /// 兼容旧 `/execute_trajectory/` 路由,并接受两种历史请求体形状。 + /// + /// 轨迹请求体。 + /// 旧 FastAPI 层风格的状态响应。 + [HttpPost("/execute_trajectory/")] + public IActionResult ExecuteTrajectory([FromBody] JsonElement waypoints) + { + try + { + _compatService.ExecuteTrajectory(ParseLegacyTrajectoryWaypoints(waypoints)); + return Ok(new { status = "trajectory executed" }); + } + catch + { + return LegacyBadRequest("ExecuteTrajectory failed"); + } + } + + /// + /// 兼容旧 `/upload_flyshot/` 路由。 + /// + /// 飞拍上传请求体。 + /// 旧 FastAPI 层风格的状态响应。 + [HttpPost("/upload_flyshot/")] + public IActionResult UploadFlyshot([FromBody] LegacyFlightTrajectoryRequest trajectory_data) + { + if (trajectory_data.shot_flags.Count != trajectory_data.waypoints.Count) + { + return LegacyValidationError("shot_flags长度必须与路点数量相同"); + } + + if (trajectory_data.offset_values.Count != trajectory_data.waypoints.Count) + { + return LegacyValidationError("offset_values长度必须与路点数量相同"); + } + + if (trajectory_data.addrs.Count != trajectory_data.waypoints.Count) + { + return LegacyValidationError("addrs长度必须与路点数量相同"); + } + + try + { + var trajectory = new ControllerClientCompatUploadedTrajectory( + name: trajectory_data.name, + waypoints: trajectory_data.waypoints, + shotFlags: trajectory_data.shot_flags, + offsetValues: trajectory_data.offset_values.Select(static value => (int)value), + addressGroups: trajectory_data.addrs); + + _compatService.UploadTrajectory(trajectory); + return Ok(new { status = "FlyShot uploaded" }); + } + catch + { + return LegacyBadRequest("UploadFlyShotTraj failed"); + } + } + + /// + /// 兼容旧 `/execute_flyshot/` 路由。 + /// + /// 包含轨迹名称的请求体。 + /// 旧 FastAPI 层风格的状态响应。 + [HttpPost("/execute_flyshot/")] + public IActionResult ExecuteFlyshot([FromBody] LegacyNameRequest data) + { + try + { + _compatService.ExecuteTrajectoryByName(data.name); + return Ok(new { status = "FlyShot executed", success = true }); + } + catch (Exception exception) + { + return StatusCode(StatusCodes.Status500InternalServerError, new { detail = exception.Message }); + } + } + + /// + /// 兼容旧 `/set_speedRatio/` 路由。 + /// + /// 速度倍率请求体。 + /// 旧 FastAPI 层风格的状态响应。 + [HttpPost("/set_speedRatio/")] + public IActionResult SetSpeedRatio([FromBody] LegacySpeedRatioRequest data) + { + try + { + _compatService.SetSpeedRatio(data.speed); + return Ok(new { message = "set_speedRatio executed", returnCode = 0 }); + } + catch + { + return LegacyBadRequest("set_speedRatio failed"); + } + } + + /// + /// 兼容旧 `/delete_flyshot/` 路由。 + /// + /// 包含轨迹名称的请求体。 + /// 旧 FastAPI 层风格的状态响应。 + [HttpPost("/delete_flyshot/")] + public IActionResult DeleteFlyshot([FromBody] LegacyNameRequest request) + { + try + { + _compatService.DeleteTrajectory(request.name); + return Ok(new { status = "FlyShot deleted" }); + } + catch + { + return LegacyBadRequest("DeleteFlyShotTraj failed"); + } + } + + /// + /// 兼容旧 `/init_mpc_robt` 路由,保留历史拼写。 + /// + /// 初始化请求体。 + /// 旧 FastAPI 层风格的初始化结果。 + [HttpPost("/init_mpc_robt")] + public IActionResult InitMpcRobot([FromBody] LegacyInitMpcRobotRequest data) + { + try + { + _compatService.ConnectServer(data.server_ip, data.port); + _compatService.SetUpRobot(data.robot_name); + if (!_compatService.IsSetUp) + { + return LegacyBadRequest("Robot not setup"); + } + + _compatService.SetActiveController(sim: false); + _compatService.Connect(data.robot_ip); + _compatService.EnableRobot(2); + return Ok(new { message = "init_Success", returnCode = 0 }); + } + catch + { + return LegacyBadRequest("Connect Server failed"); + } + } + + /// + /// 兼容旧 `/get_pose` 路由。 + /// + /// 当前末端位姿数组。 + [HttpGet("/get_pose")] + public IActionResult GetPose() + { + try + { + return Ok(new { pose = _compatService.GetPose() }); + } + catch + { + return LegacyBadRequest("GetPose failed"); + } + } + + /// + /// 解析旧 `/execute_trajectory/` 可能出现的两种历史请求体形状。 + /// + /// 原始 JSON 请求体。 + /// 统一后的关节路点集合。 + private static IReadOnlyList> ParseLegacyTrajectoryWaypoints(JsonElement waypoints) + { + if (waypoints.ValueKind != JsonValueKind.Array) + { + throw new InvalidOperationException("ExecuteTrajectory request body must be an array."); + } + + var parsedWaypoints = new List>(); + foreach (var waypointElement in waypoints.EnumerateArray()) + { + if (waypointElement.ValueKind == JsonValueKind.Array) + { + parsedWaypoints.Add(waypointElement.EnumerateArray().Select(static value => value.GetDouble()).ToArray()); + continue; + } + + if (waypointElement.ValueKind == JsonValueKind.Object && waypointElement.TryGetProperty("joints", out var jointElement)) + { + parsedWaypoints.Add(jointElement.EnumerateArray().Select(static value => value.GetDouble()).ToArray()); + continue; + } + + throw new InvalidOperationException("Unsupported waypoint payload shape."); + } + + return parsedWaypoints; + } + + /// + /// 构造与旧 FastAPI `HTTPException(status_code=400, detail=...)` 等价的响应。 + /// + /// 错误详情文本。 + /// 400 JSON 响应。 + private BadRequestObjectResult LegacyBadRequest(string detail) + { + return BadRequest(new { detail }); + } + + /// + /// 构造与旧 FastAPI `422` 输入校验失败等价的响应。 + /// + /// 错误详情文本。 + /// 422 JSON 响应。 + private ObjectResult LegacyValidationError(string detail) + { + return StatusCode(StatusCodes.Status422UnprocessableEntity, new { detail }); + } +} + +/// +/// 表示旧 `/set_tcp/` 路由使用的三维 TCP 请求体。 +/// +public sealed class LegacyTcpRequest +{ + /// + /// 获取或设置 TCP X。 + /// + public double x { get; init; } + + /// + /// 获取或设置 TCP Y。 + /// + public double y { get; init; } + + /// + /// 获取或设置 TCP Z。 + /// + public double z { get; init; } +} + +/// +/// 表示旧 `/move_joint/` 路由使用的关节请求体。 +/// +public sealed class LegacyJointPositionRequest +{ + /// + /// 获取或设置目标关节数组。 + /// + public List joints { get; init; } = []; +} + +/// +/// 表示旧 `/upload_flyshot/` 路由使用的飞拍上传请求体。 +/// +public sealed class LegacyFlightTrajectoryRequest +{ + /// + /// 获取或设置地址组集合。 + /// + public List> addrs { get; init; } = []; + + /// + /// 获取或设置飞拍轨迹名称。 + /// + public string name { get; init; } = string.Empty; + + /// + /// 获取或设置偏移周期集合。 + /// + public List offset_values { get; init; } = []; + + /// + /// 获取或设置拍照标志集合。 + /// + public List shot_flags { get; init; } = []; + + /// + /// 获取或设置关节路点集合。 + /// + public List> waypoints { get; init; } = []; +} + +/// +/// 表示旧 `/execute_flyshot/` 与 `/delete_flyshot/` 路由使用的名称请求体。 +/// +public sealed class LegacyNameRequest +{ + /// + /// 获取或设置轨迹名称。 + /// + public string name { get; init; } = string.Empty; +} + +/// +/// 表示旧 `/set_speedRatio/` 路由使用的速度倍率请求体。 +/// +public sealed class LegacySpeedRatioRequest +{ + /// + /// 获取或设置目标速度倍率。 + /// + public double speed { get; init; } +} + +/// +/// 表示旧 `/init_mpc_robt` 路由使用的初始化请求体。 +/// +public sealed class LegacyInitMpcRobotRequest +{ + /// + /// 获取或设置目标服务端 IP。 + /// + public string server_ip { get; init; } = string.Empty; + + /// + /// 获取或设置目标服务端端口。 + /// + public int port { get; init; } + + /// + /// 获取或设置机器人名称。 + /// + public string robot_name { get; init; } = string.Empty; + + /// + /// 获取或设置机器人控制器 IP。 + /// + public string robot_ip { get; init; } = string.Empty; +} diff --git a/src/Flyshot.Server.Host/Flyshot.Server.Host.csproj b/src/Flyshot.Server.Host/Flyshot.Server.Host.csproj index 21720d3..95481dd 100644 --- a/src/Flyshot.Server.Host/Flyshot.Server.Host.csproj +++ b/src/Flyshot.Server.Host/Flyshot.Server.Host.csproj @@ -1,6 +1,11 @@ + + + + + diff --git a/src/Flyshot.Server.Host/HostCorsOptions.cs b/src/Flyshot.Server.Host/HostCorsOptions.cs new file mode 100644 index 0000000..dbdfcf3 --- /dev/null +++ b/src/Flyshot.Server.Host/HostCorsOptions.cs @@ -0,0 +1,27 @@ +namespace Flyshot.Server.Host; + +/// +/// 表示宿主 CORS 策略的标准配置项。 +/// +public sealed class HostCorsOptions +{ + /// + /// 获取或设置 CORS 策略名称。 + /// + public string PolicyName { get; set; } = "LegacyHttpApi"; + + /// + /// 获取或设置允许的源列表。 + /// + public string[] AllowedOrigins { get; set; } = ["*"]; + + /// + /// 获取或设置允许的 HTTP 方法列表。 + /// + public string[] AllowedMethods { get; set; } = ["GET", "POST", "OPTIONS"]; + + /// + /// 获取或设置允许的请求头列表。 + /// + public string[] AllowedHeaders { get; set; } = ["*"]; +} diff --git a/src/Flyshot.Server.Host/HostSwaggerOptions.cs b/src/Flyshot.Server.Host/HostSwaggerOptions.cs new file mode 100644 index 0000000..3145773 --- /dev/null +++ b/src/Flyshot.Server.Host/HostSwaggerOptions.cs @@ -0,0 +1,37 @@ +namespace Flyshot.Server.Host; + +/// +/// 表示宿主 Swagger/OpenAPI 文档的标准配置项。 +/// +public sealed class HostSwaggerOptions +{ + /// + /// 获取或设置是否启用 Swagger。 + /// + public bool Enabled { get; set; } = true; + + /// + /// 获取或设置文档名称。 + /// + public string DocumentName { get; set; } = "v1"; + + /// + /// 获取或设置 Swagger 文档标题。 + /// + public string Title { get; set; } = "Flyshot Replacement HTTP API"; + + /// + /// 获取或设置 Swagger 文档版本文本。 + /// + public string Version { get; set; } = "v1"; + + /// + /// 获取或设置 Swagger JSON 路由模板。 + /// + public string JsonRouteTemplate { get; set; } = "swagger/{documentName}/swagger.json"; + + /// + /// 获取或设置 Swagger UI 路由前缀。 + /// + public string RoutePrefix { get; set; } = "swagger"; +} diff --git a/src/Flyshot.Server.Host/Program.cs b/src/Flyshot.Server.Host/Program.cs index 38ded6e..c96c8fc 100644 --- a/src/Flyshot.Server.Host/Program.cs +++ b/src/Flyshot.Server.Host/Program.cs @@ -1,12 +1,85 @@ +using Flyshot.ControllerClientCompat; +using Flyshot.Server.Host; +using Microsoft.Extensions.Options; +using Microsoft.OpenApi.Models; + var builder = WebApplication.CreateBuilder(args); + +builder.Services.Configure(builder.Configuration.GetSection("Swagger")); +builder.Services.Configure(builder.Configuration.GetSection("Cors")); +builder.Services.AddControllerClientCompat(builder.Configuration); +builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); + +var swaggerOptions = builder.Configuration.GetSection("Swagger").Get() ?? new HostSwaggerOptions(); +builder.Services.AddSwaggerGen(options => +{ + options.SwaggerDoc(swaggerOptions.DocumentName, new OpenApiInfo + { + Title = swaggerOptions.Title, + Version = swaggerOptions.Version + }); +}); + +var corsOptions = builder.Configuration.GetSection("Cors").Get() ?? new HostCorsOptions(); +builder.Services.AddCors(options => +{ + options.AddPolicy(corsOptions.PolicyName, policyBuilder => + { + // 兼容本地调试时最常见的任意源配置,同时保留显式白名单模式。 + if (corsOptions.AllowedOrigins.Length == 1 && string.Equals(corsOptions.AllowedOrigins[0], "*", StringComparison.Ordinal)) + { + policyBuilder.AllowAnyOrigin(); + } + else + { + policyBuilder.WithOrigins(corsOptions.AllowedOrigins); + } + + if (corsOptions.AllowedMethods.Length == 1 && string.Equals(corsOptions.AllowedMethods[0], "*", StringComparison.Ordinal)) + { + policyBuilder.AllowAnyMethod(); + } + else + { + policyBuilder.WithMethods(corsOptions.AllowedMethods); + } + + if (corsOptions.AllowedHeaders.Length == 1 && string.Equals(corsOptions.AllowedHeaders[0], "*", StringComparison.Ordinal)) + { + policyBuilder.AllowAnyHeader(); + } + else + { + policyBuilder.WithHeaders(corsOptions.AllowedHeaders); + } + }); +}); + var app = builder.Build(); -app.MapGet("/", () => Results.Redirect("/healthz")); -app.MapGet("/healthz", () => Results.Ok(new +var resolvedSwaggerOptions = app.Services.GetRequiredService>().Value; +var resolvedCorsOptions = app.Services.GetRequiredService>().Value; + +if (resolvedSwaggerOptions.Enabled) { - status = "ok", - service = "flyshot-server-host" -})); + app.UseSwagger(options => + { + options.RouteTemplate = resolvedSwaggerOptions.JsonRouteTemplate; + }); + + app.UseSwaggerUI(options => + { + options.RoutePrefix = resolvedSwaggerOptions.RoutePrefix; + options.SwaggerEndpoint( + $"/swagger/{resolvedSwaggerOptions.DocumentName}/swagger.json", + $"{resolvedSwaggerOptions.Title} {resolvedSwaggerOptions.Version}"); + options.DocumentTitle = resolvedSwaggerOptions.Title; + }); +} + +app.UseCors(resolvedCorsOptions.PolicyName); +app.MapControllers(); app.Run(); diff --git a/src/Flyshot.Server.Host/appsettings.Development.json b/src/Flyshot.Server.Host/appsettings.Development.json index 0c208ae..a58e1bc 100644 --- a/src/Flyshot.Server.Host/appsettings.Development.json +++ b/src/Flyshot.Server.Host/appsettings.Development.json @@ -4,5 +4,26 @@ "Default": "Information", "Microsoft.AspNetCore": "Warning" } + }, + "ControllerClientCompat": { + "ServerVersion": "flyshot-replacement-controller-client-compat/0.1.0" + }, + "Swagger": { + "Enabled": true + }, + "Cors": { + "PolicyName": "LegacyHttpApi", + "AllowedOrigins": [ + "http://localhost:3000", + "http://127.0.0.1:3000" + ], + "AllowedMethods": [ + "GET", + "POST", + "OPTIONS" + ], + "AllowedHeaders": [ + "*" + ] } } diff --git a/src/Flyshot.Server.Host/appsettings.json b/src/Flyshot.Server.Host/appsettings.json index 10f68b8..e093daa 100644 --- a/src/Flyshot.Server.Host/appsettings.json +++ b/src/Flyshot.Server.Host/appsettings.json @@ -5,5 +5,30 @@ "Microsoft.AspNetCore": "Warning" } }, + "ControllerClientCompat": { + "ServerVersion": "flyshot-replacement-controller-client-compat/0.1.0" + }, + "Swagger": { + "Enabled": true, + "DocumentName": "v1", + "Title": "Flyshot Replacement HTTP API", + "Version": "v1", + "JsonRouteTemplate": "swagger/{documentName}/swagger.json", + "RoutePrefix": "swagger" + }, + "Cors": { + "PolicyName": "LegacyHttpApi", + "AllowedOrigins": [ + "*" + ], + "AllowedMethods": [ + "GET", + "POST", + "OPTIONS" + ], + "AllowedHeaders": [ + "*" + ] + }, "AllowedHosts": "*" } diff --git a/tests/Flyshot.Core.Tests/Flyshot.Core.Tests.csproj b/tests/Flyshot.Core.Tests/Flyshot.Core.Tests.csproj index 39acecc..06712ec 100644 --- a/tests/Flyshot.Core.Tests/Flyshot.Core.Tests.csproj +++ b/tests/Flyshot.Core.Tests/Flyshot.Core.Tests.csproj @@ -23,10 +23,12 @@ + + diff --git a/tests/Flyshot.Core.Tests/RuntimeOrchestrationTests.cs b/tests/Flyshot.Core.Tests/RuntimeOrchestrationTests.cs new file mode 100644 index 0000000..f796115 --- /dev/null +++ b/tests/Flyshot.Core.Tests/RuntimeOrchestrationTests.cs @@ -0,0 +1,195 @@ +using Flyshot.ControllerClientCompat; +using Flyshot.Core.Config; +using Flyshot.Core.Domain; +using Flyshot.Runtime.Fanuc; + +namespace Flyshot.Core.Tests; + +/// +/// 验证最小运行时编排链路会把规划结果交给控制器运行时,而不是停留在兼容层内存状态。 +/// +public sealed class RuntimeOrchestrationTests +{ + /// + /// 验证 FANUC 最小运行时执行轨迹后会更新状态快照与最终关节位置。 + /// + [Fact] + public void FanucControllerRuntime_ExecuteTrajectory_UpdatesSnapshotAndFinalJointPositions() + { + var runtime = new FanucControllerRuntime(); + var robot = TestRobotFactory.CreateRobotProfile(); + runtime.ResetRobot(robot, "FANUC_LR_Mate_200iD"); + runtime.SetActiveController(sim: false); + runtime.Connect("192.168.10.101"); + runtime.EnableRobot(bufferSize: 2); + + var result = new TrajectoryResult( + programName: "demo", + method: PlanningMethod.Icsp, + isValid: true, + duration: TimeSpan.FromSeconds(1.2), + shotEvents: Array.Empty(), + triggerTimeline: Array.Empty(), + artifacts: Array.Empty(), + failureReason: null, + usedCache: false, + originalWaypointCount: 4, + plannedWaypointCount: 4); + + runtime.ExecuteTrajectory(result, [1.0, 2.0, 3.0, 4.0, 5.0, 6.0]); + + var snapshot = runtime.GetSnapshot(); + Assert.Equal("Connected", snapshot.ConnectionState); + Assert.False(snapshot.IsInMotion); + Assert.Equal([1.0, 2.0, 3.0, 4.0, 5.0, 6.0], snapshot.JointPositions); + } + + /// + /// 验证普通轨迹会先进入 ICSP 规划,并沿用 ICSP 对示教点数量的约束。 + /// + [Fact] + public void ControllerClientTrajectoryOrchestrator_PlanOrdinaryTrajectory_RejectsThreeTeachPoints() + { + var orchestrator = new ControllerClientTrajectoryOrchestrator(); + var robot = TestRobotFactory.CreateRobotProfile(); + + void Act() => + orchestrator.PlanOrdinaryTrajectory( + robot, + [ + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [0.5, 0.0, 0.0, 0.0, 0.0, 0.0], + [1.0, 0.0, 0.0, 0.0, 0.0, 0.0] + ]); + + Assert.Throws(Act); + } + + /// + /// 验证已上传飞拍轨迹会经过 self-adapt-icsp 并生成拍照触发时间轴。 + /// + [Fact] + public void ControllerClientTrajectoryOrchestrator_PlanUploadedFlyshot_BuildsShotTimeline() + { + var orchestrator = new ControllerClientTrajectoryOrchestrator(); + var robot = TestRobotFactory.CreateRobotProfile(); + var uploaded = TestRobotFactory.CreateUploadedTrajectoryWithSingleShot(); + + var bundle = orchestrator.PlanUploadedFlyshot(robot, uploaded); + + Assert.True(bundle.Result.IsValid); + Assert.Single(bundle.Result.ShotEvents); + Assert.Single(bundle.Result.TriggerTimeline); + } + + /// + /// 验证兼容服务执行普通轨迹时会进入规划链路,而不是直接把最后一个路点写入状态。 + /// + [Fact] + public void ControllerClientCompatService_ExecuteTrajectory_RejectsThreeTeachPointsAfterPlanningIsIntroduced() + { + var service = TestRobotFactory.CreateCompatService(); + service.SetUpRobot("FANUC_LR_Mate_200iD"); + service.SetActiveController(sim: false); + service.Connect("192.168.10.101"); + service.EnableRobot(2); + + void Act() => + service.ExecuteTrajectory( + [ + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [0.5, 0.0, 0.0, 0.0, 0.0, 0.0], + [1.0, 0.0, 0.0, 0.0, 0.0, 0.0] + ]); + + Assert.Throws(Act); + } +} + +/// +/// 为运行时编排测试构造稳定的最小领域对象。 +/// +internal static class TestRobotFactory +{ + /// + /// 构造六轴测试机器人配置,避免运行时测试依赖真实 .robot 文件。 + /// + /// 可用于规划和运行时状态校验的机器人配置。 + public static RobotProfile CreateRobotProfile() + { + return new RobotProfile( + name: "TestRobot", + modelPath: "Models/Test.robot", + degreesOfFreedom: 6, + jointLimits: Enumerable.Range(1, 6) + .Select(static index => new JointLimit($"J{index}", 10.0, 20.0, 100.0)) + .ToArray(), + jointCouplings: Array.Empty(), + servoPeriod: TimeSpan.FromMilliseconds(8), + triggerPeriod: TimeSpan.FromMilliseconds(8)); + } + + /// + /// 构造一条含单个拍照点的上传飞拍轨迹。 + /// + /// 可用于触发时间轴测试的上传轨迹。 + public static ControllerClientCompatUploadedTrajectory CreateUploadedTrajectoryWithSingleShot() + { + return new ControllerClientCompatUploadedTrajectory( + name: "demo-flyshot", + waypoints: + [ + [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] + ], + shotFlags: [false, true, false, false], + offsetValues: [0, 1, 0, 0], + addressGroups: + [ + Array.Empty(), + [7, 8], + Array.Empty(), + Array.Empty() + ]); + } + + /// + /// 构造一份真实依赖注入等价的兼容服务,覆盖运行时和编排器协作。 + /// + /// 可执行 ControllerClient 兼容语义的服务实例。 + public static ControllerClientCompatService CreateCompatService() + { + var options = new ControllerClientCompatOptions + { + WorkspaceRoot = GetWorkspaceRoot() + }; + + return new ControllerClientCompatService( + options, + new ControllerClientCompatRobotCatalog(options, new RobotModelLoader()), + new FanucControllerRuntime(), + new ControllerClientTrajectoryOrchestrator()); + } + + /// + /// 定位父工作区根目录,供兼容服务加载真实机器人模型。 + /// + /// 父工作区根目录。 + private static string GetWorkspaceRoot() + { + 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/tests/Flyshot.Server.IntegrationTests/ControllerClientCompatRegistrationTests.cs b/tests/Flyshot.Server.IntegrationTests/ControllerClientCompatRegistrationTests.cs new file mode 100644 index 0000000..7daf0cc --- /dev/null +++ b/tests/Flyshot.Server.IntegrationTests/ControllerClientCompatRegistrationTests.cs @@ -0,0 +1,21 @@ +using Flyshot.ControllerClientCompat; +using Microsoft.Extensions.DependencyInjection; + +namespace Flyshot.Server.IntegrationTests; + +/// +/// 锁定宿主当前应注册 HTTP-only 的 ControllerClient 兼容服务,而不是旧 TCP 网关入口。 +/// +public sealed class ControllerClientCompatRegistrationTests(FlyshotServerFactory factory) : IClassFixture +{ + /// + /// 验证宿主能从 DI 中解析新的兼容服务。 + /// + [Fact] + public void Host_Registers_ControllerClientCompat_Service() + { + var service = factory.Services.GetService(); + + Assert.NotNull(service); + } +} diff --git a/tests/Flyshot.Server.IntegrationTests/Flyshot.Server.IntegrationTests.csproj b/tests/Flyshot.Server.IntegrationTests/Flyshot.Server.IntegrationTests.csproj index 0c8181e..4a34fda 100644 --- a/tests/Flyshot.Server.IntegrationTests/Flyshot.Server.IntegrationTests.csproj +++ b/tests/Flyshot.Server.IntegrationTests/Flyshot.Server.IntegrationTests.csproj @@ -24,6 +24,7 @@ + diff --git a/tests/Flyshot.Server.IntegrationTests/HostMvcConfigurationTests.cs b/tests/Flyshot.Server.IntegrationTests/HostMvcConfigurationTests.cs new file mode 100644 index 0000000..f21b9fe --- /dev/null +++ b/tests/Flyshot.Server.IntegrationTests/HostMvcConfigurationTests.cs @@ -0,0 +1,84 @@ +using System.Net; +using System.Text.Json; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.Configuration; + +namespace Flyshot.Server.IntegrationTests; + +/// +/// 锁定标准 MVC 宿主需要提供的 Swagger 与 CORS 行为,避免后续回退成只够跑通的最小配置。 +/// +public sealed class HostMvcConfigurationTests(FlyshotServerFactory factory) : IClassFixture +{ + /// + /// 验证宿主会公开标准 Swagger JSON,并且文档标题和旧 HTTP 兼容路径都能从配置和控制器路由中导出。 + /// + [Fact] + public async Task SwaggerDocument_ExposesConfiguredMetadataAndLegacyRoutes() + { + using var configuredFactory = CreateConfiguredFactory(factory); + using var client = configuredFactory.CreateClient(); + + using var response = await client.GetAsync("/swagger/v1/swagger.json"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + await using var responseStream = await response.Content.ReadAsStreamAsync(); + using var document = await JsonDocument.ParseAsync(responseStream); + var root = document.RootElement; + var paths = root.GetProperty("paths"); + + Assert.Equal("3.0.1", root.GetProperty("openapi").GetString()); + Assert.Equal("Flyshot Replacement HTTP API", root.GetProperty("info").GetProperty("title").GetString()); + // OpenAPI 文档会把部分带尾斜杠的路由规范化为无尾斜杠形式,这里同时接受两种键。 + Assert.True(paths.TryGetProperty("/robot_info/", out _) || paths.TryGetProperty("/robot_info", out _)); + Assert.True(paths.TryGetProperty("/healthz", out _) || paths.TryGetProperty("/healthz/", out _)); + } + + /// + /// 验证宿主会按配置对旧 HTTP API 路由返回标准 CORS 预检响应。 + /// + [Fact] + public async Task CorsPreflight_ReturnsConfiguredAllowOriginHeaders() + { + using var configuredFactory = CreateConfiguredFactory(factory); + using var client = configuredFactory.CreateClient(); + + using var request = new HttpRequestMessage(HttpMethod.Options, "/robot_info/"); + request.Headers.Add("Origin", "http://localhost:3000"); + request.Headers.Add("Access-Control-Request-Method", "GET"); + request.Headers.Add("Access-Control-Request-Headers", "content-type"); + + using var response = await client.SendAsync(request); + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + Assert.True(response.Headers.TryGetValues("Access-Control-Allow-Origin", out var allowedOrigins)); + Assert.Contains("http://localhost:3000", allowedOrigins); + Assert.True(response.Headers.TryGetValues("Access-Control-Allow-Methods", out var allowedMethods)); + Assert.Contains("GET", string.Join(",", allowedMethods)); + } + + /// + /// 为测试宿主注入标准 Swagger 与 CORS 配置,避免依赖开发机本地环境。 + /// + private static WebApplicationFactory CreateConfiguredFactory(FlyshotServerFactory factory) + { + return factory.WithWebHostBuilder(builder => + { + builder.ConfigureAppConfiguration((_, configurationBuilder) => + { + configurationBuilder.AddInMemoryCollection(new Dictionary + { + ["Swagger:Enabled"] = "true", + ["Swagger:DocumentName"] = "v1", + ["Swagger:Title"] = "Flyshot Replacement HTTP API", + ["Swagger:Version"] = "v1", + ["Cors:PolicyName"] = "LegacyHttpApi", + ["Cors:AllowedOrigins:0"] = "http://localhost:3000", + ["Cors:AllowedMethods:0"] = "GET", + ["Cors:AllowedMethods:1"] = "POST", + ["Cors:AllowedMethods:2"] = "OPTIONS", + ["Cors:AllowedHeaders:0"] = "content-type" + }); + }); + }); + } +} diff --git a/tests/Flyshot.Server.IntegrationTests/LegacyHttpApiCompatibilityTests.cs b/tests/Flyshot.Server.IntegrationTests/LegacyHttpApiCompatibilityTests.cs new file mode 100644 index 0000000..c53ae7b --- /dev/null +++ b/tests/Flyshot.Server.IntegrationTests/LegacyHttpApiCompatibilityTests.cs @@ -0,0 +1,235 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; + +namespace Flyshot.Server.IntegrationTests; + +/// +/// 锁定 flyshot-uaes-interface 现有 FastAPI 层的 HTTP 路径、参数绑定和返回 JSON 外形。 +/// +public sealed class LegacyHttpApiCompatibilityTests(FlyshotServerFactory factory) : IClassFixture +{ + /// + /// 验证根路径会返回旧 HTTP 服务使用的 Hello World JSON,而不是跳转到健康检查页。 + /// + [Fact] + public async Task Root_ReturnsLegacyHelloWorldPayload() + { + using var client = factory.CreateClient(); + + using var response = await client.GetAsync("/"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + using var json = await ReadJsonAsync(response); + Assert.Equal("Hello World", json.RootElement.GetProperty("message").GetString()); + } + + /// + /// 验证初始化链路和机器人信息接口会保持旧 FastAPI 服务的路径与返回字段风格。 + /// + [Fact] + public async Task InitEndpoints_ExposeLegacyRobotInfoAndSpeedRatioShape() + { + using var client = factory.CreateClient(); + + using (var connectServerResponse = await client.PostAsync("/connect_server/?server_ip=127.0.0.1&port=50001", content: null)) + { + Assert.Equal(HttpStatusCode.OK, connectServerResponse.StatusCode); + using var connectServerJson = await ReadJsonAsync(connectServerResponse); + Assert.Equal("connected", connectServerJson.RootElement.GetProperty("status").GetString()); + } + + using (var setupResponse = await client.PostAsync("/setup_robot/?robot_name=FANUC_LR_Mate_200iD", content: null)) + { + Assert.Equal(HttpStatusCode.OK, setupResponse.StatusCode); + using var setupJson = await ReadJsonAsync(setupResponse); + Assert.Equal("robot setup", setupJson.RootElement.GetProperty("status").GetString()); + } + + using (var isSetupResponse = await client.GetAsync("/is_setup/")) + { + Assert.Equal(HttpStatusCode.OK, isSetupResponse.StatusCode); + using var isSetupJson = await ReadJsonAsync(isSetupResponse); + Assert.True(isSetupJson.RootElement.GetProperty("is_setup").GetBoolean()); + } + + using (var activeControllerResponse = await client.PostAsync("/set_active_controller/?sim=false", content: null)) + { + Assert.Equal(HttpStatusCode.OK, activeControllerResponse.StatusCode); + using var activeControllerJson = await ReadJsonAsync(activeControllerResponse); + Assert.Equal("active controller set", activeControllerJson.RootElement.GetProperty("status").GetString()); + } + + using (var connectRobotResponse = await client.PostAsync("/connect_robot/?ip=192.168.10.101", content: null)) + { + Assert.Equal(HttpStatusCode.OK, connectRobotResponse.StatusCode); + using var connectRobotJson = await ReadJsonAsync(connectRobotResponse); + Assert.Equal("robot connected", connectRobotJson.RootElement.GetProperty("status").GetString()); + } + + using (var enableRobotResponse = await client.GetAsync("/enable_robot/")) + { + Assert.Equal(HttpStatusCode.OK, enableRobotResponse.StatusCode); + using var enableRobotJson = await ReadJsonAsync(enableRobotResponse); + Assert.True(enableRobotJson.RootElement.GetProperty("enable_robot").GetBoolean()); + } + + using (var robotInfoResponse = await client.GetAsync("/robot_info/")) + { + Assert.Equal(HttpStatusCode.OK, robotInfoResponse.StatusCode); + using var robotInfoJson = await ReadJsonAsync(robotInfoResponse); + var robotInfoRoot = robotInfoJson.RootElement; + + Assert.Equal("FANUC_LR_Mate_200iD", robotInfoRoot.GetProperty("name").GetString()); + Assert.Equal("flyshot-replacement-controller-client-compat/0.1.0", robotInfoRoot.GetProperty("server_version").GetString()); + Assert.Equal(6, robotInfoRoot.GetProperty("dof").GetInt32()); + Assert.Equal(1.0, robotInfoRoot.GetProperty("speed_ratio").GetDouble(), precision: 6); + } + + using (var setSpeedRatioResponse = await client.PostAsJsonAsync("/set_speedRatio/", new { speed = 0.8 })) + { + Assert.Equal(HttpStatusCode.OK, setSpeedRatioResponse.StatusCode); + using var setSpeedRatioJson = await ReadJsonAsync(setSpeedRatioResponse); + Assert.Equal("set_speedRatio executed", setSpeedRatioJson.RootElement.GetProperty("message").GetString()); + Assert.Equal(0, setSpeedRatioJson.RootElement.GetProperty("returnCode").GetInt32()); + } + + using var updatedRobotInfoResponse = await client.GetAsync("/robot_info/"); + Assert.Equal(HttpStatusCode.OK, updatedRobotInfoResponse.StatusCode); + using var updatedRobotInfoJson = await ReadJsonAsync(updatedRobotInfoResponse); + Assert.Equal(0.8, updatedRobotInfoJson.RootElement.GetProperty("speed_ratio").GetDouble(), precision: 6); + } + + /// + /// 验证 TCP、关节位置和位姿相关 HTTP 接口会保持旧服务的请求体与响应体结构。 + /// + [Fact] + public async Task MotionStateEndpoints_RoundTripLegacyPayloadShapes() + { + using var client = factory.CreateClient(); + await InitializeRobotAsync(client); + + using (var setTcpResponse = await client.PostAsJsonAsync("/set_tcp/", new { x = 1.0, y = 2.0, z = 3.0 })) + { + Assert.Equal(HttpStatusCode.OK, setTcpResponse.StatusCode); + using var setTcpJson = await ReadJsonAsync(setTcpResponse); + Assert.Equal("TCP set", setTcpJson.RootElement.GetProperty("status").GetString()); + } + + using (var getTcpResponse = await client.GetAsync("/get_tcp/")) + { + Assert.Equal(HttpStatusCode.OK, getTcpResponse.StatusCode); + using var getTcpJson = await ReadJsonAsync(getTcpResponse); + var tcpValues = getTcpJson.RootElement.GetProperty("tcp").EnumerateArray().Select(static value => value.GetDouble()).ToArray(); + Assert.Equal([1.0, 2.0, 3.0], tcpValues); + } + + using (var moveJointResponse = await client.PostAsJsonAsync("/move_joint/", new { joints = new[] { 1.0, 2.0, 3.0, 4.0, 5.0, 6.0 } })) + { + Assert.Equal(HttpStatusCode.OK, moveJointResponse.StatusCode); + using var moveJointJson = await ReadJsonAsync(moveJointResponse); + Assert.Equal("robot moved", moveJointJson.RootElement.GetProperty("status").GetString()); + } + + using (var getJointPositionResponse = await client.GetAsync("/get_joint_position/")) + { + Assert.Equal(HttpStatusCode.OK, getJointPositionResponse.StatusCode); + using var getJointPositionJson = await ReadJsonAsync(getJointPositionResponse); + var root = getJointPositionJson.RootElement; + Assert.True(root.GetProperty("success").GetBoolean()); + var jointValues = root.GetProperty("points").EnumerateArray().Select(static value => value.GetDouble()).ToArray(); + Assert.Equal([1.0, 2.0, 3.0, 4.0, 5.0, 6.0], jointValues); + } + + using var getPoseResponse = await client.GetAsync("/get_pose"); + Assert.Equal(HttpStatusCode.OK, getPoseResponse.StatusCode); + using var getPoseJson = await ReadJsonAsync(getPoseResponse); + Assert.Equal(7, getPoseJson.RootElement.GetProperty("pose").GetArrayLength()); + } + + /// + /// 验证飞拍 HTTP 接口可以按旧 API 层的路径和字段完成上传、列出、执行与删除。 + /// + [Fact] + public async Task FlyshotEndpoints_RoundTripLegacyUploadExecuteAndDeleteFlow() + { + using var client = factory.CreateClient(); + await InitializeRobotAsync(client); + + var uploadPayload = new + { + addrs = new[] + { + new[] { 7, 8 }, + new[] { 7, 8 } + }, + name = "demo-http-flyshot", + offset_values = new[] { 0.0, 1.0 }, + shot_flags = new[] { false, true }, + waypoints = new[] + { + new[] { 0.1, 0.2, 0.3, 0.4, 0.5, 0.6 }, + new[] { 1.1, 1.2, 1.3, 1.4, 1.5, 1.6 } + } + }; + + using (var uploadResponse = await client.PostAsJsonAsync("/upload_flyshot/", uploadPayload)) + { + Assert.Equal(HttpStatusCode.OK, uploadResponse.StatusCode); + using var uploadJson = await ReadJsonAsync(uploadResponse); + Assert.Equal("FlyShot uploaded", uploadJson.RootElement.GetProperty("status").GetString()); + } + + using (var listResponse = await client.GetAsync("/list_flyShotTraj/")) + { + Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode); + using var listJson = await ReadJsonAsync(listResponse); + var names = listJson.RootElement.GetProperty("flyshot_trajs").EnumerateArray().Select(static value => value.GetString()).ToArray(); + Assert.Contains("demo-http-flyshot", names); + } + + using (var executeResponse = await client.PostAsJsonAsync("/execute_flyshot/", new { name = "demo-http-flyshot" })) + { + Assert.Equal(HttpStatusCode.OK, executeResponse.StatusCode); + using var executeJson = await ReadJsonAsync(executeResponse); + var executeRoot = executeJson.RootElement; + Assert.Equal("FlyShot executed", executeRoot.GetProperty("status").GetString()); + Assert.True(executeRoot.GetProperty("success").GetBoolean()); + } + + using (var deleteResponse = await client.PostAsJsonAsync("/delete_flyshot/", new { name = "demo-http-flyshot" })) + { + Assert.Equal(HttpStatusCode.OK, deleteResponse.StatusCode); + using var deleteJson = await ReadJsonAsync(deleteResponse); + Assert.Equal("FlyShot deleted", deleteJson.RootElement.GetProperty("status").GetString()); + } + } + + /// + /// 复用旧 API 层常见的初始化顺序,把当前宿主推进到可执行动作的最小状态。 + /// + private static async Task InitializeRobotAsync(HttpClient client) + { + using var initResponse = await client.PostAsJsonAsync("/init_mpc_robt", new + { + server_ip = "127.0.0.1", + port = 50001, + robot_name = "FANUC_LR_Mate_200iD", + robot_ip = "192.168.10.101" + }); + + Assert.Equal(HttpStatusCode.OK, initResponse.StatusCode); + using var initJson = await ReadJsonAsync(initResponse); + Assert.Equal("init_Success", initJson.RootElement.GetProperty("message").GetString()); + Assert.Equal(0, initJson.RootElement.GetProperty("returnCode").GetInt32()); + } + + /// + /// 统一把 HTTP 响应体解析成 JsonDocument,便于对旧接口的字段形状做精确断言。 + /// + private static async Task ReadJsonAsync(HttpResponseMessage response) + { + await using var responseStream = await response.Content.ReadAsStreamAsync(); + return await JsonDocument.ParseAsync(responseStream); + } +}