From 2cd42f04e561dc0d9f598f38ea26da2bce9060a8 Mon Sep 17 00:00:00 2001 From: "yunxiao.zhu" Date: Thu, 14 May 2026 17:46:42 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(fanuc):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E7=9B=B4=E8=A7=92=E5=9D=90=E6=A0=87=E7=82=B9=E5=8A=A8=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E4=B8=8E=E7=9B=B8=E5=85=B3=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 新增 `MovePose` 方法,支持以直角坐标执行点到点移动。 * 引入 `LegacyCartesianPoseRequest` 类,处理直角位姿请求体的解析与验证。 * 更新 `LegacyHttpApiController`,实现 `/move_pose/` 路由以支持新功能。 * 增强状态快照元数据,提供机器人初始化状态与已上传轨迹信息。 * 更新前端状态页面,增加直角坐标点动控制面板与步长设置选项。 * 相关文档与测试用例同步更新,确保新功能的完整性与稳定性。 --- analysis/read_fanuc_allowable_limits.py | 200 ++++++++++ docs/0.7x.pcap | Bin 0 -> 320850 bytes .../ControllerClientCompatService.cs | 334 +++++++++++++---- .../ControllerClientStatusSnapshotMetadata.cs | 62 +++ .../IControllerClientCompatService.cs | 12 + .../MovePoseTrajectoryGenerator.cs | 353 ++++++++++++++++++ src/Flyshot.Core.Domain/TrajectoryResult.cs | 12 +- .../IControllerRuntime.cs | 7 + .../FanucControllerRuntime.cs | 202 ++++++++++ .../Protocol/FanucJ519Protocol.cs | 59 ++- .../Controllers/LegacyHttpApiController.cs | 156 ++++++++ .../Controllers/StatusController.cs | 19 +- .../wwwroot/assets/debug.js | 18 +- .../wwwroot/assets/status.css | 67 ++++ .../wwwroot/assets/status.js | 165 +++++++- src/Flyshot.Server.Host/wwwroot/status.html | 30 ++ .../FanucControllerRuntimeDenseTests.cs | 68 ++++ .../FanucJ519ClientTests.cs | 22 ++ .../RuntimeOrchestrationTests.cs | 291 +++++++++++++++ .../DebugConsoleEndpointTests.cs | 22 ++ .../LegacyHttpApiCompatibilityTests.cs | 34 ++ .../StatusEndpointTests.cs | 33 ++ 22 files changed, 2062 insertions(+), 104 deletions(-) create mode 100644 analysis/read_fanuc_allowable_limits.py create mode 100644 docs/0.7x.pcap create mode 100644 src/Flyshot.ControllerClientCompat/ControllerClientStatusSnapshotMetadata.cs create mode 100644 src/Flyshot.ControllerClientCompat/MovePoseTrajectoryGenerator.cs diff --git a/analysis/read_fanuc_allowable_limits.py b/analysis/read_fanuc_allowable_limits.py new file mode 100644 index 0000000..6b8e2b7 --- /dev/null +++ b/analysis/read_fanuc_allowable_limits.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 +"""读取 FANUC J519 允许速度/加速度/jerk 上限表。 + +本脚本只做 UDP packet type=3 查询,不启动运行时服务,也不写入机器人参数。 +响应中的 limit 值保留为控制器原始单位:速度 deg/s、加速度 deg/s^2、jerk deg/s^3。 +""" + +from __future__ import annotations + +import argparse +import csv +import socket +import struct +import sys +from dataclasses import dataclass +from pathlib import Path + + +LIMIT_TYPES = { + 0: ("velocity", "deg/s"), + 1: ("acceleration", "deg/s^2"), + 2: ("jerk", "deg/s^3"), +} + + +@dataclass(frozen=True) +class LimitTable: + """保存单个轴、单种限制类型的 20 档 no-payload / max-payload 上限表。""" + + axis: int + limit_type: int + vmax_mm_s: int + intermediate_check_s: int + no_payload: tuple[float, ...] + max_payload: tuple[float, ...] + + +def pack_request(axis: int, limit_type: int) -> bytes: + """按手册 Table 5 封装允许上限表查询请求。""" + + return struct.pack(">IIII", 3, 1, axis, limit_type) + + +def parse_response(packet: bytes, expected_axis: int, expected_type: int) -> LimitTable: + """按手册 Table 6 解析允许上限表响应。""" + + if len(packet) != 184: + raise ValueError(f"响应长度应为 184 字节,实际为 {len(packet)} 字节。") + + packet_type, version, axis, limit_type, vmax, check_time = struct.unpack_from(">IIIIII", packet, 0) + if packet_type != 3 or version != 1: + raise ValueError(f"响应头不正确:packet_type={packet_type}, version={version}。") + if axis != expected_axis or limit_type != expected_type: + raise ValueError( + f"响应与请求不匹配:期望 axis={expected_axis}, type={expected_type}," + f"实际 axis={axis}, type={limit_type}。" + ) + + values = struct.unpack_from(">40f", packet, 24) + return LimitTable( + axis=axis, + limit_type=limit_type, + vmax_mm_s=vmax, + intermediate_check_s=check_time, + no_payload=tuple(values[:20]), + max_payload=tuple(values[20:]), + ) + + +def query_table(sock: socket.socket, robot_ip: str, port: int, axis: int, limit_type: int) -> LimitTable: + """发送单个查询请求并等待对应响应。""" + + sock.sendto(pack_request(axis, limit_type), (robot_ip, port)) + packet, address = sock.recvfrom(2048) + if address[0] != robot_ip: + raise ValueError(f"收到非目标机器人响应:{address[0]}:{address[1]}。") + return parse_response(packet, axis, limit_type) + + +def iter_requested_tables( + robot_ip: str, + port: int, + timeout_s: float, + axes: range, + limit_types: tuple[int, ...], +) -> list[LimitTable]: + """按轴和限制类型顺序读取所有上限表。""" + + tables: list[LimitTable] = [] + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: + sock.settimeout(timeout_s) + for axis in axes: + for limit_type in limit_types: + tables.append(query_table(sock, robot_ip, port, axis, limit_type)) + return tables + + +def write_csv(path: Path, tables: list[LimitTable]) -> None: + """把 20 档速度区间和两套 payload 表写成扁平 CSV。""" + + with path.open("w", newline="", encoding="utf-8") as file: + writer = csv.writer(file) + writer.writerow( + [ + "axis", + "limit_type", + "unit", + "vmax_mm_s", + "intermediate_check_s", + "speed_bin_index", + "speed_bin_upper_mm_s", + "no_payload_limit", + "max_payload_limit", + ] + ) + for table in tables: + limit_name, unit = LIMIT_TYPES[table.limit_type] + for index, (no_payload, max_payload) in enumerate(zip(table.no_payload, table.max_payload), start=1): + writer.writerow( + [ + table.axis, + limit_name, + unit, + table.vmax_mm_s, + table.intermediate_check_s, + index, + table.vmax_mm_s * index / 20.0, + f"{no_payload:.9g}", + f"{max_payload:.9g}", + ] + ) + + +def print_summary(tables: list[LimitTable]) -> None: + """打印每张表的首末档,便于现场快速确认返回值是否正常。""" + + for table in tables: + limit_name, unit = LIMIT_TYPES[table.limit_type] + print( + f"axis={table.axis} type={limit_name:<12} unit={unit:<8} " + f"vmax={table.vmax_mm_s}mm/s check={table.intermediate_check_s}s " + f"no_payload[1]={table.no_payload[0]:.6g} no_payload[20]={table.no_payload[-1]:.6g} " + f"max_payload[1]={table.max_payload[0]:.6g} max_payload[20]={table.max_payload[-1]:.6g}" + ) + + +def parse_args() -> argparse.Namespace: + """解析命令行参数。""" + + parser = argparse.ArgumentParser( + description="Read FANUC J519 packet type=3 allowable upper limit tables.", + ) + parser.add_argument("robot_ip", help="机器人控制柜 IP,例如 192.168.10.11。") + parser.add_argument("--port", type=int, default=60015, help="J519 UDP 端口,默认 60015。") + parser.add_argument("--timeout", type=float, default=2.0, help="单次响应超时秒数,默认 2.0。") + parser.add_argument("--axis-start", type=int, default=1, help="起始轴号,默认 1。") + parser.add_argument("--axis-end", type=int, default=9, help="结束轴号,默认 9。") + parser.add_argument( + "--types", + default="0,1,2", + help="限制类型列表:0=velocity, 1=acceleration, 2=jerk;默认 0,1,2。", + ) + parser.add_argument( + "--csv", + type=Path, + default=Path("analysis/fanuc_allowable_limit_tables.csv"), + help="CSV 输出路径。", + ) + return parser.parse_args() + + +def main() -> int: + """脚本入口。""" + + args = parse_args() + limit_types = tuple(int(item.strip()) for item in args.types.split(",") if item.strip()) + unknown_types = [item for item in limit_types if item not in LIMIT_TYPES] + if unknown_types: + print(f"未知限制类型:{unknown_types}", file=sys.stderr) + return 2 + if not 1 <= args.axis_start <= args.axis_end <= 9: + print("轴范围必须满足 1 <= axis-start <= axis-end <= 9。", file=sys.stderr) + return 2 + + tables = iter_requested_tables( + args.robot_ip, + args.port, + args.timeout, + range(args.axis_start, args.axis_end + 1), + limit_types, + ) + args.csv.parent.mkdir(parents=True, exist_ok=True) + write_csv(args.csv, tables) + print_summary(tables) + print(f"\nCSV 已写入:{args.csv}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/docs/0.7x.pcap b/docs/0.7x.pcap new file mode 100644 index 0000000000000000000000000000000000000000..a96603995ff267781292ee36c293718e9f456ded GIT binary patch literal 320850 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 F|Nq7>wsQag literal 0 HcmV?d00001 diff --git a/src/Flyshot.ControllerClientCompat/ControllerClientCompatService.cs b/src/Flyshot.ControllerClientCompat/ControllerClientCompatService.cs index 9389c3f..66f0f6b 100644 --- a/src/Flyshot.ControllerClientCompat/ControllerClientCompatService.cs +++ b/src/Flyshot.ControllerClientCompat/ControllerClientCompatService.cs @@ -12,6 +12,7 @@ namespace Flyshot.ControllerClientCompat; public sealed class ControllerClientCompatService : IControllerClientCompatService { private readonly object _stateLock = new(); + private readonly object _motionLock = new(); private readonly Dictionary _uploadedTrajectories = new(StringComparer.Ordinal); private readonly ControllerClientCompatOptions _options; private readonly ControllerClientCompatRobotCatalog _robotCatalog; @@ -197,8 +198,9 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi lock (_stateLock) { EnsureRobotSetup(); - _runtime.SetActiveController(sim); } + + _runtime.SetActiveController(sim); } /// @@ -214,9 +216,10 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi lock (_stateLock) { EnsureRobotSetup(); - _runtime.Connect(robotIp); } + _runtime.Connect(robotIp); + _logger?.LogInformation("Connect 完成: robotIp={RobotIp}", robotIp); } @@ -226,8 +229,9 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi lock (_stateLock) { EnsureRobotSetup(); - _runtime.Disconnect(); } + + _runtime.Disconnect(); } /// @@ -237,8 +241,8 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi lock (_stateLock) { EnsureRobotSetup(); - _runtime.EnableRobot(bufferSize); } + _runtime.EnableRobot(bufferSize); _logger?.LogInformation("EnableRobot 完成"); } @@ -249,8 +253,10 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi lock (_stateLock) { EnsureRobotSetup(); - _runtime.DisableRobot(); } + + // DisableRobot 是中断/控制命令,不能被长时间运动串行锁挡住。 + _runtime.DisableRobot(); _logger?.LogInformation("DisableRobot 完成"); } @@ -261,8 +267,10 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi lock (_stateLock) { EnsureRobotSetup(); - _runtime.StopMove(); } + + // StopMove 必须能在运动执行期间直接进入 runtime 取消发送任务。 + _runtime.StopMove(); _logger?.LogInformation("StopMove 完成"); } @@ -272,14 +280,31 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi return _runtime.GetSnapshot(); } + /// + public ControllerClientStatusSnapshotMetadata GetStatusSnapshotMetadata() + { + lock (_stateLock) + { + var isSetup = _activeRobotProfile is not null; + return new ControllerClientStatusSnapshotMetadata( + isSetup, + isSetup ? _configuredRobotName : null, + isSetup ? _activeRobotProfile!.DegreesOfFreedom : 0, + isSetup ? _uploadedTrajectories.Keys.ToArray() : Array.Empty(), + GetServerVersion(), + GetClientVersion()); + } + } + /// public double GetSpeedRatio() { lock (_stateLock) { EnsureRobotSetup(); - return _runtime.GetSpeedRatio(); } + + return _runtime.GetSpeedRatio(); } /// @@ -288,8 +313,9 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi lock (_stateLock) { EnsureRobotSetup(); - _runtime.SetSpeedRatio(ratio); } + + _runtime.SetSpeedRatio(ratio); } /// @@ -298,8 +324,9 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi lock (_stateLock) { EnsureRobotSetup(); - _runtime.SetIo(port, value, ioType); } + + _runtime.SetIo(port, value, ioType); } /// @@ -308,8 +335,9 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi lock (_stateLock) { EnsureRobotSetup(); - return _runtime.GetIo(port, ioType); } + + return _runtime.GetIo(port, ioType); } /// @@ -341,8 +369,9 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi lock (_stateLock) { EnsureRobotSetup(); - _runtime.SetTcp(x, y, z); } + + _runtime.SetTcp(x, y, z); } /// @@ -351,8 +380,9 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi lock (_stateLock) { EnsureRobotSetup(); - return _runtime.GetTcp(); } + + return _runtime.GetTcp(); } /// @@ -361,8 +391,9 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi lock (_stateLock) { EnsureRobotSetup(); - return _runtime.GetJointPositions(); } + + return _runtime.GetJointPositions(); } /// @@ -373,16 +404,44 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi _logger?.LogInformation("MoveJoint 开始: 目标关节数={JointCount}", jointPositions.Count); _logger?.LogDebug("MoveJoint 目标关节: {Joints}", string.Join(", ", jointPositions.Select(j => j.ToString("F4")))); + RobotProfile robot; lock (_stateLock) { - var robot = RequireActiveRobot(); + robot = RequireActiveRobot(); EnsureRuntimeEnabled(); - ExecuteMoveJointAndWaitLocked(robot, jointPositions, "MoveJoint"); + } + + lock (_motionLock) + { + ExecuteMoveJointAndWait(robot, jointPositions, "MoveJoint"); } _logger?.LogInformation("MoveJoint 完成"); } + /// + public void MovePose(IReadOnlyList pose) + { + ArgumentNullException.ThrowIfNull(pose); + + _logger?.LogInformation("MovePose 开始: 目标位姿维度={PoseCount}", pose.Count); + _logger?.LogDebug("MovePose 目标位姿: {Pose}", string.Join(", ", pose.Select(v => v.ToString("F4")))); + + RobotProfile robot; + lock (_stateLock) + { + robot = RequireActiveRobot(); + EnsureRuntimeEnabled(); + } + + lock (_motionLock) + { + ExecuteMovePoseAndWait(robot, pose, "MovePose"); + } + + _logger?.LogInformation("MovePose 完成"); + } + /// public void ExecuteTrajectory(IReadOnlyList> waypoints, TrajectoryExecutionOptions? options = null) { @@ -398,13 +457,19 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi _logger?.LogDebug("ExecuteTrajectory 路点详情: {Waypoints}", string.Join(" | ", waypoints.Select(wp => $"[{string.Join(", ", wp.Select(j => j.ToString("F4")))}]"))); + RobotProfile robot; + CompatibilityRobotSettings settings; lock (_stateLock) { - var robot = RequireActiveRobot(); + robot = RequireActiveRobot(); EnsureRuntimeEnabled(); + settings = RequireRobotSettings(); + } + lock (_motionLock) + { // 普通轨迹必须按调用方指定 method 规划,再把规划结果交给运行时执行。 - var planningSpeedScale = RequireRobotSettings().PlanningSpeedScale; + var planningSpeedScale = settings.PlanningSpeedScale; var speedRatio = _runtime.GetSnapshot().SpeedRatio; var bundle = _trajectoryOrchestrator.PlanOrdinaryTrajectory(robot, waypoints, options, planningSpeedScale, speedRatio); _logger?.LogInformation( @@ -428,8 +493,9 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi lock (_stateLock) { EnsureRobotSetup(); - return _runtime.GetPose(); } + + return _runtime.GetPose(); } /// @@ -443,16 +509,20 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi trajectory.Waypoints.Count, trajectory.ShotFlags.Count(static f => f)); + string robotName; + CompatibilityRobotSettings settings; lock (_stateLock) { EnsureRuntimeEnabled(); _uploadedTrajectories[trajectory.Name] = trajectory; - var robotName = _configuredRobotName ?? throw new InvalidOperationException("Robot has not been setup."); - var settings = _robotSettings ?? CreateDefaultRobotSettings(); - _trajectoryStore.Save(robotName, settings, trajectory); + robotName = _configuredRobotName ?? throw new InvalidOperationException("Robot has not been setup."); + settings = _robotSettings ?? CreateDefaultRobotSettings(); } + // RobotConfig.json 持久化是文件 I/O,放在状态锁外,避免状态页轮询被磁盘写入拖住。 + _trajectoryStore.Save(robotName, settings, trajectory); + _logger?.LogInformation("UploadTrajectory 完成: name={Name}", trajectory.Name); } @@ -478,25 +548,34 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi "ExecuteTrajectoryByName 开始: name={Name}, method={Method}, moveToStart={MoveToStart}, useCache={UseCache}, wait={Wait}", name, options.Method, options.MoveToStart, options.UseCache, options.Wait); + RobotProfile robot; + CompatibilityRobotSettings settings; + ControllerClientCompatUploadedTrajectory trajectory; lock (_stateLock) { - var robot = RequireActiveRobot(); + robot = RequireActiveRobot(); EnsureRuntimeEnabled(); - if (!_uploadedTrajectories.TryGetValue(name, out var trajectory)) + if (!_uploadedTrajectories.TryGetValue(name, out var uploadedTrajectory)) { _logger?.LogWarning("ExecuteTrajectoryByName 失败: 轨迹不存在 name={Name}", name); throw new InvalidOperationException("FlyShot trajectory does not exist."); } + // 飞拍执行只拿上传轨迹的瞬时副本,后续规划/导出都不再依赖字典锁。 + trajectory = CloneUploadedTrajectory(uploadedTrajectory); if (trajectory.Waypoints.Count == 0) { _logger?.LogWarning("ExecuteTrajectoryByName 失败: 轨迹无路点 name={Name}", name); throw new InvalidOperationException("FlyShot trajectory contains no waypoints."); } + settings = RequireRobotSettings(); + } + + lock (_motionLock) + { // 已上传飞拍轨迹必须按调用方指定 method 生成 shot timeline 后再交给运行时。 - var settings = RequireRobotSettings(); var speedRatio = _runtime.GetSnapshot().SpeedRatio; var bundle = _trajectoryOrchestrator.PlanUploadedFlyshot(robot, trajectory, options, settings, settings.PlanningSpeedScale, speedRatio); bundle = PrepareFlyshotExecutionBundle(robot, bundle, speedRatio); @@ -523,7 +602,7 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi if (options.MoveToStart) { _logger?.LogInformation("ExecuteTrajectoryByName 先移动到起点"); - ExecuteMoveJointAndWaitLocked(robot, bundle.PlannedTrajectory.PlannedWaypoints[0].Positions, "ExecuteTrajectoryByName.move_to_start"); + ExecuteMoveJointAndWait(robot, bundle.PlannedTrajectory.PlannedWaypoints[0].Positions, "ExecuteTrajectoryByName.move_to_start"); EnsureFeedbackNearFlyshotStart(bundle.PlannedTrajectory.PlannedWaypoints[0].Positions, name); } else @@ -555,7 +634,7 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi /// 当前机器人模型。 /// 目标关节位置,单位为弧度。 /// 用于日志和超时异常的操作名。 - private void ExecuteMoveJointAndWaitLocked(RobotProfile robot, IReadOnlyList targetJointPositions, string operationName) + private void ExecuteMoveJointAndWait(RobotProfile robot, IReadOnlyList targetJointPositions, string operationName) { var currentJointPositions = _runtime.GetJointPositions(); EnsureJointVector(currentJointPositions, robot.DegreesOfFreedom, nameof(currentJointPositions)); @@ -574,6 +653,30 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi WaitForRuntimeMotionComplete(operationName, moveResult.Duration); } + /// + /// 从当前 TCP 位姿生成临时直角 PTP 稠密轨迹并阻塞等待运行时完成。 + /// + /// 当前机器人模型,用于取得 J519 伺服周期。 + /// 目标位姿 [x,y,z,w,p,r],单位为 mm/deg。 + /// 用于日志和超时异常的操作名。 + private void ExecuteMovePoseAndWait(RobotProfile robot, IReadOnlyList targetPose, string operationName) + { + var currentPose = NormalizeRuntimePose(_runtime.GetPose()); + EnsurePoseVector(targetPose, nameof(targetPose)); + + var speedRatio = _runtime.GetSnapshot().SpeedRatio; + var moveResult = MovePoseTrajectoryGenerator.CreateResult(currentPose, targetPose, robot.ServoPeriod, speedRatio, _logger); + _logger?.LogInformation( + "{OperationName} 直角PTP规划完成: 当前速度倍率={SpeedRatio}, 规划时长={Duration}s, 采样点数={SampleCount}", + operationName, + speedRatio, + moveResult.Duration.TotalSeconds, + moveResult.DenseCartesianTrajectory?.Count ?? 0); + + _runtime.ExecuteCartesianTrajectory(moveResult, targetPose); + WaitForRuntimeMotionComplete(operationName, moveResult.Duration); + } + /// /// 校验当前反馈是否接近飞拍起点;不接近时直接抛出兼容错误。 /// @@ -649,43 +752,45 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi _logger?.LogInformation("SaveTrajectoryInfo 开始: name={Name}, method={Method}", name, method); + RobotProfile robot; + CompatibilityRobotSettings planningSettings; + ControllerClientCompatUploadedTrajectory trajectory; lock (_stateLock) { - var robot = RequireActiveRobot(); - if (!_uploadedTrajectories.TryGetValue(name, out var trajectory)) + robot = RequireActiveRobot(); + if (!_uploadedTrajectories.TryGetValue(name, out var uploadedTrajectory)) { _logger?.LogWarning("SaveTrajectoryInfo 失败: 轨迹不存在 name={Name}", name); throw new InvalidOperationException("FlyShot trajectory does not exist."); } // 先通过规划校验避免静默接受非法参数,同时把轨迹信息强制刷写到本地 JSON。 - var planningSettings = RequireRobotSettings(); - var speedRatio = _runtime.GetSnapshot().SpeedRatio; - var bundle = _trajectoryOrchestrator.PlanUploadedFlyshot( - robot, - trajectory, - new FlyshotExecutionOptions(useCache:false,saveTrajectory: true, method: method), - planningSettings, - planningSettings.PlanningSpeedScale, - speedRatio); - bundle = PrepareFlyshotExecutionBundle(robot, bundle, speedRatio); - _logger?.LogInformation("SaveTrajectoryInfo 规划完成记录到本地"); - ExportFlyshotArtifactsIfRequested( - name, - saveTrajectory: true, - robot, - trajectory, - new FlyshotExecutionOptions(useCache: false, saveTrajectory: true, method: method), - planningSettings, - bundle, - planningSettings.PlanningSpeedScale, - speedRatio); - - // var robotName = _configuredRobotName ?? throw new InvalidOperationException("Robot has not been setup."); - // var settings = _robotSettings ?? CreateDefaultRobotSettings(); - // _trajectoryStore.Save(robotName, settings, trajectory); + // 保存轨迹信息会执行规划和文件导出,先复制上传数据再释放状态锁。 + trajectory = CloneUploadedTrajectory(uploadedTrajectory); + planningSettings = RequireRobotSettings(); } + var speedRatio = _runtime.GetSnapshot().SpeedRatio; + var bundle = _trajectoryOrchestrator.PlanUploadedFlyshot( + robot, + trajectory, + new FlyshotExecutionOptions(useCache: false, saveTrajectory: true, method: method), + planningSettings, + planningSettings.PlanningSpeedScale, + speedRatio); + bundle = PrepareFlyshotExecutionBundle(robot, bundle, speedRatio); + _logger?.LogInformation("SaveTrajectoryInfo 规划完成记录到本地"); + ExportFlyshotArtifactsIfRequested( + name, + saveTrajectory: true, + robot, + trajectory, + new FlyshotExecutionOptions(useCache: false, saveTrajectory: true, method: method), + planningSettings, + bundle, + planningSettings.PlanningSpeedScale, + speedRatio); + _logger?.LogInformation("SaveTrajectoryInfo 完成: name={Name}", name); } @@ -699,42 +804,48 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi _logger?.LogInformation("IsFlyshotTrajectoryValid 开始: name={Name}, method={Method}", name, method); + RobotProfile robot; + CompatibilityRobotSettings planningSettings; + ControllerClientCompatUploadedTrajectory trajectory; lock (_stateLock) { - var robot = RequireActiveRobot(); - if (!_uploadedTrajectories.TryGetValue(name, out var trajectory)) + robot = RequireActiveRobot(); + if (!_uploadedTrajectories.TryGetValue(name, out var uploadedTrajectory)) { _logger?.LogWarning("IsFlyshotTrajectoryValid 失败: 轨迹不存在 name={Name}", name); throw new InvalidOperationException("FlyShot trajectory does not exist."); } - var planningSettings = RequireRobotSettings(); - var speedRatio = _runtime.GetSnapshot().SpeedRatio; - var bundle = _trajectoryOrchestrator.PlanUploadedFlyshot( - robot, - trajectory, - new FlyshotExecutionOptions(method: method, saveTrajectory: saveTrajectory), - planningSettings, - planningSettings.PlanningSpeedScale, - speedRatio); - bundle = PrepareFlyshotExecutionBundle(robot, bundle, speedRatio); - ExportFlyshotArtifactsIfRequested( - name, - saveTrajectory, - robot, - trajectory, - new FlyshotExecutionOptions(method: method, saveTrajectory: saveTrajectory), - planningSettings, - bundle, - planningSettings.PlanningSpeedScale, - speedRatio); - - duration = bundle.Result.Duration; - _logger?.LogInformation( - "IsFlyshotTrajectoryValid 结果: name={Name}, valid={Valid}, duration={Duration}s", - name, bundle.Result.IsValid, duration.TotalSeconds); - return bundle.Result.IsValid; + // 有效性检查只消费当前快照,不要求和后续上传/删除形成长事务。 + trajectory = CloneUploadedTrajectory(uploadedTrajectory); + planningSettings = RequireRobotSettings(); } + + var speedRatio = _runtime.GetSnapshot().SpeedRatio; + var bundle = _trajectoryOrchestrator.PlanUploadedFlyshot( + robot, + trajectory, + new FlyshotExecutionOptions(method: method, saveTrajectory: saveTrajectory), + planningSettings, + planningSettings.PlanningSpeedScale, + speedRatio); + bundle = PrepareFlyshotExecutionBundle(robot, bundle, speedRatio); + ExportFlyshotArtifactsIfRequested( + name, + saveTrajectory, + robot, + trajectory, + new FlyshotExecutionOptions(method: method, saveTrajectory: saveTrajectory), + planningSettings, + bundle, + planningSettings.PlanningSpeedScale, + speedRatio); + + duration = bundle.Result.Duration; + _logger?.LogInformation( + "IsFlyshotTrajectoryValid 结果: name={Name}, valid={Valid}, duration={Duration}s", + name, bundle.Result.IsValid, duration.TotalSeconds); + return bundle.Result.IsValid; } /// @@ -747,6 +858,7 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi _logger?.LogInformation("DeleteTrajectory 开始: name={Name}", name); + string robotName; lock (_stateLock) { if (!_uploadedTrajectories.Remove(name)) @@ -755,10 +867,12 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi throw new InvalidOperationException("DeleteFlyShotTraj failed"); } - var robotName = _configuredRobotName ?? throw new InvalidOperationException("Robot has not been setup."); - _trajectoryStore.Delete(robotName, name); + robotName = _configuredRobotName ?? throw new InvalidOperationException("Robot has not been setup."); } + // 删除持久化文件不占用状态锁,状态页只需要看到内存字典的即时快照。 + _trajectoryStore.Delete(robotName, name); + _logger?.LogInformation("DeleteTrajectory 完成: name={Name}", name); } @@ -798,6 +912,21 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi return _robotSettings ?? CreateDefaultRobotSettings(); } + /// + /// 复制一份上传轨迹快照,避免锁外规划期间观察到可变集合引用。 + /// + /// 待复制的上传轨迹。 + /// 上传轨迹副本。 + private static ControllerClientCompatUploadedTrajectory CloneUploadedTrajectory(ControllerClientCompatUploadedTrajectory trajectory) + { + return new ControllerClientCompatUploadedTrajectory( + trajectory.Name, + trajectory.Waypoints, + trajectory.ShotFlags, + trajectory.OffsetValues, + trajectory.AddressGroups); + } + /// /// 校验机器人已经完成初始化。 /// @@ -841,6 +970,45 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi } } + /// + /// 校验直角位姿为 [x,y,z,w,p,r] 六维有限数。 + /// + /// 待校验位姿,单位为 mm/deg。 + /// 调用方参数名。 + private static void EnsurePoseVector(IReadOnlyList pose, string paramName) + { + if (pose.Count != 6) + { + throw new ArgumentException("位姿必须为 [x,y,z,w,p,r] 六维数组。", paramName); + } + + for (var index = 0; index < pose.Count; index++) + { + var value = pose[index]; + if (double.IsNaN(value) || double.IsInfinity(value)) + { + throw new ArgumentOutOfRangeException(paramName, $"第 {index} 个位姿值必须是有限数值。"); + } + } + } + + /// + /// 将运行时位姿快照归一化为 J519 直角命令需要的 [x,y,z,w,p,r] 六维。 + /// + /// 运行时返回的位姿数组。 + /// 六维直角位姿。 + private static IReadOnlyList NormalizeRuntimePose(IReadOnlyList pose) + { + if (pose.Count < 6) + { + throw new InvalidOperationException("Runtime pose must contain at least [x,y,z,w,p,r]."); + } + + var normalized = pose.Take(6).ToArray(); + EnsurePoseVector(normalized, nameof(pose)); + return normalized; + } + /// /// 根据 saveTrajectory 参数把规划结果点位写入运行目录 Config/Data/name。 /// diff --git a/src/Flyshot.ControllerClientCompat/ControllerClientStatusSnapshotMetadata.cs b/src/Flyshot.ControllerClientCompat/ControllerClientStatusSnapshotMetadata.cs new file mode 100644 index 0000000..09d86d1 --- /dev/null +++ b/src/Flyshot.ControllerClientCompat/ControllerClientStatusSnapshotMetadata.cs @@ -0,0 +1,62 @@ +namespace Flyshot.ControllerClientCompat; + +/// +/// 保存状态页需要的 ControllerClient 兼容层元数据快照,避免状态页连续读取多个受保护字段。 +/// +public sealed record ControllerClientStatusSnapshotMetadata +{ + /// + /// 初始化一份兼容层状态元数据快照。 + /// + /// 当前是否已经完成机器人初始化。 + /// 当前机器人名称;未初始化时为空。 + /// 当前机器人自由度;未初始化时为 0。 + /// 当前已上传轨迹名称快照。 + /// 兼容服务端版本。 + /// 兼容客户端版本。 + public ControllerClientStatusSnapshotMetadata( + bool isSetup, + string? robotName, + int degreesOfFreedom, + IReadOnlyList uploadedTrajectories, + string serverVersion, + string clientVersion) + { + IsSetup = isSetup; + RobotName = robotName; + DegreesOfFreedom = degreesOfFreedom; + UploadedTrajectories = uploadedTrajectories.ToArray(); + ServerVersion = serverVersion; + ClientVersion = clientVersion; + } + + /// + /// 获取当前是否已经完成机器人初始化。 + /// + public bool IsSetup { get; } + + /// + /// 获取当前机器人名称;未初始化时为空。 + /// + public string? RobotName { get; } + + /// + /// 获取当前机器人自由度;未初始化时为 0。 + /// + public int DegreesOfFreedom { get; } + + /// + /// 获取已上传轨迹名称数组快照。 + /// + public IReadOnlyList UploadedTrajectories { get; } + + /// + /// 获取兼容服务端版本。 + /// + public string ServerVersion { get; } + + /// + /// 获取兼容客户端版本。 + /// + public string ClientVersion { get; } +} diff --git a/src/Flyshot.ControllerClientCompat/IControllerClientCompatService.cs b/src/Flyshot.ControllerClientCompat/IControllerClientCompatService.cs index 0e8573e..3236873 100644 --- a/src/Flyshot.ControllerClientCompat/IControllerClientCompatService.cs +++ b/src/Flyshot.ControllerClientCompat/IControllerClientCompatService.cs @@ -95,6 +95,12 @@ public interface IControllerClientCompatService /// 控制器运行时状态快照。 ControllerStateSnapshot GetControllerSnapshot(); + /// + /// 读取状态页所需的兼容层元数据快照。 + /// + /// 兼容层元数据快照。 + ControllerClientStatusSnapshotMetadata GetStatusSnapshotMetadata(); + /// /// 获取当前速度倍率。 /// @@ -157,6 +163,12 @@ public interface IControllerClientCompatService /// 目标关节位置。 void MoveJoint(IReadOnlyList jointPositions); + /// + /// 按直角坐标移动到目标 TCP 位姿。 + /// + /// 目标位姿 [x,y,z,w,p,r],单位为 mm/deg。 + void MovePose(IReadOnlyList pose); + /// /// 执行普通轨迹。 /// diff --git a/src/Flyshot.ControllerClientCompat/MovePoseTrajectoryGenerator.cs b/src/Flyshot.ControllerClientCompat/MovePoseTrajectoryGenerator.cs new file mode 100644 index 0000000..4a148cc --- /dev/null +++ b/src/Flyshot.ControllerClientCompat/MovePoseTrajectoryGenerator.cs @@ -0,0 +1,353 @@ +using Flyshot.Core.Domain; +using Microsoft.Extensions.Logging; + +namespace Flyshot.ControllerClientCompat; + +/// +/// MovePose 直角坐标轨迹生成器。 +/// 将起始 TCP 位姿到目标 TCP 位姿的单段运动,按保守直角速度、加速度和 jerk 限制生成 8ms 稠密点。 +/// +internal static class MovePoseTrajectoryGenerator +{ + /// + /// 直角位置轴最大速度,单位为 mm/s。 + /// + private const double LinearVelocityLimit = 50.0; + + /// + /// 直角位置轴最大加速度,单位为 mm/s^2。 + /// + private const double LinearAccelerationLimit = 250.0; + + /// + /// 直角位置轴最大 jerk,单位为 mm/s^3。 + /// + private const double LinearJerkLimit = 1250.0; + + /// + /// 姿态轴最大速度,单位为 deg/s。 + /// + private const double AngularVelocityLimit = 10.0; + + /// + /// 姿态轴最大加速度,单位为 deg/s^2。 + /// + private const double AngularAccelerationLimit = 50.0; + + /// + /// 姿态轴最大 jerk,单位为 deg/s^3。 + /// + private const double AngularJerkLimit = 250.0; + + /// + /// 7 阶平滑点到点时间律的一阶导数最大值。 + /// + private const double SmoothPtpVelocityShapeCoefficient = 2.1875; + + /// + /// 7 阶平滑点到点时间律的二阶导数最大值。 + /// + private const double SmoothPtpAccelerationShapeCoefficient = 7.513188404399293; + + /// + /// 7 阶平滑点到点时间律的三阶导数最大值。 + /// + private const double SmoothPtpJerkShapeCoefficient = 52.5; + + /// + /// 单次 MovePose 最大采样点数上限,避免极低速度倍率生成过大的队列。 + /// + private const int MaxMovePoseSampleCount = 1_000_000; + + /// + /// 离散限位校验允许的浮点容差。 + /// + private const double DiscreteLimitTolerance = 1.000001; + + /// + /// 离散限位校验失败时最多拉长的采样周期次数。 + /// + private const int MaxDiscreteLimitStretchIterations = 10_000; + + /// + /// 计算 MovePose 轨迹的完整结果。 + /// + /// 起始位姿 [x,y,z,w,p,r],单位为 mm/deg。 + /// 目标位姿 [x,y,z,w,p,r],单位为 mm/deg。 + /// J519 伺服发送周期。 + /// 速度倍率,必须大于 0。 + /// 可选诊断日志。 + /// 包含稠密直角轨迹的规划结果。 + public static TrajectoryResult CreateResult( + IReadOnlyList startPose, + IReadOnlyList targetPose, + TimeSpan servoPeriod, + double speedRatio, + ILogger? logger = null) + { + ArgumentNullException.ThrowIfNull(startPose); + ArgumentNullException.ThrowIfNull(targetPose); + EnsurePoseVector(startPose, nameof(startPose)); + EnsurePoseVector(targetPose, nameof(targetPose)); + + if (speedRatio <= 0.0 || double.IsNaN(speedRatio) || double.IsInfinity(speedRatio)) + { + throw new InvalidOperationException("Speed ratio must be greater than zero for MovePose execution."); + } + + var samplePeriodSeconds = servoPeriod.TotalSeconds; + if (samplePeriodSeconds <= 0.0 || double.IsNaN(samplePeriodSeconds) || double.IsInfinity(samplePeriodSeconds)) + { + throw new InvalidOperationException("MovePose servo period must be a finite positive duration."); + } + + var requestedDurationSeconds = ResolveDurationSeconds(startPose, targetPose, speedRatio); + var durationSeconds = AlignDurationToServoStep(requestedDurationSeconds, samplePeriodSeconds); + var denseCartesianTrajectory = GenerateDenseTrajectory(startPose, targetPose, durationSeconds, samplePeriodSeconds); + + var stretchCount = 0; + while (!SatisfiesDefaultCartesianLimits(denseCartesianTrajectory, speedRatio)) + { + stretchCount++; + if (stretchCount > MaxDiscreteLimitStretchIterations) + { + throw new InvalidOperationException("MovePose duration cannot be stretched enough to satisfy Cartesian limits."); + } + + // 离散差分以真实下发点为准;不满足时逐周期拉长后重采样。 + durationSeconds = AlignDurationToServoStep(durationSeconds + samplePeriodSeconds, samplePeriodSeconds); + denseCartesianTrajectory = GenerateDenseTrajectory(startPose, targetPose, durationSeconds, samplePeriodSeconds); + } + + logger?.LogDebug( + "MovePoseTrajectoryGenerator: requestedDuration={RequestedDuration:F4}s, duration={Duration:F4}s, speedRatio={SpeedRatio}, samplePeriod={SamplePeriod:F6}s, sampleCount={SampleCount}, stretchCount={StretchCount}", + requestedDurationSeconds, + durationSeconds, + speedRatio, + samplePeriodSeconds, + denseCartesianTrajectory.Count, + stretchCount); + + return new TrajectoryResult( + programName: "move-pose", + method: PlanningMethod.Doubles, + isValid: true, + duration: TimeSpan.FromSeconds(durationSeconds), + shotEvents: Array.Empty(), + triggerTimeline: Array.Empty(), + artifacts: Array.Empty(), + failureReason: null, + usedCache: false, + originalWaypointCount: 2, + plannedWaypointCount: denseCartesianTrajectory.Count, + denseCartesianTrajectory: denseCartesianTrajectory); + } + + /// + /// 检查稠密直角轨迹是否满足默认六维速度、加速度和 jerk 限制。 + /// + /// 稠密轨迹行,每行为 [time,x,y,z,w,p,r]。 + /// 生成该轨迹时使用的速度倍率。 + /// 满足限制时返回 true,否则返回 false。 + public static bool SatisfiesDefaultCartesianLimits(IReadOnlyList> rows, double speedRatio) + { + if (speedRatio <= 0.0 || double.IsNaN(speedRatio) || double.IsInfinity(speedRatio)) + { + throw new InvalidOperationException("Speed ratio must be greater than zero for MovePose limit validation."); + } + + double? previousTime = null; + double[]? previousPositions = null; + double[]? previousVelocities = null; + double[]? previousAccelerations = null; + + foreach (var row in rows) + { + if (row.Count != 7) + { + throw new InvalidOperationException("MovePose dense trajectory rows must contain time plus six pose values."); + } + + var currentTime = row[0]; + var currentPositions = row.Skip(1).Take(6).ToArray(); + + if (previousTime is not null && previousPositions is not null) + { + var dt = currentTime - previousTime.Value; + if (dt <= 0.0) + { + throw new InvalidOperationException("MovePose dense trajectory timestamps must be strictly increasing."); + } + + var velocities = new double[6]; + var accelerations = new double[6]; + for (var index = 0; index < 6; index++) + { + var limits = GetAxisLimits(index, speedRatio); + velocities[index] = (currentPositions[index] - previousPositions[index]) / dt; + if (Math.Abs(velocities[index]) > limits.Velocity * DiscreteLimitTolerance) + { + return false; + } + + accelerations[index] = previousVelocities is null + ? 0.0 + : (velocities[index] - previousVelocities[index]) / dt; + if (Math.Abs(accelerations[index]) > limits.Acceleration * DiscreteLimitTolerance) + { + return false; + } + + if (previousAccelerations is not null) + { + var jerk = (accelerations[index] - previousAccelerations[index]) / dt; + if (Math.Abs(jerk) > limits.Jerk * DiscreteLimitTolerance) + { + return false; + } + } + } + + previousVelocities = velocities; + previousAccelerations = accelerations; + } + + previousTime = currentTime; + previousPositions = currentPositions; + } + + return true; + } + + /// + /// 根据 7 阶平滑点到点时间律和直角六维限制计算理论最短时长。 + /// + private static double ResolveDurationSeconds(IReadOnlyList startPose, IReadOnlyList targetPose, double speedRatio) + { + var duration = 0.0; + for (var index = 0; index < 6; index++) + { + var distance = Math.Abs(targetPose[index] - startPose[index]); + if (distance <= 0.0) + { + continue; + } + + var limits = GetAxisLimits(index, speedRatio); + var velocityDuration = distance * SmoothPtpVelocityShapeCoefficient / limits.Velocity; + var accelerationDuration = Math.Sqrt(distance * SmoothPtpAccelerationShapeCoefficient / limits.Acceleration); + var jerkDuration = Math.Cbrt(distance * SmoothPtpJerkShapeCoefficient / limits.Jerk); + duration = Math.Max(duration, Math.Max(velocityDuration, Math.Max(accelerationDuration, jerkDuration))); + } + + return duration; + } + + /// + /// 生成从起始位姿到目标位姿的稠密等时间隔直角轨迹点序列。 + /// + private static IReadOnlyList> GenerateDenseTrajectory( + IReadOnlyList startPose, + IReadOnlyList targetPose, + double durationSeconds, + double samplePeriodSeconds) + { + var sampleCount = ResolveSampleIntervalCount(durationSeconds, samplePeriodSeconds) + 1; + var rows = new List>(checked((int)sampleCount)); + + for (var index = 0L; index < sampleCount; index++) + { + var time = Math.Min(index * samplePeriodSeconds, durationSeconds); + rows.Add(CreateRow(time, durationSeconds, startPose, targetPose)); + } + + return rows; + } + + /// + /// 将请求时长向上对齐到整数个伺服周期。 + /// + private static double AlignDurationToServoStep(double durationSeconds, double samplePeriodSeconds) + { + return ResolveSampleIntervalCount(durationSeconds, samplePeriodSeconds) * samplePeriodSeconds; + } + + /// + /// 计算时长对应的采样间隔数,轨迹至少包含起点和终点两帧。 + /// + private static long ResolveSampleIntervalCount(double durationSeconds, double samplePeriodSeconds) + { + var rawIntervals = durationSeconds / samplePeriodSeconds; + if (double.IsNaN(rawIntervals) || double.IsInfinity(rawIntervals)) + { + throw new InvalidOperationException("MovePose sample count is not representable."); + } + + var intervals = (long)Math.Ceiling(Math.Max(0.0, rawIntervals) - 1e-9); + intervals = Math.Max(1, intervals); + if (intervals + 1 > MaxMovePoseSampleCount) + { + throw new InvalidOperationException($"MovePose sample count must be between 2 and {MaxMovePoseSampleCount}."); + } + + return intervals; + } + + /// + /// 构造单个轨迹行:[time_seconds,x,y,z,w,p,r]。 + /// + private static IReadOnlyList CreateRow( + double timeSeconds, + double durationSeconds, + IReadOnlyList startPose, + IReadOnlyList targetPose) + { + var u = durationSeconds <= 0.0 ? 1.0 : Math.Clamp(timeSeconds / durationSeconds, 0.0, 1.0); + var scale = MoveJointTrajectoryGenerator.EvaluateSmoothPtpPositionScale(u); + var row = new double[7]; + row[0] = Math.Round(timeSeconds, 9); + + for (var index = 0; index < 6; index++) + { + row[index + 1] = startPose[index] + ((targetPose[index] - startPose[index]) * scale); + } + + return row; + } + + /// + /// 获取指定直角轴在当前速度倍率下的有效限制。 + /// + private static CartesianAxisLimit GetAxisLimits(int index, double speedRatio) + { + var linearAxis = index < 3; + var velocity = linearAxis ? LinearVelocityLimit : AngularVelocityLimit; + var acceleration = linearAxis ? LinearAccelerationLimit : AngularAccelerationLimit; + var jerk = linearAxis ? LinearJerkLimit : AngularJerkLimit; + return new CartesianAxisLimit( + velocity * speedRatio, + acceleration * speedRatio * speedRatio, + jerk * speedRatio * speedRatio * speedRatio); + } + + /// + /// 校验位姿向量必须为六维有限数。 + /// + private static void EnsurePoseVector(IReadOnlyList pose, string parameterName) + { + if (pose.Count != 6) + { + throw new ArgumentException("MovePose expects pose [x,y,z,w,p,r].", parameterName); + } + + if (pose.Any(static value => double.IsNaN(value) || double.IsInfinity(value))) + { + throw new ArgumentException("MovePose pose values must be finite.", parameterName); + } + } + + /// + /// 表示单个直角轴的有效速度、加速度和 jerk 限制。 + /// + private readonly record struct CartesianAxisLimit(double Velocity, double Acceleration, double Jerk); +} diff --git a/src/Flyshot.Core.Domain/TrajectoryResult.cs b/src/Flyshot.Core.Domain/TrajectoryResult.cs index e80f373..d90e515 100644 --- a/src/Flyshot.Core.Domain/TrajectoryResult.cs +++ b/src/Flyshot.Core.Domain/TrajectoryResult.cs @@ -24,7 +24,8 @@ public sealed class TrajectoryResult int plannedWaypointCount, int triggerSampleIndexOffsetCycles = 0, IEnumerable>? denseJointTrajectory = null, - FlyshotPreparedExecution? preparedFlyshotExecution = null) + FlyshotPreparedExecution? preparedFlyshotExecution = null, + IEnumerable>? denseCartesianTrajectory = null) { if (string.IsNullOrWhiteSpace(programName)) { @@ -60,6 +61,7 @@ public sealed class TrajectoryResult var copiedTriggerTimeline = triggerTimeline.ToArray(); var copiedArtifacts = artifacts.ToArray(); var copiedDenseJointTrajectory = denseJointTrajectory?.Select(static row => row.ToArray()).ToArray(); + var copiedDenseCartesianTrajectory = denseCartesianTrajectory?.Select(static row => row.ToArray()).ToArray(); ProgramName = programName; Method = method; @@ -74,6 +76,7 @@ public sealed class TrajectoryResult PlannedWaypointCount = plannedWaypointCount; TriggerSampleIndexOffsetCycles = triggerSampleIndexOffsetCycles; DenseJointTrajectory = copiedDenseJointTrajectory; + DenseCartesianTrajectory = copiedDenseCartesianTrajectory; PreparedFlyshotExecution = preparedFlyshotExecution; } @@ -156,6 +159,13 @@ public sealed class TrajectoryResult [JsonPropertyName("denseJointTrajectory")] public IReadOnlyList>? DenseJointTrajectory { get; } + /// + /// 获取稠密直角坐标轨迹采样点,每行格式为 [time, x, y, z, w, p, r]。 + /// Null 表示本结果不是直角流式运动。 + /// + [JsonPropertyName("denseCartesianTrajectory")] + public IReadOnlyList>? DenseCartesianTrajectory { get; } + /// /// Gets the prepared flyshot execution queue when the flyshot chain has already built the final 8ms send sequence. /// diff --git a/src/Flyshot.Runtime.Common/IControllerRuntime.cs b/src/Flyshot.Runtime.Common/IControllerRuntime.cs index d71421b..c3ae7e5 100644 --- a/src/Flyshot.Runtime.Common/IControllerRuntime.cs +++ b/src/Flyshot.Runtime.Common/IControllerRuntime.cs @@ -113,4 +113,11 @@ public interface IControllerRuntime /// 规划结果。 /// 轨迹执行结束后的关节位置。 void ExecuteTrajectory(TrajectoryResult result, IReadOnlyList finalJointPositions); + + /// + /// 执行一条已经完成规划的直角坐标轨迹,并更新最终 TCP 位姿。 + /// + /// 规划结果,必须包含稠密直角坐标轨迹。 + /// 轨迹执行结束后的 [x,y,z,w,p,r] 位姿。 + void ExecuteCartesianTrajectory(TrajectoryResult result, IReadOnlyList finalPose); } diff --git a/src/Flyshot.Runtime.Fanuc/FanucControllerRuntime.cs b/src/Flyshot.Runtime.Fanuc/FanucControllerRuntime.cs index 69697f0..b8a86b1 100644 --- a/src/Flyshot.Runtime.Fanuc/FanucControllerRuntime.cs +++ b/src/Flyshot.Runtime.Fanuc/FanucControllerRuntime.cs @@ -538,6 +538,78 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable } } + /// + public void ExecuteCartesianTrajectory(TrajectoryResult result, IReadOnlyList finalPose) + { + ArgumentNullException.ThrowIfNull(result); + ArgumentNullException.ThrowIfNull(finalPose); + CancellationToken denseSendCancellationToken = default; + CancellationTokenSource? denseSendCancellationSource = null; + var shouldRunDenseTrajectory = false; + + _logger?.LogInformation( + "ExecuteCartesianTrajectory 开始: program={ProgramName}, 时长={Duration}s, 稠密采样={HasDense}, speedRatio={SpeedRatio}", + result.ProgramName, result.Duration.TotalSeconds, result.DenseCartesianTrajectory is not null, _speedRatio); + + lock (_stateLock) + { + EnsureEnabled(); + EnsureValidTrajectory(result); + EnsurePoseCount(finalPose.Count); + CancelSendTaskLocked(); + + if (!IsSimulationMode && result.DenseCartesianTrajectory is not null) + { + EnsureJ519ReadyForDenseExecution(); + + // 真机直角模式同样预装完整队列,由机器人状态包节拍驱动出队。 + _isInMotion = true; + _sendCts = new CancellationTokenSource(); + denseSendCancellationSource = _sendCts; + denseSendCancellationToken = _sendCts.Token; + shouldRunDenseTrajectory = true; + + _logger?.LogInformation("ExecuteCartesianTrajectory 开始同步直角稠密发送任务"); + } + else + { + if (!IsSimulationMode) + { + var command = new FanucJ519Command( + sequence: 0, + targetValues: BuildCartesianTargetValues(finalPose), + dataStyle: 0); + _j519Client.UpdateCommand(command); + } + + _isInMotion = true; + _pose = MergeFinalCartesianPose(finalPose); + _isInMotion = false; + _logger?.LogInformation("ExecuteCartesianTrajectory 完成(单点模式)"); + } + } + + if (shouldRunDenseTrajectory) + { + try + { + SendDenseCartesianTrajectory(result, finalPose, denseSendCancellationToken); + _logger?.LogInformation("ExecuteCartesianTrajectory 完成(稠密模式)"); + } + finally + { + lock (_stateLock) + { + if (ReferenceEquals(_sendCts, denseSendCancellationSource)) + { + denseSendCancellationSource?.Dispose(); + _sendCts = null; + } + } + } + } + } + /// /// 释放运行时持有的所有 Socket 客户端。 /// @@ -759,6 +831,71 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable } } + /// + /// 直角稠密轨迹发送任务:预生成 Data format=0 的 J519 命令队列,并等待状态包驱动执行完成。 + /// + private void SendDenseCartesianTrajectory(TrajectoryResult result, IReadOnlyList finalPose, CancellationToken cancellationToken) + { + var rows = result.DenseCartesianTrajectory ?? throw new InvalidOperationException("Cartesian trajectory requires dense Cartesian samples."); + var commands = new List(rows.Count); + var sentPoseRows = new List>(rows.Count); + var outputDir = CreateDenseSendOutputDirectory(result.ProgramName); + var stopwatch = Stopwatch.StartNew(); + + try + { + foreach (var row in rows) + { + cancellationToken.ThrowIfCancellationRequested(); + if (row.Count != 7) + { + throw new InvalidOperationException("Cartesian dense trajectory rows must contain time plus six pose values."); + } + + var pose = row.Skip(1).Take(6).ToArray(); + var command = new FanucJ519Command( + sequence: 0, + targetValues: BuildCartesianTargetValues(pose), + dataStyle: 0, + writeIoType: 2, + writeIoIndex: 1, + writeIoMask: 0, + writeIoValue: 0); + + commands.Add(command); + sentPoseRows.Add(BuildDenseSendPoseRow(row[0], pose)); + } + + TryWriteDenseCartesianSendArtifacts(outputDir, sentPoseRows); + + _j519Client.LoadCommandQueue(commands); + if (_j519Client.IsConnected) + { + _j519Client.WaitForCommandQueueDrainedAsync(cancellationToken).GetAwaiter().GetResult(); + } + + _logger?.LogInformation( + "SendDenseCartesianTrajectory 正常完成: 采样数={SampleCount}, 队列装载耗时={ElapsedMs}ms", + commands.Count, + stopwatch.ElapsedMilliseconds); + } + catch (OperationCanceledException) + { + _logger?.LogWarning( + "SendDenseCartesianTrajectory 被取消: 已生成 {Current}/{Total} 条命令", + commands.Count, + rows.Count); + } + finally + { + lock (_stateLock) + { + _isInMotion = false; + _pose = MergeFinalCartesianPose(finalPose); + } + } + } + /// /// 若已有 J519 响应,则在启动稠密轨迹前检查伺服侧是否接受命令并处于系统就绪状态。 /// @@ -879,6 +1016,41 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable return row; } + /// + /// 构造实际发送直角位姿文本行,格式为 send_time + X/Y/Z/W/P/R。 + /// + private static IReadOnlyList BuildDenseSendPoseRow(double sendTime, IReadOnlyList pose) + { + var row = new double[pose.Count + 1]; + row[0] = Math.Round(sendTime, 6); + for (var index = 0; index < pose.Count; index++) + { + row[index + 1] = Math.Round(pose[index], 6); + } + + return row; + } + + /// + /// 构造 J519 直角命令的 9 个目标槽位,当前现场无扩展轴时 E1/E2/E3 补零。 + /// + private static double[] BuildCartesianTargetValues(IReadOnlyList pose) + { + EnsurePoseCount(pose.Count); + return [pose[0], pose[1], pose[2], pose[3], pose[4], pose[5], 0.0, 0.0, 0.0]; + } + + /// + /// 将六维 WPR 直角目标合并回运行时缓存,保留旧 GetPose 仿真路径的 7 维外形。 + /// + private double[] MergeFinalCartesianPose(IReadOnlyList pose) + { + EnsurePoseCount(pose.Count); + return _pose.Length >= 7 + ? [pose[0], pose[1], pose[2], pose[3], pose[4], pose[5], _pose[6]] + : pose.ToArray(); + } + /// /// 尝试把实际发送点位、时间映射和跃度统计写入纯文本文件;若落盘失败,只记录日志,不影响运动主流程。 /// @@ -910,6 +1082,25 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable } } + /// + /// 尝试把直角实际发送点位写入文本文件;若落盘失败,只记录日志,不影响运动主流程。 + /// + private void TryWriteDenseCartesianSendArtifacts(string outputDir, IReadOnlyList> sentPoseRows) + { + try + { + WriteDenseRows(Path.Combine(outputDir, "ActualSendPoseTraj.txt"), sentPoseRows); + _logger?.LogInformation( + "SendDenseCartesianTrajectory 已写出实际发送记录: outputDir={OutputDir}, poseRows={PoseRows}", + outputDir, + sentPoseRows.Count); + } + catch (Exception exception) + { + _logger?.LogWarning(exception, "SendDenseCartesianTrajectory 写出实际发送记录失败: outputDir={OutputDir}", outputDir); + } + } + /// /// 以旧轨迹文本兼容的空格分隔格式写出数值行。 /// @@ -1089,6 +1280,17 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable } } + /// + /// 校验直角位姿固定为 [x,y,z,w,p,r] 六维。 + /// + private static void EnsurePoseCount(int poseCount) + { + if (poseCount != 6) + { + throw new InvalidOperationException($"Expected 6 Cartesian pose values but received {poseCount}."); + } + } + /// /// 校验机器人已经完成初始化。 /// diff --git a/src/Flyshot.Runtime.Fanuc/Protocol/FanucJ519Protocol.cs b/src/Flyshot.Runtime.Fanuc/Protocol/FanucJ519Protocol.cs index cb13f70..4d29a95 100644 --- a/src/Flyshot.Runtime.Fanuc/Protocol/FanucJ519Protocol.cs +++ b/src/Flyshot.Runtime.Fanuc/Protocol/FanucJ519Protocol.cs @@ -7,7 +7,7 @@ namespace Flyshot.Runtime.Fanuc.Protocol; /// public sealed class FanucJ519Command { - private readonly double[] _targetJoints; + private readonly double[] _targetValues; /// /// 初始化 J519 命令数据。 @@ -35,11 +35,53 @@ public sealed class FanucJ519Command ushort writeIoIndex = 1, ushort writeIoMask = 0, ushort writeIoValue = 0) + : this( + sequence, + targetValues: targetJoints, + lastData, + readIoType, + readIoIndex, + readIoMask, + dataStyle, + writeIoType, + writeIoIndex, + writeIoMask, + writeIoValue) { - ArgumentNullException.ThrowIfNull(targetJoints); - if (targetJoints.Count is <= 0 or > 9) + } + + /// + /// 初始化 J519 命令数据。 + /// + /// 命令序号。 + /// J519 目标槽位,直角模式为 X/Y/Z/W/P/R/E1/E2/E3,关节模式为 J1..J9。 + /// 是否为最后一帧数据。 + /// 读取 IO 类型。 + /// 读取 IO 起始索引。 + /// 读取 IO 掩码。 + /// 目标数据类型,0 为直角坐标,1 为关节坐标。 + /// 写入 IO 类型。 + /// 写入 IO 起始索引。 + /// 写入 IO 掩码。 + /// 写入 IO 数值。 + public FanucJ519Command( + uint sequence, + IEnumerable targetValues, + byte lastData = 0, + byte readIoType = 2, + ushort readIoIndex = 1, + ushort readIoMask = 255, + byte dataStyle = 1, + byte writeIoType = 2, + ushort writeIoIndex = 1, + ushort writeIoMask = 0, + ushort writeIoValue = 0) + { + ArgumentNullException.ThrowIfNull(targetValues); + var copiedTargetValues = targetValues.ToArray(); + if (copiedTargetValues.Length is <= 0 or > 9) { - throw new ArgumentOutOfRangeException(nameof(targetJoints), "J519 目标数据必须包含 1 到 9 个槽位。"); + throw new ArgumentOutOfRangeException(nameof(targetValues), "J519 目标数据必须包含 1 到 9 个槽位。"); } Sequence = sequence; @@ -52,7 +94,7 @@ public sealed class FanucJ519Command WriteIoIndex = writeIoIndex; WriteIoMask = writeIoMask; WriteIoValue = writeIoValue; - _targetJoints = targetJoints.ToArray(); + _targetValues = copiedTargetValues; } /// @@ -108,7 +150,12 @@ public sealed class FanucJ519Command /// /// 获取目标关节或扩展轴数据。 /// - public IReadOnlyList TargetJoints => _targetJoints; + public IReadOnlyList TargetJoints => _targetValues; + + /// + /// 获取 J519 目标槽位,直角模式为 X/Y/Z/W/P/R/E1/E2/E3,关节模式为 J1..J9。 + /// + public IReadOnlyList TargetValues => _targetValues; } /// diff --git a/src/Flyshot.Server.Host/Controllers/LegacyHttpApiController.cs b/src/Flyshot.Server.Host/Controllers/LegacyHttpApiController.cs index 483ba07..38a60d2 100644 --- a/src/Flyshot.Server.Host/Controllers/LegacyHttpApiController.cs +++ b/src/Flyshot.Server.Host/Controllers/LegacyHttpApiController.cs @@ -431,6 +431,32 @@ public sealed class LegacyHttpApiController : ControllerBase } } + /// + /// 以直角坐标 `[x,y,z,w,p,r]` 执行点到点移动。 + /// + /// 直角位姿请求体。 + /// 旧 FastAPI 层风格的状态响应。 + [HttpPost("/move_pose/")] + public IActionResult MovePose([FromBody] JsonElement pose_data) + { + try + { + var poseRequest = LegacyCartesianPoseRequest.FromJson(pose_data); + var pose = poseRequest.ToPoseArray(); + _logger.LogInformation("MovePose 调用: x={X}, y={Y}, z={Z}, w={W}, p={P}, r={R}", + poseRequest.x, poseRequest.y, poseRequest.z, poseRequest.w, poseRequest.p, poseRequest.r); + + _compatService.MovePose(pose); + _logger.LogInformation("MovePose 成功"); + return Ok(new { status = "robot moved" }); + } + catch (Exception exception) + { + _logger.LogError(exception, "MovePose 失败"); + return LegacyBadRequest("MovePose failed"); + } + } + /// /// 兼容旧 `GetNearestIK(pose, seed, ik)` 参数形状。 /// @@ -874,6 +900,136 @@ public sealed class LegacyJointPositionRequest public List joints { get; init; } = []; } +/// +/// 表示 `/move_pose/` 路由使用的直角位姿请求体。 +/// +public sealed class LegacyCartesianPoseRequest +{ + /// + /// MovePose 第一版入口硬限制:TCP X/Y 最大绝对值,单位 mm。 + /// + private const double MaxHorizontalMillimeters = 1000.0; + + /// + /// MovePose 第一版入口硬限制:TCP Z 最小值,单位 mm。 + /// + private const double MinZMillimeters = 0.0; + + /// + /// MovePose 第一版入口硬限制:TCP Z 最大值,单位 mm。 + /// + private const double MaxZMillimeters = 1200.0; + + /// + /// MovePose 第一版入口硬限制:W/R 姿态角最大绝对值,单位 deg。 + /// + private const double MaxRollYawDegrees = 180.0; + + /// + /// MovePose 第一版入口硬限制:P 姿态角最大绝对值,单位 deg。 + /// + private const double MaxPitchDegrees = 90.0; + + /// + /// 获取或设置 TCP X,单位为 mm。 + /// + public double x { get; init; } + + /// + /// 获取或设置 TCP Y,单位为 mm。 + /// + public double y { get; init; } + + /// + /// 获取或设置 TCP Z,单位为 mm。 + /// + public double z { get; init; } + + /// + /// 获取或设置姿态 W,单位为 deg。 + /// + public double w { get; init; } + + /// + /// 获取或设置姿态 P,单位为 deg。 + /// + public double p { get; init; } + + /// + /// 获取或设置姿态 R,单位为 deg。 + /// + public double r { get; init; } + + /// + /// 从原始 JSON 请求体解析直角位姿,并显式拒绝缺字段、null 和非有限数。 + /// + /// 原始 JSON 请求体。 + /// 解析后的直角位姿请求。 + public static LegacyCartesianPoseRequest FromJson(JsonElement json) + { + if (json.ValueKind != JsonValueKind.Object) + { + throw new ArgumentException("MovePose request body must be an object."); + } + + // 旧接口要求请求体必须完整提供 x/y/z/w/p/r,不能让模型绑定把缺字段静默补 0。 + return new LegacyCartesianPoseRequest + { + x = ReadRequiredFiniteDouble(json, "x", -MaxHorizontalMillimeters, MaxHorizontalMillimeters), + y = ReadRequiredFiniteDouble(json, "y", -MaxHorizontalMillimeters, MaxHorizontalMillimeters), + z = ReadRequiredFiniteDouble(json, "z", MinZMillimeters, MaxZMillimeters), + w = ReadRequiredFiniteDouble(json, "w", -MaxRollYawDegrees, MaxRollYawDegrees), + p = ReadRequiredFiniteDouble(json, "p", -MaxPitchDegrees, MaxPitchDegrees), + r = ReadRequiredFiniteDouble(json, "r", -MaxRollYawDegrees, MaxRollYawDegrees) + }; + } + + /// + /// 转换为兼容层使用的六维位姿数组。 + /// + /// [x,y,z,w,p,r] 数组。 + public IReadOnlyList ToPoseArray() + { + return [x, y, z, w, p, r]; + } + + /// + /// 读取必填有限数值字段。 + /// + /// 请求体 JSON 对象。 + /// 字段名。 + /// 允许的最小值。 + /// 允许的最大值。 + /// 字段对应的有限 double 数值。 + private static double ReadRequiredFiniteDouble( + JsonElement json, + string propertyName, + double minInclusive, + double maxInclusive) + { + if (!json.TryGetProperty(propertyName, out var property) || property.ValueKind != JsonValueKind.Number) + { + throw new ArgumentException($"MovePose request field '{propertyName}' is required and must be a number."); + } + + var value = property.GetDouble(); + if (double.IsNaN(value) || double.IsInfinity(value)) + { + throw new ArgumentOutOfRangeException(propertyName, "MovePose request values must be finite."); + } + + if (value < minInclusive || value > maxInclusive) + { + throw new ArgumentOutOfRangeException( + propertyName, + value, + $"MovePose request field '{propertyName}' must be between {minInclusive} and {maxInclusive}."); + } + + return value; + } +} + /// /// 表示旧 `/upload_flyshot/` 路由使用的飞拍上传请求体。 /// diff --git a/src/Flyshot.Server.Host/Controllers/StatusController.cs b/src/Flyshot.Server.Host/Controllers/StatusController.cs index b641850..7e466b3 100644 --- a/src/Flyshot.Server.Host/Controllers/StatusController.cs +++ b/src/Flyshot.Server.Host/Controllers/StatusController.cs @@ -49,23 +49,18 @@ public sealed class StatusController : ControllerBase public IActionResult GetSnapshot() { var snapshot = _compatService.GetControllerSnapshot(); - var isSetup = _compatService.IsSetUp; - - // 状态页需要在机器人未初始化时仍能打开,因此只有初始化后才读取机器人元数据。 - var robotName = isSetup ? _compatService.GetRobotName() : null; - var degreesOfFreedom = isSetup ? _compatService.GetDegreesOfFreedom() : 0; - var uploadedTrajectories = isSetup ? _compatService.ListTrajectoryNames() : Array.Empty(); + var metadata = _compatService.GetStatusSnapshotMetadata(); return Ok(new { Status = "ok", Service = "flyshot-server-host", - ServerVersion = _compatService.GetServerVersion(), - ClientVersion = _compatService.GetClientVersion(), - IsSetup = isSetup, - RobotName = robotName, - DegreesOfFreedom = degreesOfFreedom, - UploadedTrajectories = uploadedTrajectories, + ServerVersion = metadata.ServerVersion, + ClientVersion = metadata.ClientVersion, + IsSetup = metadata.IsSetup, + RobotName = metadata.RobotName, + DegreesOfFreedom = metadata.DegreesOfFreedom, + UploadedTrajectories = metadata.UploadedTrajectories, Snapshot = snapshot }); } diff --git a/src/Flyshot.Server.Host/wwwroot/assets/debug.js b/src/Flyshot.Server.Host/wwwroot/assets/debug.js index 1efef3f..c1175ad 100644 --- a/src/Flyshot.Server.Host/wwwroot/assets/debug.js +++ b/src/Flyshot.Server.Host/wwwroot/assets/debug.js @@ -16,6 +16,17 @@ const state = { history: [] }; +const requestBodySamples = { + "POST /move_pose/": { + x: 100.0, + y: 200.0, + z: 300.0, + w: 0.0, + p: 45.0, + r: 0.0 + } +}; + /** 简单的 escape:把任意字符串安全嵌入 textContent 之外的位置时使用。 */ function escapeHtml(value) { return String(value).replace(/[&<>"']/g, function (ch) { @@ -137,6 +148,11 @@ function storageKey(op) { return STORAGE_PREFIX + op.method + ":" + op.path; } +/** 返回指定端点的手工调试样例,处理 JsonElement 等 OpenAPI 无法自动推断字段的接口。 */ +function getRequestBodySample(op) { + return requestBodySamples[op.method + " " + op.path] || null; +} + /** 读取本端点最近一次输入;解析失败则当作空。 */ function loadInputs(op) { try { @@ -281,7 +297,7 @@ function renderBodyEditor(container, op, savedBody) { if (savedBody !== undefined && savedBody !== null) { initialText = typeof savedBody === "string" ? savedBody : JSON.stringify(savedBody, null, 2); } else { - const sample = buildSampleFromSchema(op.bodySchema, 0); + const sample = getRequestBodySample(op) || buildSampleFromSchema(op.bodySchema, 0); initialText = sample === null ? "" : JSON.stringify(sample, null, 2); } textarea.value = initialText; diff --git a/src/Flyshot.Server.Host/wwwroot/assets/status.css b/src/Flyshot.Server.Host/wwwroot/assets/status.css index afc0ccb..4206ed3 100644 --- a/src/Flyshot.Server.Host/wwwroot/assets/status.css +++ b/src/Flyshot.Server.Host/wwwroot/assets/status.css @@ -188,6 +188,68 @@ dd { font-family: inherit; } +.jog-panel { + grid-column: 1 / -1; +} + +.jog-content { + padding: 16px; +} + +.jog-settings { + display: grid; + grid-template-columns: repeat(2, minmax(180px, 1fr)); + gap: 12px; + margin-bottom: 14px; +} + +.jog-settings label { + display: grid; + gap: 6px; + color: var(--muted); + font-size: 13px; +} + +.jog-settings input { + min-height: 36px; + width: 100%; + padding: 0 10px; + border: 1px solid var(--line); + border-radius: 6px; + background: #ffffff; + color: var(--text); + font: inherit; +} + +.jog-grid { + display: grid; + grid-template-columns: repeat(6, minmax(64px, 1fr)); + gap: 8px; +} + +.jog-button { + min-height: 44px; + padding: 0 8px; + font-weight: 650; + touch-action: none; + user-select: none; +} + +.jog-button.active { + filter: brightness(0.88); +} + +.jog-status { + min-height: 24px; + margin-top: 12px; + color: var(--muted); + font-family: Consolas, "Cascadia Mono", monospace; +} + +.jog-status.error { + color: var(--bad); +} + @media (max-width: 820px) { .topbar { align-items: flex-start; @@ -199,6 +261,11 @@ dd { grid-template-columns: 1fr; } + .jog-settings, + .jog-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + dl { grid-template-columns: 1fr; } diff --git a/src/Flyshot.Server.Host/wwwroot/assets/status.js b/src/Flyshot.Server.Host/wwwroot/assets/status.js index 417c431..7720c75 100644 --- a/src/Flyshot.Server.Host/wwwroot/assets/status.js +++ b/src/Flyshot.Server.Host/wwwroot/assets/status.js @@ -15,7 +15,28 @@ const fields = { joints: document.getElementById("joints"), pose: document.getElementById("pose"), trajectories: document.getElementById("trajectories"), - refresh: document.getElementById("refresh") + refresh: document.getElementById("refresh"), + linearStep: document.getElementById("linear-step"), + angularStep: document.getElementById("angular-step"), + jogStatus: document.getElementById("jog-status"), + jogButtons: Array.from(document.querySelectorAll(".jog-button")) +}; + +const axisIndexes = { + x: 0, + y: 1, + z: 2, + w: 3, + p: 4, + r: 5 +}; + +// 点动状态集中保存,确保按住按钮时不会并发发送多条 MovePose 请求。 +const jogState = { + timer: null, + activeButton: null, + inFlight: false, + lastSnapshot: null }; function formatArray(values) { @@ -56,12 +77,136 @@ function setDot(connectionState) { } } +function clampStep(input, min, max) { + const value = Number(input.value); + if (!Number.isFinite(value)) { + input.value = String(min); + return min; + } + + const clamped = Math.min(Math.max(value, min), max); + input.value = String(clamped); + return clamped; +} + +function getStepForAxis(axis) { + // 平移和姿态使用不同单位,但共享 0.1 到 10 的现场可调范围。 + return axis === "x" || axis === "y" || axis === "z" + ? clampStep(fields.linearStep, 0.1, 10) + : clampStep(fields.angularStep, 0.1, 10); +} + +function setJogStatus(message, isError) { + fields.jogStatus.textContent = message; + fields.jogStatus.classList.toggle("error", Boolean(isError)); +} + +async function loadSnapshotForJog() { + const response = await fetch("/api/status/snapshot", { cache: "no-store" }); + if (!response.ok) { + throw new Error(`状态快照读取失败: HTTP ${response.status}`); + } + + const payload = await response.json(); + jogState.lastSnapshot = payload.snapshot; + return payload.snapshot; +} + +function buildJogPose(snapshot, axis, direction) { + // 每次点动都从最新 TCP 位姿出发,只修改一个轴,避免连续按压时累积本地误差。 + const sourcePose = Array.isArray(snapshot.cartesianPose) ? snapshot.cartesianPose : []; + if (sourcePose.length < 6) { + throw new Error("当前 TCP 位姿不足 6 维,无法点动。"); + } + + const pose = sourcePose.slice(0, 6).map(Number); + if (pose.some(value => !Number.isFinite(value))) { + throw new Error("当前 TCP 位姿包含非数值,无法点动。"); + } + + const axisIndex = axisIndexes[axis]; + pose[axisIndex] = Number((pose[axisIndex] + direction * getStepForAxis(axis)).toFixed(6)); + return { + x: pose[0], + y: pose[1], + z: pose[2], + w: pose[3], + p: pose[4], + r: pose[5] + }; +} + +async function sendJog(button) { + // MovePose 底层会生成完整直角轨迹;前端这里只负责构造增量目标位姿。 + if (jogState.inFlight) { + return; + } + + const axis = button.dataset.axis; + const direction = Number(button.dataset.direction); + if (!Object.prototype.hasOwnProperty.call(axisIndexes, axis) || !Number.isFinite(direction)) { + return; + } + + jogState.inFlight = true; + try { + const snapshot = await loadSnapshotForJog(); + const pose = buildJogPose(snapshot, axis, direction); + const response = await fetch("/move_pose/", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(pose) + }); + + if (!response.ok) { + throw new Error(`MovePose 调用失败: HTTP ${response.status}`); + } + + setJogStatus(`${axis.toUpperCase()}${direction > 0 ? "+" : "-"} 已发送`, false); + await refreshStatus(); + } catch (error) { + setJogStatus(error instanceof Error ? error.message : "点动失败", true); + stopJog(); + } finally { + jogState.inFlight = false; + } +} + +function startJog(event) { + const button = event.currentTarget; + if (!button || button.disabled) { + return; + } + + event.preventDefault(); + stopJog(); + jogState.activeButton = button; + button.classList.add("active"); + sendJog(button); + jogState.timer = window.setInterval(function () { + sendJog(button); + }, 250); +} + +function stopJog() { + if (jogState.timer !== null) { + window.clearInterval(jogState.timer); + jogState.timer = null; + } + + if (jogState.activeButton) { + jogState.activeButton.classList.remove("active"); + jogState.activeButton = null; + } +} + async function refreshStatus() { fields.refresh.disabled = true; try { const response = await fetch("/api/status/snapshot", { cache: "no-store" }); const payload = await response.json(); const snapshot = payload.snapshot; + jogState.lastSnapshot = snapshot; fields.connectionState.textContent = snapshot.connectionState; fields.robotName.textContent = payload.robotName || "--"; @@ -88,5 +233,23 @@ async function refreshStatus() { } fields.refresh.addEventListener("click", refreshStatus); +fields.linearStep.addEventListener("change", function () { + clampStep(fields.linearStep, 0.1, 10); +}); +fields.angularStep.addEventListener("change", function () { + clampStep(fields.angularStep, 0.1, 10); +}); +fields.jogButtons.forEach(function (button) { + button.addEventListener("pointerdown", startJog); + button.addEventListener("pointerup", stopJog); + button.addEventListener("pointercancel", stopJog); + button.addEventListener("pointerleave", stopJog); +}); +window.addEventListener("blur", stopJog); +window.addEventListener("keyup", function (event) { + if (event.key === "Escape") { + stopJog(); + } +}); refreshStatus(); window.setInterval(refreshStatus, 2000); diff --git a/src/Flyshot.Server.Host/wwwroot/status.html b/src/Flyshot.Server.Host/wwwroot/status.html index df326a8..5366dd1 100644 --- a/src/Flyshot.Server.Host/wwwroot/status.html +++ b/src/Flyshot.Server.Host/wwwroot/status.html @@ -57,6 +57,36 @@
已上传轨迹
--
+
+

直角坐标点动

+
+
+ + +
+
+ + + + + + + + + + + + +
+
--
+
+
diff --git a/tests/Flyshot.Core.Tests/FanucControllerRuntimeDenseTests.cs b/tests/Flyshot.Core.Tests/FanucControllerRuntimeDenseTests.cs index 57a4fbc..02d35a0 100644 --- a/tests/Flyshot.Core.Tests/FanucControllerRuntimeDenseTests.cs +++ b/tests/Flyshot.Core.Tests/FanucControllerRuntimeDenseTests.cs @@ -555,6 +555,30 @@ public sealed class FanucControllerRuntimeDenseTests Assert.True(speed05.Duration.TotalSeconds >= ExpectedSmoothPtpDuration(robot, startJoints, targetJoints, speedRatio: 0.5)); } + /// + /// 验证 MovePose 低速倍率仍保持固定伺服周期,并通过拉长时长降低直角运动速度。 + /// + [Fact] + public void MovePoseTrajectoryGenerator_LowerSpeedUsesFixedServoPeriodAndLongerPlannedDuration() + { + var servoPeriod = TimeSpan.FromMilliseconds(8); + var startPose = new[] { 100.0, 200.0, 300.0, 1.0, 2.0, 3.0 }; + var targetPose = new[] { 140.0, 260.0, 330.0, 8.0, 10.0, 12.0 }; + + var fullSpeed = MovePoseTrajectoryGenerator.CreateResult(startPose, targetPose, servoPeriod, speedRatio: 1.0); + var speed07 = MovePoseTrajectoryGenerator.CreateResult(startPose, targetPose, servoPeriod, speedRatio: 0.7); + var speed05 = MovePoseTrajectoryGenerator.CreateResult(startPose, targetPose, servoPeriod, speedRatio: 0.5); + + Assert.True(speed07.DenseCartesianTrajectory!.Count > fullSpeed.DenseCartesianTrajectory!.Count); + Assert.True(speed05.DenseCartesianTrajectory!.Count > speed07.DenseCartesianTrajectory!.Count); + AssertDenseRowsUseServoPeriod(fullSpeed.DenseCartesianTrajectory, servoPeriod.TotalSeconds); + AssertDenseRowsUseServoPeriod(speed07.DenseCartesianTrajectory, servoPeriod.TotalSeconds); + AssertDenseRowsUseServoPeriod(speed05.DenseCartesianTrajectory, servoPeriod.TotalSeconds); + AssertPoseEqual(startPose, fullSpeed.DenseCartesianTrajectory[0].Skip(1).ToArray()); + AssertPoseEqual(targetPose, fullSpeed.DenseCartesianTrajectory[^1].Skip(1).ToArray()); + Assert.True(MovePoseTrajectoryGenerator.SatisfiesDefaultCartesianLimits(speed05.DenseCartesianTrajectory, speedRatio: 0.5)); + } + [Fact] public void MoveJoint_RealMode_LeavesFinalTargetForHoldStreaming() { @@ -578,6 +602,41 @@ public sealed class FanucControllerRuntimeDenseTests AssertJointDegreesEqual(targetJoints, currentCommand.TargetJoints); } + /// + /// 验证 MovePose 会生成直角坐标 J519 队列,并使用 Data format=0 下发 X/Y/Z/W/P/R。 + /// + [Fact] + public void MovePose_RealMode_GeneratesCartesianJ519Queue() + { + using var commandClient = new FanucCommandClient(); + using var stateClient = new FanucStateClient(); + using var j519Client = new FanucJ519Client(); + using var runtime = new FanucControllerRuntime(commandClient, stateClient, j519Client); + var service = CreateCompatService(runtime); + var startPose = new[] { 100.0, 200.0, 300.0, 1.0, 2.0, 3.0 }; + var targetPose = new[] { 110.0, 220.0, 315.0, 4.0, 5.0, 6.0 }; + + service.SetUpRobot("FANUC_LR_Mate_200iD"); + j519Client.EnableCommandHistoryForTests(); + ForceRealModeEnabled(runtime, speedRatio: 1.0); + SetPrivateField(runtime, "_pose", startPose); + + service.MovePose(targetPose); + WaitUntilIdle(runtime); + + var commands = j519Client.GetCommandHistoryForTests(); + Assert.NotEmpty(commands); + Assert.All(commands, static command => Assert.Equal(0, command.DataStyle)); + AssertPoseEqual(startPose, commands[0].TargetValues.Take(6).ToArray()); + AssertPoseEqual(targetPose, commands[^1].TargetValues.Take(6).ToArray()); + Assert.All(commands, static command => + { + Assert.Equal(0.0, command.TargetValues[6], precision: 6); + Assert.Equal(0.0, command.TargetValues[7], precision: 6); + Assert.Equal(0.0, command.TargetValues[8], precision: 6); + }); + } + /// /// 验证运行时稠密发送不再依赖当前 speed_ratio;倍率合法性应在上游规划/生成阶段处理。 /// @@ -1103,6 +1162,15 @@ public sealed class FanucControllerRuntimeDenseTests } } + private static void AssertPoseEqual(IReadOnlyList expected, IReadOnlyList actual) + { + Assert.Equal(expected.Count, actual.Count); + for (var index = 0; index < expected.Count; index++) + { + Assert.Equal(expected[index], actual[index], precision: 6); + } + } + /// /// 创建用于就绪状态测试的最小 J519 响应。 /// diff --git a/tests/Flyshot.Core.Tests/FanucJ519ClientTests.cs b/tests/Flyshot.Core.Tests/FanucJ519ClientTests.cs index 8fcb0a9..f026b2b 100644 --- a/tests/Flyshot.Core.Tests/FanucJ519ClientTests.cs +++ b/tests/Flyshot.Core.Tests/FanucJ519ClientTests.cs @@ -163,6 +163,28 @@ public sealed class FanucJ519ClientTests : IDisposable await client.StopMotionAsync(_cts.Token); } + /// + /// 验证直角坐标命令会把 Data format 写为 0,并按通用目标槽位写入 X/Y/Z/W/P/R。 + /// + [Fact] + public void PackCommandPacket_WritesCartesianDataFormatAndTargetValues() + { + var command = new FanucJ519Command( + sequence: 7, + targetValues: [100.0, 200.0, 300.0, 1.0, 2.0, 3.0, 0.0, 0.0, 0.0], + dataStyle: 0); + + var packet = FanucJ519Protocol.PackCommandPacket(command); + + Assert.Equal(0, packet[0x12]); + Assert.Equal(100.0f, BinaryPrimitives.ReadSingleBigEndian(packet.AsSpan(0x1c, 4))); + Assert.Equal(200.0f, BinaryPrimitives.ReadSingleBigEndian(packet.AsSpan(0x20, 4))); + Assert.Equal(300.0f, BinaryPrimitives.ReadSingleBigEndian(packet.AsSpan(0x24, 4))); + Assert.Equal(1.0f, BinaryPrimitives.ReadSingleBigEndian(packet.AsSpan(0x28, 4))); + Assert.Equal(2.0f, BinaryPrimitives.ReadSingleBigEndian(packet.AsSpan(0x2c, 4))); + Assert.Equal(3.0f, BinaryPrimitives.ReadSingleBigEndian(packet.AsSpan(0x30, 4))); + } + /// /// 验证配置 J519 buffer size 后,实际回发命令序号会在状态包序号基础上增加该缓冲深度。 /// diff --git a/tests/Flyshot.Core.Tests/RuntimeOrchestrationTests.cs b/tests/Flyshot.Core.Tests/RuntimeOrchestrationTests.cs index 6aa94bd..ec8c673 100644 --- a/tests/Flyshot.Core.Tests/RuntimeOrchestrationTests.cs +++ b/tests/Flyshot.Core.Tests/RuntimeOrchestrationTests.cs @@ -956,6 +956,111 @@ public sealed class RuntimeOrchestrationTests } } + /// + /// 验证飞拍执行阻塞在运行时时,状态页元数据快照仍能通过短锁快速返回。 + /// + [Fact] + public async Task ControllerClientCompatService_GetStatusSnapshotMetadata_DoesNotWaitForRunningFlyshot() + { + var configRoot = CreateTempConfigRoot(); + try + { + WriteRobotConfigWithDemoTrajectory(configRoot); + var options = new ControllerClientCompatOptions + { + ConfigRoot = configRoot + }; + var runtime = new BlockingExecutionControllerRuntime([0.0, 0.0, 0.0, 0.0, 0.0, 0.0]); + var service = new ControllerClientCompatService( + options, + new ControllerClientCompatRobotCatalog(options, new RobotModelLoader()), + runtime, + new ControllerClientTrajectoryOrchestrator(), + new RobotConfigLoader()); + + service.SetUpRobot("FANUC_LR_Mate_200iD"); + service.SetActiveController(sim: false); + service.Connect("192.168.10.101"); + service.EnableRobot(2); + service.UploadTrajectory(TestRobotFactory.CreateUploadedTrajectoryWithSingleShot()); + + var executing = Task.Run(() => service.ExecuteTrajectoryByName( + "demo-flyshot", + new FlyshotExecutionOptions(moveToStart: false, method: "icsp", saveTrajectory: false, useCache: false, wait: true))); + Assert.True(runtime.WaitForExecutionStarted(TimeSpan.FromSeconds(2))); + + var metadataTask = Task.Run(() => service.GetStatusSnapshotMetadata()); + var completed = await Task.WhenAny(metadataTask, Task.Delay(TimeSpan.FromMilliseconds(150))); + + runtime.ReleaseExecution(); + await executing; + + Assert.Same(metadataTask, completed); + var metadata = await metadataTask; + Assert.True(metadata.IsSetup); + Assert.Equal("FANUC_LR_Mate_200iD", metadata.RobotName); + Assert.Equal(["demo-flyshot"], metadata.UploadedTrajectories); + } + finally + { + Directory.Delete(configRoot, recursive: true); + } + } + + /// + /// 验证两个飞拍执行命令必须串行进入 runtime,避免 J519 队列被并发执行覆盖。 + /// + [Fact] + public async Task ControllerClientCompatService_ExecuteTrajectoryByName_SerializesConcurrentExecutionCommands() + { + var configRoot = CreateTempConfigRoot(); + try + { + WriteRobotConfigWithDemoTrajectory(configRoot); + var options = new ControllerClientCompatOptions + { + ConfigRoot = configRoot + }; + var runtime = new BlockingExecutionControllerRuntime([0.0, 0.0, 0.0, 0.0, 0.0, 0.0]); + var service = new ControllerClientCompatService( + options, + new ControllerClientCompatRobotCatalog(options, new RobotModelLoader()), + runtime, + new ControllerClientTrajectoryOrchestrator(), + new RobotConfigLoader()); + + service.SetUpRobot("FANUC_LR_Mate_200iD"); + service.SetActiveController(sim: false); + service.Connect("192.168.10.101"); + service.EnableRobot(2); + service.UploadTrajectory(TestRobotFactory.CreateUploadedTrajectoryWithSingleShot()); + + var first = Task.Run(() => service.ExecuteTrajectoryByName( + "demo-flyshot", + new FlyshotExecutionOptions(moveToStart: false, method: "icsp", saveTrajectory: false, useCache: false, wait: true))); + Assert.True(runtime.WaitForExecutionStarted(TimeSpan.FromSeconds(2))); + + var second = Task.Run(() => service.ExecuteTrajectoryByName( + "demo-flyshot", + new FlyshotExecutionOptions(moveToStart: false, method: "icsp", saveTrajectory: false, useCache: false, wait: true))); + await Task.Delay(TimeSpan.FromMilliseconds(100)); + + Assert.Equal(1, runtime.ExecuteCallCount); + + runtime.ReleaseExecution(); + await first; + Assert.True(runtime.WaitForExecutionStarted(TimeSpan.FromSeconds(2), expectedCallCount: 2)); + runtime.ReleaseExecution(); + await second; + + Assert.Equal(2, runtime.ExecuteCallCount); + } + finally + { + Directory.Delete(configRoot, recursive: true); + } + } + /// /// 验证飞拍链路在进入运行时前就会准备最终发送队列,而不是把 speedRatio 重采样留给运行时临场处理。 /// @@ -2005,6 +2110,12 @@ internal sealed class RecordingControllerRuntime : IControllerRuntime { LastExecutedResult = result; } + + /// + public void ExecuteCartesianTrajectory(TrajectoryResult result, IReadOnlyList finalPose) + { + LastExecutedResult = result; + } } /// @@ -2166,6 +2277,182 @@ internal sealed class DelayedCompletionControllerRuntime : IControllerRuntime _jointPositions = finalJointPositions.ToArray(); } } + + /// + public void ExecuteCartesianTrajectory(TrajectoryResult result, IReadOnlyList finalPose) + { + } +} + +/// +/// 模拟 runtime 执行入口长期占用的测试运行时,用于验证兼容层锁边界。 +/// +internal sealed class BlockingExecutionControllerRuntime : IControllerRuntime +{ + private readonly object _lock = new(); + private readonly ManualResetEventSlim _executionStarted = new(false); + private readonly ManualResetEventSlim _releaseExecution = new(false); + private readonly double[] _jointPositions; + private bool _isEnabled; + private bool _isInMotion; + private int _executeCallCount; + + /// + /// 初始化一份会阻塞 ExecuteTrajectory 的测试运行时。 + /// + /// 初始关节反馈。 + public BlockingExecutionControllerRuntime(IReadOnlyList initialJointPositions) + { + _jointPositions = initialJointPositions.ToArray(); + } + + /// + /// 获取 runtime 执行入口被调用的次数。 + /// + public int ExecuteCallCount + { + get + { + lock (_lock) + { + return _executeCallCount; + } + } + } + + /// + /// 等待指定序号的执行调用进入 runtime。 + /// + /// 最长等待时间。 + /// 期望已经进入的执行次数。 + /// 是否在超时前等到。 + public bool WaitForExecutionStarted(TimeSpan timeout, int expectedCallCount = 1) + { + var deadline = DateTimeOffset.UtcNow.Add(timeout); + while (DateTimeOffset.UtcNow < deadline) + { + lock (_lock) + { + if (_executeCallCount >= expectedCallCount) + { + return true; + } + } + + _executionStarted.Wait(TimeSpan.FromMilliseconds(10)); + } + + return false; + } + + /// + /// 释放当前阻塞的执行调用。 + /// + public void ReleaseExecution() + { + _releaseExecution.Set(); + } + + public void ResetRobot(RobotProfile robot, string robotName) + { + } + + public void SetActiveController(bool sim) + { + } + + public void Connect(string robotIp) + { + } + + public void Disconnect() + { + } + + public void EnableRobot(int bufferSize) + { + _isEnabled = true; + } + + public void DisableRobot() + { + _isEnabled = false; + } + + public void StopMove() + { + lock (_lock) + { + _isInMotion = false; + } + + ReleaseExecution(); + } + + public double GetSpeedRatio() => 1.0; + + public void SetSpeedRatio(double ratio) + { + } + + public IReadOnlyList GetTcp() => [0.0, 0.0, 0.0]; + + public void SetTcp(double x, double y, double z) + { + } + + public bool GetIo(int port, string ioType) => false; + + public void SetIo(int port, bool value, string ioType) + { + } + + public IReadOnlyList GetJointPositions() + { + return _jointPositions.ToArray(); + } + + public IReadOnlyList GetPose() => [0.0, 0.0, 0.0, 0.0, 0.0, 0.0]; + + public ControllerStateSnapshot GetSnapshot() + { + lock (_lock) + { + return new ControllerStateSnapshot( + capturedAt: DateTimeOffset.UtcNow, + connectionState: "Connected", + isEnabled: _isEnabled, + isInMotion: _isInMotion, + speedRatio: 1.0, + jointPositions: _jointPositions.ToArray(), + cartesianPose: Array.Empty(), + activeAlarms: Array.Empty()); + } + } + + public void ExecuteTrajectory(TrajectoryResult result, IReadOnlyList finalJointPositions) + { + lock (_lock) + { + _executeCallCount++; + _isInMotion = true; + _executionStarted.Set(); + _releaseExecution.Reset(); + } + + _releaseExecution.Wait(); + + lock (_lock) + { + _isInMotion = false; + _executionStarted.Reset(); + } + } + + public void ExecuteCartesianTrajectory(TrajectoryResult result, IReadOnlyList finalPose) + { + ExecuteTrajectory(result, _jointPositions); + } } /// @@ -2292,4 +2579,8 @@ internal sealed class StickyFeedbackControllerRuntime : IControllerRuntime _jointPositions = finalJointPositions.ToArray(); } } + + public void ExecuteCartesianTrajectory(TrajectoryResult result, IReadOnlyList finalPose) + { + } } diff --git a/tests/Flyshot.Server.IntegrationTests/DebugConsoleEndpointTests.cs b/tests/Flyshot.Server.IntegrationTests/DebugConsoleEndpointTests.cs index 601d581..0fcf321 100644 --- a/tests/Flyshot.Server.IntegrationTests/DebugConsoleEndpointTests.cs +++ b/tests/Flyshot.Server.IntegrationTests/DebugConsoleEndpointTests.cs @@ -44,6 +44,28 @@ public sealed class DebugConsoleEndpointTests(FlyshotServerFactory factory) : IC Assert.Contains("/api/debug/config", script, StringComparison.Ordinal); } + /// + /// 调试页应当为 MovePose 提供可直接发送的六字段请求体模板。 + /// + [Fact] + public async Task GetDebugScript_ContainsMovePoseRequestSample() + { + using var configuredFactory = CreateFactoryWithSwaggerEnabled(true); + using var client = configuredFactory.CreateClient(); + + using var scriptResponse = await client.GetAsync("/assets/debug.js"); + Assert.Equal(HttpStatusCode.OK, scriptResponse.StatusCode); + + var script = await scriptResponse.Content.ReadAsStringAsync(); + Assert.Contains("/move_pose/", script, StringComparison.Ordinal); + Assert.Contains("x: 100.0", script, StringComparison.Ordinal); + Assert.Contains("y: 200.0", script, StringComparison.Ordinal); + Assert.Contains("z: 300.0", script, StringComparison.Ordinal); + Assert.Contains("w: 0.0", script, StringComparison.Ordinal); + Assert.Contains("p: 45.0", script, StringComparison.Ordinal); + Assert.Contains("r: 0.0", script, StringComparison.Ordinal); + } + /// /// 当 Swagger 启用时,调试配置 API 应当返回实际 Swagger JSON 地址。 /// diff --git a/tests/Flyshot.Server.IntegrationTests/LegacyHttpApiCompatibilityTests.cs b/tests/Flyshot.Server.IntegrationTests/LegacyHttpApiCompatibilityTests.cs index 89ff82e..0777072 100644 --- a/tests/Flyshot.Server.IntegrationTests/LegacyHttpApiCompatibilityTests.cs +++ b/tests/Flyshot.Server.IntegrationTests/LegacyHttpApiCompatibilityTests.cs @@ -1,5 +1,6 @@ using System.Net; using System.Net.Http.Json; +using System.Text; using System.Text.Json; namespace Flyshot.Server.IntegrationTests; @@ -131,6 +132,13 @@ public sealed class LegacyHttpApiCompatibilityTests(FlyshotServerFactory factory Assert.Equal("robot moved", moveJointJson.RootElement.GetProperty("status").GetString()); } + using (var movePoseResponse = await client.PostAsJsonAsync("/move_pose/", new { x = 100.0, y = 200.0, z = 300.0, w = 1.0, p = 2.0, r = 3.0 })) + { + Assert.Equal(HttpStatusCode.OK, movePoseResponse.StatusCode); + using var movePoseJson = await ReadJsonAsync(movePoseResponse); + Assert.Equal("robot moved", movePoseJson.RootElement.GetProperty("status").GetString()); + } + using (var getJointPositionResponse = await client.GetAsync("/get_joint_position/")) { Assert.Equal(HttpStatusCode.OK, getJointPositionResponse.StatusCode); @@ -165,6 +173,32 @@ public sealed class LegacyHttpApiCompatibilityTests(FlyshotServerFactory factory } } + /// + /// 验证 MovePose 请求必须显式提供六个有限直角坐标字段,避免缺字段被模型绑定静默补 0。 + /// + [Theory] + [InlineData("""{"x":100.0,"y":200.0,"z":300.0,"w":1.0,"p":2.0}""")] + [InlineData("null")] + [InlineData("""{"x":1e999,"y":200.0,"z":300.0,"w":1.0,"p":2.0,"r":3.0}""")] + [InlineData("""{"x":1000.1,"y":200.0,"z":300.0,"w":1.0,"p":2.0,"r":3.0}""")] + [InlineData("""{"x":100.0,"y":-1000.1,"z":300.0,"w":1.0,"p":2.0,"r":3.0}""")] + [InlineData("""{"x":100.0,"y":200.0,"z":-0.1,"w":1.0,"p":2.0,"r":3.0}""")] + [InlineData("""{"x":100.0,"y":200.0,"z":300.0,"w":-180.1,"p":2.0,"r":3.0}""")] + [InlineData("""{"x":100.0,"y":200.0,"z":300.0,"w":1.0,"p":90.1,"r":3.0}""")] + [InlineData("""{"x":100.0,"y":200.0,"z":300.0,"w":1.0,"p":2.0,"r":180.1}""")] + public async Task MovePose_InvalidPayload_ReturnsLegacyBadRequest(string payload) + { + using var client = factory.CreateClient(); + await InitializeRobotAsync(client); + + using var content = new StringContent(payload, Encoding.UTF8, "application/json"); + using var response = await client.PostAsync("/move_pose/", content); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + using var json = await ReadJsonAsync(response); + Assert.Equal("MovePose failed", json.RootElement.GetProperty("detail").GetString()); + } + /// /// 验证飞拍 HTTP 接口可以按旧 API 层的路径和字段完成上传、列出、执行与删除。 /// diff --git a/tests/Flyshot.Server.IntegrationTests/StatusEndpointTests.cs b/tests/Flyshot.Server.IntegrationTests/StatusEndpointTests.cs index cfcdcdc..2c8c3f9 100644 --- a/tests/Flyshot.Server.IntegrationTests/StatusEndpointTests.cs +++ b/tests/Flyshot.Server.IntegrationTests/StatusEndpointTests.cs @@ -34,6 +34,39 @@ public sealed class StatusEndpointTests(FlyshotServerFactory factory) : IClassFi Assert.Contains("/api/status/snapshot", script, StringComparison.Ordinal); } + /// + /// 状态页应当提供直角坐标点动按钮,并复用现有 MovePose HTTP 接口。 + /// + [Fact] + public async Task GetStatusPageAssets_ExposeCartesianJogControls() + { + using var client = factory.CreateClient(); + + using var htmlResponse = await client.GetAsync("/status.html"); + Assert.Equal(HttpStatusCode.OK, htmlResponse.StatusCode); + + var html = await htmlResponse.Content.ReadAsStringAsync(); + Assert.Contains("直角坐标点动", html, StringComparison.Ordinal); + Assert.Contains("id=\"linear-step\"", html, StringComparison.Ordinal); + Assert.Contains("id=\"angular-step\"", html, StringComparison.Ordinal); + + foreach (var axis in new[] { "x", "y", "z", "w", "p", "r" }) + { + Assert.Contains($"data-axis=\"{axis}\"", html, StringComparison.Ordinal); + Assert.Contains($"data-axis=\"{axis}\" data-direction=\"1\"", html, StringComparison.Ordinal); + Assert.Contains($"data-axis=\"{axis}\" data-direction=\"-1\"", html, StringComparison.Ordinal); + } + + using var scriptResponse = await client.GetAsync("/assets/status.js"); + Assert.Equal(HttpStatusCode.OK, scriptResponse.StatusCode); + + var script = await scriptResponse.Content.ReadAsStringAsync(); + Assert.Contains("/move_pose/", script, StringComparison.Ordinal); + Assert.Contains("cartesianPose", script, StringComparison.Ordinal); + Assert.Contains("pointerdown", script, StringComparison.Ordinal); + Assert.Contains("pointerup", script, StringComparison.Ordinal); + } + /// /// 验证状态快照 API 会返回运行时连接、使能、速度和机器人元数据。 ///