diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index 760813e..7e69559 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -8,7 +8,8 @@
"Bash(DOTNET_CLI_HOME=/tmp NUGET_PACKAGES=/tmp/nuget-packages dotnet build FlyshotReplacement.sln --no-restore -v minimal)",
"Bash(DOTNET_CLI_HOME=/tmp NUGET_PACKAGES=/tmp/nuget-packages dotnet test tests/Flyshot.Server.IntegrationTests/Flyshot.Server.IntegrationTests.csproj --no-build -v minimal)",
"Bash(python -c \"import json; json.load\\(open\\('.vscode/launch.json'\\)\\); json.load\\(open\\('.vscode/tasks.json'\\)\\); print\\('JSON valid.'\\)\")",
- "Bash(python -c \"import json; json.load\\(open\\('.vscode/launch.json', encoding='utf-8'\\)\\); json.load\\(open\\('.vscode/tasks.json', encoding='utf-8'\\)\\); print\\('JSON valid.'\\)\")"
+ "Bash(python -c \"import json; json.load\\(open\\('.vscode/launch.json', encoding='utf-8'\\)\\); json.load\\(open\\('.vscode/tasks.json', encoding='utf-8'\\)\\); print\\('JSON valid.'\\)\")",
+ "Bash(/bin/bash -lc 'DOTNET_CLI_HOME=/tmp NUGET_PACKAGES=/tmp/nuget-packages dotnet build FlyshotReplacement.sln --no-restore -v minimal')"
]
}
}
diff --git a/NLog.config b/NLog.config
index d2a3eaf..d9341a3 100644
--- a/NLog.config
+++ b/NLog.config
@@ -6,38 +6,35 @@
throwExceptions="false"
internalLogLevel="Off" >
-
-
-
+
-
+
+
+
-
-
-
-
+
+
-
-
+
+
-
-
+
+
+
+
+
diff --git a/src/Flyshot.ControllerClientCompat/ControllerClientCompatService.cs b/src/Flyshot.ControllerClientCompat/ControllerClientCompatService.cs
index 8c7c5a9..96f3374 100644
--- a/src/Flyshot.ControllerClientCompat/ControllerClientCompatService.cs
+++ b/src/Flyshot.ControllerClientCompat/ControllerClientCompatService.cs
@@ -1,6 +1,7 @@
using Flyshot.Core.Config;
using Flyshot.Core.Domain;
using Flyshot.Runtime.Common;
+using Microsoft.Extensions.Logging;
namespace Flyshot.ControllerClientCompat;
@@ -17,6 +18,7 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
private readonly ControllerClientTrajectoryOrchestrator _trajectoryOrchestrator;
private readonly RobotConfigLoader _configLoader;
private readonly IFlyshotTrajectoryStore _trajectoryStore;
+ private readonly ILogger? _logger;
private RobotProfile? _activeRobotProfile;
private string? _configuredRobotName;
private CompatibilityRobotSettings? _robotSettings;
@@ -35,13 +37,15 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
/// 轨迹规划与触发编排器。
/// 旧版 RobotConfig.json 加载器。
/// 已上传轨迹持久化存储。
+ /// 日志记录器;允许测试直接构造时传入 null。
public ControllerClientCompatService(
ControllerClientCompatOptions options,
ControllerClientCompatRobotCatalog robotCatalog,
IControllerRuntime runtime,
ControllerClientTrajectoryOrchestrator trajectoryOrchestrator,
RobotConfigLoader configLoader,
- IFlyshotTrajectoryStore trajectoryStore)
+ IFlyshotTrajectoryStore trajectoryStore,
+ ILogger? logger = null)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_robotCatalog = robotCatalog ?? throw new ArgumentNullException(nameof(robotCatalog));
@@ -49,6 +53,7 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
_trajectoryOrchestrator = trajectoryOrchestrator ?? throw new ArgumentNullException(nameof(trajectoryOrchestrator));
_configLoader = configLoader ?? throw new ArgumentNullException(nameof(configLoader));
_trajectoryStore = trajectoryStore ?? throw new ArgumentNullException(nameof(trajectoryStore));
+ _logger = logger;
}
///
@@ -90,6 +95,8 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
_connectedServerIp = serverIp;
_connectedServerPort = port;
}
+
+ _logger?.LogInformation("ConnectServer 完成: {ServerIp}:{Port}", serverIp, port);
}
///
@@ -107,6 +114,8 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
///
public void SetUpRobot(string robotName)
{
+ _logger?.LogInformation("SetUpRobot 开始: robotName={RobotName}", robotName);
+
var robotSettings = TryLoadRobotSettings() ?? CreateDefaultRobotSettings();
var robotProfile = _robotCatalog.LoadProfile(
robotName,
@@ -129,6 +138,14 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
_uploadedTrajectories[saved.Key] = saved.Value;
}
}
+
+ _logger?.LogInformation(
+ "SetUpRobot 完成: robotName={RobotName}, dof={Dof}, accLimit={AccLimit}, jerkLimit={JerkLimit}, 恢复轨迹数={TrajCount}",
+ robotName,
+ robotProfile.DegreesOfFreedom,
+ robotSettings.AccLimitScale,
+ robotSettings.JerkLimitScale,
+ _uploadedTrajectories.Count);
}
///
@@ -184,11 +201,15 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
throw new ArgumentException("控制器 IP 不能为空。", nameof(robotIp));
}
+ _logger?.LogInformation("Connect 开始: robotIp={RobotIp}", robotIp);
+
lock (_stateLock)
{
EnsureRobotSetup();
_runtime.Connect(robotIp);
}
+
+ _logger?.LogInformation("Connect 完成: robotIp={RobotIp}", robotIp);
}
///
@@ -204,31 +225,37 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
///
public void EnableRobot(int bufferSize)
{
+ _logger?.LogInformation("EnableRobot 开始: bufferSize={BufferSize}", bufferSize);
lock (_stateLock)
{
EnsureRobotSetup();
_runtime.EnableRobot(bufferSize);
}
+ _logger?.LogInformation("EnableRobot 完成");
}
///
public void DisableRobot()
{
+ _logger?.LogInformation("DisableRobot 开始");
lock (_stateLock)
{
EnsureRobotSetup();
_runtime.DisableRobot();
}
+ _logger?.LogInformation("DisableRobot 完成");
}
///
public void StopMove()
{
+ _logger?.LogInformation("StopMove 开始");
lock (_stateLock)
{
EnsureRobotSetup();
_runtime.StopMove();
}
+ _logger?.LogInformation("StopMove 完成");
}
///
@@ -335,6 +362,9 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
{
ArgumentNullException.ThrowIfNull(jointPositions);
+ _logger?.LogInformation("MoveJoint 开始: 目标关节数={JointCount}", jointPositions.Count);
+ _logger?.LogDebug("MoveJoint 目标关节: {Joints}", string.Join(", ", jointPositions.Select(j => j.ToString("F4"))));
+
lock (_stateLock)
{
var robot = RequireActiveRobot();
@@ -345,8 +375,15 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
var speedRatio = _runtime.GetSnapshot().SpeedRatio;
var moveResult = MoveJointTrajectoryGenerator.CreateResult(robot, currentJointPositions, jointPositions, speedRatio);
+ _logger?.LogInformation(
+ "MoveJoint 规划完成: 当前速度倍率={SpeedRatio}, 规划时长={Duration}s, 采样点数={SampleCount}",
+ speedRatio,
+ moveResult.Duration.TotalSeconds,
+ moveResult.DenseJointTrajectory?.Count ?? 0);
_runtime.ExecuteTrajectory(moveResult, jointPositions);
}
+
+ _logger?.LogInformation("MoveJoint 完成");
}
///
@@ -359,6 +396,11 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
throw new ArgumentException("轨迹路点不能为空。", nameof(waypoints));
}
+ _logger?.LogInformation("ExecuteTrajectory 开始: 路点数={WaypointCount}, method={Method}, saveTraj={SaveTraj}",
+ waypoints.Count, options.Method, options.SaveTrajectory);
+ _logger?.LogDebug("ExecuteTrajectory 路点详情: {Waypoints}",
+ string.Join(" | ", waypoints.Select(wp => $"[{string.Join(", ", wp.Select(j => j.ToString("F4")))}]")));
+
lock (_stateLock)
{
var robot = RequireActiveRobot();
@@ -366,9 +408,17 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
// 普通轨迹必须按调用方指定 method 规划,再把规划结果交给运行时执行。
var bundle = _trajectoryOrchestrator.PlanOrdinaryTrajectory(robot, waypoints, options);
+ _logger?.LogInformation(
+ "ExecuteTrajectory 规划完成: method={Method}, 时长={Duration}s, 有效={IsValid}, 采样点数={SampleCount}",
+ bundle.Result.Method,
+ bundle.Result.Duration.TotalSeconds,
+ bundle.Result.IsValid,
+ bundle.Result.DenseJointTrajectory?.Count ?? 0);
var finalJointPositions = bundle.PlannedTrajectory.PlannedWaypoints[^1].Positions;
_runtime.ExecuteTrajectory(bundle.Result, finalJointPositions);
}
+
+ _logger?.LogInformation("ExecuteTrajectory 完成");
}
///
@@ -386,6 +436,12 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
{
ArgumentNullException.ThrowIfNull(trajectory);
+ _logger?.LogInformation(
+ "UploadTrajectory 开始: name={Name}, waypoints={WaypointCount}, shotFlags={ShotCount}",
+ trajectory.Name,
+ trajectory.Waypoints.Count,
+ trajectory.ShotFlags.Count(static f => f));
+
lock (_stateLock)
{
EnsureRuntimeEnabled();
@@ -395,6 +451,8 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
var settings = _robotSettings ?? CreateDefaultRobotSettings();
_trajectoryStore.Save(robotName, settings, trajectory);
}
+
+ _logger?.LogInformation("UploadTrajectory 完成: name={Name}", trajectory.Name);
}
///
@@ -415,6 +473,10 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
throw new ArgumentException("轨迹名称不能为空。", nameof(name));
}
+ _logger?.LogInformation(
+ "ExecuteTrajectoryByName 开始: name={Name}, method={Method}, moveToStart={MoveToStart}, useCache={UseCache}",
+ name, options.Method, options.MoveToStart, options.UseCache);
+
lock (_stateLock)
{
var robot = RequireActiveRobot();
@@ -422,24 +484,37 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
if (!_uploadedTrajectories.TryGetValue(name, out var trajectory))
{
+ _logger?.LogWarning("ExecuteTrajectoryByName 失败: 轨迹不存在 name={Name}", name);
throw new InvalidOperationException("FlyShot trajectory does not exist.");
}
if (trajectory.Waypoints.Count == 0)
{
+ _logger?.LogWarning("ExecuteTrajectoryByName 失败: 轨迹无路点 name={Name}", name);
throw new InvalidOperationException("FlyShot trajectory contains no waypoints.");
}
// 已上传飞拍轨迹必须按调用方指定 method 生成 shot timeline 后再交给运行时。
var bundle = _trajectoryOrchestrator.PlanUploadedFlyshot(robot, trajectory, options, RequireRobotSettings());
+ _logger?.LogInformation(
+ "ExecuteTrajectoryByName 规划完成: name={Name}, method={Method}, 时长={Duration}s, 触发事件数={TriggerCount}, 使用缓存={UsedCache}",
+ name,
+ bundle.Result.Method,
+ bundle.Result.Duration.TotalSeconds,
+ bundle.Result.TriggerTimeline.Count,
+ bundle.Result.UsedCache);
+
if (options.MoveToStart)
{
+ _logger?.LogInformation("ExecuteTrajectoryByName 先移动到起点");
_runtime.ExecuteTrajectory(CreateImmediateMoveResult(), bundle.PlannedTrajectory.PlannedWaypoints[0].Positions);
}
var finalJointPositions = bundle.PlannedTrajectory.PlannedWaypoints[^1].Positions;
_runtime.ExecuteTrajectory(bundle.Result, finalJointPositions);
}
+
+ _logger?.LogInformation("ExecuteTrajectoryByName 完成: name={Name}", name);
}
///
@@ -450,11 +525,14 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
throw new ArgumentException("轨迹名称不能为空。", nameof(name));
}
+ _logger?.LogInformation("SaveTrajectoryInfo 开始: name={Name}, method={Method}", name, method);
+
lock (_stateLock)
{
var robot = RequireActiveRobot();
if (!_uploadedTrajectories.TryGetValue(name, out var trajectory))
{
+ _logger?.LogWarning("SaveTrajectoryInfo 失败: 轨迹不存在 name={Name}", name);
throw new InvalidOperationException("FlyShot trajectory does not exist.");
}
@@ -469,6 +547,8 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
var settings = _robotSettings ?? CreateDefaultRobotSettings();
_trajectoryStore.Save(robotName, settings, trajectory);
}
+
+ _logger?.LogInformation("SaveTrajectoryInfo 完成: name={Name}", name);
}
///
@@ -479,11 +559,14 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
throw new ArgumentException("轨迹名称不能为空。", nameof(name));
}
+ _logger?.LogInformation("IsFlyshotTrajectoryValid 开始: name={Name}, method={Method}", name, method);
+
lock (_stateLock)
{
var robot = RequireActiveRobot();
if (!_uploadedTrajectories.TryGetValue(name, out var trajectory))
{
+ _logger?.LogWarning("IsFlyshotTrajectoryValid 失败: 轨迹不存在 name={Name}", name);
throw new InvalidOperationException("FlyShot trajectory does not exist.");
}
@@ -494,6 +577,9 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
RequireRobotSettings());
duration = bundle.Result.Duration;
+ _logger?.LogInformation(
+ "IsFlyshotTrajectoryValid 结果: name={Name}, valid={Valid}, duration={Duration}s",
+ name, bundle.Result.IsValid, duration.TotalSeconds);
return bundle.Result.IsValid;
}
}
@@ -506,16 +592,21 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
throw new ArgumentException("轨迹名称不能为空。", nameof(name));
}
+ _logger?.LogInformation("DeleteTrajectory 开始: name={Name}", name);
+
lock (_stateLock)
{
if (!_uploadedTrajectories.Remove(name))
{
+ _logger?.LogWarning("DeleteTrajectory 失败: 轨迹不存在 name={Name}", name);
throw new InvalidOperationException("DeleteFlyShotTraj failed");
}
var robotName = _configuredRobotName ?? throw new InvalidOperationException("Robot has not been setup.");
_trajectoryStore.Delete(robotName, name);
}
+
+ _logger?.LogInformation("DeleteTrajectory 完成: name={Name}", name);
}
///
diff --git a/src/Flyshot.ControllerClientCompat/ControllerClientTrajectoryOrchestrator.cs b/src/Flyshot.ControllerClientCompat/ControllerClientTrajectoryOrchestrator.cs
index a7013ec..f0b1148 100644
--- a/src/Flyshot.ControllerClientCompat/ControllerClientTrajectoryOrchestrator.cs
+++ b/src/Flyshot.ControllerClientCompat/ControllerClientTrajectoryOrchestrator.cs
@@ -3,6 +3,7 @@ using Flyshot.Core.Domain;
using Flyshot.Core.Planning;
using Flyshot.Core.Planning.Sampling;
using Flyshot.Core.Triggering;
+using Microsoft.Extensions.Logging;
namespace Flyshot.ControllerClientCompat;
@@ -11,10 +12,22 @@ namespace Flyshot.ControllerClientCompat;
///
public sealed class ControllerClientTrajectoryOrchestrator
{
- private readonly ICspPlanner _icspPlanner = new();
- private readonly SelfAdaptIcspPlanner _selfAdaptIcspPlanner = new();
+ private readonly ICspPlanner _icspPlanner;
+ private readonly SelfAdaptIcspPlanner _selfAdaptIcspPlanner;
private readonly ShotTimelineBuilder _shotTimelineBuilder = new(new WaypointTimestampResolver());
private readonly Dictionary _flyshotCache = new(StringComparer.Ordinal);
+ private readonly ILogger? _logger;
+
+ ///
+ /// 初始化轨迹编排器。
+ ///
+ /// 日志记录器;允许 null。
+ public ControllerClientTrajectoryOrchestrator(ILogger? logger = null)
+ {
+ _logger = logger;
+ _icspPlanner = new(logger: null);
+ _selfAdaptIcspPlanner = new(logger: null);
+ }
///
/// 对普通轨迹执行 ICSP 规划。
@@ -31,6 +44,10 @@ public sealed class ControllerClientTrajectoryOrchestrator
ArgumentNullException.ThrowIfNull(waypoints);
options ??= new TrajectoryExecutionOptions();
+ _logger?.LogInformation(
+ "PlanOrdinaryTrajectory 开始: 路点数={WaypointCount}, method={Method}",
+ waypoints.Count, options.Method);
+
var program = CreateProgram(
name: "ordinary-trajectory",
waypoints: waypoints,
@@ -49,6 +66,11 @@ public sealed class ControllerClientTrajectoryOrchestrator
var shotTimeline = new ShotTimeline(Array.Empty(), Array.Empty());
var result = CreateResult(plannedTrajectory, shotTimeline, usedCache: false);
+ _logger?.LogInformation(
+ "PlanOrdinaryTrajectory 完成: 时长={Duration}s, 采样点数={SampleCount}",
+ result.Duration.TotalSeconds,
+ result.DenseJointTrajectory?.Count ?? 0);
+
return new PlannedExecutionBundle(plannedTrajectory, shotTimeline, result);
}
@@ -69,6 +91,10 @@ public sealed class ControllerClientTrajectoryOrchestrator
options ??= new FlyshotExecutionOptions();
settings ??= CreateDefaultRobotSettings();
+ _logger?.LogInformation(
+ "PlanUploadedFlyshot 开始: name={Name}, waypoints={WaypointCount}, method={Method}, useCache={UseCache}",
+ uploaded.Name, uploaded.Waypoints.Count, options.Method, options.UseCache);
+
var program = CreateProgram(
name: uploaded.Name,
waypoints: uploaded.Waypoints,
@@ -80,6 +106,7 @@ public sealed class ControllerClientTrajectoryOrchestrator
var cacheKey = CreateFlyshotCacheKey(robot, uploaded, options, settings);
if (options.UseCache && _flyshotCache.TryGetValue(cacheKey, out var cachedBundle))
{
+ _logger?.LogInformation("PlanUploadedFlyshot 命中缓存: name={Name}, cacheKey={CacheKey}", uploaded.Name, cacheKey);
// 命中缓存时只替换 TrajectoryResult 的 usedCache 标志,规划轨迹和触发时间轴保持不可变复用。
return new PlannedExecutionBundle(
cachedBundle.PlannedTrajectory,
@@ -104,6 +131,10 @@ public sealed class ControllerClientTrajectoryOrchestrator
var result = CreateResult(plannedTrajectory, shotTimeline, usedCache: false);
var bundle = new PlannedExecutionBundle(plannedTrajectory, shotTimeline, result);
+ _logger?.LogInformation(
+ "PlanUploadedFlyshot 完成: name={Name}, 时长={Duration}s, 触发事件数={TriggerCount}, 采样点数={SampleCount}",
+ uploaded.Name, result.Duration.TotalSeconds, result.TriggerTimeline.Count, result.DenseJointTrajectory?.Count ?? 0);
+
if (options.UseCache)
{
_flyshotCache[cacheKey] = bundle;
diff --git a/src/Flyshot.ControllerClientCompat/FlyshotTrajectoryStore.cs b/src/Flyshot.ControllerClientCompat/FlyshotTrajectoryStore.cs
index 1cbcce6..7f54aff 100644
--- a/src/Flyshot.ControllerClientCompat/FlyshotTrajectoryStore.cs
+++ b/src/Flyshot.ControllerClientCompat/FlyshotTrajectoryStore.cs
@@ -2,6 +2,7 @@ using System.Text.Json;
using System.Text.Json.Nodes;
using Flyshot.Core.Config;
using Flyshot.Core.Domain;
+using Microsoft.Extensions.Logging;
namespace Flyshot.ControllerClientCompat;
@@ -41,16 +42,19 @@ public sealed class JsonFlyshotTrajectoryStore : IFlyshotTrajectoryStore
{
private readonly ControllerClientCompatOptions _options;
private readonly RobotConfigLoader _configLoader;
+ private readonly ILogger? _logger;
///
/// 初始化基于 JSON 文件的轨迹存储。
///
/// 兼容层基础配置,用于定位工作区根目录。
/// 旧版 RobotConfig.json 加载器,用于反序列化已保存的轨迹。
- public JsonFlyshotTrajectoryStore(ControllerClientCompatOptions options, RobotConfigLoader configLoader)
+ /// 日志记录器;允许 null。
+ public JsonFlyshotTrajectoryStore(ControllerClientCompatOptions options, RobotConfigLoader configLoader, ILogger? logger = null)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_configLoader = configLoader ?? throw new ArgumentNullException(nameof(configLoader));
+ _logger = logger;
}
///
@@ -59,6 +63,12 @@ public sealed class JsonFlyshotTrajectoryStore : IFlyshotTrajectoryStore
ArgumentNullException.ThrowIfNull(settings);
ArgumentNullException.ThrowIfNull(trajectory);
+ _logger?.LogInformation(
+ "TrajectoryStore 保存轨迹: robot={RobotName}, name={TrajectoryName}, waypoints={WaypointCount}",
+ robotName,
+ trajectory.Name,
+ trajectory.Waypoints.Count);
+
var path = ResolveStorePath(robotName);
var directory = Path.GetDirectoryName(path)!;
Directory.CreateDirectory(directory);
@@ -92,6 +102,8 @@ public sealed class JsonFlyshotTrajectoryStore : IFlyshotTrajectoryStore
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
};
File.WriteAllText(path, root.ToJsonString(writeOptions));
+
+ _logger?.LogInformation("TrajectoryStore 轨迹已保存到 {Path}", path);
}
///
@@ -102,9 +114,12 @@ public sealed class JsonFlyshotTrajectoryStore : IFlyshotTrajectoryStore
throw new ArgumentException("轨迹名称不能为空。", nameof(trajectoryName));
}
+ _logger?.LogInformation("TrajectoryStore 删除轨迹: robot={RobotName}, name={TrajectoryName}", robotName, trajectoryName);
+
var path = ResolveStorePath(robotName);
if (!File.Exists(path))
{
+ _logger?.LogWarning("TrajectoryStore 删除失败: 文件不存在 {Path}", path);
return;
}
@@ -112,19 +127,27 @@ public sealed class JsonFlyshotTrajectoryStore : IFlyshotTrajectoryStore
var root = JsonNode.Parse(existingJson)?.AsObject();
if (root is null)
{
+ _logger?.LogWarning("TrajectoryStore 删除失败: 无法解析 JSON {Path}", path);
return;
}
if (root.TryGetPropertyValue("flying_shots", out var flyingShotsNode) && flyingShotsNode is JsonObject flyingShotsObj)
{
- flyingShotsObj.Remove(trajectoryName);
-
- var writeOptions = new JsonSerializerOptions
+ var removed = flyingShotsObj.Remove(trajectoryName);
+ if (removed)
{
- WriteIndented = true,
- PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
- };
- File.WriteAllText(path, root.ToJsonString(writeOptions));
+ var writeOptions = new JsonSerializerOptions
+ {
+ WriteIndented = true,
+ PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
+ };
+ File.WriteAllText(path, root.ToJsonString(writeOptions));
+ _logger?.LogInformation("TrajectoryStore 轨迹已删除: {TrajectoryName}", trajectoryName);
+ }
+ else
+ {
+ _logger?.LogWarning("TrajectoryStore 删除失败: 轨迹不存在 {TrajectoryName}", trajectoryName);
+ }
}
}
@@ -134,12 +157,15 @@ public sealed class JsonFlyshotTrajectoryStore : IFlyshotTrajectoryStore
var path = ResolveStorePath(robotName);
if (!File.Exists(path))
{
+ _logger?.LogInformation("TrajectoryStore 无持久化数据: {Path}", path);
settings = null;
return new Dictionary(StringComparer.Ordinal);
}
try
{
+ _logger?.LogInformation("TrajectoryStore 正在加载: {Path}", path);
+
var workspaceRoot = ResolveWorkspaceRoot();
var loaded = _configLoader.Load(path, workspaceRoot);
settings = loaded.Robot;
@@ -156,10 +182,18 @@ public sealed class JsonFlyshotTrajectoryStore : IFlyshotTrajectoryStore
dict[program.Key] = traj;
}
+ _logger?.LogInformation(
+ "TrajectoryStore 加载完成: robot={RobotName}, 轨迹数={Count}, useDo={UseDo}, ioKeepCycles={IoKeepCycles}",
+ robotName,
+ dict.Count,
+ settings?.UseDo,
+ settings?.IoKeepCycles);
+
return dict;
}
- catch
+ catch (Exception ex)
{
+ _logger?.LogError(ex, "TrajectoryStore 加载失败: {Path}", path);
settings = null;
return new Dictionary(StringComparer.Ordinal);
}
diff --git a/src/Flyshot.Core.Planning/Flyshot.Core.Planning.csproj b/src/Flyshot.Core.Planning/Flyshot.Core.Planning.csproj
index 1ccc45d..d159671 100644
--- a/src/Flyshot.Core.Planning/Flyshot.Core.Planning.csproj
+++ b/src/Flyshot.Core.Planning/Flyshot.Core.Planning.csproj
@@ -6,6 +6,10 @@
enable
+
+
+
+
diff --git a/src/Flyshot.Core.Planning/ICspPlanner.cs b/src/Flyshot.Core.Planning/ICspPlanner.cs
index cbe97bd..65f571a 100644
--- a/src/Flyshot.Core.Planning/ICspPlanner.cs
+++ b/src/Flyshot.Core.Planning/ICspPlanner.cs
@@ -1,4 +1,5 @@
using Flyshot.Core.Domain;
+using Microsoft.Extensions.Logging;
namespace Flyshot.Core.Planning;
@@ -33,6 +34,7 @@ public sealed class ICspPlanner
private readonly int _maxIterations;
private readonly bool _enforceFinalScale;
private readonly double _finalScaleTolerance;
+ private readonly ILogger? _logger;
///
/// 初始化 ICSP 规划器。
@@ -41,11 +43,13 @@ public sealed class ICspPlanner
/// 最大迭代轮数。
/// 是否在最终最优 scale 仍大于 1.0 时抛出失败。
/// 最终 scale 判定容差。
+ /// 日志记录器;允许 null,供无日志场景使用。
public ICspPlanner(
double threshold = DefaultThreshold,
int maxIterations = DefaultMaxIterations,
bool enforceFinalScale = true,
- double finalScaleTolerance = DefaultFinalScaleTolerance)
+ double finalScaleTolerance = DefaultFinalScaleTolerance,
+ ILogger? logger = null)
{
if (threshold <= 0.0 || double.IsNaN(threshold) || double.IsInfinity(threshold))
{
@@ -66,6 +70,7 @@ public sealed class ICspPlanner
_maxIterations = maxIterations;
_enforceFinalScale = enforceFinalScale;
_finalScaleTolerance = finalScaleTolerance;
+ _logger = logger;
}
///
@@ -81,9 +86,22 @@ public sealed class ICspPlanner
throw new ArgumentException("ICSP 至少需要 4 个示教点。", nameof(request));
}
+ _logger?.LogInformation(
+ "ICSP 规划开始: 名称={Name}, 路点数={WaypointCount}, 自由度={Dof}, threshold={Threshold}, maxIterations={MaxIterations}",
+ request.Program.Name, waypoints.Count, request.Robot.DegreesOfFreedom, _threshold, _maxIterations);
+ _logger?.LogDebug(
+ "ICSP 输入路点: {Waypoints}",
+ string.Join(" | ", waypoints.Select(wp => $"[{string.Join(", ", wp.Positions.Select(j => j.ToString("F4")))}]")));
+
var qs = WaypointsToArray(waypoints);
var (velLimits, accLimits, jerkLimits) = ExtractLimits(request.Robot);
+ _logger?.LogDebug(
+ "ICSP 约束限值: vel=[{Vel}], acc=[{Acc}], jerk=[{Jerk}]",
+ string.Join(", ", velLimits.Select(v => v.ToString("F2"))),
+ string.Join(", ", accLimits.Select(a => a.ToString("F2"))),
+ string.Join(", ", jerkLimits.Select(j => j.ToString("F2"))));
+
// 初始段时长直接取相邻示教点的关节空间欧氏距离。
var segmentDurations = ComputeInitialDurations(qs);
int nseg = segmentDurations.Length;
@@ -135,6 +153,9 @@ public sealed class ICspPlanner
if (currentThreshold < _threshold)
{
+ _logger?.LogDebug(
+ "ICSP 第 {Iteration} 轮收敛: threshold={CurrentThreshold:E6}",
+ iteration + 1, currentThreshold);
break;
}
@@ -152,10 +173,22 @@ public sealed class ICspPlanner
var globalScale = bestScales.Max();
if (_enforceFinalScale && globalScale > 1.0 + _finalScaleTolerance)
{
+ _logger?.LogError(
+ "ICSP 规划未收敛: global_scale={GlobalScale:F6} > {Tolerance:F6}, 段缩放=[{Scales}]",
+ globalScale, 1.0 + _finalScaleTolerance,
+ string.Join(", ", bestScales.Select(s => s.ToString("F4"))));
throw new InvalidOperationException(
$"ICSP 规划未收敛,global_scale={globalScale:F6} > {1.0 + _finalScaleTolerance:F6},轨迹不可执行。");
}
+ _logger?.LogInformation(
+ "ICSP 规划完成: 名称={Name}, 迭代轮数={Iterations}, 收敛阈值={Threshold:E6}, 总时长={Duration:F4}s, global_scale={GlobalScale:F6}",
+ request.Program.Name, bestIterations, bestThreshold, bestWaypointTimes[^1], globalScale);
+ _logger?.LogDebug(
+ "ICSP 段时长: [{Durations}], 段缩放: [{Scales}]",
+ string.Join(", ", bestDurations.Select(d => d.ToString("F4"))),
+ string.Join(", ", bestScales.Select(s => s.ToString("F4"))));
+
return new PlannedTrajectory(
robot: request.Robot,
originalProgram: request.Program,
diff --git a/src/Flyshot.Core.Planning/SelfAdaptIcspPlanner.cs b/src/Flyshot.Core.Planning/SelfAdaptIcspPlanner.cs
index be3fb86..aef327a 100644
--- a/src/Flyshot.Core.Planning/SelfAdaptIcspPlanner.cs
+++ b/src/Flyshot.Core.Planning/SelfAdaptIcspPlanner.cs
@@ -1,182 +1,213 @@
-using Flyshot.Core.Domain;
-
-namespace Flyshot.Core.Planning;
-
-///
-/// 在 ICSP 外层包裹补中点策略,实现 self-adapt-icsp 行为。
-///
-/// 为什么需要这层?
-/// ---
-/// 逆向分析已经指出:原系统里普通 icsp 若仍有段 scale > 1,不会直接返回未收敛结果;
-/// 配置中还明确存在 adapt_icsp_try_num。本层把“超限段统一插入中点后再重规划”的逻辑显式落地,
-/// 补上 demo 缺失的失败恢复路径。
-///
-/// 补点策略:
-/// ---
-/// 对当前所有 scale > 1 + tolerance 的段统一插入关节空间中点,然后把新路点集交给 ICSPPlanner
-/// 重新规划。这种"先把明显病灶都降一档,再整体重规划"的策略比逐段拆分更稳定,
-/// 也更符合服务端 adapt_icsp_try_num 的意图。
-///
-public sealed class SelfAdaptIcspPlanner
-{
- ///
- /// 判定段是否超限的数值容差,过滤浮点噪声。
- ///
- public const double ScaleTolerance = 5e-4;
-
- private readonly ICspPlanner _innerPlanner = new(enforceFinalScale: false);
-
- ///
- /// 执行自适应 ICSP 规划,允许在超限段插入中点后重试。
- ///
- /// 轨迹规划请求。
- /// 最大补点重试次数(默认 5)。
- /// 规划后的轨迹结果。
- /// 超过最大重试次数仍未收敛时抛出。
- public PlannedTrajectory Plan(TrajectoryRequest request, int adaptIcspTryNum = 5)
- {
- ArgumentNullException.ThrowIfNull(request);
-
- var currentWaypoints = request.Program.Waypoints.ToArray();
- var currentShotFlags = request.Program.ShotFlags.ToArray();
- var currentOffsets = request.Program.OffsetValues.ToArray();
- var currentAddrs = request.Program.AddressGroups.ToArray();
- int originalWaypointCount = currentWaypoints.Length;
-
- if (originalWaypointCount < 4)
- {
- throw new ArgumentException("ICSP 至少需要 4 个示教点。", nameof(request));
- }
-
- var currentProgram = BuildProgram(request.Program.Name, currentWaypoints, currentShotFlags, currentOffsets, currentAddrs);
- var currentRequest = new TrajectoryRequest(
- robot: request.Robot,
- program: currentProgram,
- method: PlanningMethod.SelfAdaptIcsp,
- moveToStart: request.MoveToStart,
- saveTrajectoryArtifacts: request.SaveTrajectoryArtifacts,
- useCache: request.UseCache);
-
- PlannedTrajectory? lastTrajectory = null;
- int maxAttempts = Math.Max(0, adaptIcspTryNum);
-
- for (int attempt = 0; attempt <= maxAttempts; attempt++)
- {
- var trajectory = _innerPlanner.Plan(currentRequest);
- lastTrajectory = trajectory;
-
- var badSegments = new List();
- for (int seg = 0; seg < trajectory.SegmentScales.Count; seg++)
- {
- if (trajectory.SegmentScales[seg] > 1.0 + ScaleTolerance)
- {
- badSegments.Add(seg);
- }
- }
-
- if (badSegments.Count == 0)
- {
- // 所有段都满足约束,收敛成功。返回包含补中点后路点的轨迹。
- return new PlannedTrajectory(
- robot: trajectory.Robot,
- originalProgram: request.Program,
- plannedWaypoints: currentWaypoints,
- waypointTimes: trajectory.WaypointTimes,
- segmentDurations: trajectory.SegmentDurations,
- segmentScales: trajectory.SegmentScales,
- method: PlanningMethod.SelfAdaptIcsp,
- iterations: trajectory.Iterations,
- threshold: trajectory.Threshold);
- }
-
- if (attempt >= maxAttempts)
- {
- break;
- }
-
- // 对超限段插入中点,并同步扩展 shot 元数据。
- (currentWaypoints, currentShotFlags, currentOffsets, currentAddrs) =
- InsertSegmentMidpoints(currentWaypoints, currentShotFlags, currentOffsets, currentAddrs, badSegments);
-
- currentProgram = BuildProgram(request.Program.Name, currentWaypoints, currentShotFlags, currentOffsets, currentAddrs);
- currentRequest = new TrajectoryRequest(
- robot: request.Robot,
- program: currentProgram,
- method: PlanningMethod.SelfAdaptIcsp,
- moveToStart: request.MoveToStart,
- saveTrajectoryArtifacts: request.SaveTrajectoryArtifacts,
- useCache: request.UseCache);
- }
-
- double maxScale = lastTrajectory?.SegmentScales.Max() ?? double.NaN;
- throw new InvalidOperationException(
- $"self-adapt ICSP 在 {adaptIcspTryNum} 轮补点后仍未收敛,最大段缩放因子={maxScale:F6}。");
- }
-
- ///
- /// 用当前路点集和元数据构造 FlyshotProgram。
- ///
- private static FlyshotProgram BuildProgram(
- string name,
- JointWaypoint[] waypoints,
- bool[] shotFlags,
- int[] offsets,
- IoAddressGroup[] addrs)
- {
- return new FlyshotProgram(
- name: name,
- waypoints: waypoints,
- shotFlags: shotFlags,
- offsetValues: offsets,
- addressGroups: addrs);
- }
-
- ///
- /// 对超限段统一插入关节空间中点,并同步扩展 shot 元数据。
- /// 新插入的路点默认 shotFlag=false、offset=0、addr=空。
- ///
- private static (JointWaypoint[] waypoints, bool[] shotFlags, int[] offsets, IoAddressGroup[] addrs)
- InsertSegmentMidpoints(
- JointWaypoint[] waypoints,
- bool[] shotFlags,
- int[] offsets,
- IoAddressGroup[] addrs,
- List badSegments)
- {
- if (badSegments.Count == 0)
- {
- return (waypoints, shotFlags, offsets, addrs);
- }
-
- var badSet = new HashSet(badSegments);
- var newWaypoints = new List(waypoints.Length + badSegments.Count);
- var newShotFlags = new List(waypoints.Length + badSegments.Count);
- var newOffsets = new List(waypoints.Length + badSegments.Count);
- var newAddrs = new List(waypoints.Length + badSegments.Count);
-
- newWaypoints.Add(waypoints[0]);
- newShotFlags.Add(shotFlags[0]);
- newOffsets.Add(offsets[0]);
- newAddrs.Add(addrs[0]);
-
- for (int seg = 0; seg < waypoints.Length - 1; seg++)
- {
- if (badSet.Contains(seg))
- {
- var mid = new JointWaypoint(
- waypoints[seg].Positions.Zip(waypoints[seg + 1].Positions, (a, b) => (a + b) / 2.0));
- newWaypoints.Add(mid);
- newShotFlags.Add(false);
- newOffsets.Add(0);
- newAddrs.Add(new IoAddressGroup(Array.Empty()));
- }
-
- newWaypoints.Add(waypoints[seg + 1]);
- newShotFlags.Add(shotFlags[seg + 1]);
- newOffsets.Add(offsets[seg + 1]);
- newAddrs.Add(addrs[seg + 1]);
- }
-
- return (newWaypoints.ToArray(), newShotFlags.ToArray(), newOffsets.ToArray(), newAddrs.ToArray());
- }
-}
+using Flyshot.Core.Domain;
+using Microsoft.Extensions.Logging;
+
+namespace Flyshot.Core.Planning;
+
+///
+/// 在 ICSP 外层包裹补中点策略,实现 self-adapt-icsp 行为。
+///
+/// 为什么需要这层?
+/// ---
+/// 逆向分析已经指出:原系统里普通 icsp 若仍有段 scale > 1,不会直接返回未收敛结果;
+/// 配置中还明确存在 adapt_icsp_try_num。本层把"超限段统一插入中点后再重规划"的逻辑显式落地,
+/// 补上 demo 缺失的失败恢复路径。
+///
+/// 补点策略:
+/// ---
+/// 对当前所有 scale > 1 + tolerance 的段统一插入关节空间中点,然后把新路点集交给 ICSPPlanner
+/// 重新规划。这种"先把明显病灶都降一档,再整体重规划"的策略比逐段拆分更稳定,
+/// 也更符合服务端 adapt_icsp_try_num 的意图。
+///
+public sealed class SelfAdaptIcspPlanner
+{
+ ///
+ /// 判定段是否超限的数值容差,过滤浮点噪声。
+ ///
+ public const double ScaleTolerance = 5e-4;
+
+ private readonly ICspPlanner _innerPlanner;
+ private readonly ILogger? _logger;
+
+ ///
+ /// 初始化 SelfAdaptIcspPlanner。
+ ///
+ /// 日志记录器;允许 null。
+ public SelfAdaptIcspPlanner(ILogger? logger = null)
+ {
+ _innerPlanner = new ICspPlanner(enforceFinalScale: false, logger: null);
+ _logger = logger;
+ }
+
+ ///
+ /// 执行自适应 ICSP 规划,允许在超限段插入中点后重试。
+ ///
+ /// 轨迹规划请求。
+ /// 最大补点重试次数(默认 5)。
+ /// 规划后的轨迹结果。
+ /// 超过最大重试次数仍未收敛时抛出。
+ public PlannedTrajectory Plan(TrajectoryRequest request, int adaptIcspTryNum = 5)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+
+ var currentWaypoints = request.Program.Waypoints.ToArray();
+ var currentShotFlags = request.Program.ShotFlags.ToArray();
+ var currentOffsets = request.Program.OffsetValues.ToArray();
+ var currentAddrs = request.Program.AddressGroups.ToArray();
+ int originalWaypointCount = currentWaypoints.Length;
+
+ if (originalWaypointCount < 4)
+ {
+ throw new ArgumentException("ICSP 至少需要 4 个示教点。", nameof(request));
+ }
+
+ _logger?.LogInformation(
+ "SelfAdaptICSP 规划开始: 名称={Name}, 原始路点数={WaypointCount}, 最大补点次数={MaxAttempts}",
+ request.Program.Name, originalWaypointCount, adaptIcspTryNum);
+
+ var currentProgram = BuildProgram(request.Program.Name, currentWaypoints, currentShotFlags, currentOffsets, currentAddrs);
+ var currentRequest = new TrajectoryRequest(
+ robot: request.Robot,
+ program: currentProgram,
+ method: PlanningMethod.SelfAdaptIcsp,
+ moveToStart: request.MoveToStart,
+ saveTrajectoryArtifacts: request.SaveTrajectoryArtifacts,
+ useCache: request.UseCache);
+
+ PlannedTrajectory? lastTrajectory = null;
+ int maxAttempts = Math.Max(0, adaptIcspTryNum);
+
+ for (int attempt = 0; attempt <= maxAttempts; attempt++)
+ {
+ var trajectory = _innerPlanner.Plan(currentRequest);
+ lastTrajectory = trajectory;
+
+ var badSegments = new List();
+ for (int seg = 0; seg < trajectory.SegmentScales.Count; seg++)
+ {
+ if (trajectory.SegmentScales[seg] > 1.0 + ScaleTolerance)
+ {
+ badSegments.Add(seg);
+ }
+ }
+
+ if (badSegments.Count == 0)
+ {
+ _logger?.LogInformation(
+ "SelfAdaptICSP 规划完成: 名称={Name}, 补点轮数={Attempts}, 最终路点数={WaypointCount}, 迭代次数={Iterations}, 总时长={Duration:F4}s",
+ request.Program.Name, attempt, currentWaypoints.Length, trajectory.Iterations, trajectory.WaypointTimes[^1]);
+ // 所有段都满足约束,收敛成功。返回包含补中点后路点的轨迹。
+ return new PlannedTrajectory(
+ robot: trajectory.Robot,
+ originalProgram: request.Program,
+ plannedWaypoints: currentWaypoints,
+ waypointTimes: trajectory.WaypointTimes,
+ segmentDurations: trajectory.SegmentDurations,
+ segmentScales: trajectory.SegmentScales,
+ method: PlanningMethod.SelfAdaptIcsp,
+ iterations: trajectory.Iterations,
+ threshold: trajectory.Threshold);
+ }
+
+ _logger?.LogWarning(
+ "SelfAdaptICSP 第 {Attempt} 轮存在超限段: 超限段数={BadCount}, 段索引=[{Segments}], 最大缩放={MaxScale:F4}",
+ attempt, badSegments.Count, string.Join(", ", badSegments), trajectory.SegmentScales.Max());
+
+ if (attempt >= maxAttempts)
+ {
+ break;
+ }
+
+ // 对超限段插入中点,并同步扩展 shot 元数据。
+ int waypointCountBefore = currentWaypoints.Length;
+ (currentWaypoints, currentShotFlags, currentOffsets, currentAddrs) =
+ InsertSegmentMidpoints(currentWaypoints, currentShotFlags, currentOffsets, currentAddrs, badSegments);
+
+ _logger?.LogDebug(
+ "SelfAdaptICSP 补中点: 路点数 {Before} -> {After}, 插入段=[{Segments}]",
+ waypointCountBefore, currentWaypoints.Length, string.Join(", ", badSegments));
+
+ currentProgram = BuildProgram(request.Program.Name, currentWaypoints, currentShotFlags, currentOffsets, currentAddrs);
+ currentRequest = new TrajectoryRequest(
+ robot: request.Robot,
+ program: currentProgram,
+ method: PlanningMethod.SelfAdaptIcsp,
+ moveToStart: request.MoveToStart,
+ saveTrajectoryArtifacts: request.SaveTrajectoryArtifacts,
+ useCache: request.UseCache);
+ }
+
+ double maxScale = lastTrajectory?.SegmentScales.Max() ?? double.NaN;
+ _logger?.LogError(
+ "SelfAdaptICSP 规划失败: 名称={Name}, 在 {Attempts} 轮补点后仍未收敛, 最大段缩放因子={MaxScale:F6}",
+ request.Program.Name, adaptIcspTryNum, maxScale);
+ throw new InvalidOperationException(
+ $"self-adapt ICSP 在 {adaptIcspTryNum} 轮补点后仍未收敛,最大段缩放因子={maxScale:F6}。");
+ }
+
+ ///
+ /// 用当前路点集和元数据构造 FlyshotProgram。
+ ///
+ private static FlyshotProgram BuildProgram(
+ string name,
+ JointWaypoint[] waypoints,
+ bool[] shotFlags,
+ int[] offsets,
+ IoAddressGroup[] addrs)
+ {
+ return new FlyshotProgram(
+ name: name,
+ waypoints: waypoints,
+ shotFlags: shotFlags,
+ offsetValues: offsets,
+ addressGroups: addrs);
+ }
+
+ ///
+ /// 对超限段统一插入关节空间中点,并同步扩展 shot 元数据。
+ /// 新插入的路点默认 shotFlag=false、offset=0、addr=空。
+ ///
+ private static (JointWaypoint[] waypoints, bool[] shotFlags, int[] offsets, IoAddressGroup[] addrs)
+ InsertSegmentMidpoints(
+ JointWaypoint[] waypoints,
+ bool[] shotFlags,
+ int[] offsets,
+ IoAddressGroup[] addrs,
+ List badSegments)
+ {
+ if (badSegments.Count == 0)
+ {
+ return (waypoints, shotFlags, offsets, addrs);
+ }
+
+ var badSet = new HashSet(badSegments);
+ var newWaypoints = new List(waypoints.Length + badSegments.Count);
+ var newShotFlags = new List(waypoints.Length + badSegments.Count);
+ var newOffsets = new List(waypoints.Length + badSegments.Count);
+ var newAddrs = new List(waypoints.Length + badSegments.Count);
+
+ newWaypoints.Add(waypoints[0]);
+ newShotFlags.Add(shotFlags[0]);
+ newOffsets.Add(offsets[0]);
+ newAddrs.Add(addrs[0]);
+
+ for (int seg = 0; seg < waypoints.Length - 1; seg++)
+ {
+ if (badSet.Contains(seg))
+ {
+ var mid = new JointWaypoint(
+ waypoints[seg].Positions.Zip(waypoints[seg + 1].Positions, (a, b) => (a + b) / 2.0));
+ newWaypoints.Add(mid);
+ newShotFlags.Add(false);
+ newOffsets.Add(0);
+ newAddrs.Add(new IoAddressGroup(Array.Empty()));
+ }
+
+ newWaypoints.Add(waypoints[seg + 1]);
+ newShotFlags.Add(shotFlags[seg + 1]);
+ newOffsets.Add(offsets[seg + 1]);
+ newAddrs.Add(addrs[seg + 1]);
+ }
+
+ return (newWaypoints.ToArray(), newShotFlags.ToArray(), newOffsets.ToArray(), newAddrs.ToArray());
+ }
+}
diff --git a/src/Flyshot.Runtime.Fanuc/FanucControllerRuntime.cs b/src/Flyshot.Runtime.Fanuc/FanucControllerRuntime.cs
index 1c8eae6..cf26d17 100644
--- a/src/Flyshot.Runtime.Fanuc/FanucControllerRuntime.cs
+++ b/src/Flyshot.Runtime.Fanuc/FanucControllerRuntime.cs
@@ -2,6 +2,7 @@ using System.Diagnostics;
using Flyshot.Core.Domain;
using Flyshot.Runtime.Common;
using Flyshot.Runtime.Fanuc.Protocol;
+using Microsoft.Extensions.Logging;
namespace Flyshot.Runtime.Fanuc;
@@ -16,6 +17,7 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
private readonly FanucCommandClient _commandClient;
private readonly FanucStateClient _stateClient;
private readonly FanucJ519Client _j519Client;
+ private readonly ILogger? _logger;
private RobotProfile? _robot;
private string? _robotName;
@@ -35,21 +37,24 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
///
/// 初始化 FANUC 控制器运行时。
///
- public FanucControllerRuntime()
+ /// 日志记录器;允许 null,供无日志场景使用。
+ public FanucControllerRuntime(ILogger? logger = null)
{
_commandClient = new FanucCommandClient();
_stateClient = new FanucStateClient();
_j519Client = new FanucJ519Client();
+ _logger = logger;
}
///
/// 供测试注入 mock 客户端的内部构造函数。
///
- internal FanucControllerRuntime(FanucCommandClient commandClient, FanucStateClient stateClient, FanucJ519Client j519Client)
+ internal FanucControllerRuntime(FanucCommandClient commandClient, FanucStateClient stateClient, FanucJ519Client j519Client, ILogger? logger = null)
{
_commandClient = commandClient;
_stateClient = stateClient;
_j519Client = j519Client;
+ _logger = logger;
}
///
@@ -61,6 +66,8 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
throw new ArgumentException("机器人名称不能为空。", nameof(robotName));
}
+ _logger?.LogInformation("ResetRobot: robotName={RobotName}, dof={Dof}", robotName, robot.DegreesOfFreedom);
+
lock (_stateLock)
{
DisconnectClients();
@@ -82,6 +89,7 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
///
public void SetActiveController(bool sim)
{
+ _logger?.LogInformation("SetActiveController: sim={Sim}", sim);
lock (_stateLock)
{
EnsureRobotSetup();
@@ -101,6 +109,8 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
throw new ArgumentException("控制器 IP 不能为空。", nameof(robotIp));
}
+ _logger?.LogInformation("Connect 开始: robotIp={RobotIp}, 仿真={IsSim}", robotIp, _activeControllerIsSimulation);
+
lock (_stateLock)
{
EnsureActiveControllerSelected();
@@ -109,6 +119,7 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
_connectedRobotIp = robotIp;
_isEnabled = false;
_isInMotion = false;
+ _logger?.LogInformation("Connect 完成(仿真): robotIp={RobotIp}", robotIp);
return;
}
@@ -121,11 +132,14 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
_isEnabled = false;
_isInMotion = false;
}
+
+ _logger?.LogInformation("Connect 完成(真机): robotIp={RobotIp}, 三条通道已建立", robotIp);
}
///
public void Disconnect()
{
+ _logger?.LogInformation("Disconnect 开始");
lock (_stateLock)
{
EnsureRobotSetup();
@@ -135,6 +149,7 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
_isEnabled = false;
_isInMotion = false;
}
+ _logger?.LogInformation("Disconnect 完成");
}
///
@@ -145,6 +160,8 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
throw new ArgumentOutOfRangeException(nameof(bufferSize), "buffer_size 必须大于 0。");
}
+ _logger?.LogInformation("EnableRobot 开始: bufferSize={BufferSize}, 仿真={IsSim}", bufferSize, _activeControllerIsSimulation);
+
lock (_stateLock)
{
EnsureConnected();
@@ -153,6 +170,7 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
if (IsSimulationMode)
{
_isEnabled = true;
+ _logger?.LogInformation("EnableRobot 完成(仿真)");
return;
}
@@ -165,11 +183,14 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
_j519Client.StartMotion();
_isEnabled = true;
}
+
+ _logger?.LogInformation("EnableRobot 完成(真机): RVBUSTSM 已启动, J519 运动循环已开启");
}
///
public void DisableRobot()
{
+ _logger?.LogInformation("DisableRobot 开始");
lock (_stateLock)
{
EnsureRobotSetup();
@@ -183,11 +204,13 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
_isEnabled = false;
_isInMotion = false;
}
+ _logger?.LogInformation("DisableRobot 完成");
}
///
public void StopMove()
{
+ _logger?.LogInformation("StopMove 开始");
lock (_stateLock)
{
EnsureRobotSetup();
@@ -199,6 +222,7 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
_isInMotion = false;
}
+ _logger?.LogInformation("StopMove 完成");
}
///
@@ -225,6 +249,8 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
throw new ArgumentOutOfRangeException(nameof(ratio), "ratio 必须是有限数值。");
}
+ _logger?.LogInformation("SetSpeedRatio: ratio={Ratio}", ratio);
+
lock (_stateLock)
{
EnsureConnected();
@@ -236,6 +262,8 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
_speedRatio = clampedRatio;
}
+
+ _logger?.LogInformation("SetSpeedRatio 完成: clampedRatio={ClampedRatio}", _speedRatio);
}
///
@@ -426,6 +454,11 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
ArgumentNullException.ThrowIfNull(result);
ArgumentNullException.ThrowIfNull(finalJointPositions);
+ _logger?.LogInformation(
+ "ExecuteTrajectory 开始: program={ProgramName}, method={Method}, 时长={Duration}s, 稠密采样={HasDense}, 触发事件数={TriggerCount}, speedRatio={SpeedRatio}",
+ result.ProgramName, result.Method, result.Duration.TotalSeconds,
+ result.DenseJointTrajectory is not null, result.TriggerTimeline.Count, _speedRatio);
+
lock (_stateLock)
{
EnsureEnabled();
@@ -447,6 +480,7 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
_sendCts = new CancellationTokenSource();
var ct = _sendCts.Token;
_sendTask = Task.Run(() => SendDenseTrajectory(result, finalJointPositions, ct), ct);
+ _logger?.LogInformation("ExecuteTrajectory 已启动后台稠密发送任务");
return;
}
@@ -462,6 +496,7 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
_isInMotion = true;
_jointPositions = finalJointPositions.ToArray();
_isInMotion = false;
+ _logger?.LogInformation("ExecuteTrajectory 完成(单点模式)");
}
}
@@ -498,12 +533,18 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
var sampleCount = CalculateDenseSendSampleCount(durationSeconds, trajectoryStepSeconds);
var periodTicks = (long)(servoPeriodSeconds * Stopwatch.Frequency);
+ _logger?.LogInformation(
+ "SendDenseTrajectory 开始: program={ProgramName}, 采样数={SampleCount}, 时长={Duration}s, speedRatio={SpeedRatio}, 周期={Period}ms, 触发事件数={TriggerCount}",
+ result.ProgramName, sampleCount, durationSeconds, speedRatio, servoPeriodSeconds * 1000, triggers.Count);
+
var stopwatch = Stopwatch.StartNew();
long nextTick = stopwatch.ElapsedTicks;
ushort ioValue = 0;
ushort ioMask = 0;
int holdRemaining = -1;
int segmentIndex = 0;
+ long logInterval = Math.Max(1, sampleCount / 10);
+ int triggerFiredCount = 0;
try
{
@@ -538,6 +579,10 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
ioMask = ComputeIoValue(trigger.AddressGroup);
ioValue = ioMask;
holdRemaining = Math.Max(trigger.HoldCycles - 1, 0);
+ triggerFiredCount++;
+ _logger?.LogInformation(
+ "J519 IO触发: time={Time:F4}s, addr=[{Addr}], holdCycles={HoldCycles}",
+ trajectoryTime, string.Join(",", trigger.AddressGroup.Addresses), trigger.HoldCycles);
break;
}
}
@@ -558,15 +603,30 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
ioMask = 0;
}
+ // 周期性记录进度(Debug 级别,避免高频 Info 日志)。
+ if (sampleIndex > 0 && sampleIndex % logInterval == 0)
+ {
+ _logger?.LogDebug(
+ "SendDenseTrajectory 进度: {Percent}% ({Current}/{Total}), time={Time:F4}s",
+ (int)((double)sampleIndex / sampleCount * 100), sampleIndex, sampleCount, trajectoryTime);
+ }
+
// 高精度忙等待直到下一伺服周期。
while (stopwatch.ElapsedTicks < nextTick)
{
Thread.SpinWait(1);
}
}
+
+ _logger?.LogInformation(
+ "SendDenseTrajectory 正常完成: 采样数={SampleCount}, 触发次数={TriggerFiredCount}, 实际耗时={ElapsedMs}ms",
+ sampleCount, triggerFiredCount, stopwatch.ElapsedMilliseconds);
}
catch (OperationCanceledException)
{
+ _logger?.LogWarning(
+ "SendDenseTrajectory 被取消: 已完成 {Percent}% ({Current}/{Total}), 触发次数={TriggerFiredCount}",
+ (int)((double)(sampleCount > 0 ? 0 : 0) / sampleCount * 100), 0, sampleCount, triggerFiredCount);
// 正常取消,轨迹被中断。
}
finally
diff --git a/src/Flyshot.Runtime.Fanuc/Flyshot.Runtime.Fanuc.csproj b/src/Flyshot.Runtime.Fanuc/Flyshot.Runtime.Fanuc.csproj
index 707afe4..fb0f8ff 100644
--- a/src/Flyshot.Runtime.Fanuc/Flyshot.Runtime.Fanuc.csproj
+++ b/src/Flyshot.Runtime.Fanuc/Flyshot.Runtime.Fanuc.csproj
@@ -6,6 +6,10 @@
enable
+
+
+
+
<_Parameter1>Flyshot.Core.Tests
diff --git a/src/Flyshot.Runtime.Fanuc/Protocol/FanucCommandClient.cs b/src/Flyshot.Runtime.Fanuc/Protocol/FanucCommandClient.cs
index 3a9750c..a2a38e4 100644
--- a/src/Flyshot.Runtime.Fanuc/Protocol/FanucCommandClient.cs
+++ b/src/Flyshot.Runtime.Fanuc/Protocol/FanucCommandClient.cs
@@ -1,4 +1,5 @@
using System.Net.Sockets;
+using Microsoft.Extensions.Logging;
namespace Flyshot.Runtime.Fanuc.Protocol;
@@ -8,6 +9,7 @@ namespace Flyshot.Runtime.Fanuc.Protocol;
public sealed class FanucCommandClient : IDisposable
{
private readonly SemaphoreSlim _sendLock = new(1, 1);
+ private readonly ILogger? _logger;
private TcpClient? _tcpClient;
private NetworkStream? _stream;
private bool _disposed;
@@ -17,6 +19,15 @@ public sealed class FanucCommandClient : IDisposable
///
public bool IsConnected => _tcpClient?.Connected ?? false;
+ ///
+ /// 初始化 FANUC 命令通道客户端。
+ ///
+ /// 日志记录器;允许 null。
+ public FanucCommandClient(ILogger? logger = null)
+ {
+ _logger = logger;
+ }
+
///
/// 建立到 FANUC 控制柜 TCP 10012 命令通道的连接。
///
@@ -37,9 +48,13 @@ public sealed class FanucCommandClient : IDisposable
throw new InvalidOperationException("命令通道已经连接,请先 Disconnect。");
}
+ _logger?.LogInformation("CommandClient ConnectAsync: {Ip}:{Port}", ip, port);
+
_tcpClient = new TcpClient { NoDelay = true };
await _tcpClient.ConnectAsync(ip, port, cancellationToken).ConfigureAwait(false);
_stream = _tcpClient.GetStream();
+
+ _logger?.LogInformation("CommandClient 已连接: {Ip}:{Port}", ip, port);
}
///
@@ -49,6 +64,8 @@ public sealed class FanucCommandClient : IDisposable
{
ObjectDisposedException.ThrowIf(_disposed, this);
+ _logger?.LogInformation("CommandClient Disconnect");
+
_stream?.Dispose();
_stream = null;
_tcpClient?.Dispose();
@@ -259,7 +276,9 @@ public sealed class FanucCommandClient : IDisposable
try
{
await _stream.WriteAsync(frame, cancellationToken).ConfigureAwait(false);
- return await ReadResponseFrameAsync(cancellationToken).ConfigureAwait(false);
+ var response = await ReadResponseFrameAsync(cancellationToken).ConfigureAwait(false);
+ _logger?.LogDebug("CommandClient 发送帧成功: 帧长度={FrameLength}, 响应长度={ResponseLength}", frame.Length, response.Length);
+ return response;
}
finally
{
@@ -270,10 +289,11 @@ public sealed class FanucCommandClient : IDisposable
///
/// 校验普通命令响应结果码,失败时抛出包含消息号和结果码的诊断异常。
///
- private static FanucCommandResultResponse EnsureSuccess(FanucCommandResultResponse response)
+ private FanucCommandResultResponse EnsureSuccess(FanucCommandResultResponse response)
{
if (!response.IsSuccess)
{
+ _logger?.LogError("FANUC 命令失败: msgId=0x{MessageId:X4}, resultCode={ResultCode}", response.MessageId, response.ResultCode);
throw CreateCommandFailureException(response.MessageId, response.ResultCode);
}
@@ -283,10 +303,11 @@ public sealed class FanucCommandClient : IDisposable
///
/// 校验程序状态响应结果码,失败时抛出包含消息号和结果码的诊断异常。
///
- private static FanucProgramStatusResponse EnsureSuccess(FanucProgramStatusResponse response)
+ private FanucProgramStatusResponse EnsureSuccess(FanucProgramStatusResponse response)
{
if (!response.IsSuccess)
{
+ _logger?.LogError("FANUC 命令失败: msgId=0x{MessageId:X4}, resultCode={ResultCode}", response.MessageId, response.ResultCode);
throw CreateCommandFailureException(response.MessageId, response.ResultCode);
}
@@ -296,10 +317,11 @@ public sealed class FanucCommandClient : IDisposable
///
/// 校验速度倍率响应结果码,失败时抛出包含消息号和结果码的诊断异常。
///
- private static FanucSpeedRatioResponse EnsureSuccess(FanucSpeedRatioResponse response)
+ private FanucSpeedRatioResponse EnsureSuccess(FanucSpeedRatioResponse response)
{
if (!response.IsSuccess)
{
+ _logger?.LogError("FANUC 命令失败: msgId=0x{MessageId:X4}, resultCode={ResultCode}", response.MessageId, response.ResultCode);
throw CreateCommandFailureException(response.MessageId, response.ResultCode);
}
@@ -309,10 +331,11 @@ public sealed class FanucCommandClient : IDisposable
///
/// 校验 TCP 位姿响应结果码,失败时抛出包含消息号和结果码的诊断异常。
///
- private static FanucTcpResponse EnsureSuccess(FanucTcpResponse response)
+ private FanucTcpResponse EnsureSuccess(FanucTcpResponse response)
{
if (!response.IsSuccess)
{
+ _logger?.LogError("FANUC 命令失败: msgId=0x{MessageId:X4}, resultCode={ResultCode}", response.MessageId, response.ResultCode);
throw CreateCommandFailureException(response.MessageId, response.ResultCode);
}
@@ -322,10 +345,11 @@ public sealed class FanucCommandClient : IDisposable
///
/// 校验 IO 读取响应结果码,失败时抛出包含消息号和结果码的诊断异常。
///
- private static FanucIoResponse EnsureSuccess(FanucIoResponse response)
+ private FanucIoResponse EnsureSuccess(FanucIoResponse response)
{
if (!response.IsSuccess)
{
+ _logger?.LogError("FANUC 命令失败: msgId=0x{MessageId:X4}, resultCode={ResultCode}", response.MessageId, response.ResultCode);
throw CreateCommandFailureException(response.MessageId, response.ResultCode);
}
diff --git a/src/Flyshot.Runtime.Fanuc/Protocol/FanucJ519Client.cs b/src/Flyshot.Runtime.Fanuc/Protocol/FanucJ519Client.cs
index cd4ab19..cbd1602 100644
--- a/src/Flyshot.Runtime.Fanuc/Protocol/FanucJ519Client.cs
+++ b/src/Flyshot.Runtime.Fanuc/Protocol/FanucJ519Client.cs
@@ -1,5 +1,6 @@
using System.Diagnostics;
using System.Net.Sockets;
+using Microsoft.Extensions.Logging;
namespace Flyshot.Runtime.Fanuc.Protocol;
@@ -10,6 +11,7 @@ public sealed class FanucJ519Client : IDisposable
{
private readonly object _commandLock = new();
private readonly object _responseLock = new();
+ private readonly ILogger? _logger;
private UdpClient? _udpClient;
private CancellationTokenSource? _cts;
private CancellationTokenSource? _sendCts;
@@ -26,6 +28,15 @@ public sealed class FanucJ519Client : IDisposable
///
public bool IsConnected => _udpClient is not null;
+ ///
+ /// 初始化 FANUC J519 客户端。
+ ///
+ /// 日志记录器;允许 null。
+ public FanucJ519Client(ILogger? logger = null)
+ {
+ _logger = logger;
+ }
+
///
/// 建立到 FANUC 控制柜 UDP 60015 运动通道的连接并启动接收循环。
///
@@ -46,11 +57,14 @@ public sealed class FanucJ519Client : IDisposable
throw new InvalidOperationException("J519 通道已经连接,请先 Disconnect。");
}
+ _logger?.LogInformation("J519 ConnectAsync: {Ip}:{Port}", ip, port);
+
_udpClient = new UdpClient();
_udpClient.Connect(ip, port);
// 发送初始化包。
await _udpClient.SendAsync(FanucJ519Protocol.PackInitPacket(), cancellationToken).ConfigureAwait(false);
+ _logger?.LogInformation("J519 初始化包已发送");
_cts = new CancellationTokenSource();
_receiveTask = Task.Run(() => ReceiveLoopAsync(_cts.Token), _cts.Token);
@@ -70,9 +84,11 @@ public sealed class FanucJ519Client : IDisposable
if (_sendTask is not null)
{
+ _logger?.LogDebug("J519 StartMotion: 发送循环已在运行");
return; // 已在运行。
}
+ _logger?.LogInformation("J519 StartMotion: 启动发送循环");
_sendCts = CancellationTokenSource.CreateLinkedTokenSource(_cts!.Token);
_sendTask = Task.Run(() => SendLoopAsync(_sendCts.Token), _sendCts.Token);
}
@@ -89,6 +105,8 @@ public sealed class FanucJ519Client : IDisposable
return;
}
+ _logger?.LogInformation("J519 StopMotionAsync: 停止发送循环");
+
if (_sendTask is not null)
{
_sendCts?.Cancel();
@@ -99,7 +117,7 @@ public sealed class FanucJ519Client : IDisposable
}
catch (TimeoutException)
{
- // 发送循环可能未能在 1 秒内结束,继续执行后续清理。
+ _logger?.LogWarning("J519 StopMotionAsync: 发送循环未能在 1 秒内结束");
}
catch (OperationCanceledException)
{
@@ -114,6 +132,7 @@ public sealed class FanucJ519Client : IDisposable
// 发送结束包通知控制器停止伺服流。
await _udpClient.SendAsync(FanucJ519Protocol.PackEndPacket(), cancellationToken).ConfigureAwait(false);
+ _logger?.LogInformation("J519 StopMotionAsync: 结束包已发送");
}
///
@@ -130,6 +149,12 @@ public sealed class FanucJ519Client : IDisposable
_currentCommand = command;
_commandHistoryForTests?.Add(command);
}
+
+ _logger?.LogDebug(
+ "J519 UpdateCommand: joints=[{Joints}], ioMask=0x{IoMask:X4}, ioValue=0x{IoValue:X4}",
+ string.Join(", ", command.TargetJoints.Select(j => j.ToString("F2"))),
+ command.WriteIoMask,
+ command.WriteIoValue);
}
///
@@ -353,6 +378,10 @@ public sealed class FanucJ519Client : IDisposable
return;
}
+ _logger?.LogInformation("J519 ReceiveLoop 启动");
+ long receiveCount = 0;
+ FanucJ519Response? lastLoggedResponse = null;
+
try
{
while (!cancellationToken.IsCancellationRequested)
@@ -365,16 +394,40 @@ public sealed class FanucJ519Client : IDisposable
{
_latestResponse = response;
}
+
+ receiveCount++;
+
+ // 仅在状态变化时记录 Info,避免高频日志。
+ if (lastLoggedResponse is null
+ || lastLoggedResponse.Status != response.Status
+ || lastLoggedResponse.RobotInMotion != response.RobotInMotion
+ || lastLoggedResponse.SystemReady != response.SystemReady)
+ {
+ _logger?.LogInformation(
+ "J519 响应: status=0x{Status:X2}, seq={Seq}, accept={Accept}, sysrdy={SysRdy}, motion={Motion}, pose=[{Pose}], joints=[{Joints}]",
+ response.Status,
+ response.Sequence,
+ response.AcceptsCommand,
+ response.SystemReady,
+ response.RobotInMotion,
+ string.Join(", ", response.Pose.Select(v => v.ToString("F1"))),
+ string.Join(", ", response.JointDegrees.Take(6).Select(v => v.ToString("F2"))));
+ lastLoggedResponse = response;
+ }
+ else if (receiveCount % 1000 == 0)
+ {
+ _logger?.LogDebug("J519 已接收 {Count} 个响应包", receiveCount);
+ }
}
}
}
catch (OperationCanceledException)
{
- // 正常取消,退出循环。
+ _logger?.LogInformation("J519 ReceiveLoop 正常取消,共接收 {Count} 个包", receiveCount);
}
catch (ObjectDisposedException)
{
- // UDP 客户端已释放,退出循环。
+ _logger?.LogInformation("J519 ReceiveLoop 因 UDP 释放退出,共接收 {Count} 个包", receiveCount);
}
}
}
diff --git a/src/Flyshot.Runtime.Fanuc/Protocol/FanucStateClient.cs b/src/Flyshot.Runtime.Fanuc/Protocol/FanucStateClient.cs
index f607d00..080c3b1 100644
--- a/src/Flyshot.Runtime.Fanuc/Protocol/FanucStateClient.cs
+++ b/src/Flyshot.Runtime.Fanuc/Protocol/FanucStateClient.cs
@@ -1,4 +1,5 @@
using System.Net.Sockets;
+using Microsoft.Extensions.Logging;
namespace Flyshot.Runtime.Fanuc.Protocol;
@@ -114,6 +115,7 @@ public sealed class FanucStateClient : IDisposable
{
private readonly object _stateLock = new();
private readonly FanucStateClientOptions _options;
+ private readonly ILogger? _logger;
private TcpClient? _tcpClient;
private NetworkStream? _stream;
private CancellationTokenSource? _receiveCts;
@@ -130,7 +132,7 @@ public sealed class FanucStateClient : IDisposable
/// 使用默认状态通道参数初始化客户端。
///
public FanucStateClient()
- : this(new FanucStateClientOptions())
+ : this(new FanucStateClientOptions(), null)
{
}
@@ -139,10 +141,21 @@ public sealed class FanucStateClient : IDisposable
///
/// 超时和重连参数。
public FanucStateClient(FanucStateClientOptions options)
+ : this(options, null)
+ {
+ }
+
+ ///
+ /// 使用指定状态通道参数和日志记录器初始化客户端。
+ ///
+ /// 超时和重连参数。
+ /// 日志记录器;允许 null。
+ public FanucStateClient(FanucStateClientOptions options, ILogger? logger)
{
ArgumentNullException.ThrowIfNull(options);
ValidateOptions(options);
_options = options;
+ _logger = logger;
}
///
@@ -170,6 +183,8 @@ public sealed class FanucStateClient : IDisposable
throw new InvalidOperationException("状态通道已经连接,请先 Disconnect。");
}
+ _logger?.LogInformation("StateClient ConnectAsync: {Ip}:{Port}", ip, port);
+
_receiveCts = new CancellationTokenSource();
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _receiveCts.Token);
@@ -187,8 +202,9 @@ public sealed class FanucStateClient : IDisposable
{
await OpenConnectionAsync(ip, port, linkedCts.Token).ConfigureAwait(false);
}
- catch
+ catch (Exception exception)
{
+ _logger?.LogError(exception, "StateClient 连接失败: {Ip}:{Port}", ip, port);
CloseCurrentConnection();
lock (_stateLock)
{
@@ -203,6 +219,8 @@ public sealed class FanucStateClient : IDisposable
_receiveTask = Task.Run(
() => ReceiveAndReconnectLoopAsync(ip, port, _receiveCts.Token),
_receiveCts.Token);
+
+ _logger?.LogInformation("StateClient 已连接并启动接收循环: {Ip}:{Port}", ip, port);
}
///
@@ -212,6 +230,7 @@ public sealed class FanucStateClient : IDisposable
{
ObjectDisposedException.ThrowIf(_disposed, this);
+ _logger?.LogInformation("StateClient Disconnect");
Shutdown(clearLatestFrame: true);
}
@@ -268,6 +287,7 @@ public sealed class FanucStateClient : IDisposable
private async Task ReceiveAndReconnectLoopAsync(string ip, int port, CancellationToken cancellationToken)
{
var reconnectDelay = _options.ReconnectInitialDelay;
+ _logger?.LogInformation("StateClient 接收循环启动: {Ip}:{Port}", ip, port);
while (!cancellationToken.IsCancellationRequested)
{
@@ -278,14 +298,17 @@ public sealed class FanucStateClient : IDisposable
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
+ _logger?.LogInformation("StateClient 接收循环正常取消");
return;
}
catch (TimeoutException ex)
{
+ _logger?.LogWarning(ex, "StateClient 接收超时");
MarkReceiveFailure(FanucStateConnectionState.TimedOut, ex.Message);
}
catch (Exception ex) when (ex is IOException or InvalidDataException or SocketException or ObjectDisposedException)
{
+ _logger?.LogWarning(ex, "StateClient 连接异常,准备重连");
MarkReceiveFailure(FanucStateConnectionState.Reconnecting, ex.Message);
}
@@ -312,6 +335,8 @@ public sealed class FanucStateClient : IDisposable
}
var buffer = new byte[FanucStateProtocol.StateFrameLength];
+ long frameCount = 0;
+ FanucStateFrame? lastLoggedFrame = null;
while (!cancellationToken.IsCancellationRequested)
{
@@ -325,6 +350,26 @@ public sealed class FanucStateClient : IDisposable
_connectionState = FanucStateConnectionState.Connected;
_lastErrorMessage = null;
}
+
+ frameCount++;
+
+ // 仅在状态变化或首次接收时记录 Info,避免高频日志。
+ if (lastLoggedFrame is null
+ || lastLoggedFrame.CartesianPose[0] != frame.CartesianPose[0]
+ || !lastLoggedFrame.RawTailWords.SequenceEqual(frame.RawTailWords))
+ {
+ _logger?.LogInformation(
+ "StateClient 收到状态帧: pose=[{X:F1}, {Y:F1}, {Z:F1}], tail=[{Tail}]",
+ frame.CartesianPose[0],
+ frame.CartesianPose[1],
+ frame.CartesianPose[2],
+ string.Join(", ", frame.RawTailWords));
+ lastLoggedFrame = frame;
+ }
+ else if (frameCount % 1000 == 0)
+ {
+ _logger?.LogDebug("StateClient 已接收 {Count} 个状态帧", frameCount);
+ }
}
}
@@ -367,6 +412,8 @@ public sealed class FanucStateClient : IDisposable
var tcpClient = new TcpClient { NoDelay = true };
try
{
+ _logger?.LogInformation("StateClient 正在连接 {Ip}:{Port}...", ip, port);
+
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(_options.ConnectTimeout);
await tcpClient.ConnectAsync(ip, port, timeoutCts.Token).ConfigureAwait(false);
@@ -378,14 +425,18 @@ public sealed class FanucStateClient : IDisposable
_lastConnectedAt = DateTimeOffset.UtcNow;
_connectionState = FanucStateConnectionState.Connected;
}
+
+ _logger?.LogInformation("StateClient 已连接到 {Ip}:{Port}", ip, port);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
+ _logger?.LogWarning("StateClient 连接 {Ip}:{Port} 超时", ip, port);
tcpClient.Dispose();
throw new TimeoutException("状态通道建连超时。");
}
- catch
+ catch (Exception ex)
{
+ _logger?.LogWarning(ex, "StateClient 连接 {Ip}:{Port} 失败", ip, port);
tcpClient.Dispose();
throw;
}
@@ -409,6 +460,12 @@ public sealed class FanucStateClient : IDisposable
_connectionState = FanucStateConnectionState.Reconnecting;
}
+ _logger?.LogInformation(
+ "StateClient 将在 {Delay}ms 后尝试重连 {Ip}:{Port}...",
+ nextDelay.TotalMilliseconds,
+ ip,
+ port);
+
await Task.Delay(nextDelay, cancellationToken).ConfigureAwait(false);
lock (_stateLock)
@@ -419,6 +476,11 @@ public sealed class FanucStateClient : IDisposable
try
{
await OpenConnectionAsync(ip, port, cancellationToken).ConfigureAwait(false);
+ _logger?.LogInformation(
+ "StateClient 重连成功: {Ip}:{Port}, 累计重连次数={Count}",
+ ip,
+ port,
+ _reconnectAttemptCount);
return _options.ReconnectInitialDelay;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
@@ -434,6 +496,13 @@ public sealed class FanucStateClient : IDisposable
_lastErrorMessage = ex.Message;
}
+ _logger?.LogWarning(
+ ex,
+ "StateClient 重连失败: {Ip}:{Port}, 下次等待={NextDelay}ms",
+ ip,
+ port,
+ nextDelay.TotalMilliseconds * 2);
+
nextDelay = IncreaseReconnectDelay(nextDelay);
}
}
@@ -470,6 +539,8 @@ public sealed class FanucStateClient : IDisposable
_connectionState = state;
_lastErrorMessage = message;
}
+
+ _logger?.LogWarning("StateClient 接收失败: state={State}, message={Message}", state, message);
}
///
diff --git a/src/Flyshot.Server.Host/Controllers/DebugConsoleController.cs b/src/Flyshot.Server.Host/Controllers/DebugConsoleController.cs
index d0e226f..1edb439 100644
--- a/src/Flyshot.Server.Host/Controllers/DebugConsoleController.cs
+++ b/src/Flyshot.Server.Host/Controllers/DebugConsoleController.cs
@@ -5,1159 +5,17 @@ using Microsoft.Extensions.Options;
namespace Flyshot.Server.Host.Controllers;
///
-/// 提供浏览器内的在线 API 调试页面。
-/// 页面会在加载时拉取 Swagger JSON,按 OpenAPI 自动渲染所有端点的入参表单、Body 编辑器和响应面板。
+/// 提供浏览器调试页所需的运行时配置 API。
///
///
-/// 本控制器自身不进入 Swagger 文档(),
-/// 仅作为静态 HTML 出口;调试页和 Swagger UI 共用 Swagger:Enabled 开关,
-/// 关闭后两者一同下线,避免生产环境意外暴露调试入口。
+/// 本控制器自身不进入 Swagger 文档()。
+/// 调试页静态资源位于 wwwroot,Swagger 地址由配置 API 下发。
///
[ApiController]
[ApiExplorerSettings(IgnoreApi = true)]
[Tags("基础与状态")]
public sealed class DebugConsoleController : ControllerBase
{
- ///
- /// 调试页 HTML 模板:内嵌 CSS、HTML 骨架与原生 JS,零外部依赖,适配现场离线环境。
- /// 模板中的 __SWAGGER_JSON_URL__ 占位符在返回前会被替换为实际的 Swagger JSON 地址。
- ///
- private const string DebugConsoleHtmlTemplate = """
-
-
-
-
-
- Flyshot Replacement 接口调试
-
-
-
-
-
-
Flyshot Replacement 接口调试
-
-
-
-
-
-
-
-
-
-
-
-""";
-
///
/// Swagger 配置项,用于决定调试页是否对外暴露以及拼接 OpenAPI JSON 地址。
///
@@ -1174,22 +32,21 @@ public sealed class DebugConsoleController : ControllerBase
}
///
- /// 返回浏览器内可直接打开的 API 调试控制台。
+ /// 返回静态调试页启动时所需的 Swagger 文档地址。
///
- /// 当 Swagger 启用时返回 HTML;否则返回 404,与 Swagger UI 保持一致的可见性策略。
- [HttpGet("/debug")]
- public IActionResult GetDebugConsole()
+ /// 当 Swagger 启用时返回配置;否则返回 404,与 Swagger UI 保持一致的可见性策略。
+ [HttpGet("/api/debug/config")]
+ public IActionResult GetDebugConfig()
{
- // Swagger 关闭时调试页一同下线,避免生产环境意外暴露调试入口。
if (!_swaggerOptions.Enabled)
{
return NotFound();
}
- // 由控制器一侧解析 Swagger JSON 路径,前端不再硬编码路由前缀和文档名。
- var swaggerJsonUrl = ResolveSwaggerJsonUrl(_swaggerOptions);
- var html = DebugConsoleHtmlTemplate.Replace("__SWAGGER_JSON_URL__", swaggerJsonUrl, StringComparison.Ordinal);
- return Content(html, "text/html; charset=utf-8");
+ return Ok(new
+ {
+ SwaggerJsonUrl = ResolveSwaggerJsonUrl(_swaggerOptions)
+ });
}
///
diff --git a/src/Flyshot.Server.Host/Controllers/LegacyHttpApiController.cs b/src/Flyshot.Server.Host/Controllers/LegacyHttpApiController.cs
index e73bc9a..7cdfa47 100644
--- a/src/Flyshot.Server.Host/Controllers/LegacyHttpApiController.cs
+++ b/src/Flyshot.Server.Host/Controllers/LegacyHttpApiController.cs
@@ -12,14 +12,17 @@ namespace Flyshot.Server.Host.Controllers;
public sealed class LegacyHttpApiController : ControllerBase
{
private readonly IControllerClientCompatService _compatService;
+ private readonly ILogger _logger;
///
/// 初始化旧 HTTP 兼容控制器。
///
/// ControllerClient 兼容服务。
- public LegacyHttpApiController(IControllerClientCompatService compatService)
+ /// 日志记录器。
+ public LegacyHttpApiController(IControllerClientCompatService compatService, ILogger logger)
{
_compatService = compatService ?? throw new ArgumentNullException(nameof(compatService));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
///
@@ -41,13 +44,16 @@ public sealed class LegacyHttpApiController : ControllerBase
[HttpPost("/connect_server/")]
public IActionResult ConnectServer([FromQuery] string server_ip, [FromQuery] int port)
{
+ _logger.LogInformation("ConnectServer 调用: server_ip={ServerIp}, port={Port}", server_ip, port);
try
{
_compatService.ConnectServer(server_ip, port);
+ _logger.LogInformation("ConnectServer 成功: server_ip={ServerIp}, port={Port}", server_ip, port);
return Ok(new { status = "connected" });
}
- catch
+ catch (Exception exception)
{
+ _logger.LogError(exception, "ConnectServer 失败: server_ip={ServerIp}, port={Port}", server_ip, port);
return LegacyBadRequest("Connect Server failed");
}
}
@@ -80,13 +86,16 @@ public sealed class LegacyHttpApiController : ControllerBase
[HttpPost("/setup_robot/")]
public IActionResult SetupRobot([FromQuery] string robot_name)
{
+ _logger.LogInformation("SetupRobot 调用: robot_name={RobotName}", robot_name);
try
{
_compatService.SetUpRobot(robot_name);
+ _logger.LogInformation("SetupRobot 成功: robot_name={RobotName}", robot_name);
return Ok(new { status = "robot setup" });
}
- catch
+ catch (Exception exception)
{
+ _logger.LogError(exception, "SetupRobot 失败: robot_name={RobotName}", robot_name);
return LegacyBadRequest("SetUpRobot failed");
}
}
@@ -152,13 +161,16 @@ public sealed class LegacyHttpApiController : ControllerBase
[HttpGet("/enable_robot/")]
public IActionResult EnableRobot([FromQuery] int buffer_size = 2)
{
+ _logger.LogInformation("EnableRobot 调用: buffer_size={BufferSize}", buffer_size);
try
{
_compatService.EnableRobot(buffer_size);
+ _logger.LogInformation("EnableRobot 成功");
return Ok(new { enable_robot = true });
}
- catch
+ catch (Exception exception)
{
+ _logger.LogError(exception, "EnableRobot 失败");
return LegacyBadRequest("EnableRobot failed");
}
}
@@ -170,13 +182,16 @@ public sealed class LegacyHttpApiController : ControllerBase
[HttpGet("/disable_robot/")]
public IActionResult DisableRobot()
{
+ _logger.LogInformation("DisableRobot 调用");
try
{
_compatService.DisableRobot();
+ _logger.LogInformation("DisableRobot 成功");
return Ok(new { disable_robot = true });
}
- catch
+ catch (Exception exception)
{
+ _logger.LogError(exception, "DisableRobot 失败");
return LegacyBadRequest("DisableRobot failed");
}
}
@@ -188,13 +203,16 @@ public sealed class LegacyHttpApiController : ControllerBase
[HttpGet("/stop_move/")]
public IActionResult StopMove()
{
+ _logger.LogInformation("StopMove 调用");
try
{
_compatService.StopMove();
+ _logger.LogInformation("StopMove 成功");
return Ok(new { status = "move stopped" });
}
- catch
+ catch (Exception exception)
{
+ _logger.LogError(exception, "StopMove 失败");
return LegacyBadRequest("StopMove failed");
}
}
@@ -207,13 +225,16 @@ public sealed class LegacyHttpApiController : ControllerBase
[HttpPost("/set_active_controller/")]
public IActionResult SetActiveController([FromQuery] bool sim)
{
+ _logger.LogInformation("SetActiveController 调用: sim={Sim}", sim);
try
{
_compatService.SetActiveController(sim);
+ _logger.LogInformation("SetActiveController 成功: sim={Sim}", sim);
return Ok(new { status = "active controller set" });
}
- catch
+ catch (Exception exception)
{
+ _logger.LogError(exception, "SetActiveController 失败: sim={Sim}", sim);
return LegacyBadRequest("SetActiveController failed");
}
}
@@ -226,13 +247,16 @@ public sealed class LegacyHttpApiController : ControllerBase
[HttpPost("/connect_robot/")]
public IActionResult ConnectRobot([FromQuery] string ip)
{
+ _logger.LogInformation("ConnectRobot 调用: ip={Ip}", ip);
try
{
_compatService.Connect(ip);
+ _logger.LogInformation("ConnectRobot 成功: ip={Ip}", ip);
return Ok(new { status = "robot connected" });
}
- catch
+ catch (Exception exception)
{
+ _logger.LogError(exception, "ConnectRobot 失败: ip={Ip}", ip);
return LegacyBadRequest("Connect failed");
}
}
@@ -244,13 +268,16 @@ public sealed class LegacyHttpApiController : ControllerBase
[HttpPost("/disconnect_robot/")]
public IActionResult DisconnectRobot()
{
+ _logger.LogInformation("DisconnectRobot 调用");
try
{
_compatService.Disconnect();
+ _logger.LogInformation("DisconnectRobot 成功");
return Ok(new { status = "robot disconnected" });
}
- catch
+ catch (Exception exception)
{
+ _logger.LogError(exception, "DisconnectRobot 失败");
return LegacyBadRequest("Disconnect failed");
}
}
@@ -286,13 +313,16 @@ public sealed class LegacyHttpApiController : ControllerBase
[HttpPost("/set_tcp/")]
public IActionResult SetTcp([FromBody] LegacyTcpRequest tcp_data)
{
+ _logger.LogInformation("SetTcp 调用: x={X}, y={Y}, z={Z}", tcp_data.x, tcp_data.y, tcp_data.z);
try
{
_compatService.SetTcp(tcp_data.x, tcp_data.y, tcp_data.z);
+ _logger.LogInformation("SetTcp 成功");
return Ok(new { status = "TCP set" });
}
- catch
+ catch (Exception exception)
{
+ _logger.LogError(exception, "SetTcp 失败");
return LegacyBadRequest("SetTCP failed");
}
}
@@ -324,13 +354,16 @@ public sealed class LegacyHttpApiController : ControllerBase
[HttpPost("/set_io/")]
public IActionResult SetIo([FromQuery] int port, [FromQuery] bool value, [FromQuery] string io_type)
{
+ _logger.LogInformation("SetIo 调用: port={Port}, value={Value}, io_type={IoType}", port, value, io_type);
try
{
_compatService.SetIo(port, value, io_type);
+ _logger.LogInformation("SetIo 成功: port={Port}, value={Value}", port, value);
return Ok(new { status = "IO set" });
}
- catch
+ catch (Exception exception)
{
+ _logger.LogError(exception, "SetIo 失败: port={Port}, value={Value}", port, value);
return LegacyBadRequest("SetDigitalOutput failed");
}
}
@@ -344,12 +377,16 @@ public sealed class LegacyHttpApiController : ControllerBase
[HttpGet("/get_io/")]
public IActionResult GetIo([FromQuery] int port, [FromQuery] string io_type)
{
+ _logger.LogInformation("GetIo 调用: port={Port}, io_type={IoType}", port, io_type);
try
{
- return Ok(new { value = _compatService.GetIo(port, io_type) });
+ var value = _compatService.GetIo(port, io_type);
+ _logger.LogInformation("GetIo 成功: port={Port}, value={Value}", port, value);
+ return Ok(new { value });
}
- catch
+ catch (Exception exception)
{
+ _logger.LogError(exception, "GetIo 失败: port={Port}", port);
return LegacyBadRequest("GetDigitalOutput failed");
}
}
@@ -379,13 +416,17 @@ public sealed class LegacyHttpApiController : ControllerBase
[HttpPost("/move_joint/")]
public IActionResult MoveJoint([FromBody] LegacyJointPositionRequest joint_data)
{
+ _logger.LogInformation("MoveJoint 调用: 关节数={JointCount}", joint_data.joints.Count);
+ _logger.LogDebug("MoveJoint 路点: {Joints}", string.Join(", ", joint_data.joints.Select(j => j.ToString("F4"))));
try
{
_compatService.MoveJoint(joint_data.joints);
+ _logger.LogInformation("MoveJoint 成功");
return Ok(new { status = "robot moved" });
}
- catch
+ catch (Exception exception)
{
+ _logger.LogError(exception, "MoveJoint 失败");
return LegacyBadRequest("MoveJoint failed");
}
}
@@ -441,16 +482,20 @@ public sealed class LegacyHttpApiController : ControllerBase
[FromQuery] string? method = null,
[FromQuery] bool? save_traj = null)
{
+ _logger.LogInformation("ExecuteTrajectory 调用: method={Method}, save_traj={SaveTraj}", method ?? "icsp", save_traj ?? false);
try
{
var request = ParseExecuteTrajectoryRequest(waypoints, method, save_traj);
+ _logger.LogDebug("ExecuteTrajectory 路点数={WaypointCount}, method={Method}", request.Waypoints.Count, request.Method);
_compatService.ExecuteTrajectory(
request.Waypoints,
new TrajectoryExecutionOptions(request.Method, request.SaveTrajectory));
+ _logger.LogInformation("ExecuteTrajectory 成功: method={Method}", request.Method);
return Ok(new { status = "trajectory executed" });
}
- catch
+ catch (Exception exception)
{
+ _logger.LogError(exception, "ExecuteTrajectory 失败");
return LegacyBadRequest("ExecuteTrajectory failed");
}
}
@@ -463,18 +508,30 @@ public sealed class LegacyHttpApiController : ControllerBase
[HttpPost("/upload_flyshot/")]
public IActionResult UploadFlyshot([FromBody] LegacyFlightTrajectoryRequest trajectory_data)
{
+ _logger.LogInformation(
+ "UploadFlyshot 调用: name={Name}, waypoints={WaypointCount}, shot_flags={ShotCount}",
+ trajectory_data.name,
+ trajectory_data.waypoints.Count,
+ trajectory_data.shot_flags.Count(static f => f));
+
if (trajectory_data.shot_flags.Count != trajectory_data.waypoints.Count)
{
+ _logger.LogWarning("UploadFlyshot 校验失败: shot_flags长度({ShotFlagsCount}) != 路点数({WaypointCount})",
+ trajectory_data.shot_flags.Count, trajectory_data.waypoints.Count);
return LegacyValidationError("shot_flags长度必须与路点数量相同");
}
if (trajectory_data.offset_values.Count != trajectory_data.waypoints.Count)
{
+ _logger.LogWarning("UploadFlyshot 校验失败: offset_values长度({OffsetCount}) != 路点数({WaypointCount})",
+ trajectory_data.offset_values.Count, trajectory_data.waypoints.Count);
return LegacyValidationError("offset_values长度必须与路点数量相同");
}
if (trajectory_data.addrs.Count != trajectory_data.waypoints.Count)
{
+ _logger.LogWarning("UploadFlyshot 校验失败: addrs长度({AddrCount}) != 路点数({WaypointCount})",
+ trajectory_data.addrs.Count, trajectory_data.waypoints.Count);
return LegacyValidationError("addrs长度必须与路点数量相同");
}
@@ -488,10 +545,12 @@ public sealed class LegacyHttpApiController : ControllerBase
addressGroups: trajectory_data.addrs);
_compatService.UploadTrajectory(trajectory);
+ _logger.LogInformation("UploadFlyshot 成功: name={Name}", trajectory_data.name);
return Ok(new { status = "FlyShot uploaded" });
}
- catch
+ catch (Exception exception)
{
+ _logger.LogError(exception, "UploadFlyshot 失败: name={Name}", trajectory_data.name);
return LegacyBadRequest("UploadFlyShotTraj failed");
}
}
@@ -504,6 +563,9 @@ public sealed class LegacyHttpApiController : ControllerBase
[HttpPost("/execute_flyshot/")]
public IActionResult ExecuteFlyshot([FromBody] LegacyExecuteFlyshotRequest data)
{
+ _logger.LogInformation(
+ "ExecuteFlyshot 调用: name={Name}, method={Method}, move_to_start={MoveToStart}, use_cache={UseCache}",
+ data.name, data.method, data.move_to_start, data.use_cache);
try
{
_compatService.ExecuteTrajectoryByName(
@@ -513,10 +575,12 @@ public sealed class LegacyHttpApiController : ControllerBase
method: data.method,
saveTrajectory: data.save_traj,
useCache: data.use_cache));
+ _logger.LogInformation("ExecuteFlyshot 成功: name={Name}", data.name);
return Ok(new { status = "FlyShot executed", success = true });
}
catch (Exception exception)
{
+ _logger.LogError(exception, "ExecuteFlyshot 失败: name={Name}", data.name);
return StatusCode(StatusCodes.Status500InternalServerError, new { detail = exception.Message });
}
}
@@ -529,17 +593,21 @@ public sealed class LegacyHttpApiController : ControllerBase
[HttpPost("/save_traj_info/")]
public IActionResult SaveTrajectoryInfo([FromBody] LegacyTrajectoryInfoRequest request)
{
+ _logger.LogInformation("SaveTrajectoryInfo 调用: name={Name}, method={Method}", request.name, request.method);
try
{
_compatService.SaveTrajectoryInfo(request.name, request.method);
+ _logger.LogInformation("SaveTrajectoryInfo 成功: name={Name}", request.name);
return Ok(new { status = "trajectory info saved", success = true });
}
catch (NotSupportedException exception)
{
+ _logger.LogWarning(exception, "SaveTrajectoryInfo 不支持: name={Name}", request.name);
return StatusCode(StatusCodes.Status501NotImplemented, new { detail = exception.Message });
}
- catch
+ catch (Exception exception)
{
+ _logger.LogError(exception, "SaveTrajectoryInfo 失败: name={Name}", request.name);
return LegacyBadRequest("SaveTrajInfo failed");
}
}
@@ -552,6 +620,7 @@ public sealed class LegacyHttpApiController : ControllerBase
[HttpPost("/is_flyShotTrajValid/")]
public IActionResult IsFlyshotTrajectoryValid([FromBody] LegacyFlyshotValidationRequest request)
{
+ _logger.LogInformation("IsFlyshotTrajectoryValid 调用: name={Name}, method={Method}", request.name, request.method);
try
{
var isValid = _compatService.IsFlyshotTrajectoryValid(
@@ -560,14 +629,17 @@ public sealed class LegacyHttpApiController : ControllerBase
request.method,
request.save_traj);
+ _logger.LogInformation("IsFlyshotTrajectoryValid 结果: name={Name}, valid={Valid}, duration={Duration}s", request.name, isValid, duration.TotalSeconds);
return Ok(new { success = isValid, valid = isValid, time = duration.TotalSeconds });
}
catch (NotSupportedException exception)
{
+ _logger.LogWarning(exception, "IsFlyshotTrajectoryValid 不支持: name={Name}", request.name);
return StatusCode(StatusCodes.Status501NotImplemented, new { detail = exception.Message });
}
- catch
+ catch (Exception exception)
{
+ _logger.LogError(exception, "IsFlyshotTrajectoryValid 失败: name={Name}", request.name);
return LegacyBadRequest("IsFlyShotTrajValid failed");
}
}
@@ -580,13 +652,23 @@ public sealed class LegacyHttpApiController : ControllerBase
[HttpPost("/set_speedRatio/")]
public IActionResult SetSpeedRatio([FromBody] LegacySpeedRatioRequest data)
{
+ _logger.LogInformation("SetSpeedRatio 调用: speed={Speed}", data.speed);
try
{
+ // 验证数值 范围符合预期(例如 0.01到 1.0),以避免对控制器造成潜在风险
+ if (data.speed < 0.01 || data.speed > 1.0)
+ {
+ _logger.LogWarning("SetSpeedRatio 参数无效: speed={Speed}", data.speed);
+ return BadRequest(new { detail = "Speed ratio must be between 0.01 and 1.0." });
+ }
+
_compatService.SetSpeedRatio(data.speed);
+ _logger.LogInformation("SetSpeedRatio 成功: speed={Speed}", data.speed);
return Ok(new { message = "set_speedRatio executed", returnCode = 0 });
}
- catch
+ catch (Exception exception)
{
+ _logger.LogError(exception, "SetSpeedRatio 失败: speed={Speed}", data.speed);
return LegacyBadRequest("set_speedRatio failed");
}
}
@@ -599,13 +681,16 @@ public sealed class LegacyHttpApiController : ControllerBase
[HttpPost("/delete_flyshot/")]
public IActionResult DeleteFlyshot([FromBody] LegacyNameRequest request)
{
+ _logger.LogInformation("DeleteFlyshot 调用: name={Name}", request.name);
try
{
_compatService.DeleteTrajectory(request.name);
+ _logger.LogInformation("DeleteFlyshot 成功: name={Name}", request.name);
return Ok(new { status = "FlyShot deleted" });
}
- catch
+ catch (Exception exception)
{
+ _logger.LogError(exception, "DeleteFlyshot 失败: name={Name}", request.name);
return LegacyBadRequest("DeleteFlyShotTraj failed");
}
}
@@ -618,22 +703,28 @@ public sealed class LegacyHttpApiController : ControllerBase
[HttpPost("/init_mpc_robt")]
public IActionResult InitMpcRobot([FromBody] LegacyInitMpcRobotRequest data)
{
+ _logger.LogInformation(
+ "InitMpcRobot 调用: robot_name={RobotName}, robot_ip={RobotIp}, sim={Sim}, server={ServerIp}:{Port}",
+ data.robot_name, data.robot_ip, data.sim, data.server_ip, data.port);
try
{
_compatService.ConnectServer(data.server_ip, data.port);
_compatService.SetUpRobot(data.robot_name);
if (!_compatService.IsSetUp)
{
+ _logger.LogWarning("InitMpcRobot 失败: Robot not setup");
return LegacyBadRequest("Robot not setup");
}
_compatService.SetActiveController(data.sim);
_compatService.Connect(data.robot_ip);
_compatService.EnableRobot(2);
+ _logger.LogInformation("InitMpcRobot 成功: robot_name={RobotName}", data.robot_name);
return Ok(new { message = "init_Success", returnCode = 0 });
}
- catch
+ catch (Exception exception)
{
+ _logger.LogError(exception, "InitMpcRobot 失败");
return LegacyBadRequest("Connect Server failed");
}
}
diff --git a/src/Flyshot.Server.Host/Controllers/StatusController.cs b/src/Flyshot.Server.Host/Controllers/StatusController.cs
index f78f464..b641850 100644
--- a/src/Flyshot.Server.Host/Controllers/StatusController.cs
+++ b/src/Flyshot.Server.Host/Controllers/StatusController.cs
@@ -4,390 +4,12 @@ using Microsoft.AspNetCore.Mvc;
namespace Flyshot.Server.Host.Controllers;
///
-/// 提供只读状态监控页面和控制器状态快照 API。
+/// 提供控制器状态快照 API,状态监控页面由 wwwroot 静态资源承载。
///
[ApiController]
[Tags("基础与状态")]
public sealed class StatusController : ControllerBase
{
- ///
- /// 浏览器端状态监控页面,页面逻辑只读取状态快照,不触发控制动作。
- ///
- private const string StatusPageHtml = """
-
-
-
-
-
- Flyshot Replacement 状态监控
-
-
-
-
-
-
Flyshot Replacement 状态监控
-
-
-
-
-
-
-
- 控制器
-
- - 服务端版本
- --
- - 客户端版本
- --
- - 已初始化
- --
- - 已使能
- --
- - J519 状态
- --
- - J519 序号
- --
- - 采样时间
- --
-
-
-
- 机器人
-
- - 自由度
- --
- - 关节位置
- --
- - TCP 位姿
- --
- - 已上传轨迹
- --
-
-
-
-
-
-
-
-""";
-
private readonly IControllerClientCompatService _compatService;
///
@@ -400,13 +22,23 @@ public sealed class StatusController : ControllerBase
}
///
- /// 返回浏览器可直接打开的状态监控页面。
+ /// 提供短路由 `/status`,跳转到静态状态页。
///
- /// HTML 状态页面。
+ /// 重定向到 /status.html。
[HttpGet("/status")]
- public ContentResult GetStatusPage()
+ public IActionResult StatusPage()
{
- return Content(StatusPageHtml, "text/html; charset=utf-8");
+ return Redirect("/status.html");
+ }
+
+ ///
+ /// 提供短路由 `/debug`,跳转到静态调试页。
+ ///
+ /// 重定向到 /debug.html。
+ [HttpGet("/debug")]
+ public IActionResult DebugPage()
+ {
+ return Redirect("/debug.html");
}
///
diff --git a/src/Flyshot.Server.Host/Flyshot.Server.Host.csproj b/src/Flyshot.Server.Host/Flyshot.Server.Host.csproj
index 88c78ea..f76444e 100644
--- a/src/Flyshot.Server.Host/Flyshot.Server.Host.csproj
+++ b/src/Flyshot.Server.Host/Flyshot.Server.Host.csproj
@@ -7,9 +7,20 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Flyshot.Server.Host/Middleware/RequestResponseLoggingMiddleware.cs b/src/Flyshot.Server.Host/Middleware/RequestResponseLoggingMiddleware.cs
new file mode 100644
index 0000000..5334b19
--- /dev/null
+++ b/src/Flyshot.Server.Host/Middleware/RequestResponseLoggingMiddleware.cs
@@ -0,0 +1,147 @@
+using System.Diagnostics;
+using System.Text;
+
+namespace Flyshot.Server.Host.Middleware;
+
+///
+/// HTTP 请求与响应日志中间件。
+/// 记录每个 HTTP 请求的进入时间、方法、路径、查询串、请求体,
+/// 以及响应的状态码、耗时和响应体(调试级别)。
+///
+public sealed class RequestResponseLoggingMiddleware
+{
+ private readonly RequestDelegate _next;
+ private readonly ILogger _logger;
+
+ ///
+ /// 请求体最大日志长度,超出则截断并附加省略标记。
+ ///
+ private const int MaxBodyLogLength = 4096;
+
+ ///
+ /// 初始化请求响应日志中间件。
+ ///
+ /// 下一个中间件委托。
+ /// 日志记录器。
+ public RequestResponseLoggingMiddleware(RequestDelegate next, ILogger logger)
+ {
+ _next = next ?? throw new ArgumentNullException(nameof(next));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ ///
+ /// 处理 HTTP 请求并记录输入输出。
+ ///
+ /// HTTP 上下文。
+ public async Task InvokeAsync(HttpContext context)
+ {
+ var stopwatch = Stopwatch.StartNew();
+ var request = context.Request;
+ var requestId = Activity.Current?.Id ?? context.TraceIdentifier;
+
+ // 记录请求进入信息(Info 级别:方法、路径、查询参数)。
+ _logger.LogInformation(
+ "[HTTP-REQ] [{RequestId}] {Method} {Path}{QueryString} — 客户端 {RemoteIp}",
+ requestId,
+ request.Method,
+ request.Path,
+ request.QueryString.HasValue ? request.QueryString.Value : string.Empty,
+ context.Connection.RemoteIpAddress);
+
+ // 读取并记录请求体(Debug 级别)。
+ string? requestBody = null;
+ if (request.ContentLength > 0 && request.Body.CanRead)
+ {
+ request.EnableBuffering();
+ requestBody = await ReadBodyAsync(request.Body, context.RequestAborted).ConfigureAwait(false);
+ request.Body.Position = 0;
+
+ if (!string.IsNullOrEmpty(requestBody))
+ {
+ _logger.LogDebug(
+ "[HTTP-REQ-BODY] [{RequestId}] {Body}",
+ requestId,
+ TruncateBody(requestBody));
+ }
+ }
+
+ // 拦截响应流以便读取响应体。
+ var originalResponseBody = context.Response.Body;
+ using var responseBodyStream = new MemoryStream();
+ context.Response.Body = responseBodyStream;
+
+ try
+ {
+ await _next(context).ConfigureAwait(false);
+ }
+ catch (Exception exception)
+ {
+ _logger.LogError(
+ exception,
+ "[HTTP-ERR] [{RequestId}] {Method} {Path} 处理过程中发生未捕获异常",
+ requestId,
+ request.Method,
+ request.Path);
+ throw;
+ }
+ finally
+ {
+ stopwatch.Stop();
+ responseBodyStream.Position = 0;
+ var responseBody = await ReadBodyAsync(responseBodyStream, context.RequestAborted).ConfigureAwait(false);
+ responseBodyStream.Position = 0;
+ await responseBodyStream.CopyToAsync(originalResponseBody, context.RequestAborted).ConfigureAwait(false);
+ context.Response.Body = originalResponseBody;
+
+ var statusCode = context.Response.StatusCode;
+ var level = statusCode >= 500 ? LogLevel.Error : statusCode >= 400 ? LogLevel.Warning : LogLevel.Information;
+
+ // 记录响应概要(Info/Warning/Error 级别)。
+ _logger.Log(
+ level,
+ "[HTTP-RES] [{RequestId}] {Method} {Path} => {StatusCode} ({ElapsedMs}ms)",
+ requestId,
+ request.Method,
+ request.Path,
+ statusCode,
+ stopwatch.ElapsedMilliseconds);
+
+ // 记录响应体(Debug 级别)。
+ if (!string.IsNullOrEmpty(responseBody))
+ {
+ _logger.LogDebug(
+ "[HTTP-RES-BODY] [{RequestId}] {Body}",
+ requestId,
+ TruncateBody(responseBody));
+ }
+ }
+ }
+
+ ///
+ /// 从流中读取文本内容。
+ ///
+ private static async Task ReadBodyAsync(Stream stream, CancellationToken cancellationToken)
+ {
+ if (!stream.CanRead)
+ {
+ return string.Empty;
+ }
+
+ using var reader = new StreamReader(stream, Encoding.UTF8, leaveOpen: true);
+ var body = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
+ return body;
+ }
+
+ ///
+ /// 截断过长内容,避免日志膨胀。
+ ///
+ private static string TruncateBody(string body)
+ {
+ if (body.Length <= MaxBodyLogLength)
+ {
+ return body;
+ }
+
+ return body[..MaxBodyLogLength] + " ... [截断,总长度=" + body.Length + "]";
+ }
+}
diff --git a/src/Flyshot.Server.Host/Program.cs b/src/Flyshot.Server.Host/Program.cs
index 6bcd974..6e313ff 100644
--- a/src/Flyshot.Server.Host/Program.cs
+++ b/src/Flyshot.Server.Host/Program.cs
@@ -1,94 +1,124 @@
using Flyshot.ControllerClientCompat;
using Flyshot.Server.Host;
+using Flyshot.Server.Host.Middleware;
using Microsoft.Extensions.Options;
using Microsoft.OpenApi.Models;
+using NLog.Web;
using Swashbuckle.AspNetCore.SwaggerGen;
-var builder = WebApplication.CreateBuilder(args);
+// NLog: 在 ASP.NET Core 启动前完成配置加载,确保最早期的日志也能被捕获。
+NLog.LogManager.Setup().LoadConfigurationFromAppSettings();
+var logger = NLog.LogManager.GetCurrentClassLogger();
-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 =>
+try
{
- options.SwaggerDoc(swaggerOptions.DocumentName, new OpenApiInfo
+ logger.Info("Flyshot Server Host 启动中...");
+
+ var builder = WebApplication.CreateBuilder(args);
+
+ // NLog: 替换默认日志提供者为 NLog,清除其他 Provider 避免重复输出。
+ builder.Logging.ClearProviders();
+ builder.Host.UseNLog();
+
+ 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 =>
{
- Title = swaggerOptions.Title,
- Version = swaggerOptions.Version
+ options.SwaggerDoc(swaggerOptions.DocumentName, new OpenApiInfo
+ {
+ Title = swaggerOptions.Title,
+ Version = swaggerOptions.Version
+ });
+
+ // 把控制器与 DTO 上的 /// summary 注释纳入 OpenAPI 文档;调试页据此渲染端点标题。
+ var xmlDocumentationPath = Path.Combine(AppContext.BaseDirectory, $"{typeof(Program).Assembly.GetName().Name}.xml");
+ if (File.Exists(xmlDocumentationPath))
+ {
+ options.IncludeXmlComments(xmlDocumentationPath, includeControllerXmlComments: true);
+ }
});
- // 把控制器与 DTO 上的 /// summary 注释纳入 OpenAPI 文档;调试页据此渲染端点标题。
- var xmlDocumentationPath = Path.Combine(AppContext.BaseDirectory, $"{typeof(Program).Assembly.GetName().Name}.xml");
- if (File.Exists(xmlDocumentationPath))
+ var corsOptions = builder.Configuration.GetSection("Cors").Get() ?? new HostCorsOptions();
+ builder.Services.AddCors(options =>
{
- options.IncludeXmlComments(xmlDocumentationPath, includeControllerXmlComments: true);
+ 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();
+
+ var resolvedSwaggerOptions = app.Services.GetRequiredService>().Value;
+ var resolvedCorsOptions = app.Services.GetRequiredService>().Value;
+
+ if (resolvedSwaggerOptions.Enabled)
+ {
+ 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;
+ });
}
-});
-var corsOptions = builder.Configuration.GetSection("Cors").Get() ?? new HostCorsOptions();
-builder.Services.AddCors(options =>
+ app.UseCors(resolvedCorsOptions.PolicyName);
+ app.UseStaticFiles();
+
+ // 注册 HTTP 请求/响应日志中间件,记录所有 API 调用的输入输出。
+ app.UseMiddleware();
+
+ app.MapControllers();
+
+ logger.Info("Flyshot Server Host 已就绪,开始监听请求。");
+ app.Run();
+}
+catch (Exception exception)
{
- 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();
-
-var resolvedSwaggerOptions = app.Services.GetRequiredService>().Value;
-var resolvedCorsOptions = app.Services.GetRequiredService>().Value;
-
-if (resolvedSwaggerOptions.Enabled)
+ logger.Error(exception, "Flyshot Server Host 启动失败。");
+ throw;
+}
+finally
{
- 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;
- });
+ NLog.LogManager.Shutdown();
}
-app.UseCors(resolvedCorsOptions.PolicyName);
-app.MapControllers();
-
-app.Run();
-
public partial class Program;
diff --git a/src/Flyshot.Server.Host/wwwroot/assets/debug.css b/src/Flyshot.Server.Host/wwwroot/assets/debug.css
new file mode 100644
index 0000000..03f6c3d
--- /dev/null
+++ b/src/Flyshot.Server.Host/wwwroot/assets/debug.css
@@ -0,0 +1,424 @@
+:root {
+ color-scheme: light;
+ --bg: #f5f7fb;
+ --surface: #ffffff;
+ --line: #d8dee9;
+ --text: #172033;
+ --muted: #5b667a;
+ --accent: #007c89;
+ --good: #12805c;
+ --warn: #b7791f;
+ --bad: #b42318;
+ --get: #1f6feb;
+ --post: #2da44e;
+ --put: #9a6700;
+ --delete: #cf222e;
+ --code-bg: #f4f6fa;
+}
+
+* { box-sizing: border-box; }
+
+body {
+ margin: 0;
+ min-height: 100vh;
+ background: var(--bg);
+ color: var(--text);
+ font-family: "Segoe UI", "Microsoft YaHei", Arial, sans-serif;
+ font-size: 14px;
+}
+
+header {
+ border-bottom: 1px solid var(--line);
+ background: var(--surface);
+ position: sticky;
+ top: 0;
+ z-index: 10;
+}
+
+.topbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ width: min(1280px, calc(100% - 32px));
+ margin: 0 auto;
+ padding: 18px 0;
+}
+
+h1 {
+ margin: 0;
+ font-size: 22px;
+ font-weight: 650;
+}
+
+.actions {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+button {
+ min-height: 34px;
+ padding: 0 14px;
+ border: 1px solid var(--accent);
+ border-radius: 6px;
+ background: var(--accent);
+ color: #ffffff;
+ font: inherit;
+ cursor: pointer;
+}
+
+button.secondary {
+ background: transparent;
+ color: var(--accent);
+}
+
+button:disabled {
+ opacity: 0.55;
+ cursor: default;
+}
+
+.link-button {
+ display: inline-flex;
+ align-items: center;
+ min-height: 34px;
+ padding: 0 14px;
+ border: 1px solid var(--accent);
+ border-radius: 6px;
+ background: transparent;
+ color: var(--accent);
+ font: inherit;
+ text-decoration: none;
+}
+
+.link-button:hover {
+ background: rgba(0, 124, 137, 0.08);
+}
+
+main {
+ width: min(1280px, calc(100% - 32px));
+ margin: 22px auto 60px;
+}
+
+.meta {
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: var(--surface);
+ margin-bottom: 18px;
+}
+
+.meta dl {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 0;
+ margin: 0;
+ padding: 12px 16px;
+}
+
+.meta dt {
+ color: var(--muted);
+ font-size: 12px;
+}
+
+.meta dd {
+ margin: 4px 0 0;
+ font-family: Consolas, "Cascadia Mono", monospace;
+ overflow-wrap: anywhere;
+}
+
+.meta dd.bad { color: var(--bad); }
+.meta dd.good { color: var(--good); }
+
+.group {
+ margin-top: 22px;
+}
+
+.group h2 {
+ margin: 0 0 10px 4px;
+ font-size: 16px;
+ font-weight: 650;
+ color: var(--muted);
+}
+
+.card {
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: var(--surface);
+ margin-bottom: 12px;
+ overflow: hidden;
+}
+
+.card-head {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 12px 16px;
+ cursor: pointer;
+ user-select: none;
+}
+
+.card-head:hover {
+ background: #fafbfd;
+}
+
+.badge {
+ flex: 0 0 auto;
+ min-width: 60px;
+ padding: 3px 10px;
+ border-radius: 999px;
+ text-align: center;
+ color: #ffffff;
+ font-weight: 650;
+ font-size: 12px;
+ letter-spacing: 0.5px;
+}
+
+.badge.GET { background: var(--get); }
+.badge.POST { background: var(--post); }
+.badge.PUT { background: var(--put); }
+.badge.DELETE { background: var(--delete); }
+.badge.OTHER { background: var(--muted); }
+
+.card-path {
+ flex: 1 1 auto;
+ font-family: Consolas, "Cascadia Mono", monospace;
+ font-size: 14px;
+ overflow-wrap: anywhere;
+}
+
+.card-summary {
+ flex: 0 1 auto;
+ max-width: 50%;
+ color: var(--muted);
+ font-size: 13px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.card-toggle {
+ flex: 0 0 auto;
+ color: var(--muted);
+ font-size: 12px;
+}
+
+.card-body {
+ padding: 12px 16px 16px;
+ border-top: 1px solid var(--line);
+}
+
+.card.collapsed .card-body {
+ display: none;
+}
+
+.form-row {
+ display: grid;
+ grid-template-columns: 180px minmax(0, 1fr) 90px;
+ gap: 8px 12px;
+ align-items: center;
+ margin-bottom: 8px;
+}
+
+.form-row .name {
+ font-family: Consolas, "Cascadia Mono", monospace;
+ color: var(--text);
+ overflow-wrap: anywhere;
+}
+
+.form-row .name .required {
+ color: var(--bad);
+ margin-left: 4px;
+}
+
+.form-row input[type="text"],
+.form-row input[type="number"] {
+ width: 100%;
+ min-height: 32px;
+ padding: 4px 10px;
+ border: 1px solid var(--line);
+ border-radius: 4px;
+ font: inherit;
+}
+
+.form-row .type {
+ color: var(--muted);
+ font-size: 12px;
+ font-family: Consolas, "Cascadia Mono", monospace;
+}
+
+.body-block {
+ margin-top: 6px;
+}
+
+.body-label {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ margin-bottom: 6px;
+}
+
+.body-label .left {
+ color: var(--muted);
+ font-size: 12px;
+}
+
+textarea.body-editor {
+ width: 100%;
+ min-height: 140px;
+ padding: 10px 12px;
+ border: 1px solid var(--line);
+ border-radius: 4px;
+ background: var(--code-bg);
+ font-family: Consolas, "Cascadia Mono", monospace;
+ font-size: 13px;
+ resize: vertical;
+}
+
+.button-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-top: 12px;
+}
+
+.response-block {
+ margin-top: 14px;
+ padding-top: 12px;
+ border-top: 1px dashed var(--line);
+}
+
+.response-summary {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 10px;
+ margin-bottom: 8px;
+ font-size: 13px;
+}
+
+.status-badge {
+ padding: 2px 8px;
+ border-radius: 4px;
+ color: #ffffff;
+ font-weight: 650;
+}
+
+.status-badge.s2xx { background: var(--good); }
+.status-badge.s3xx { background: var(--get); }
+.status-badge.s4xx { background: var(--warn); }
+.status-badge.s5xx { background: var(--bad); }
+.status-badge.error { background: var(--bad); }
+
+pre.response-body,
+pre.response-headers {
+ margin: 6px 0 0;
+ padding: 10px 12px;
+ background: var(--code-bg);
+ border: 1px solid var(--line);
+ border-radius: 4px;
+ max-height: 360px;
+ overflow: auto;
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+ font-family: Consolas, "Cascadia Mono", monospace;
+ font-size: 12.5px;
+}
+
+pre.response-headers {
+ max-height: 160px;
+}
+
+details > summary {
+ cursor: pointer;
+ color: var(--muted);
+ font-size: 12px;
+ margin: 6px 0 0;
+}
+
+.empty-hint {
+ padding: 12px 0;
+ color: var(--muted);
+ font-style: italic;
+}
+
+.history {
+ position: fixed;
+ right: 16px;
+ bottom: 16px;
+ width: 360px;
+ max-width: calc(100vw - 32px);
+ max-height: 50vh;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: var(--surface);
+ box-shadow: 0 8px 24px rgba(23, 32, 51, 0.12);
+ display: flex;
+ flex-direction: column;
+ z-index: 20;
+}
+
+.history h3 {
+ margin: 0;
+ padding: 10px 14px;
+ border-bottom: 1px solid var(--line);
+ font-size: 13px;
+ font-weight: 650;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.history h3 button {
+ min-height: 24px;
+ padding: 0 8px;
+ font-size: 12px;
+}
+
+.history ul {
+ list-style: none;
+ margin: 0;
+ padding: 6px 0;
+ overflow: auto;
+}
+
+.history li {
+ padding: 6px 14px;
+ font-size: 12px;
+ border-bottom: 1px solid #edf1f7;
+ display: grid;
+ grid-template-columns: 50px 1fr auto;
+ gap: 8px;
+ align-items: center;
+}
+
+.history li:last-child { border-bottom: 0; }
+
+.history li .h-method {
+ font-weight: 650;
+ font-family: Consolas, "Cascadia Mono", monospace;
+}
+
+.history li .h-path {
+ font-family: Consolas, "Cascadia Mono", monospace;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+@media (max-width: 920px) {
+ .form-row {
+ grid-template-columns: 1fr;
+ }
+
+ .meta dl {
+ grid-template-columns: 1fr;
+ }
+
+ .history {
+ position: static;
+ width: auto;
+ margin-top: 18px;
+ max-height: none;
+ }
+}
diff --git a/src/Flyshot.Server.Host/wwwroot/assets/debug.js b/src/Flyshot.Server.Host/wwwroot/assets/debug.js
new file mode 100644
index 0000000..1efef3f
--- /dev/null
+++ b/src/Flyshot.Server.Host/wwwroot/assets/debug.js
@@ -0,0 +1,670 @@
+// 静态调试页通过配置 API 获取实际 Swagger JSON 地址,避免硬编码路由前缀。
+const DEBUG_CONFIG_URL = "/api/debug/config";
+const STORAGE_PREFIX = "flyshot.debug.";
+const HISTORY_LIMIT = 10;
+
+const groupTitleByPrefix = [
+ // 基础与状态分组:探活和状态快照两个固定 API 路径
+ { match: function (op) { return op.path === "/healthz" || op.path === "/api/status/snapshot"; }, title: "基础与状态" },
+ // 默认兜底:剩余全部走 ControllerClient 兼容分组
+ { match: function () { return true; }, title: "ControllerClient 兼容" }
+];
+
+const state = {
+ spec: null,
+ operations: [],
+ history: []
+};
+
+/** 简单的 escape:把任意字符串安全嵌入 textContent 之外的位置时使用。 */
+function escapeHtml(value) {
+ return String(value).replace(/[&<>"']/g, function (ch) {
+ return { "&": "&", "<": "<", ">": ">", "\"": """, "'": "'" }[ch];
+ });
+}
+
+/** 解析 OpenAPI 中的 $ref 引用,仅支持本地 components.schemas 形式。 */
+function resolveRef(ref) {
+ if (!ref || !state.spec) return null;
+ const parts = ref.replace(/^#\//, "").split("/");
+ let cursor = state.spec;
+ for (const part of parts) {
+ if (cursor && Object.prototype.hasOwnProperty.call(cursor, part)) {
+ cursor = cursor[part];
+ } else {
+ return null;
+ }
+ }
+ return cursor;
+}
+
+/** 根据 schema 生成默认 JSON 模板,用于自动填充请求体编辑器。 */
+function buildSampleFromSchema(schema, depth) {
+ depth = depth || 0;
+ // 防御递归:复杂自引用 schema 在 4 层后停下,避免栈爆。
+ if (!schema || depth > 4) return null;
+
+ if (schema.$ref) {
+ const resolved = resolveRef(schema.$ref);
+ return resolved ? buildSampleFromSchema(resolved, depth + 1) : null;
+ }
+
+ // 部分 schema 只标 oneOf/anyOf/allOf,挑第一个分支即可,调试场景够用。
+ if (Array.isArray(schema.oneOf) && schema.oneOf.length > 0) return buildSampleFromSchema(schema.oneOf[0], depth + 1);
+ if (Array.isArray(schema.anyOf) && schema.anyOf.length > 0) return buildSampleFromSchema(schema.anyOf[0], depth + 1);
+ if (Array.isArray(schema.allOf) && schema.allOf.length > 0) {
+ const merged = {};
+ schema.allOf.forEach(function (sub) {
+ const value = buildSampleFromSchema(sub, depth + 1);
+ if (value && typeof value === "object" && !Array.isArray(value)) Object.assign(merged, value);
+ });
+ return merged;
+ }
+
+ const type = schema.type || (schema.properties ? "object" : "string");
+ switch (type) {
+ case "object": {
+ const result = {};
+ const props = schema.properties || {};
+ Object.keys(props).forEach(function (key) {
+ result[key] = buildSampleFromSchema(props[key], depth + 1);
+ });
+ return result;
+ }
+ case "array":
+ return [];
+ case "integer":
+ case "number":
+ return 0;
+ case "boolean":
+ return false;
+ case "string":
+ default:
+ if (schema.enum && schema.enum.length > 0) return schema.enum[0];
+ return "";
+ }
+}
+
+/** 把 schema.type 翻译成 input type 与展示文本。 */
+function inputKindForType(schema) {
+ if (!schema) return { kind: "text", label: "string" };
+ const type = schema.type || "string";
+ if (type === "boolean") return { kind: "checkbox", label: "boolean" };
+ if (type === "integer" || type === "number") return { kind: "number", label: type };
+ return { kind: "text", label: type };
+}
+
+/** 把 OpenAPI 的 paths 节点展开成扁平的 operation 列表。 */
+function extractOperations(spec) {
+ const operations = [];
+ const paths = spec.paths || {};
+ Object.keys(paths).forEach(function (path) {
+ const pathItem = paths[path] || {};
+ ["get", "post", "put", "delete", "patch", "options", "head"].forEach(function (method) {
+ const op = pathItem[method];
+ if (!op) return;
+ const parameters = (op.parameters || []).filter(function (p) { return p.in === "query" || p.in === "path"; });
+ let bodySchema = null;
+ if (op.requestBody && op.requestBody.content) {
+ const json = op.requestBody.content["application/json"];
+ if (json && json.schema) bodySchema = json.schema;
+ }
+ operations.push({
+ method: method.toUpperCase(),
+ path: path,
+ summary: op.summary || "",
+ description: op.description || "",
+ tags: op.tags || [],
+ parameters: parameters,
+ bodySchema: bodySchema
+ });
+ });
+ });
+ return operations;
+}
+
+/** 选择分组:优先用第一条匹配的 groupTitleByPrefix 规则,OpenAPI tag 留作兜底。 */
+function pickGroup(op) {
+ for (const rule of groupTitleByPrefix) {
+ if (rule.match(op)) return rule.title;
+ }
+ if (op.tags && op.tags.length > 0) return op.tags[0];
+ return "其它";
+}
+
+/** localStorage key 必须避免冲突,使用 method:path 复合键。 */
+function storageKey(op) {
+ return STORAGE_PREFIX + op.method + ":" + op.path;
+}
+
+/** 读取本端点最近一次输入;解析失败则当作空。 */
+function loadInputs(op) {
+ try {
+ const raw = window.localStorage.getItem(storageKey(op));
+ return raw ? JSON.parse(raw) : null;
+ } catch (e) {
+ return null;
+ }
+}
+
+/** 保存本端点最近一次输入;写入失败时静默忽略,避免影响调试体验。 */
+function saveInputs(op, payload) {
+ try {
+ window.localStorage.setItem(storageKey(op), JSON.stringify(payload));
+ } catch (e) {
+ // localStorage 可能被禁用或满载,忽略写入失败。
+ }
+}
+
+/** 拼接最终请求 URL(含 query 串与 path 参数替换)。 */
+function buildRequestUrl(op, paramValues) {
+ let path = op.path;
+ const queryPairs = [];
+ op.parameters.forEach(function (param) {
+ const raw = paramValues[param.name];
+ if (raw === undefined || raw === null || raw === "") return;
+ if (param.in === "path") {
+ path = path.replace("{" + param.name + "}", encodeURIComponent(raw));
+ } else if (param.in === "query") {
+ queryPairs.push(encodeURIComponent(param.name) + "=" + encodeURIComponent(raw));
+ }
+ });
+ return path + (queryPairs.length > 0 ? "?" + queryPairs.join("&") : "");
+}
+
+/** 生成与浏览器请求等价的 curl 命令,便于复制到终端复现。 */
+function buildCurlCommand(op, requestUrl, body) {
+ const parts = ["curl", "-X", op.method, JSON.stringify(window.location.origin + requestUrl)];
+ if (body !== null && body !== undefined && body !== "") {
+ parts.push("-H", "\"Content-Type: application/json\"");
+ parts.push("--data-raw", JSON.stringify(body));
+ }
+ return parts.join(" ");
+}
+
+/** 渲染参数输入表单,返回收集函数。 */
+function renderParameterRows(container, op, savedValues) {
+ if (op.parameters.length === 0) return function () { return {}; };
+
+ const inputs = {};
+ op.parameters.forEach(function (param) {
+ const row = document.createElement("div");
+ row.className = "form-row";
+
+ const nameNode = document.createElement("div");
+ nameNode.className = "name";
+ nameNode.textContent = param.name + " (" + param.in + ")";
+ if (param.required) {
+ const requiredMark = document.createElement("span");
+ requiredMark.className = "required";
+ requiredMark.textContent = "*";
+ nameNode.appendChild(requiredMark);
+ }
+ row.appendChild(nameNode);
+
+ const kind = inputKindForType(param.schema);
+ const inputNode = document.createElement("input");
+ inputNode.type = kind.kind;
+ if (kind.kind === "checkbox") {
+ inputNode.checked = savedValues && Object.prototype.hasOwnProperty.call(savedValues, param.name)
+ ? Boolean(savedValues[param.name])
+ : Boolean(param.schema && param.schema.default);
+ } else {
+ let initial = "";
+ if (savedValues && Object.prototype.hasOwnProperty.call(savedValues, param.name)) {
+ initial = String(savedValues[param.name]);
+ } else if (param.schema && param.schema.default !== undefined) {
+ initial = String(param.schema.default);
+ }
+ inputNode.value = initial;
+ if (kind.kind === "number") inputNode.step = "any";
+ }
+ row.appendChild(inputNode);
+
+ const typeNode = document.createElement("div");
+ typeNode.className = "type";
+ typeNode.textContent = kind.label;
+ row.appendChild(typeNode);
+
+ container.appendChild(row);
+ inputs[param.name] = { node: inputNode, kind: kind.kind, schema: param.schema };
+ });
+
+ return function collect() {
+ const collected = {};
+ Object.keys(inputs).forEach(function (key) {
+ const item = inputs[key];
+ if (item.kind === "checkbox") {
+ collected[key] = item.node.checked;
+ } else {
+ const raw = item.node.value;
+ if (raw === "") {
+ collected[key] = "";
+ } else if (item.kind === "number") {
+ const num = Number(raw);
+ collected[key] = Number.isNaN(num) ? raw : num;
+ } else {
+ collected[key] = raw;
+ }
+ }
+ });
+ return collected;
+ };
+}
+
+/** 渲染请求体编辑器,返回收集函数。 */
+function renderBodyEditor(container, op, savedBody) {
+ if (!op.bodySchema) return function () { return null; };
+
+ const block = document.createElement("div");
+ block.className = "body-block";
+
+ const labelRow = document.createElement("div");
+ labelRow.className = "body-label";
+ const left = document.createElement("div");
+ left.className = "left";
+ left.textContent = "请求体 (application/json)";
+ labelRow.appendChild(left);
+
+ const formatBtn = document.createElement("button");
+ formatBtn.type = "button";
+ formatBtn.className = "secondary";
+ formatBtn.textContent = "格式化 JSON";
+ labelRow.appendChild(formatBtn);
+
+ block.appendChild(labelRow);
+
+ const textarea = document.createElement("textarea");
+ textarea.className = "body-editor";
+ textarea.spellcheck = false;
+ let initialText;
+ if (savedBody !== undefined && savedBody !== null) {
+ initialText = typeof savedBody === "string" ? savedBody : JSON.stringify(savedBody, null, 2);
+ } else {
+ const sample = buildSampleFromSchema(op.bodySchema, 0);
+ initialText = sample === null ? "" : JSON.stringify(sample, null, 2);
+ }
+ textarea.value = initialText;
+ block.appendChild(textarea);
+
+ formatBtn.addEventListener("click", function () {
+ try {
+ const parsed = JSON.parse(textarea.value || "null");
+ textarea.value = parsed === null ? "" : JSON.stringify(parsed, null, 2);
+ } catch (e) {
+ window.alert("JSON 解析失败: " + e.message);
+ }
+ });
+
+ container.appendChild(block);
+
+ return function collect() {
+ return textarea.value;
+ };
+}
+
+/** 把 HTTP 状态码翻译成颜色徽标 class。 */
+function statusBadgeClass(status) {
+ if (status >= 200 && status < 300) return "s2xx";
+ if (status >= 300 && status < 400) return "s3xx";
+ if (status >= 400 && status < 500) return "s4xx";
+ if (status >= 500) return "s5xx";
+ return "error";
+}
+
+/** 把响应头展开成可读字符串。 */
+function formatHeaders(headers) {
+ const lines = [];
+ headers.forEach(function (value, key) { lines.push(key + ": " + value); });
+ return lines.join("\n");
+}
+
+/** 在历史面板顶部追加一条记录,超过上限则丢弃尾部。 */
+function pushHistory(entry) {
+ state.history.unshift(entry);
+ if (state.history.length > HISTORY_LIMIT) state.history.length = HISTORY_LIMIT;
+ renderHistory();
+}
+
+function renderHistory() {
+ const list = document.getElementById("history-list");
+ list.innerHTML = "";
+ if (state.history.length === 0) {
+ const empty = document.createElement("li");
+ empty.textContent = "暂无调用记录";
+ empty.style.color = "var(--muted)";
+ empty.style.gridTemplateColumns = "1fr";
+ list.appendChild(empty);
+ return;
+ }
+ state.history.forEach(function (entry) {
+ const li = document.createElement("li");
+ const method = document.createElement("span");
+ method.className = "h-method";
+ method.textContent = entry.method;
+ method.style.color = entry.method === "GET" ? "var(--get)" : entry.method === "POST" ? "var(--post)" : "var(--muted)";
+ const path = document.createElement("span");
+ path.className = "h-path";
+ path.title = entry.url;
+ path.textContent = entry.url;
+ const meta = document.createElement("span");
+ meta.style.color = "var(--muted)";
+ meta.textContent = (entry.status || "ERR") + " · " + entry.elapsedMs + "ms";
+ li.appendChild(method);
+ li.appendChild(path);
+ li.appendChild(meta);
+ list.appendChild(li);
+ });
+}
+
+/** 渲染单个端点的卡片。 */
+function renderOperationCard(op) {
+ const card = document.createElement("section");
+ card.className = "card collapsed";
+
+ const head = document.createElement("div");
+ head.className = "card-head";
+
+ const badge = document.createElement("span");
+ badge.className = "badge " + (["GET", "POST", "PUT", "DELETE"].indexOf(op.method) >= 0 ? op.method : "OTHER");
+ badge.textContent = op.method;
+ head.appendChild(badge);
+
+ const path = document.createElement("span");
+ path.className = "card-path";
+ path.textContent = op.path;
+ head.appendChild(path);
+
+ const summary = document.createElement("span");
+ summary.className = "card-summary";
+ summary.textContent = op.summary;
+ summary.title = op.summary;
+ head.appendChild(summary);
+
+ const toggle = document.createElement("span");
+ toggle.className = "card-toggle";
+ toggle.textContent = "展开 ▾";
+ head.appendChild(toggle);
+
+ head.addEventListener("click", function () {
+ const collapsed = card.classList.toggle("collapsed");
+ toggle.textContent = collapsed ? "展开 ▾" : "收起 ▴";
+ });
+ card.appendChild(head);
+
+ const body = document.createElement("div");
+ body.className = "card-body";
+
+ // 描述(来自 XML summary)独立成一段
+ if (op.summary) {
+ const desc = document.createElement("div");
+ desc.style.color = "var(--muted)";
+ desc.style.marginBottom = "10px";
+ desc.style.fontSize = "13px";
+ desc.textContent = op.summary;
+ body.appendChild(desc);
+ }
+
+ const saved = loadInputs(op) || {};
+
+ // 参数区
+ let collectParams = function () { return {}; };
+ if (op.parameters.length > 0) {
+ const paramsContainer = document.createElement("div");
+ paramsContainer.className = "params";
+ body.appendChild(paramsContainer);
+ collectParams = renderParameterRows(paramsContainer, op, saved.params);
+ }
+
+ // 请求体区
+ const collectBody = renderBodyEditor(body, op, saved.body);
+
+ // 操作按钮
+ const buttonRow = document.createElement("div");
+ buttonRow.className = "button-row";
+
+ const sendBtn = document.createElement("button");
+ sendBtn.type = "button";
+ sendBtn.textContent = "发送";
+
+ const resetBtn = document.createElement("button");
+ resetBtn.type = "button";
+ resetBtn.className = "secondary";
+ resetBtn.textContent = "重置";
+
+ const curlBtn = document.createElement("button");
+ curlBtn.type = "button";
+ curlBtn.className = "secondary";
+ curlBtn.textContent = "复制 curl";
+
+ buttonRow.appendChild(sendBtn);
+ buttonRow.appendChild(resetBtn);
+ buttonRow.appendChild(curlBtn);
+ body.appendChild(buttonRow);
+
+ // 响应面板
+ const responseBlock = document.createElement("div");
+ responseBlock.className = "response-block";
+ responseBlock.style.display = "none";
+ body.appendChild(responseBlock);
+
+ function renderResponse(payload) {
+ responseBlock.style.display = "block";
+ responseBlock.innerHTML = "";
+
+ const summaryRow = document.createElement("div");
+ summaryRow.className = "response-summary";
+
+ const statusBadge = document.createElement("span");
+ statusBadge.className = "status-badge " + statusBadgeClass(payload.status || 0);
+ statusBadge.textContent = payload.status ? payload.status + " " + (payload.statusText || "") : "请求失败";
+ summaryRow.appendChild(statusBadge);
+
+ const elapsed = document.createElement("span");
+ elapsed.style.color = "var(--muted)";
+ elapsed.textContent = payload.elapsedMs + " ms · " + payload.url;
+ summaryRow.appendChild(elapsed);
+
+ responseBlock.appendChild(summaryRow);
+
+ if (payload.error) {
+ const pre = document.createElement("pre");
+ pre.className = "response-body";
+ pre.textContent = payload.error;
+ responseBlock.appendChild(pre);
+ return;
+ }
+
+ const headersDetails = document.createElement("details");
+ const headersSummary = document.createElement("summary");
+ headersSummary.textContent = "响应头";
+ headersDetails.appendChild(headersSummary);
+ const headersPre = document.createElement("pre");
+ headersPre.className = "response-headers";
+ headersPre.textContent = payload.headers;
+ headersDetails.appendChild(headersPre);
+ responseBlock.appendChild(headersDetails);
+
+ const bodyPre = document.createElement("pre");
+ bodyPre.className = "response-body";
+ bodyPre.textContent = payload.bodyText;
+ responseBlock.appendChild(bodyPre);
+ }
+
+ sendBtn.addEventListener("click", async function () {
+ sendBtn.disabled = true;
+ const params = collectParams();
+ const rawBody = collectBody();
+ saveInputs(op, { params: params, body: rawBody });
+
+ const requestUrl = buildRequestUrl(op, params);
+ const init = { method: op.method, headers: {} };
+
+ // 仅 POST/PUT/PATCH/DELETE 才认为可能携带 body;对没有 bodySchema 的方法直接跳过。
+ const methodAllowsBody = ["POST", "PUT", "PATCH", "DELETE"].indexOf(op.method) >= 0;
+ if (methodAllowsBody && op.bodySchema && rawBody !== null && rawBody !== undefined && rawBody !== "") {
+ init.headers["Content-Type"] = "application/json";
+ init.body = rawBody;
+ }
+
+ const startedAt = performance.now();
+ try {
+ const response = await fetch(requestUrl, init);
+ const elapsedMs = Math.round(performance.now() - startedAt);
+ const text = await response.text();
+ const contentType = response.headers.get("content-type") || "";
+ let bodyText = text;
+ if (contentType.indexOf("application/json") >= 0) {
+ try {
+ bodyText = JSON.stringify(JSON.parse(text), null, 2);
+ } catch (e) {
+ bodyText = text;
+ }
+ }
+ renderResponse({
+ status: response.status,
+ statusText: response.statusText,
+ headers: formatHeaders(response.headers),
+ bodyText: bodyText,
+ url: requestUrl,
+ elapsedMs: elapsedMs
+ });
+ pushHistory({ method: op.method, url: requestUrl, status: response.status, elapsedMs: elapsedMs });
+ } catch (err) {
+ const elapsedMs = Math.round(performance.now() - startedAt);
+ renderResponse({
+ error: String(err && err.message ? err.message : err),
+ url: requestUrl,
+ elapsedMs: elapsedMs
+ });
+ pushHistory({ method: op.method, url: requestUrl, status: 0, elapsedMs: elapsedMs });
+ } finally {
+ sendBtn.disabled = false;
+ }
+ });
+
+ resetBtn.addEventListener("click", function () {
+ try { window.localStorage.removeItem(storageKey(op)); } catch (e) { /* 忽略 */ }
+ // 直接重新渲染当前卡片:替换原 DOM 节点。
+ const refreshed = renderOperationCard(op);
+ refreshed.classList.remove("collapsed");
+ refreshed.querySelector(".card-toggle").textContent = "收起 ▴";
+ card.parentNode.replaceChild(refreshed, card);
+ });
+
+ curlBtn.addEventListener("click", function () {
+ const params = collectParams();
+ const rawBody = collectBody();
+ const requestUrl = buildRequestUrl(op, params);
+ const methodAllowsBody = ["POST", "PUT", "PATCH", "DELETE"].indexOf(op.method) >= 0;
+ const bodyForCurl = methodAllowsBody && op.bodySchema ? rawBody : null;
+ const command = buildCurlCommand(op, requestUrl, bodyForCurl);
+ if (navigator.clipboard && navigator.clipboard.writeText) {
+ navigator.clipboard.writeText(command).then(function () {
+ curlBtn.textContent = "已复制 ✓";
+ window.setTimeout(function () { curlBtn.textContent = "复制 curl"; }, 1500);
+ }).catch(function () {
+ window.prompt("复制失败,手动复制:", command);
+ });
+ } else {
+ window.prompt("复制失败,手动复制:", command);
+ }
+ });
+
+ card.appendChild(body);
+ return card;
+}
+
+/** 把 operation 列表按分组渲染到主区域。 */
+function renderGroups(operations) {
+ const root = document.getElementById("debug-console-app");
+ root.innerHTML = "";
+ if (operations.length === 0) {
+ const empty = document.createElement("div");
+ empty.className = "empty-hint";
+ empty.textContent = "OpenAPI 文档中没有任何端点。";
+ root.appendChild(empty);
+ return;
+ }
+
+ const grouped = new Map();
+ operations.forEach(function (op) {
+ const groupTitle = pickGroup(op);
+ if (!grouped.has(groupTitle)) grouped.set(groupTitle, []);
+ grouped.get(groupTitle).push(op);
+ });
+
+ // 固定输出顺序:基础与状态在前,ControllerClient 兼容在后,其余按字典序。
+ const orderedTitles = [];
+ ["基础与状态", "ControllerClient 兼容"].forEach(function (title) {
+ if (grouped.has(title)) orderedTitles.push(title);
+ });
+ Array.from(grouped.keys()).sort().forEach(function (title) {
+ if (orderedTitles.indexOf(title) < 0) orderedTitles.push(title);
+ });
+
+ orderedTitles.forEach(function (title) {
+ const ops = grouped.get(title);
+ ops.sort(function (a, b) {
+ if (a.path === b.path) return a.method.localeCompare(b.method);
+ return a.path.localeCompare(b.path);
+ });
+
+ const section = document.createElement("section");
+ section.className = "group";
+ const heading = document.createElement("h2");
+ heading.textContent = title + " (" + ops.length + ")";
+ section.appendChild(heading);
+ ops.forEach(function (op) { section.appendChild(renderOperationCard(op)); });
+ root.appendChild(section);
+ });
+}
+
+/** 加载 OpenAPI 文档并渲染。 */
+async function loadSpecAndRender() {
+ const metaSpec = document.getElementById("meta-spec-url");
+ const metaCount = document.getElementById("meta-operation-count");
+ const metaStatus = document.getElementById("meta-status");
+
+ metaSpec.textContent = "正在读取调试配置...";
+ metaStatus.textContent = "正在拉取 OpenAPI 文档...";
+ metaStatus.className = "";
+
+ try {
+ const configResponse = await fetch(DEBUG_CONFIG_URL, { cache: "no-store" });
+ if (!configResponse.ok) throw new Error("调试配置 HTTP " + configResponse.status + " " + configResponse.statusText);
+ const config = await configResponse.json();
+ const swaggerJsonUrl = config.swaggerJsonUrl;
+ if (!swaggerJsonUrl) throw new Error("调试配置缺少 swaggerJsonUrl");
+ metaSpec.textContent = swaggerJsonUrl;
+
+ const response = await fetch(swaggerJsonUrl, { cache: "no-store" });
+ if (!response.ok) throw new Error("HTTP " + response.status + " " + response.statusText);
+ const spec = await response.json();
+ state.spec = spec;
+ state.operations = extractOperations(spec);
+ metaCount.textContent = state.operations.length;
+ metaStatus.textContent = "已加载";
+ metaStatus.className = "good";
+ renderGroups(state.operations);
+ } catch (err) {
+ metaStatus.textContent = "加载失败: " + (err && err.message ? err.message : err);
+ metaStatus.className = "bad";
+ metaCount.textContent = "0";
+ const root = document.getElementById("debug-console-app");
+ root.innerHTML = "";
+ const errBlock = document.createElement("div");
+ errBlock.className = "empty-hint";
+ errBlock.textContent = "无法加载 OpenAPI 文档,请确认 Swagger:Enabled = true 且 " + DEBUG_CONFIG_URL + " 可访问。";
+ root.appendChild(errBlock);
+ }
+}
+
+document.getElementById("reload-spec").addEventListener("click", loadSpecAndRender);
+document.getElementById("history-clear").addEventListener("click", function () {
+ state.history.length = 0;
+ renderHistory();
+});
+
+renderHistory();
+loadSpecAndRender();
diff --git a/src/Flyshot.Server.Host/wwwroot/assets/status.css b/src/Flyshot.Server.Host/wwwroot/assets/status.css
new file mode 100644
index 0000000..afc0ccb
--- /dev/null
+++ b/src/Flyshot.Server.Host/wwwroot/assets/status.css
@@ -0,0 +1,214 @@
+:root {
+ color-scheme: light;
+ --bg: #f5f7fb;
+ --surface: #ffffff;
+ --line: #d8dee9;
+ --text: #172033;
+ --muted: #5b667a;
+ --accent: #007c89;
+ --good: #12805c;
+ --warn: #b7791f;
+ --bad: #b42318;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ min-height: 100vh;
+ background: var(--bg);
+ color: var(--text);
+ font-family: "Segoe UI", "Microsoft YaHei", Arial, sans-serif;
+ font-size: 15px;
+ letter-spacing: 0;
+}
+
+header {
+ border-bottom: 1px solid var(--line);
+ background: var(--surface);
+}
+
+.topbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ width: min(1180px, calc(100% - 32px));
+ margin: 0 auto;
+ padding: 18px 0;
+}
+
+h1 {
+ margin: 0;
+ font-size: 22px;
+ font-weight: 650;
+}
+
+button {
+ min-height: 36px;
+ padding: 0 14px;
+ border: 1px solid var(--accent);
+ border-radius: 6px;
+ background: var(--accent);
+ color: #ffffff;
+ font: inherit;
+ cursor: pointer;
+}
+
+button:disabled {
+ opacity: 0.6;
+ cursor: default;
+}
+
+/* 顶部操作区按钮和外链按钮共用同一组视觉样式,便于现场顺手跳转。 */
+.actions {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.link-button {
+ display: inline-flex;
+ align-items: center;
+ min-height: 36px;
+ padding: 0 14px;
+ border: 1px solid var(--accent);
+ border-radius: 6px;
+ background: transparent;
+ color: var(--accent);
+ font: inherit;
+ text-decoration: none;
+}
+
+.link-button:hover {
+ background: rgba(0, 124, 137, 0.08);
+}
+
+main {
+ width: min(1180px, calc(100% - 32px));
+ margin: 22px auto;
+}
+
+.summary {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 12px;
+ margin-bottom: 16px;
+}
+
+.metric,
+section {
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: var(--surface);
+}
+
+.metric {
+ min-height: 86px;
+ padding: 14px;
+}
+
+.label {
+ color: var(--muted);
+ font-size: 13px;
+}
+
+.value {
+ margin-top: 8px;
+ overflow-wrap: anywhere;
+ font-size: 24px;
+ font-weight: 650;
+}
+
+.status-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.dot {
+ width: 12px;
+ height: 12px;
+ flex: 0 0 12px;
+ border-radius: 999px;
+ background: var(--warn);
+}
+
+.dot.good {
+ background: var(--good);
+}
+
+.dot.bad {
+ background: var(--bad);
+}
+
+.grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 16px;
+}
+
+section h2 {
+ margin: 0;
+ padding: 14px 16px;
+ border-bottom: 1px solid var(--line);
+ font-size: 16px;
+ font-weight: 650;
+}
+
+dl {
+ display: grid;
+ grid-template-columns: 160px minmax(0, 1fr);
+ gap: 0;
+ margin: 0;
+ padding: 4px 16px 12px;
+}
+
+dt,
+dd {
+ min-height: 36px;
+ margin: 0;
+ padding: 9px 0;
+ border-bottom: 1px solid #edf1f7;
+}
+
+dt {
+ color: var(--muted);
+}
+
+dd {
+ overflow-wrap: anywhere;
+ font-family: Consolas, "Cascadia Mono", monospace;
+}
+
+.empty {
+ color: var(--muted);
+ font-family: inherit;
+}
+
+@media (max-width: 820px) {
+ .topbar {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+
+ .summary,
+ .grid {
+ grid-template-columns: 1fr;
+ }
+
+ dl {
+ grid-template-columns: 1fr;
+ }
+
+ dt {
+ border-bottom: 0;
+ padding-bottom: 2px;
+ }
+
+ dd {
+ padding-top: 2px;
+ }
+}
diff --git a/src/Flyshot.Server.Host/wwwroot/assets/status.js b/src/Flyshot.Server.Host/wwwroot/assets/status.js
new file mode 100644
index 0000000..417c431
--- /dev/null
+++ b/src/Flyshot.Server.Host/wwwroot/assets/status.js
@@ -0,0 +1,92 @@
+const fields = {
+ connectionState: document.getElementById("connection-state"),
+ stateDot: document.getElementById("state-dot"),
+ robotName: document.getElementById("robot-name"),
+ speedRatio: document.getElementById("speed-ratio"),
+ motionState: document.getElementById("motion-state"),
+ serverVersion: document.getElementById("server-version"),
+ clientVersion: document.getElementById("client-version"),
+ setupState: document.getElementById("setup-state"),
+ enabledState: document.getElementById("enabled-state"),
+ j519Status: document.getElementById("j519-status"),
+ j519Sequence: document.getElementById("j519-sequence"),
+ capturedAt: document.getElementById("captured-at"),
+ dof: document.getElementById("dof"),
+ joints: document.getElementById("joints"),
+ pose: document.getElementById("pose"),
+ trajectories: document.getElementById("trajectories"),
+ refresh: document.getElementById("refresh")
+};
+
+function formatArray(values) {
+ if (!Array.isArray(values) || values.length === 0) {
+ return "--";
+ }
+
+ return values.map(value => Number(value).toFixed(4)).join(", ");
+}
+
+function formatNullableBool(value) {
+ if (value === true) {
+ return "是";
+ }
+
+ if (value === false) {
+ return "否";
+ }
+
+ return "--";
+}
+
+function formatJ519Status(snapshot) {
+ if (snapshot.j519Status === null || snapshot.j519Status === undefined) {
+ return "--";
+ }
+
+ const status = Number(snapshot.j519Status).toString(16).padStart(2, "0").toUpperCase();
+ return `0x${status} accept=${formatNullableBool(snapshot.j519AcceptsCommand)} received=${formatNullableBool(snapshot.j519ReceivedCommand)} sysrdy=${formatNullableBool(snapshot.j519SystemReady)} motion=${formatNullableBool(snapshot.j519RobotInMotion)}`;
+}
+
+function setDot(connectionState) {
+ fields.stateDot.className = "dot";
+ if (connectionState === "Connected") {
+ fields.stateDot.classList.add("good");
+ } else if (connectionState === "NotConfigured") {
+ fields.stateDot.classList.add("bad");
+ }
+}
+
+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;
+
+ fields.connectionState.textContent = snapshot.connectionState;
+ fields.robotName.textContent = payload.robotName || "--";
+ fields.speedRatio.textContent = Number(snapshot.speedRatio).toFixed(2);
+ fields.motionState.textContent = snapshot.isInMotion ? "是" : "否";
+ fields.serverVersion.textContent = payload.serverVersion;
+ fields.clientVersion.textContent = payload.clientVersion;
+ fields.setupState.textContent = payload.isSetup ? "是" : "否";
+ fields.enabledState.textContent = snapshot.isEnabled ? "是" : "否";
+ fields.j519Status.textContent = formatJ519Status(snapshot);
+ fields.j519Sequence.textContent = snapshot.j519Sequence ?? "--";
+ fields.capturedAt.textContent = new Date(snapshot.capturedAt).toLocaleString();
+ fields.dof.textContent = payload.degreesOfFreedom;
+ fields.joints.textContent = formatArray(snapshot.jointPositions);
+ fields.pose.textContent = formatArray(snapshot.cartesianPose);
+ fields.trajectories.textContent = payload.uploadedTrajectories.length > 0
+ ? payload.uploadedTrajectories.join(", ")
+ : "--";
+ fields.trajectories.classList.toggle("empty", payload.uploadedTrajectories.length === 0);
+ setDot(snapshot.connectionState);
+ } finally {
+ fields.refresh.disabled = false;
+ }
+}
+
+fields.refresh.addEventListener("click", refreshStatus);
+refreshStatus();
+window.setInterval(refreshStatus, 2000);
diff --git a/src/Flyshot.Server.Host/wwwroot/debug.html b/src/Flyshot.Server.Host/wwwroot/debug.html
new file mode 100644
index 0000000..3b85ee4
--- /dev/null
+++ b/src/Flyshot.Server.Host/wwwroot/debug.html
@@ -0,0 +1,44 @@
+
+
+
+
+
+ Flyshot Replacement 接口调试
+
+
+
+
+
+
Flyshot Replacement 接口调试
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Flyshot.Server.Host/wwwroot/status.html b/src/Flyshot.Server.Host/wwwroot/status.html
new file mode 100644
index 0000000..df326a8
--- /dev/null
+++ b/src/Flyshot.Server.Host/wwwroot/status.html
@@ -0,0 +1,64 @@
+
+
+
+
+
+ Flyshot Replacement 状态监控
+
+
+
+
+
+
Flyshot Replacement 状态监控
+
+
+
+
+
+
+
+ 控制器
+
+ - 服务端版本
- --
+ - 客户端版本
- --
+ - 已初始化
- --
+ - 已使能
- --
+ - J519 状态
- --
+ - J519 序号
- --
+ - 采样时间
- --
+
+
+
+ 机器人
+
+ - 自由度
- --
+ - 关节位置
- --
+ - TCP 位姿
- --
+ - 已上传轨迹
- --
+
+
+
+
+
+
+
diff --git a/tests/Flyshot.Server.IntegrationTests/DebugConsoleEndpointTests.cs b/tests/Flyshot.Server.IntegrationTests/DebugConsoleEndpointTests.cs
index 5278a25..601d581 100644
--- a/tests/Flyshot.Server.IntegrationTests/DebugConsoleEndpointTests.cs
+++ b/tests/Flyshot.Server.IntegrationTests/DebugConsoleEndpointTests.cs
@@ -5,54 +5,74 @@ using Microsoft.Extensions.Configuration;
namespace Flyshot.Server.IntegrationTests;
///
-/// 验证 `/debug` 在线 API 调试页的暴露策略与基础内容契约。
+/// 验证 `wwwroot` 静态调试页和调试配置 API 的基础内容契约。
///
///
-/// 调试页与 Swagger UI 共用 Swagger:Enabled 开关,开关关闭时
-/// 调试页与 `/swagger` 一同下线,避免生产环境意外暴露调试入口。
+/// 调试页自身是静态 HTML,真正的 Swagger 地址由配置 API 下发;
+/// 当 Swagger 关闭时,配置 API 返回 404,前端据此显示不可用状态。
///
public sealed class DebugConsoleEndpointTests(FlyshotServerFactory factory) : IClassFixture
{
private readonly FlyshotServerFactory _factory = factory;
///
- /// 当 Swagger 启用时,`/debug` 应当返回完整的 HTML 调试页。
+ /// `debug.html` 应当作为可直接调试的静态页面暴露。
///
[Fact]
- public async Task GetDebug_WhenSwaggerEnabled_ReturnsConsoleHtml()
+ public async Task GetDebugHtml_ReturnsConsoleStaticPage()
{
- // 默认配置即开启 Swagger,调试页应当作为浏览器可直接打开的 HTML 暴露。
using var configuredFactory = CreateFactoryWithSwaggerEnabled(true);
using var client = configuredFactory.CreateClient();
- using var response = await client.GetAsync("/debug");
+ using var response = await client.GetAsync("/debug.html");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.StartsWith("text/html", response.Content.Headers.ContentType?.MediaType);
var html = await response.Content.ReadAsStringAsync();
- // 页面标题与稳定锚点用于回归保护:调试页骨架一旦丢失,测试立即报警。
Assert.Contains("Flyshot Replacement 接口调试", html, StringComparison.Ordinal);
Assert.Contains("id=\"debug-console-app\"", html, StringComparison.Ordinal);
-
- // 控制器需要在返回 HTML 前把 Swagger JSON URL 注入到页面占位符里,
- // 否则前端无法在加载时拉取 OpenAPI 文档。
- Assert.Contains("/swagger/v1/swagger.json", html, StringComparison.Ordinal);
Assert.DoesNotContain("__SWAGGER_JSON_URL__", html, StringComparison.Ordinal);
+ Assert.Contains("/assets/debug.css", html, StringComparison.Ordinal);
+ Assert.Contains("/assets/debug.js", html, StringComparison.Ordinal);
+
+ using var scriptResponse = await client.GetAsync("/assets/debug.js");
+ Assert.Equal(HttpStatusCode.OK, scriptResponse.StatusCode);
+
+ var script = await scriptResponse.Content.ReadAsStringAsync();
+ Assert.Contains("/api/debug/config", script, StringComparison.Ordinal);
}
///
- /// 当 Swagger 关闭时,`/debug` 应当与 `/swagger` 同步下线(404)。
+ /// 当 Swagger 启用时,调试配置 API 应当返回实际 Swagger JSON 地址。
///
[Fact]
- public async Task GetDebug_WhenSwaggerDisabled_ReturnsNotFound()
+ public async Task GetDebugConfig_WhenSwaggerEnabled_ReturnsSwaggerJsonUrl()
+ {
+ using var configuredFactory = CreateFactoryWithSwaggerEnabled(true);
+ using var client = configuredFactory.CreateClient();
+
+ using var response = await client.GetAsync("/api/debug/config");
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+
+ await using var responseStream = await response.Content.ReadAsStreamAsync();
+ using var document = await System.Text.Json.JsonDocument.ParseAsync(responseStream);
+
+ Assert.Equal("/swagger/v1/swagger.json", document.RootElement.GetProperty("swaggerJsonUrl").GetString());
+ }
+
+ ///
+ /// 当 Swagger 关闭时,调试配置 API 应当与 Swagger UI 同步下线(404)。
+ ///
+ [Fact]
+ public async Task GetDebugConfig_WhenSwaggerDisabled_ReturnsNotFound()
{
- // 显式把 Swagger:Enabled 置为 false,此时调试页也不应当被访问到。
using var configuredFactory = CreateFactoryWithSwaggerEnabled(false);
using var client = configuredFactory.CreateClient();
- using var response = await client.GetAsync("/debug");
+ using var response = await client.GetAsync("/api/debug/config");
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
@@ -81,21 +101,20 @@ public sealed class DebugConsoleEndpointTests(FlyshotServerFactory factory) : IC
}
///
- /// 状态页应当提供跳转到调试页的入口,便于现场顺手跳转。
+ /// 状态页应当提供跳转到静态调试页的入口,便于现场顺手跳转。
///
[Fact]
- public async Task GetStatusPage_LinksToDebugConsole()
+ public async Task GetStatusHtml_LinksToDebugConsole()
{
using var configuredFactory = CreateFactoryWithSwaggerEnabled(true);
using var client = configuredFactory.CreateClient();
- using var response = await client.GetAsync("/status");
+ using var response = await client.GetAsync("/status.html");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var html = await response.Content.ReadAsStringAsync();
- // 状态页头部需要至少一个指向 `/debug` 的链接,文案不强制以保留排版调整空间。
- Assert.Contains("href=\"/debug\"", html, StringComparison.Ordinal);
+ Assert.Contains("href=\"/debug.html\"", html, StringComparison.Ordinal);
}
///
diff --git a/tests/Flyshot.Server.IntegrationTests/StatusEndpointTests.cs b/tests/Flyshot.Server.IntegrationTests/StatusEndpointTests.cs
index d577394..1e78f3c 100644
--- a/tests/Flyshot.Server.IntegrationTests/StatusEndpointTests.cs
+++ b/tests/Flyshot.Server.IntegrationTests/StatusEndpointTests.cs
@@ -10,21 +10,28 @@ namespace Flyshot.Server.IntegrationTests;
public sealed class StatusEndpointTests(FlyshotServerFactory factory) : IClassFixture
{
///
- /// 验证状态页返回可由浏览器直接打开的 HTML,并引用状态快照 API。
+ /// 验证状态页作为 wwwroot 静态 HTML 暴露,并引用状态快照 API。
///
[Fact]
- public async Task GetStatusPage_ReturnsMonitoringHtml()
+ public async Task GetStatusHtml_ReturnsMonitoringStaticPage()
{
using var client = factory.CreateClient();
- using var response = await client.GetAsync("/status");
+ using var response = await client.GetAsync("/status.html");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.StartsWith("text/html", response.Content.Headers.ContentType?.MediaType);
var html = await response.Content.ReadAsStringAsync();
Assert.Contains("Flyshot Replacement 状态监控", html, StringComparison.Ordinal);
- Assert.Contains("/api/status/snapshot", html, StringComparison.Ordinal);
+ Assert.Contains("/assets/status.css", html, StringComparison.Ordinal);
+ Assert.Contains("/assets/status.js", 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("/api/status/snapshot", script, StringComparison.Ordinal);
}
///