✨ feat(server): 添加静态状态页与调试入口
- 将状态页、调试页改为 `wwwroot` 静态资源 - 补充调试配置接口与前端脚本 - 为兼容层、规划层和运行时补充日志 - 更新集成测试覆盖新入口
This commit is contained in:
@@ -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 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(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'\\)\\); 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')"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
49
NLog.config
49
NLog.config
@@ -6,38 +6,35 @@
|
|||||||
throwExceptions="false"
|
throwExceptions="false"
|
||||||
internalLogLevel="Off" >
|
internalLogLevel="Off" >
|
||||||
|
|
||||||
<!-- optional, add some variables
|
<!-- 环境变量配置:如果 ASPNETCORE_ENVIRONMENT 为空,则默认为 Production -->
|
||||||
https://github.com/nlog/NLog/wiki/Configuration-file#variables
|
|
||||||
-->
|
|
||||||
<variable name="myvar" value="myvalue"/>
|
|
||||||
<!-- 环境变量配置:如果 ASPNETCORE_ENVIRONMENT 为空,则默认为 Development -->
|
|
||||||
<variable name="env" value="${environment:ASPNETCORE_ENVIRONMENT:whenEmpty=Production}"/>
|
<variable name="env" value="${environment:ASPNETCORE_ENVIRONMENT:whenEmpty=Production}"/>
|
||||||
|
|
||||||
<!--
|
<!-- 文件目标:按日期分文件,单文件超过 4MB 自动归档,保留最近 50 个归档文件 -->
|
||||||
See https://github.com/nlog/nlog/wiki/Configuration-file
|
<targets>
|
||||||
for information on customizing logging rules and outputs.
|
<target name="logfile" xsi:type="File"
|
||||||
-->
|
fileName="${basedir}/logs/${shortdate}.log"
|
||||||
|
layout="${longdate}|${level:uppercase=true}|${threadid}|${logger}|${message}${onexception:${newline} ${exception:format=tostring}}"
|
||||||
|
archiveFileName="${basedir}/logs/${shortdate}.{#}.log"
|
||||||
|
archiveAboveSize="4048576"
|
||||||
|
archiveNumbering="Sequence"
|
||||||
|
maxArchiveFiles="50"
|
||||||
|
concurrentWrites="true"
|
||||||
|
keepFileOpen="false"
|
||||||
|
encoding="utf-8" />
|
||||||
|
|
||||||
<target name="logfile" xsi:type="File"
|
<!-- 控制台目标:开发环境使用,带颜色高亮 -->
|
||||||
fileName="${basedir}/logs/${shortdate}.log"
|
<target name="logconsole" xsi:type="Console"
|
||||||
layout="${longdate}|${level:uppercase=true}|${message}"
|
layout="${longdate}|${level:uppercase=true}|${threadid}|${logger}|${message}${onexception:${newline} ${exception:format=tostring}}" />
|
||||||
archiveFileName="${basedir}/logs/${shortdate}.{#}.log"
|
|
||||||
archiveAboveSize="4048576"
|
|
||||||
archiveNumbering="Sequence"
|
|
||||||
maxArchiveFiles="50"
|
|
||||||
concurrentWrites="true"
|
|
||||||
keepFileOpen="false"
|
|
||||||
encoding="utf-8" />
|
|
||||||
<!--<target name="logfile" xsi:type="File" fileName="${basedir}/logs/${shortdate}.log" layout="${longdate}|${level:uppercase=true}|${logger}|${message}" />-->
|
|
||||||
<target name="logconsole" xsi:type="Console" layout="${longdate}|${level:uppercase=true}|${message}" />
|
|
||||||
|
|
||||||
</targets>
|
</targets>
|
||||||
|
|
||||||
<rules>
|
<rules>
|
||||||
<!-- 开发环境:显示控制台 + 详细文件,最低 Debug -->
|
<!-- 压制 ASP.NET Core MVC 的常规信息日志,只保留 Error 及以上级别。 -->
|
||||||
<logger name="*" minLevel="Debug" writeTo="logconsole,logfile" condition="equals('${var:env}','Development')" />
|
<logger name="Microsoft.AspNetCore.Mvc.*" minlevel="Error" writeTo="logconsole,logfile" />
|
||||||
|
|
||||||
<!-- 生产环境:仅文件+UI,最低 Info -->
|
<!-- 开发环境:显示控制台 + 详细文件,最低 Debug -->
|
||||||
<logger name="*" minLevel="Info" writeTo="logfile" condition="not_equals('${var:env}','Development')" />
|
<logger name="*" minlevel="Debug" writeTo="logconsole,logfile" condition="equals('${var:env}','Development')" />
|
||||||
|
|
||||||
|
<!-- 生产环境:仅文件,最低 Info -->
|
||||||
|
<logger name="*" minlevel="Info" writeTo="logfile" condition="not_equals('${var:env}','Development')" />
|
||||||
</rules>
|
</rules>
|
||||||
</nlog>
|
</nlog>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using Flyshot.Core.Config;
|
using Flyshot.Core.Config;
|
||||||
using Flyshot.Core.Domain;
|
using Flyshot.Core.Domain;
|
||||||
using Flyshot.Runtime.Common;
|
using Flyshot.Runtime.Common;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace Flyshot.ControllerClientCompat;
|
namespace Flyshot.ControllerClientCompat;
|
||||||
|
|
||||||
@@ -17,6 +18,7 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
|
|||||||
private readonly ControllerClientTrajectoryOrchestrator _trajectoryOrchestrator;
|
private readonly ControllerClientTrajectoryOrchestrator _trajectoryOrchestrator;
|
||||||
private readonly RobotConfigLoader _configLoader;
|
private readonly RobotConfigLoader _configLoader;
|
||||||
private readonly IFlyshotTrajectoryStore _trajectoryStore;
|
private readonly IFlyshotTrajectoryStore _trajectoryStore;
|
||||||
|
private readonly ILogger<ControllerClientCompatService>? _logger;
|
||||||
private RobotProfile? _activeRobotProfile;
|
private RobotProfile? _activeRobotProfile;
|
||||||
private string? _configuredRobotName;
|
private string? _configuredRobotName;
|
||||||
private CompatibilityRobotSettings? _robotSettings;
|
private CompatibilityRobotSettings? _robotSettings;
|
||||||
@@ -35,13 +37,15 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
|
|||||||
/// <param name="trajectoryOrchestrator">轨迹规划与触发编排器。</param>
|
/// <param name="trajectoryOrchestrator">轨迹规划与触发编排器。</param>
|
||||||
/// <param name="configLoader">旧版 RobotConfig.json 加载器。</param>
|
/// <param name="configLoader">旧版 RobotConfig.json 加载器。</param>
|
||||||
/// <param name="trajectoryStore">已上传轨迹持久化存储。</param>
|
/// <param name="trajectoryStore">已上传轨迹持久化存储。</param>
|
||||||
|
/// <param name="logger">日志记录器;允许测试直接构造时传入 null。</param>
|
||||||
public ControllerClientCompatService(
|
public ControllerClientCompatService(
|
||||||
ControllerClientCompatOptions options,
|
ControllerClientCompatOptions options,
|
||||||
ControllerClientCompatRobotCatalog robotCatalog,
|
ControllerClientCompatRobotCatalog robotCatalog,
|
||||||
IControllerRuntime runtime,
|
IControllerRuntime runtime,
|
||||||
ControllerClientTrajectoryOrchestrator trajectoryOrchestrator,
|
ControllerClientTrajectoryOrchestrator trajectoryOrchestrator,
|
||||||
RobotConfigLoader configLoader,
|
RobotConfigLoader configLoader,
|
||||||
IFlyshotTrajectoryStore trajectoryStore)
|
IFlyshotTrajectoryStore trajectoryStore,
|
||||||
|
ILogger<ControllerClientCompatService>? logger = null)
|
||||||
{
|
{
|
||||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||||
_robotCatalog = robotCatalog ?? throw new ArgumentNullException(nameof(robotCatalog));
|
_robotCatalog = robotCatalog ?? throw new ArgumentNullException(nameof(robotCatalog));
|
||||||
@@ -49,6 +53,7 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
|
|||||||
_trajectoryOrchestrator = trajectoryOrchestrator ?? throw new ArgumentNullException(nameof(trajectoryOrchestrator));
|
_trajectoryOrchestrator = trajectoryOrchestrator ?? throw new ArgumentNullException(nameof(trajectoryOrchestrator));
|
||||||
_configLoader = configLoader ?? throw new ArgumentNullException(nameof(configLoader));
|
_configLoader = configLoader ?? throw new ArgumentNullException(nameof(configLoader));
|
||||||
_trajectoryStore = trajectoryStore ?? throw new ArgumentNullException(nameof(trajectoryStore));
|
_trajectoryStore = trajectoryStore ?? throw new ArgumentNullException(nameof(trajectoryStore));
|
||||||
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -90,6 +95,8 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
|
|||||||
_connectedServerIp = serverIp;
|
_connectedServerIp = serverIp;
|
||||||
_connectedServerPort = port;
|
_connectedServerPort = port;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_logger?.LogInformation("ConnectServer 完成: {ServerIp}:{Port}", serverIp, port);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -107,6 +114,8 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void SetUpRobot(string robotName)
|
public void SetUpRobot(string robotName)
|
||||||
{
|
{
|
||||||
|
_logger?.LogInformation("SetUpRobot 开始: robotName={RobotName}", robotName);
|
||||||
|
|
||||||
var robotSettings = TryLoadRobotSettings() ?? CreateDefaultRobotSettings();
|
var robotSettings = TryLoadRobotSettings() ?? CreateDefaultRobotSettings();
|
||||||
var robotProfile = _robotCatalog.LoadProfile(
|
var robotProfile = _robotCatalog.LoadProfile(
|
||||||
robotName,
|
robotName,
|
||||||
@@ -129,6 +138,14 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
|
|||||||
_uploadedTrajectories[saved.Key] = saved.Value;
|
_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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -184,11 +201,15 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
|
|||||||
throw new ArgumentException("控制器 IP 不能为空。", nameof(robotIp));
|
throw new ArgumentException("控制器 IP 不能为空。", nameof(robotIp));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_logger?.LogInformation("Connect 开始: robotIp={RobotIp}", robotIp);
|
||||||
|
|
||||||
lock (_stateLock)
|
lock (_stateLock)
|
||||||
{
|
{
|
||||||
EnsureRobotSetup();
|
EnsureRobotSetup();
|
||||||
_runtime.Connect(robotIp);
|
_runtime.Connect(robotIp);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_logger?.LogInformation("Connect 完成: robotIp={RobotIp}", robotIp);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -204,31 +225,37 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void EnableRobot(int bufferSize)
|
public void EnableRobot(int bufferSize)
|
||||||
{
|
{
|
||||||
|
_logger?.LogInformation("EnableRobot 开始: bufferSize={BufferSize}", bufferSize);
|
||||||
lock (_stateLock)
|
lock (_stateLock)
|
||||||
{
|
{
|
||||||
EnsureRobotSetup();
|
EnsureRobotSetup();
|
||||||
_runtime.EnableRobot(bufferSize);
|
_runtime.EnableRobot(bufferSize);
|
||||||
}
|
}
|
||||||
|
_logger?.LogInformation("EnableRobot 完成");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void DisableRobot()
|
public void DisableRobot()
|
||||||
{
|
{
|
||||||
|
_logger?.LogInformation("DisableRobot 开始");
|
||||||
lock (_stateLock)
|
lock (_stateLock)
|
||||||
{
|
{
|
||||||
EnsureRobotSetup();
|
EnsureRobotSetup();
|
||||||
_runtime.DisableRobot();
|
_runtime.DisableRobot();
|
||||||
}
|
}
|
||||||
|
_logger?.LogInformation("DisableRobot 完成");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void StopMove()
|
public void StopMove()
|
||||||
{
|
{
|
||||||
|
_logger?.LogInformation("StopMove 开始");
|
||||||
lock (_stateLock)
|
lock (_stateLock)
|
||||||
{
|
{
|
||||||
EnsureRobotSetup();
|
EnsureRobotSetup();
|
||||||
_runtime.StopMove();
|
_runtime.StopMove();
|
||||||
}
|
}
|
||||||
|
_logger?.LogInformation("StopMove 完成");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -335,6 +362,9 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
|
|||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(jointPositions);
|
ArgumentNullException.ThrowIfNull(jointPositions);
|
||||||
|
|
||||||
|
_logger?.LogInformation("MoveJoint 开始: 目标关节数={JointCount}", jointPositions.Count);
|
||||||
|
_logger?.LogDebug("MoveJoint 目标关节: {Joints}", string.Join(", ", jointPositions.Select(j => j.ToString("F4"))));
|
||||||
|
|
||||||
lock (_stateLock)
|
lock (_stateLock)
|
||||||
{
|
{
|
||||||
var robot = RequireActiveRobot();
|
var robot = RequireActiveRobot();
|
||||||
@@ -345,8 +375,15 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
|
|||||||
|
|
||||||
var speedRatio = _runtime.GetSnapshot().SpeedRatio;
|
var speedRatio = _runtime.GetSnapshot().SpeedRatio;
|
||||||
var moveResult = MoveJointTrajectoryGenerator.CreateResult(robot, currentJointPositions, jointPositions, 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);
|
_runtime.ExecuteTrajectory(moveResult, jointPositions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_logger?.LogInformation("MoveJoint 完成");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -359,6 +396,11 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
|
|||||||
throw new ArgumentException("轨迹路点不能为空。", nameof(waypoints));
|
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)
|
lock (_stateLock)
|
||||||
{
|
{
|
||||||
var robot = RequireActiveRobot();
|
var robot = RequireActiveRobot();
|
||||||
@@ -366,9 +408,17 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
|
|||||||
|
|
||||||
// 普通轨迹必须按调用方指定 method 规划,再把规划结果交给运行时执行。
|
// 普通轨迹必须按调用方指定 method 规划,再把规划结果交给运行时执行。
|
||||||
var bundle = _trajectoryOrchestrator.PlanOrdinaryTrajectory(robot, waypoints, options);
|
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;
|
var finalJointPositions = bundle.PlannedTrajectory.PlannedWaypoints[^1].Positions;
|
||||||
_runtime.ExecuteTrajectory(bundle.Result, finalJointPositions);
|
_runtime.ExecuteTrajectory(bundle.Result, finalJointPositions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_logger?.LogInformation("ExecuteTrajectory 完成");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -386,6 +436,12 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
|
|||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(trajectory);
|
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)
|
lock (_stateLock)
|
||||||
{
|
{
|
||||||
EnsureRuntimeEnabled();
|
EnsureRuntimeEnabled();
|
||||||
@@ -395,6 +451,8 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
|
|||||||
var settings = _robotSettings ?? CreateDefaultRobotSettings();
|
var settings = _robotSettings ?? CreateDefaultRobotSettings();
|
||||||
_trajectoryStore.Save(robotName, settings, trajectory);
|
_trajectoryStore.Save(robotName, settings, trajectory);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_logger?.LogInformation("UploadTrajectory 完成: name={Name}", trajectory.Name);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -415,6 +473,10 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
|
|||||||
throw new ArgumentException("轨迹名称不能为空。", nameof(name));
|
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)
|
lock (_stateLock)
|
||||||
{
|
{
|
||||||
var robot = RequireActiveRobot();
|
var robot = RequireActiveRobot();
|
||||||
@@ -422,24 +484,37 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
|
|||||||
|
|
||||||
if (!_uploadedTrajectories.TryGetValue(name, out var trajectory))
|
if (!_uploadedTrajectories.TryGetValue(name, out var trajectory))
|
||||||
{
|
{
|
||||||
|
_logger?.LogWarning("ExecuteTrajectoryByName 失败: 轨迹不存在 name={Name}", name);
|
||||||
throw new InvalidOperationException("FlyShot trajectory does not exist.");
|
throw new InvalidOperationException("FlyShot trajectory does not exist.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (trajectory.Waypoints.Count == 0)
|
if (trajectory.Waypoints.Count == 0)
|
||||||
{
|
{
|
||||||
|
_logger?.LogWarning("ExecuteTrajectoryByName 失败: 轨迹无路点 name={Name}", name);
|
||||||
throw new InvalidOperationException("FlyShot trajectory contains no waypoints.");
|
throw new InvalidOperationException("FlyShot trajectory contains no waypoints.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 已上传飞拍轨迹必须按调用方指定 method 生成 shot timeline 后再交给运行时。
|
// 已上传飞拍轨迹必须按调用方指定 method 生成 shot timeline 后再交给运行时。
|
||||||
var bundle = _trajectoryOrchestrator.PlanUploadedFlyshot(robot, trajectory, options, RequireRobotSettings());
|
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)
|
if (options.MoveToStart)
|
||||||
{
|
{
|
||||||
|
_logger?.LogInformation("ExecuteTrajectoryByName 先移动到起点");
|
||||||
_runtime.ExecuteTrajectory(CreateImmediateMoveResult(), bundle.PlannedTrajectory.PlannedWaypoints[0].Positions);
|
_runtime.ExecuteTrajectory(CreateImmediateMoveResult(), bundle.PlannedTrajectory.PlannedWaypoints[0].Positions);
|
||||||
}
|
}
|
||||||
|
|
||||||
var finalJointPositions = bundle.PlannedTrajectory.PlannedWaypoints[^1].Positions;
|
var finalJointPositions = bundle.PlannedTrajectory.PlannedWaypoints[^1].Positions;
|
||||||
_runtime.ExecuteTrajectory(bundle.Result, finalJointPositions);
|
_runtime.ExecuteTrajectory(bundle.Result, finalJointPositions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_logger?.LogInformation("ExecuteTrajectoryByName 完成: name={Name}", name);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -450,11 +525,14 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
|
|||||||
throw new ArgumentException("轨迹名称不能为空。", nameof(name));
|
throw new ArgumentException("轨迹名称不能为空。", nameof(name));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_logger?.LogInformation("SaveTrajectoryInfo 开始: name={Name}, method={Method}", name, method);
|
||||||
|
|
||||||
lock (_stateLock)
|
lock (_stateLock)
|
||||||
{
|
{
|
||||||
var robot = RequireActiveRobot();
|
var robot = RequireActiveRobot();
|
||||||
if (!_uploadedTrajectories.TryGetValue(name, out var trajectory))
|
if (!_uploadedTrajectories.TryGetValue(name, out var trajectory))
|
||||||
{
|
{
|
||||||
|
_logger?.LogWarning("SaveTrajectoryInfo 失败: 轨迹不存在 name={Name}", name);
|
||||||
throw new InvalidOperationException("FlyShot trajectory does not exist.");
|
throw new InvalidOperationException("FlyShot trajectory does not exist.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -469,6 +547,8 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
|
|||||||
var settings = _robotSettings ?? CreateDefaultRobotSettings();
|
var settings = _robotSettings ?? CreateDefaultRobotSettings();
|
||||||
_trajectoryStore.Save(robotName, settings, trajectory);
|
_trajectoryStore.Save(robotName, settings, trajectory);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_logger?.LogInformation("SaveTrajectoryInfo 完成: name={Name}", name);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -479,11 +559,14 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
|
|||||||
throw new ArgumentException("轨迹名称不能为空。", nameof(name));
|
throw new ArgumentException("轨迹名称不能为空。", nameof(name));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_logger?.LogInformation("IsFlyshotTrajectoryValid 开始: name={Name}, method={Method}", name, method);
|
||||||
|
|
||||||
lock (_stateLock)
|
lock (_stateLock)
|
||||||
{
|
{
|
||||||
var robot = RequireActiveRobot();
|
var robot = RequireActiveRobot();
|
||||||
if (!_uploadedTrajectories.TryGetValue(name, out var trajectory))
|
if (!_uploadedTrajectories.TryGetValue(name, out var trajectory))
|
||||||
{
|
{
|
||||||
|
_logger?.LogWarning("IsFlyshotTrajectoryValid 失败: 轨迹不存在 name={Name}", name);
|
||||||
throw new InvalidOperationException("FlyShot trajectory does not exist.");
|
throw new InvalidOperationException("FlyShot trajectory does not exist.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -494,6 +577,9 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
|
|||||||
RequireRobotSettings());
|
RequireRobotSettings());
|
||||||
|
|
||||||
duration = bundle.Result.Duration;
|
duration = bundle.Result.Duration;
|
||||||
|
_logger?.LogInformation(
|
||||||
|
"IsFlyshotTrajectoryValid 结果: name={Name}, valid={Valid}, duration={Duration}s",
|
||||||
|
name, bundle.Result.IsValid, duration.TotalSeconds);
|
||||||
return bundle.Result.IsValid;
|
return bundle.Result.IsValid;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -506,16 +592,21 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
|
|||||||
throw new ArgumentException("轨迹名称不能为空。", nameof(name));
|
throw new ArgumentException("轨迹名称不能为空。", nameof(name));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_logger?.LogInformation("DeleteTrajectory 开始: name={Name}", name);
|
||||||
|
|
||||||
lock (_stateLock)
|
lock (_stateLock)
|
||||||
{
|
{
|
||||||
if (!_uploadedTrajectories.Remove(name))
|
if (!_uploadedTrajectories.Remove(name))
|
||||||
{
|
{
|
||||||
|
_logger?.LogWarning("DeleteTrajectory 失败: 轨迹不存在 name={Name}", name);
|
||||||
throw new InvalidOperationException("DeleteFlyShotTraj failed");
|
throw new InvalidOperationException("DeleteFlyShotTraj failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
var robotName = _configuredRobotName ?? throw new InvalidOperationException("Robot has not been setup.");
|
var robotName = _configuredRobotName ?? throw new InvalidOperationException("Robot has not been setup.");
|
||||||
_trajectoryStore.Delete(robotName, name);
|
_trajectoryStore.Delete(robotName, name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_logger?.LogInformation("DeleteTrajectory 完成: name={Name}", name);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using Flyshot.Core.Domain;
|
|||||||
using Flyshot.Core.Planning;
|
using Flyshot.Core.Planning;
|
||||||
using Flyshot.Core.Planning.Sampling;
|
using Flyshot.Core.Planning.Sampling;
|
||||||
using Flyshot.Core.Triggering;
|
using Flyshot.Core.Triggering;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace Flyshot.ControllerClientCompat;
|
namespace Flyshot.ControllerClientCompat;
|
||||||
|
|
||||||
@@ -11,10 +12,22 @@ namespace Flyshot.ControllerClientCompat;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ControllerClientTrajectoryOrchestrator
|
public sealed class ControllerClientTrajectoryOrchestrator
|
||||||
{
|
{
|
||||||
private readonly ICspPlanner _icspPlanner = new();
|
private readonly ICspPlanner _icspPlanner;
|
||||||
private readonly SelfAdaptIcspPlanner _selfAdaptIcspPlanner = new();
|
private readonly SelfAdaptIcspPlanner _selfAdaptIcspPlanner;
|
||||||
private readonly ShotTimelineBuilder _shotTimelineBuilder = new(new WaypointTimestampResolver());
|
private readonly ShotTimelineBuilder _shotTimelineBuilder = new(new WaypointTimestampResolver());
|
||||||
private readonly Dictionary<string, PlannedExecutionBundle> _flyshotCache = new(StringComparer.Ordinal);
|
private readonly Dictionary<string, PlannedExecutionBundle> _flyshotCache = new(StringComparer.Ordinal);
|
||||||
|
private readonly ILogger<ControllerClientTrajectoryOrchestrator>? _logger;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 初始化轨迹编排器。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="logger">日志记录器;允许 null。</param>
|
||||||
|
public ControllerClientTrajectoryOrchestrator(ILogger<ControllerClientTrajectoryOrchestrator>? logger = null)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_icspPlanner = new(logger: null);
|
||||||
|
_selfAdaptIcspPlanner = new(logger: null);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 对普通轨迹执行 ICSP 规划。
|
/// 对普通轨迹执行 ICSP 规划。
|
||||||
@@ -31,6 +44,10 @@ public sealed class ControllerClientTrajectoryOrchestrator
|
|||||||
ArgumentNullException.ThrowIfNull(waypoints);
|
ArgumentNullException.ThrowIfNull(waypoints);
|
||||||
options ??= new TrajectoryExecutionOptions();
|
options ??= new TrajectoryExecutionOptions();
|
||||||
|
|
||||||
|
_logger?.LogInformation(
|
||||||
|
"PlanOrdinaryTrajectory 开始: 路点数={WaypointCount}, method={Method}",
|
||||||
|
waypoints.Count, options.Method);
|
||||||
|
|
||||||
var program = CreateProgram(
|
var program = CreateProgram(
|
||||||
name: "ordinary-trajectory",
|
name: "ordinary-trajectory",
|
||||||
waypoints: waypoints,
|
waypoints: waypoints,
|
||||||
@@ -49,6 +66,11 @@ public sealed class ControllerClientTrajectoryOrchestrator
|
|||||||
var shotTimeline = new ShotTimeline(Array.Empty<ShotEvent>(), Array.Empty<TrajectoryDoEvent>());
|
var shotTimeline = new ShotTimeline(Array.Empty<ShotEvent>(), Array.Empty<TrajectoryDoEvent>());
|
||||||
var result = CreateResult(plannedTrajectory, shotTimeline, usedCache: false);
|
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);
|
return new PlannedExecutionBundle(plannedTrajectory, shotTimeline, result);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,6 +91,10 @@ public sealed class ControllerClientTrajectoryOrchestrator
|
|||||||
options ??= new FlyshotExecutionOptions();
|
options ??= new FlyshotExecutionOptions();
|
||||||
settings ??= CreateDefaultRobotSettings();
|
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(
|
var program = CreateProgram(
|
||||||
name: uploaded.Name,
|
name: uploaded.Name,
|
||||||
waypoints: uploaded.Waypoints,
|
waypoints: uploaded.Waypoints,
|
||||||
@@ -80,6 +106,7 @@ public sealed class ControllerClientTrajectoryOrchestrator
|
|||||||
var cacheKey = CreateFlyshotCacheKey(robot, uploaded, options, settings);
|
var cacheKey = CreateFlyshotCacheKey(robot, uploaded, options, settings);
|
||||||
if (options.UseCache && _flyshotCache.TryGetValue(cacheKey, out var cachedBundle))
|
if (options.UseCache && _flyshotCache.TryGetValue(cacheKey, out var cachedBundle))
|
||||||
{
|
{
|
||||||
|
_logger?.LogInformation("PlanUploadedFlyshot 命中缓存: name={Name}, cacheKey={CacheKey}", uploaded.Name, cacheKey);
|
||||||
// 命中缓存时只替换 TrajectoryResult 的 usedCache 标志,规划轨迹和触发时间轴保持不可变复用。
|
// 命中缓存时只替换 TrajectoryResult 的 usedCache 标志,规划轨迹和触发时间轴保持不可变复用。
|
||||||
return new PlannedExecutionBundle(
|
return new PlannedExecutionBundle(
|
||||||
cachedBundle.PlannedTrajectory,
|
cachedBundle.PlannedTrajectory,
|
||||||
@@ -104,6 +131,10 @@ public sealed class ControllerClientTrajectoryOrchestrator
|
|||||||
var result = CreateResult(plannedTrajectory, shotTimeline, usedCache: false);
|
var result = CreateResult(plannedTrajectory, shotTimeline, usedCache: false);
|
||||||
var bundle = new PlannedExecutionBundle(plannedTrajectory, shotTimeline, result);
|
var bundle = new PlannedExecutionBundle(plannedTrajectory, shotTimeline, result);
|
||||||
|
|
||||||
|
_logger?.LogInformation(
|
||||||
|
"PlanUploadedFlyshot 完成: name={Name}, 时长={Duration}s, 触发事件数={TriggerCount}, 采样点数={SampleCount}",
|
||||||
|
uploaded.Name, result.Duration.TotalSeconds, result.TriggerTimeline.Count, result.DenseJointTrajectory?.Count ?? 0);
|
||||||
|
|
||||||
if (options.UseCache)
|
if (options.UseCache)
|
||||||
{
|
{
|
||||||
_flyshotCache[cacheKey] = bundle;
|
_flyshotCache[cacheKey] = bundle;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System.Text.Json;
|
|||||||
using System.Text.Json.Nodes;
|
using System.Text.Json.Nodes;
|
||||||
using Flyshot.Core.Config;
|
using Flyshot.Core.Config;
|
||||||
using Flyshot.Core.Domain;
|
using Flyshot.Core.Domain;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace Flyshot.ControllerClientCompat;
|
namespace Flyshot.ControllerClientCompat;
|
||||||
|
|
||||||
@@ -41,16 +42,19 @@ public sealed class JsonFlyshotTrajectoryStore : IFlyshotTrajectoryStore
|
|||||||
{
|
{
|
||||||
private readonly ControllerClientCompatOptions _options;
|
private readonly ControllerClientCompatOptions _options;
|
||||||
private readonly RobotConfigLoader _configLoader;
|
private readonly RobotConfigLoader _configLoader;
|
||||||
|
private readonly ILogger<JsonFlyshotTrajectoryStore>? _logger;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 初始化基于 JSON 文件的轨迹存储。
|
/// 初始化基于 JSON 文件的轨迹存储。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="options">兼容层基础配置,用于定位工作区根目录。</param>
|
/// <param name="options">兼容层基础配置,用于定位工作区根目录。</param>
|
||||||
/// <param name="configLoader">旧版 RobotConfig.json 加载器,用于反序列化已保存的轨迹。</param>
|
/// <param name="configLoader">旧版 RobotConfig.json 加载器,用于反序列化已保存的轨迹。</param>
|
||||||
public JsonFlyshotTrajectoryStore(ControllerClientCompatOptions options, RobotConfigLoader configLoader)
|
/// <param name="logger">日志记录器;允许 null。</param>
|
||||||
|
public JsonFlyshotTrajectoryStore(ControllerClientCompatOptions options, RobotConfigLoader configLoader, ILogger<JsonFlyshotTrajectoryStore>? logger = null)
|
||||||
{
|
{
|
||||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||||
_configLoader = configLoader ?? throw new ArgumentNullException(nameof(configLoader));
|
_configLoader = configLoader ?? throw new ArgumentNullException(nameof(configLoader));
|
||||||
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -59,6 +63,12 @@ public sealed class JsonFlyshotTrajectoryStore : IFlyshotTrajectoryStore
|
|||||||
ArgumentNullException.ThrowIfNull(settings);
|
ArgumentNullException.ThrowIfNull(settings);
|
||||||
ArgumentNullException.ThrowIfNull(trajectory);
|
ArgumentNullException.ThrowIfNull(trajectory);
|
||||||
|
|
||||||
|
_logger?.LogInformation(
|
||||||
|
"TrajectoryStore 保存轨迹: robot={RobotName}, name={TrajectoryName}, waypoints={WaypointCount}",
|
||||||
|
robotName,
|
||||||
|
trajectory.Name,
|
||||||
|
trajectory.Waypoints.Count);
|
||||||
|
|
||||||
var path = ResolveStorePath(robotName);
|
var path = ResolveStorePath(robotName);
|
||||||
var directory = Path.GetDirectoryName(path)!;
|
var directory = Path.GetDirectoryName(path)!;
|
||||||
Directory.CreateDirectory(directory);
|
Directory.CreateDirectory(directory);
|
||||||
@@ -92,6 +102,8 @@ public sealed class JsonFlyshotTrajectoryStore : IFlyshotTrajectoryStore
|
|||||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
|
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
|
||||||
};
|
};
|
||||||
File.WriteAllText(path, root.ToJsonString(writeOptions));
|
File.WriteAllText(path, root.ToJsonString(writeOptions));
|
||||||
|
|
||||||
|
_logger?.LogInformation("TrajectoryStore 轨迹已保存到 {Path}", path);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -102,9 +114,12 @@ public sealed class JsonFlyshotTrajectoryStore : IFlyshotTrajectoryStore
|
|||||||
throw new ArgumentException("轨迹名称不能为空。", nameof(trajectoryName));
|
throw new ArgumentException("轨迹名称不能为空。", nameof(trajectoryName));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_logger?.LogInformation("TrajectoryStore 删除轨迹: robot={RobotName}, name={TrajectoryName}", robotName, trajectoryName);
|
||||||
|
|
||||||
var path = ResolveStorePath(robotName);
|
var path = ResolveStorePath(robotName);
|
||||||
if (!File.Exists(path))
|
if (!File.Exists(path))
|
||||||
{
|
{
|
||||||
|
_logger?.LogWarning("TrajectoryStore 删除失败: 文件不存在 {Path}", path);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,19 +127,27 @@ public sealed class JsonFlyshotTrajectoryStore : IFlyshotTrajectoryStore
|
|||||||
var root = JsonNode.Parse(existingJson)?.AsObject();
|
var root = JsonNode.Parse(existingJson)?.AsObject();
|
||||||
if (root is null)
|
if (root is null)
|
||||||
{
|
{
|
||||||
|
_logger?.LogWarning("TrajectoryStore 删除失败: 无法解析 JSON {Path}", path);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (root.TryGetPropertyValue("flying_shots", out var flyingShotsNode) && flyingShotsNode is JsonObject flyingShotsObj)
|
if (root.TryGetPropertyValue("flying_shots", out var flyingShotsNode) && flyingShotsNode is JsonObject flyingShotsObj)
|
||||||
{
|
{
|
||||||
flyingShotsObj.Remove(trajectoryName);
|
var removed = flyingShotsObj.Remove(trajectoryName);
|
||||||
|
if (removed)
|
||||||
var writeOptions = new JsonSerializerOptions
|
|
||||||
{
|
{
|
||||||
WriteIndented = true,
|
var writeOptions = new JsonSerializerOptions
|
||||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
|
{
|
||||||
};
|
WriteIndented = true,
|
||||||
File.WriteAllText(path, root.ToJsonString(writeOptions));
|
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);
|
var path = ResolveStorePath(robotName);
|
||||||
if (!File.Exists(path))
|
if (!File.Exists(path))
|
||||||
{
|
{
|
||||||
|
_logger?.LogInformation("TrajectoryStore 无持久化数据: {Path}", path);
|
||||||
settings = null;
|
settings = null;
|
||||||
return new Dictionary<string, ControllerClientCompatUploadedTrajectory>(StringComparer.Ordinal);
|
return new Dictionary<string, ControllerClientCompatUploadedTrajectory>(StringComparer.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
_logger?.LogInformation("TrajectoryStore 正在加载: {Path}", path);
|
||||||
|
|
||||||
var workspaceRoot = ResolveWorkspaceRoot();
|
var workspaceRoot = ResolveWorkspaceRoot();
|
||||||
var loaded = _configLoader.Load(path, workspaceRoot);
|
var loaded = _configLoader.Load(path, workspaceRoot);
|
||||||
settings = loaded.Robot;
|
settings = loaded.Robot;
|
||||||
@@ -156,10 +182,18 @@ public sealed class JsonFlyshotTrajectoryStore : IFlyshotTrajectoryStore
|
|||||||
dict[program.Key] = traj;
|
dict[program.Key] = traj;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_logger?.LogInformation(
|
||||||
|
"TrajectoryStore 加载完成: robot={RobotName}, 轨迹数={Count}, useDo={UseDo}, ioKeepCycles={IoKeepCycles}",
|
||||||
|
robotName,
|
||||||
|
dict.Count,
|
||||||
|
settings?.UseDo,
|
||||||
|
settings?.IoKeepCycles);
|
||||||
|
|
||||||
return dict;
|
return dict;
|
||||||
}
|
}
|
||||||
catch
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
_logger?.LogError(ex, "TrajectoryStore 加载失败: {Path}", path);
|
||||||
settings = null;
|
settings = null;
|
||||||
return new Dictionary<string, ControllerClientCompatUploadedTrajectory>(StringComparer.Ordinal);
|
return new Dictionary<string, ControllerClientCompatUploadedTrajectory>(StringComparer.Ordinal);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,10 @@
|
|||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.1" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\Flyshot.Core.Domain\Flyshot.Core.Domain.csproj" />
|
<ProjectReference Include="..\Flyshot.Core.Domain\Flyshot.Core.Domain.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Flyshot.Core.Domain;
|
using Flyshot.Core.Domain;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace Flyshot.Core.Planning;
|
namespace Flyshot.Core.Planning;
|
||||||
|
|
||||||
@@ -33,6 +34,7 @@ public sealed class ICspPlanner
|
|||||||
private readonly int _maxIterations;
|
private readonly int _maxIterations;
|
||||||
private readonly bool _enforceFinalScale;
|
private readonly bool _enforceFinalScale;
|
||||||
private readonly double _finalScaleTolerance;
|
private readonly double _finalScaleTolerance;
|
||||||
|
private readonly ILogger<ICspPlanner>? _logger;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 初始化 ICSP 规划器。
|
/// 初始化 ICSP 规划器。
|
||||||
@@ -41,11 +43,13 @@ public sealed class ICspPlanner
|
|||||||
/// <param name="maxIterations">最大迭代轮数。</param>
|
/// <param name="maxIterations">最大迭代轮数。</param>
|
||||||
/// <param name="enforceFinalScale">是否在最终最优 scale 仍大于 1.0 时抛出失败。</param>
|
/// <param name="enforceFinalScale">是否在最终最优 scale 仍大于 1.0 时抛出失败。</param>
|
||||||
/// <param name="finalScaleTolerance">最终 scale 判定容差。</param>
|
/// <param name="finalScaleTolerance">最终 scale 判定容差。</param>
|
||||||
|
/// <param name="logger">日志记录器;允许 null,供无日志场景使用。</param>
|
||||||
public ICspPlanner(
|
public ICspPlanner(
|
||||||
double threshold = DefaultThreshold,
|
double threshold = DefaultThreshold,
|
||||||
int maxIterations = DefaultMaxIterations,
|
int maxIterations = DefaultMaxIterations,
|
||||||
bool enforceFinalScale = true,
|
bool enforceFinalScale = true,
|
||||||
double finalScaleTolerance = DefaultFinalScaleTolerance)
|
double finalScaleTolerance = DefaultFinalScaleTolerance,
|
||||||
|
ILogger<ICspPlanner>? logger = null)
|
||||||
{
|
{
|
||||||
if (threshold <= 0.0 || double.IsNaN(threshold) || double.IsInfinity(threshold))
|
if (threshold <= 0.0 || double.IsNaN(threshold) || double.IsInfinity(threshold))
|
||||||
{
|
{
|
||||||
@@ -66,6 +70,7 @@ public sealed class ICspPlanner
|
|||||||
_maxIterations = maxIterations;
|
_maxIterations = maxIterations;
|
||||||
_enforceFinalScale = enforceFinalScale;
|
_enforceFinalScale = enforceFinalScale;
|
||||||
_finalScaleTolerance = finalScaleTolerance;
|
_finalScaleTolerance = finalScaleTolerance;
|
||||||
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -81,9 +86,22 @@ public sealed class ICspPlanner
|
|||||||
throw new ArgumentException("ICSP 至少需要 4 个示教点。", nameof(request));
|
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 qs = WaypointsToArray(waypoints);
|
||||||
var (velLimits, accLimits, jerkLimits) = ExtractLimits(request.Robot);
|
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);
|
var segmentDurations = ComputeInitialDurations(qs);
|
||||||
int nseg = segmentDurations.Length;
|
int nseg = segmentDurations.Length;
|
||||||
@@ -135,6 +153,9 @@ public sealed class ICspPlanner
|
|||||||
|
|
||||||
if (currentThreshold < _threshold)
|
if (currentThreshold < _threshold)
|
||||||
{
|
{
|
||||||
|
_logger?.LogDebug(
|
||||||
|
"ICSP 第 {Iteration} 轮收敛: threshold={CurrentThreshold:E6}",
|
||||||
|
iteration + 1, currentThreshold);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,10 +173,22 @@ public sealed class ICspPlanner
|
|||||||
var globalScale = bestScales.Max();
|
var globalScale = bestScales.Max();
|
||||||
if (_enforceFinalScale && globalScale > 1.0 + _finalScaleTolerance)
|
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(
|
throw new InvalidOperationException(
|
||||||
$"ICSP 规划未收敛,global_scale={globalScale:F6} > {1.0 + _finalScaleTolerance:F6},轨迹不可执行。");
|
$"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(
|
return new PlannedTrajectory(
|
||||||
robot: request.Robot,
|
robot: request.Robot,
|
||||||
originalProgram: request.Program,
|
originalProgram: request.Program,
|
||||||
|
|||||||
@@ -1,182 +1,213 @@
|
|||||||
using Flyshot.Core.Domain;
|
using Flyshot.Core.Domain;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
namespace Flyshot.Core.Planning;
|
|
||||||
|
namespace Flyshot.Core.Planning;
|
||||||
/// <summary>
|
|
||||||
/// 在 ICSP 外层包裹补中点策略,实现 self-adapt-icsp 行为。
|
/// <summary>
|
||||||
///
|
/// 在 ICSP 外层包裹补中点策略,实现 self-adapt-icsp 行为。
|
||||||
/// 为什么需要这层?
|
///
|
||||||
/// ---
|
/// 为什么需要这层?
|
||||||
/// 逆向分析已经指出:原系统里普通 icsp 若仍有段 scale > 1,不会直接返回未收敛结果;
|
/// ---
|
||||||
/// 配置中还明确存在 adapt_icsp_try_num。本层把“超限段统一插入中点后再重规划”的逻辑显式落地,
|
/// 逆向分析已经指出:原系统里普通 icsp 若仍有段 scale > 1,不会直接返回未收敛结果;
|
||||||
/// 补上 demo 缺失的失败恢复路径。
|
/// 配置中还明确存在 adapt_icsp_try_num。本层把"超限段统一插入中点后再重规划"的逻辑显式落地,
|
||||||
///
|
/// 补上 demo 缺失的失败恢复路径。
|
||||||
/// 补点策略:
|
///
|
||||||
/// ---
|
/// 补点策略:
|
||||||
/// 对当前所有 scale > 1 + tolerance 的段统一插入关节空间中点,然后把新路点集交给 ICSPPlanner
|
/// ---
|
||||||
/// 重新规划。这种"先把明显病灶都降一档,再整体重规划"的策略比逐段拆分更稳定,
|
/// 对当前所有 scale > 1 + tolerance 的段统一插入关节空间中点,然后把新路点集交给 ICSPPlanner
|
||||||
/// 也更符合服务端 adapt_icsp_try_num 的意图。
|
/// 重新规划。这种"先把明显病灶都降一档,再整体重规划"的策略比逐段拆分更稳定,
|
||||||
/// </summary>
|
/// 也更符合服务端 adapt_icsp_try_num 的意图。
|
||||||
public sealed class SelfAdaptIcspPlanner
|
/// </summary>
|
||||||
{
|
public sealed class SelfAdaptIcspPlanner
|
||||||
/// <summary>
|
{
|
||||||
/// 判定段是否超限的数值容差,过滤浮点噪声。
|
/// <summary>
|
||||||
/// </summary>
|
/// 判定段是否超限的数值容差,过滤浮点噪声。
|
||||||
public const double ScaleTolerance = 5e-4;
|
/// </summary>
|
||||||
|
public const double ScaleTolerance = 5e-4;
|
||||||
private readonly ICspPlanner _innerPlanner = new(enforceFinalScale: false);
|
|
||||||
|
private readonly ICspPlanner _innerPlanner;
|
||||||
/// <summary>
|
private readonly ILogger<SelfAdaptIcspPlanner>? _logger;
|
||||||
/// 执行自适应 ICSP 规划,允许在超限段插入中点后重试。
|
|
||||||
/// </summary>
|
/// <summary>
|
||||||
/// <param name="request">轨迹规划请求。</param>
|
/// 初始化 SelfAdaptIcspPlanner。
|
||||||
/// <param name="adaptIcspTryNum">最大补点重试次数(默认 5)。</param>
|
/// </summary>
|
||||||
/// <returns>规划后的轨迹结果。</returns>
|
/// <param name="logger">日志记录器;允许 null。</param>
|
||||||
/// <exception cref="InvalidOperationException">超过最大重试次数仍未收敛时抛出。</exception>
|
public SelfAdaptIcspPlanner(ILogger<SelfAdaptIcspPlanner>? logger = null)
|
||||||
public PlannedTrajectory Plan(TrajectoryRequest request, int adaptIcspTryNum = 5)
|
{
|
||||||
{
|
_innerPlanner = new ICspPlanner(enforceFinalScale: false, logger: null);
|
||||||
ArgumentNullException.ThrowIfNull(request);
|
_logger = logger;
|
||||||
|
}
|
||||||
var currentWaypoints = request.Program.Waypoints.ToArray();
|
|
||||||
var currentShotFlags = request.Program.ShotFlags.ToArray();
|
/// <summary>
|
||||||
var currentOffsets = request.Program.OffsetValues.ToArray();
|
/// 执行自适应 ICSP 规划,允许在超限段插入中点后重试。
|
||||||
var currentAddrs = request.Program.AddressGroups.ToArray();
|
/// </summary>
|
||||||
int originalWaypointCount = currentWaypoints.Length;
|
/// <param name="request">轨迹规划请求。</param>
|
||||||
|
/// <param name="adaptIcspTryNum">最大补点重试次数(默认 5)。</param>
|
||||||
if (originalWaypointCount < 4)
|
/// <returns>规划后的轨迹结果。</returns>
|
||||||
{
|
/// <exception cref="InvalidOperationException">超过最大重试次数仍未收敛时抛出。</exception>
|
||||||
throw new ArgumentException("ICSP 至少需要 4 个示教点。", nameof(request));
|
public PlannedTrajectory Plan(TrajectoryRequest request, int adaptIcspTryNum = 5)
|
||||||
}
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
var currentProgram = BuildProgram(request.Program.Name, currentWaypoints, currentShotFlags, currentOffsets, currentAddrs);
|
|
||||||
var currentRequest = new TrajectoryRequest(
|
var currentWaypoints = request.Program.Waypoints.ToArray();
|
||||||
robot: request.Robot,
|
var currentShotFlags = request.Program.ShotFlags.ToArray();
|
||||||
program: currentProgram,
|
var currentOffsets = request.Program.OffsetValues.ToArray();
|
||||||
method: PlanningMethod.SelfAdaptIcsp,
|
var currentAddrs = request.Program.AddressGroups.ToArray();
|
||||||
moveToStart: request.MoveToStart,
|
int originalWaypointCount = currentWaypoints.Length;
|
||||||
saveTrajectoryArtifacts: request.SaveTrajectoryArtifacts,
|
|
||||||
useCache: request.UseCache);
|
if (originalWaypointCount < 4)
|
||||||
|
{
|
||||||
PlannedTrajectory? lastTrajectory = null;
|
throw new ArgumentException("ICSP 至少需要 4 个示教点。", nameof(request));
|
||||||
int maxAttempts = Math.Max(0, adaptIcspTryNum);
|
}
|
||||||
|
|
||||||
for (int attempt = 0; attempt <= maxAttempts; attempt++)
|
_logger?.LogInformation(
|
||||||
{
|
"SelfAdaptICSP 规划开始: 名称={Name}, 原始路点数={WaypointCount}, 最大补点次数={MaxAttempts}",
|
||||||
var trajectory = _innerPlanner.Plan(currentRequest);
|
request.Program.Name, originalWaypointCount, adaptIcspTryNum);
|
||||||
lastTrajectory = trajectory;
|
|
||||||
|
var currentProgram = BuildProgram(request.Program.Name, currentWaypoints, currentShotFlags, currentOffsets, currentAddrs);
|
||||||
var badSegments = new List<int>();
|
var currentRequest = new TrajectoryRequest(
|
||||||
for (int seg = 0; seg < trajectory.SegmentScales.Count; seg++)
|
robot: request.Robot,
|
||||||
{
|
program: currentProgram,
|
||||||
if (trajectory.SegmentScales[seg] > 1.0 + ScaleTolerance)
|
method: PlanningMethod.SelfAdaptIcsp,
|
||||||
{
|
moveToStart: request.MoveToStart,
|
||||||
badSegments.Add(seg);
|
saveTrajectoryArtifacts: request.SaveTrajectoryArtifacts,
|
||||||
}
|
useCache: request.UseCache);
|
||||||
}
|
|
||||||
|
PlannedTrajectory? lastTrajectory = null;
|
||||||
if (badSegments.Count == 0)
|
int maxAttempts = Math.Max(0, adaptIcspTryNum);
|
||||||
{
|
|
||||||
// 所有段都满足约束,收敛成功。返回包含补中点后路点的轨迹。
|
for (int attempt = 0; attempt <= maxAttempts; attempt++)
|
||||||
return new PlannedTrajectory(
|
{
|
||||||
robot: trajectory.Robot,
|
var trajectory = _innerPlanner.Plan(currentRequest);
|
||||||
originalProgram: request.Program,
|
lastTrajectory = trajectory;
|
||||||
plannedWaypoints: currentWaypoints,
|
|
||||||
waypointTimes: trajectory.WaypointTimes,
|
var badSegments = new List<int>();
|
||||||
segmentDurations: trajectory.SegmentDurations,
|
for (int seg = 0; seg < trajectory.SegmentScales.Count; seg++)
|
||||||
segmentScales: trajectory.SegmentScales,
|
{
|
||||||
method: PlanningMethod.SelfAdaptIcsp,
|
if (trajectory.SegmentScales[seg] > 1.0 + ScaleTolerance)
|
||||||
iterations: trajectory.Iterations,
|
{
|
||||||
threshold: trajectory.Threshold);
|
badSegments.Add(seg);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (attempt >= maxAttempts)
|
|
||||||
{
|
if (badSegments.Count == 0)
|
||||||
break;
|
{
|
||||||
}
|
_logger?.LogInformation(
|
||||||
|
"SelfAdaptICSP 规划完成: 名称={Name}, 补点轮数={Attempts}, 最终路点数={WaypointCount}, 迭代次数={Iterations}, 总时长={Duration:F4}s",
|
||||||
// 对超限段插入中点,并同步扩展 shot 元数据。
|
request.Program.Name, attempt, currentWaypoints.Length, trajectory.Iterations, trajectory.WaypointTimes[^1]);
|
||||||
(currentWaypoints, currentShotFlags, currentOffsets, currentAddrs) =
|
// 所有段都满足约束,收敛成功。返回包含补中点后路点的轨迹。
|
||||||
InsertSegmentMidpoints(currentWaypoints, currentShotFlags, currentOffsets, currentAddrs, badSegments);
|
return new PlannedTrajectory(
|
||||||
|
robot: trajectory.Robot,
|
||||||
currentProgram = BuildProgram(request.Program.Name, currentWaypoints, currentShotFlags, currentOffsets, currentAddrs);
|
originalProgram: request.Program,
|
||||||
currentRequest = new TrajectoryRequest(
|
plannedWaypoints: currentWaypoints,
|
||||||
robot: request.Robot,
|
waypointTimes: trajectory.WaypointTimes,
|
||||||
program: currentProgram,
|
segmentDurations: trajectory.SegmentDurations,
|
||||||
method: PlanningMethod.SelfAdaptIcsp,
|
segmentScales: trajectory.SegmentScales,
|
||||||
moveToStart: request.MoveToStart,
|
method: PlanningMethod.SelfAdaptIcsp,
|
||||||
saveTrajectoryArtifacts: request.SaveTrajectoryArtifacts,
|
iterations: trajectory.Iterations,
|
||||||
useCache: request.UseCache);
|
threshold: trajectory.Threshold);
|
||||||
}
|
}
|
||||||
|
|
||||||
double maxScale = lastTrajectory?.SegmentScales.Max() ?? double.NaN;
|
_logger?.LogWarning(
|
||||||
throw new InvalidOperationException(
|
"SelfAdaptICSP 第 {Attempt} 轮存在超限段: 超限段数={BadCount}, 段索引=[{Segments}], 最大缩放={MaxScale:F4}",
|
||||||
$"self-adapt ICSP 在 {adaptIcspTryNum} 轮补点后仍未收敛,最大段缩放因子={maxScale:F6}。");
|
attempt, badSegments.Count, string.Join(", ", badSegments), trajectory.SegmentScales.Max());
|
||||||
}
|
|
||||||
|
if (attempt >= maxAttempts)
|
||||||
/// <summary>
|
{
|
||||||
/// 用当前路点集和元数据构造 FlyshotProgram。
|
break;
|
||||||
/// </summary>
|
}
|
||||||
private static FlyshotProgram BuildProgram(
|
|
||||||
string name,
|
// 对超限段插入中点,并同步扩展 shot 元数据。
|
||||||
JointWaypoint[] waypoints,
|
int waypointCountBefore = currentWaypoints.Length;
|
||||||
bool[] shotFlags,
|
(currentWaypoints, currentShotFlags, currentOffsets, currentAddrs) =
|
||||||
int[] offsets,
|
InsertSegmentMidpoints(currentWaypoints, currentShotFlags, currentOffsets, currentAddrs, badSegments);
|
||||||
IoAddressGroup[] addrs)
|
|
||||||
{
|
_logger?.LogDebug(
|
||||||
return new FlyshotProgram(
|
"SelfAdaptICSP 补中点: 路点数 {Before} -> {After}, 插入段=[{Segments}]",
|
||||||
name: name,
|
waypointCountBefore, currentWaypoints.Length, string.Join(", ", badSegments));
|
||||||
waypoints: waypoints,
|
|
||||||
shotFlags: shotFlags,
|
currentProgram = BuildProgram(request.Program.Name, currentWaypoints, currentShotFlags, currentOffsets, currentAddrs);
|
||||||
offsetValues: offsets,
|
currentRequest = new TrajectoryRequest(
|
||||||
addressGroups: addrs);
|
robot: request.Robot,
|
||||||
}
|
program: currentProgram,
|
||||||
|
method: PlanningMethod.SelfAdaptIcsp,
|
||||||
/// <summary>
|
moveToStart: request.MoveToStart,
|
||||||
/// 对超限段统一插入关节空间中点,并同步扩展 shot 元数据。
|
saveTrajectoryArtifacts: request.SaveTrajectoryArtifacts,
|
||||||
/// 新插入的路点默认 shotFlag=false、offset=0、addr=空。
|
useCache: request.UseCache);
|
||||||
/// </summary>
|
}
|
||||||
private static (JointWaypoint[] waypoints, bool[] shotFlags, int[] offsets, IoAddressGroup[] addrs)
|
|
||||||
InsertSegmentMidpoints(
|
double maxScale = lastTrajectory?.SegmentScales.Max() ?? double.NaN;
|
||||||
JointWaypoint[] waypoints,
|
_logger?.LogError(
|
||||||
bool[] shotFlags,
|
"SelfAdaptICSP 规划失败: 名称={Name}, 在 {Attempts} 轮补点后仍未收敛, 最大段缩放因子={MaxScale:F6}",
|
||||||
int[] offsets,
|
request.Program.Name, adaptIcspTryNum, maxScale);
|
||||||
IoAddressGroup[] addrs,
|
throw new InvalidOperationException(
|
||||||
List<int> badSegments)
|
$"self-adapt ICSP 在 {adaptIcspTryNum} 轮补点后仍未收敛,最大段缩放因子={maxScale:F6}。");
|
||||||
{
|
}
|
||||||
if (badSegments.Count == 0)
|
|
||||||
{
|
/// <summary>
|
||||||
return (waypoints, shotFlags, offsets, addrs);
|
/// 用当前路点集和元数据构造 FlyshotProgram。
|
||||||
}
|
/// </summary>
|
||||||
|
private static FlyshotProgram BuildProgram(
|
||||||
var badSet = new HashSet<int>(badSegments);
|
string name,
|
||||||
var newWaypoints = new List<JointWaypoint>(waypoints.Length + badSegments.Count);
|
JointWaypoint[] waypoints,
|
||||||
var newShotFlags = new List<bool>(waypoints.Length + badSegments.Count);
|
bool[] shotFlags,
|
||||||
var newOffsets = new List<int>(waypoints.Length + badSegments.Count);
|
int[] offsets,
|
||||||
var newAddrs = new List<IoAddressGroup>(waypoints.Length + badSegments.Count);
|
IoAddressGroup[] addrs)
|
||||||
|
{
|
||||||
newWaypoints.Add(waypoints[0]);
|
return new FlyshotProgram(
|
||||||
newShotFlags.Add(shotFlags[0]);
|
name: name,
|
||||||
newOffsets.Add(offsets[0]);
|
waypoints: waypoints,
|
||||||
newAddrs.Add(addrs[0]);
|
shotFlags: shotFlags,
|
||||||
|
offsetValues: offsets,
|
||||||
for (int seg = 0; seg < waypoints.Length - 1; seg++)
|
addressGroups: addrs);
|
||||||
{
|
}
|
||||||
if (badSet.Contains(seg))
|
|
||||||
{
|
/// <summary>
|
||||||
var mid = new JointWaypoint(
|
/// 对超限段统一插入关节空间中点,并同步扩展 shot 元数据。
|
||||||
waypoints[seg].Positions.Zip(waypoints[seg + 1].Positions, (a, b) => (a + b) / 2.0));
|
/// 新插入的路点默认 shotFlag=false、offset=0、addr=空。
|
||||||
newWaypoints.Add(mid);
|
/// </summary>
|
||||||
newShotFlags.Add(false);
|
private static (JointWaypoint[] waypoints, bool[] shotFlags, int[] offsets, IoAddressGroup[] addrs)
|
||||||
newOffsets.Add(0);
|
InsertSegmentMidpoints(
|
||||||
newAddrs.Add(new IoAddressGroup(Array.Empty<int>()));
|
JointWaypoint[] waypoints,
|
||||||
}
|
bool[] shotFlags,
|
||||||
|
int[] offsets,
|
||||||
newWaypoints.Add(waypoints[seg + 1]);
|
IoAddressGroup[] addrs,
|
||||||
newShotFlags.Add(shotFlags[seg + 1]);
|
List<int> badSegments)
|
||||||
newOffsets.Add(offsets[seg + 1]);
|
{
|
||||||
newAddrs.Add(addrs[seg + 1]);
|
if (badSegments.Count == 0)
|
||||||
}
|
{
|
||||||
|
return (waypoints, shotFlags, offsets, addrs);
|
||||||
return (newWaypoints.ToArray(), newShotFlags.ToArray(), newOffsets.ToArray(), newAddrs.ToArray());
|
}
|
||||||
}
|
|
||||||
}
|
var badSet = new HashSet<int>(badSegments);
|
||||||
|
var newWaypoints = new List<JointWaypoint>(waypoints.Length + badSegments.Count);
|
||||||
|
var newShotFlags = new List<bool>(waypoints.Length + badSegments.Count);
|
||||||
|
var newOffsets = new List<int>(waypoints.Length + badSegments.Count);
|
||||||
|
var newAddrs = new List<IoAddressGroup>(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<int>()));
|
||||||
|
}
|
||||||
|
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System.Diagnostics;
|
|||||||
using Flyshot.Core.Domain;
|
using Flyshot.Core.Domain;
|
||||||
using Flyshot.Runtime.Common;
|
using Flyshot.Runtime.Common;
|
||||||
using Flyshot.Runtime.Fanuc.Protocol;
|
using Flyshot.Runtime.Fanuc.Protocol;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace Flyshot.Runtime.Fanuc;
|
namespace Flyshot.Runtime.Fanuc;
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
|
|||||||
private readonly FanucCommandClient _commandClient;
|
private readonly FanucCommandClient _commandClient;
|
||||||
private readonly FanucStateClient _stateClient;
|
private readonly FanucStateClient _stateClient;
|
||||||
private readonly FanucJ519Client _j519Client;
|
private readonly FanucJ519Client _j519Client;
|
||||||
|
private readonly ILogger<FanucControllerRuntime>? _logger;
|
||||||
|
|
||||||
private RobotProfile? _robot;
|
private RobotProfile? _robot;
|
||||||
private string? _robotName;
|
private string? _robotName;
|
||||||
@@ -35,21 +37,24 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 初始化 FANUC 控制器运行时。
|
/// 初始化 FANUC 控制器运行时。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public FanucControllerRuntime()
|
/// <param name="logger">日志记录器;允许 null,供无日志场景使用。</param>
|
||||||
|
public FanucControllerRuntime(ILogger<FanucControllerRuntime>? logger = null)
|
||||||
{
|
{
|
||||||
_commandClient = new FanucCommandClient();
|
_commandClient = new FanucCommandClient();
|
||||||
_stateClient = new FanucStateClient();
|
_stateClient = new FanucStateClient();
|
||||||
_j519Client = new FanucJ519Client();
|
_j519Client = new FanucJ519Client();
|
||||||
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 供测试注入 mock 客户端的内部构造函数。
|
/// 供测试注入 mock 客户端的内部构造函数。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal FanucControllerRuntime(FanucCommandClient commandClient, FanucStateClient stateClient, FanucJ519Client j519Client)
|
internal FanucControllerRuntime(FanucCommandClient commandClient, FanucStateClient stateClient, FanucJ519Client j519Client, ILogger<FanucControllerRuntime>? logger = null)
|
||||||
{
|
{
|
||||||
_commandClient = commandClient;
|
_commandClient = commandClient;
|
||||||
_stateClient = stateClient;
|
_stateClient = stateClient;
|
||||||
_j519Client = j519Client;
|
_j519Client = j519Client;
|
||||||
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -61,6 +66,8 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
|
|||||||
throw new ArgumentException("机器人名称不能为空。", nameof(robotName));
|
throw new ArgumentException("机器人名称不能为空。", nameof(robotName));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_logger?.LogInformation("ResetRobot: robotName={RobotName}, dof={Dof}", robotName, robot.DegreesOfFreedom);
|
||||||
|
|
||||||
lock (_stateLock)
|
lock (_stateLock)
|
||||||
{
|
{
|
||||||
DisconnectClients();
|
DisconnectClients();
|
||||||
@@ -82,6 +89,7 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void SetActiveController(bool sim)
|
public void SetActiveController(bool sim)
|
||||||
{
|
{
|
||||||
|
_logger?.LogInformation("SetActiveController: sim={Sim}", sim);
|
||||||
lock (_stateLock)
|
lock (_stateLock)
|
||||||
{
|
{
|
||||||
EnsureRobotSetup();
|
EnsureRobotSetup();
|
||||||
@@ -101,6 +109,8 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
|
|||||||
throw new ArgumentException("控制器 IP 不能为空。", nameof(robotIp));
|
throw new ArgumentException("控制器 IP 不能为空。", nameof(robotIp));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_logger?.LogInformation("Connect 开始: robotIp={RobotIp}, 仿真={IsSim}", robotIp, _activeControllerIsSimulation);
|
||||||
|
|
||||||
lock (_stateLock)
|
lock (_stateLock)
|
||||||
{
|
{
|
||||||
EnsureActiveControllerSelected();
|
EnsureActiveControllerSelected();
|
||||||
@@ -109,6 +119,7 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
|
|||||||
_connectedRobotIp = robotIp;
|
_connectedRobotIp = robotIp;
|
||||||
_isEnabled = false;
|
_isEnabled = false;
|
||||||
_isInMotion = false;
|
_isInMotion = false;
|
||||||
|
_logger?.LogInformation("Connect 完成(仿真): robotIp={RobotIp}", robotIp);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,11 +132,14 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
|
|||||||
_isEnabled = false;
|
_isEnabled = false;
|
||||||
_isInMotion = false;
|
_isInMotion = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_logger?.LogInformation("Connect 完成(真机): robotIp={RobotIp}, 三条通道已建立", robotIp);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void Disconnect()
|
public void Disconnect()
|
||||||
{
|
{
|
||||||
|
_logger?.LogInformation("Disconnect 开始");
|
||||||
lock (_stateLock)
|
lock (_stateLock)
|
||||||
{
|
{
|
||||||
EnsureRobotSetup();
|
EnsureRobotSetup();
|
||||||
@@ -135,6 +149,7 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
|
|||||||
_isEnabled = false;
|
_isEnabled = false;
|
||||||
_isInMotion = false;
|
_isInMotion = false;
|
||||||
}
|
}
|
||||||
|
_logger?.LogInformation("Disconnect 完成");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -145,6 +160,8 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
|
|||||||
throw new ArgumentOutOfRangeException(nameof(bufferSize), "buffer_size 必须大于 0。");
|
throw new ArgumentOutOfRangeException(nameof(bufferSize), "buffer_size 必须大于 0。");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_logger?.LogInformation("EnableRobot 开始: bufferSize={BufferSize}, 仿真={IsSim}", bufferSize, _activeControllerIsSimulation);
|
||||||
|
|
||||||
lock (_stateLock)
|
lock (_stateLock)
|
||||||
{
|
{
|
||||||
EnsureConnected();
|
EnsureConnected();
|
||||||
@@ -153,6 +170,7 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
|
|||||||
if (IsSimulationMode)
|
if (IsSimulationMode)
|
||||||
{
|
{
|
||||||
_isEnabled = true;
|
_isEnabled = true;
|
||||||
|
_logger?.LogInformation("EnableRobot 完成(仿真)");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,11 +183,14 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
|
|||||||
_j519Client.StartMotion();
|
_j519Client.StartMotion();
|
||||||
_isEnabled = true;
|
_isEnabled = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_logger?.LogInformation("EnableRobot 完成(真机): RVBUSTSM 已启动, J519 运动循环已开启");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void DisableRobot()
|
public void DisableRobot()
|
||||||
{
|
{
|
||||||
|
_logger?.LogInformation("DisableRobot 开始");
|
||||||
lock (_stateLock)
|
lock (_stateLock)
|
||||||
{
|
{
|
||||||
EnsureRobotSetup();
|
EnsureRobotSetup();
|
||||||
@@ -183,11 +204,13 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
|
|||||||
_isEnabled = false;
|
_isEnabled = false;
|
||||||
_isInMotion = false;
|
_isInMotion = false;
|
||||||
}
|
}
|
||||||
|
_logger?.LogInformation("DisableRobot 完成");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void StopMove()
|
public void StopMove()
|
||||||
{
|
{
|
||||||
|
_logger?.LogInformation("StopMove 开始");
|
||||||
lock (_stateLock)
|
lock (_stateLock)
|
||||||
{
|
{
|
||||||
EnsureRobotSetup();
|
EnsureRobotSetup();
|
||||||
@@ -199,6 +222,7 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
|
|||||||
|
|
||||||
_isInMotion = false;
|
_isInMotion = false;
|
||||||
}
|
}
|
||||||
|
_logger?.LogInformation("StopMove 完成");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -225,6 +249,8 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
|
|||||||
throw new ArgumentOutOfRangeException(nameof(ratio), "ratio 必须是有限数值。");
|
throw new ArgumentOutOfRangeException(nameof(ratio), "ratio 必须是有限数值。");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_logger?.LogInformation("SetSpeedRatio: ratio={Ratio}", ratio);
|
||||||
|
|
||||||
lock (_stateLock)
|
lock (_stateLock)
|
||||||
{
|
{
|
||||||
EnsureConnected();
|
EnsureConnected();
|
||||||
@@ -236,6 +262,8 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
|
|||||||
|
|
||||||
_speedRatio = clampedRatio;
|
_speedRatio = clampedRatio;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_logger?.LogInformation("SetSpeedRatio 完成: clampedRatio={ClampedRatio}", _speedRatio);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -426,6 +454,11 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
|
|||||||
ArgumentNullException.ThrowIfNull(result);
|
ArgumentNullException.ThrowIfNull(result);
|
||||||
ArgumentNullException.ThrowIfNull(finalJointPositions);
|
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)
|
lock (_stateLock)
|
||||||
{
|
{
|
||||||
EnsureEnabled();
|
EnsureEnabled();
|
||||||
@@ -447,6 +480,7 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
|
|||||||
_sendCts = new CancellationTokenSource();
|
_sendCts = new CancellationTokenSource();
|
||||||
var ct = _sendCts.Token;
|
var ct = _sendCts.Token;
|
||||||
_sendTask = Task.Run(() => SendDenseTrajectory(result, finalJointPositions, ct), ct);
|
_sendTask = Task.Run(() => SendDenseTrajectory(result, finalJointPositions, ct), ct);
|
||||||
|
_logger?.LogInformation("ExecuteTrajectory 已启动后台稠密发送任务");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -462,6 +496,7 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
|
|||||||
_isInMotion = true;
|
_isInMotion = true;
|
||||||
_jointPositions = finalJointPositions.ToArray();
|
_jointPositions = finalJointPositions.ToArray();
|
||||||
_isInMotion = false;
|
_isInMotion = false;
|
||||||
|
_logger?.LogInformation("ExecuteTrajectory 完成(单点模式)");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -498,12 +533,18 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
|
|||||||
var sampleCount = CalculateDenseSendSampleCount(durationSeconds, trajectoryStepSeconds);
|
var sampleCount = CalculateDenseSendSampleCount(durationSeconds, trajectoryStepSeconds);
|
||||||
var periodTicks = (long)(servoPeriodSeconds * Stopwatch.Frequency);
|
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();
|
var stopwatch = Stopwatch.StartNew();
|
||||||
long nextTick = stopwatch.ElapsedTicks;
|
long nextTick = stopwatch.ElapsedTicks;
|
||||||
ushort ioValue = 0;
|
ushort ioValue = 0;
|
||||||
ushort ioMask = 0;
|
ushort ioMask = 0;
|
||||||
int holdRemaining = -1;
|
int holdRemaining = -1;
|
||||||
int segmentIndex = 0;
|
int segmentIndex = 0;
|
||||||
|
long logInterval = Math.Max(1, sampleCount / 10);
|
||||||
|
int triggerFiredCount = 0;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -538,6 +579,10 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
|
|||||||
ioMask = ComputeIoValue(trigger.AddressGroup);
|
ioMask = ComputeIoValue(trigger.AddressGroup);
|
||||||
ioValue = ioMask;
|
ioValue = ioMask;
|
||||||
holdRemaining = Math.Max(trigger.HoldCycles - 1, 0);
|
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;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -558,15 +603,30 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
|
|||||||
ioMask = 0;
|
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)
|
while (stopwatch.ElapsedTicks < nextTick)
|
||||||
{
|
{
|
||||||
Thread.SpinWait(1);
|
Thread.SpinWait(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_logger?.LogInformation(
|
||||||
|
"SendDenseTrajectory 正常完成: 采样数={SampleCount}, 触发次数={TriggerFiredCount}, 实际耗时={ElapsedMs}ms",
|
||||||
|
sampleCount, triggerFiredCount, stopwatch.ElapsedMilliseconds);
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
|
_logger?.LogWarning(
|
||||||
|
"SendDenseTrajectory 被取消: 已完成 {Percent}% ({Current}/{Total}), 触发次数={TriggerFiredCount}",
|
||||||
|
(int)((double)(sampleCount > 0 ? 0 : 0) / sampleCount * 100), 0, sampleCount, triggerFiredCount);
|
||||||
// 正常取消,轨迹被中断。
|
// 正常取消,轨迹被中断。
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
|
|||||||
@@ -6,6 +6,10 @@
|
|||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.1" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
|
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
|
||||||
<_Parameter1>Flyshot.Core.Tests</_Parameter1>
|
<_Parameter1>Flyshot.Core.Tests</_Parameter1>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Net.Sockets;
|
using System.Net.Sockets;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace Flyshot.Runtime.Fanuc.Protocol;
|
namespace Flyshot.Runtime.Fanuc.Protocol;
|
||||||
|
|
||||||
@@ -8,6 +9,7 @@ namespace Flyshot.Runtime.Fanuc.Protocol;
|
|||||||
public sealed class FanucCommandClient : IDisposable
|
public sealed class FanucCommandClient : IDisposable
|
||||||
{
|
{
|
||||||
private readonly SemaphoreSlim _sendLock = new(1, 1);
|
private readonly SemaphoreSlim _sendLock = new(1, 1);
|
||||||
|
private readonly ILogger<FanucCommandClient>? _logger;
|
||||||
private TcpClient? _tcpClient;
|
private TcpClient? _tcpClient;
|
||||||
private NetworkStream? _stream;
|
private NetworkStream? _stream;
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
@@ -17,6 +19,15 @@ public sealed class FanucCommandClient : IDisposable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public bool IsConnected => _tcpClient?.Connected ?? false;
|
public bool IsConnected => _tcpClient?.Connected ?? false;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 初始化 FANUC 命令通道客户端。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="logger">日志记录器;允许 null。</param>
|
||||||
|
public FanucCommandClient(ILogger<FanucCommandClient>? logger = null)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 建立到 FANUC 控制柜 TCP 10012 命令通道的连接。
|
/// 建立到 FANUC 控制柜 TCP 10012 命令通道的连接。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -37,9 +48,13 @@ public sealed class FanucCommandClient : IDisposable
|
|||||||
throw new InvalidOperationException("命令通道已经连接,请先 Disconnect。");
|
throw new InvalidOperationException("命令通道已经连接,请先 Disconnect。");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_logger?.LogInformation("CommandClient ConnectAsync: {Ip}:{Port}", ip, port);
|
||||||
|
|
||||||
_tcpClient = new TcpClient { NoDelay = true };
|
_tcpClient = new TcpClient { NoDelay = true };
|
||||||
await _tcpClient.ConnectAsync(ip, port, cancellationToken).ConfigureAwait(false);
|
await _tcpClient.ConnectAsync(ip, port, cancellationToken).ConfigureAwait(false);
|
||||||
_stream = _tcpClient.GetStream();
|
_stream = _tcpClient.GetStream();
|
||||||
|
|
||||||
|
_logger?.LogInformation("CommandClient 已连接: {Ip}:{Port}", ip, port);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -49,6 +64,8 @@ public sealed class FanucCommandClient : IDisposable
|
|||||||
{
|
{
|
||||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||||
|
|
||||||
|
_logger?.LogInformation("CommandClient Disconnect");
|
||||||
|
|
||||||
_stream?.Dispose();
|
_stream?.Dispose();
|
||||||
_stream = null;
|
_stream = null;
|
||||||
_tcpClient?.Dispose();
|
_tcpClient?.Dispose();
|
||||||
@@ -259,7 +276,9 @@ public sealed class FanucCommandClient : IDisposable
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _stream.WriteAsync(frame, cancellationToken).ConfigureAwait(false);
|
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
|
finally
|
||||||
{
|
{
|
||||||
@@ -270,10 +289,11 @@ public sealed class FanucCommandClient : IDisposable
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 校验普通命令响应结果码,失败时抛出包含消息号和结果码的诊断异常。
|
/// 校验普通命令响应结果码,失败时抛出包含消息号和结果码的诊断异常。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static FanucCommandResultResponse EnsureSuccess(FanucCommandResultResponse response)
|
private FanucCommandResultResponse EnsureSuccess(FanucCommandResultResponse response)
|
||||||
{
|
{
|
||||||
if (!response.IsSuccess)
|
if (!response.IsSuccess)
|
||||||
{
|
{
|
||||||
|
_logger?.LogError("FANUC 命令失败: msgId=0x{MessageId:X4}, resultCode={ResultCode}", response.MessageId, response.ResultCode);
|
||||||
throw CreateCommandFailureException(response.MessageId, response.ResultCode);
|
throw CreateCommandFailureException(response.MessageId, response.ResultCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,10 +303,11 @@ public sealed class FanucCommandClient : IDisposable
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 校验程序状态响应结果码,失败时抛出包含消息号和结果码的诊断异常。
|
/// 校验程序状态响应结果码,失败时抛出包含消息号和结果码的诊断异常。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static FanucProgramStatusResponse EnsureSuccess(FanucProgramStatusResponse response)
|
private FanucProgramStatusResponse EnsureSuccess(FanucProgramStatusResponse response)
|
||||||
{
|
{
|
||||||
if (!response.IsSuccess)
|
if (!response.IsSuccess)
|
||||||
{
|
{
|
||||||
|
_logger?.LogError("FANUC 命令失败: msgId=0x{MessageId:X4}, resultCode={ResultCode}", response.MessageId, response.ResultCode);
|
||||||
throw CreateCommandFailureException(response.MessageId, response.ResultCode);
|
throw CreateCommandFailureException(response.MessageId, response.ResultCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,10 +317,11 @@ public sealed class FanucCommandClient : IDisposable
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 校验速度倍率响应结果码,失败时抛出包含消息号和结果码的诊断异常。
|
/// 校验速度倍率响应结果码,失败时抛出包含消息号和结果码的诊断异常。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static FanucSpeedRatioResponse EnsureSuccess(FanucSpeedRatioResponse response)
|
private FanucSpeedRatioResponse EnsureSuccess(FanucSpeedRatioResponse response)
|
||||||
{
|
{
|
||||||
if (!response.IsSuccess)
|
if (!response.IsSuccess)
|
||||||
{
|
{
|
||||||
|
_logger?.LogError("FANUC 命令失败: msgId=0x{MessageId:X4}, resultCode={ResultCode}", response.MessageId, response.ResultCode);
|
||||||
throw CreateCommandFailureException(response.MessageId, response.ResultCode);
|
throw CreateCommandFailureException(response.MessageId, response.ResultCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,10 +331,11 @@ public sealed class FanucCommandClient : IDisposable
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 校验 TCP 位姿响应结果码,失败时抛出包含消息号和结果码的诊断异常。
|
/// 校验 TCP 位姿响应结果码,失败时抛出包含消息号和结果码的诊断异常。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static FanucTcpResponse EnsureSuccess(FanucTcpResponse response)
|
private FanucTcpResponse EnsureSuccess(FanucTcpResponse response)
|
||||||
{
|
{
|
||||||
if (!response.IsSuccess)
|
if (!response.IsSuccess)
|
||||||
{
|
{
|
||||||
|
_logger?.LogError("FANUC 命令失败: msgId=0x{MessageId:X4}, resultCode={ResultCode}", response.MessageId, response.ResultCode);
|
||||||
throw CreateCommandFailureException(response.MessageId, response.ResultCode);
|
throw CreateCommandFailureException(response.MessageId, response.ResultCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,10 +345,11 @@ public sealed class FanucCommandClient : IDisposable
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 校验 IO 读取响应结果码,失败时抛出包含消息号和结果码的诊断异常。
|
/// 校验 IO 读取响应结果码,失败时抛出包含消息号和结果码的诊断异常。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static FanucIoResponse EnsureSuccess(FanucIoResponse response)
|
private FanucIoResponse EnsureSuccess(FanucIoResponse response)
|
||||||
{
|
{
|
||||||
if (!response.IsSuccess)
|
if (!response.IsSuccess)
|
||||||
{
|
{
|
||||||
|
_logger?.LogError("FANUC 命令失败: msgId=0x{MessageId:X4}, resultCode={ResultCode}", response.MessageId, response.ResultCode);
|
||||||
throw CreateCommandFailureException(response.MessageId, response.ResultCode);
|
throw CreateCommandFailureException(response.MessageId, response.ResultCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Net.Sockets;
|
using System.Net.Sockets;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace Flyshot.Runtime.Fanuc.Protocol;
|
namespace Flyshot.Runtime.Fanuc.Protocol;
|
||||||
|
|
||||||
@@ -10,6 +11,7 @@ public sealed class FanucJ519Client : IDisposable
|
|||||||
{
|
{
|
||||||
private readonly object _commandLock = new();
|
private readonly object _commandLock = new();
|
||||||
private readonly object _responseLock = new();
|
private readonly object _responseLock = new();
|
||||||
|
private readonly ILogger<FanucJ519Client>? _logger;
|
||||||
private UdpClient? _udpClient;
|
private UdpClient? _udpClient;
|
||||||
private CancellationTokenSource? _cts;
|
private CancellationTokenSource? _cts;
|
||||||
private CancellationTokenSource? _sendCts;
|
private CancellationTokenSource? _sendCts;
|
||||||
@@ -26,6 +28,15 @@ public sealed class FanucJ519Client : IDisposable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public bool IsConnected => _udpClient is not null;
|
public bool IsConnected => _udpClient is not null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 初始化 FANUC J519 客户端。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="logger">日志记录器;允许 null。</param>
|
||||||
|
public FanucJ519Client(ILogger<FanucJ519Client>? logger = null)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 建立到 FANUC 控制柜 UDP 60015 运动通道的连接并启动接收循环。
|
/// 建立到 FANUC 控制柜 UDP 60015 运动通道的连接并启动接收循环。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -46,11 +57,14 @@ public sealed class FanucJ519Client : IDisposable
|
|||||||
throw new InvalidOperationException("J519 通道已经连接,请先 Disconnect。");
|
throw new InvalidOperationException("J519 通道已经连接,请先 Disconnect。");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_logger?.LogInformation("J519 ConnectAsync: {Ip}:{Port}", ip, port);
|
||||||
|
|
||||||
_udpClient = new UdpClient();
|
_udpClient = new UdpClient();
|
||||||
_udpClient.Connect(ip, port);
|
_udpClient.Connect(ip, port);
|
||||||
|
|
||||||
// 发送初始化包。
|
// 发送初始化包。
|
||||||
await _udpClient.SendAsync(FanucJ519Protocol.PackInitPacket(), cancellationToken).ConfigureAwait(false);
|
await _udpClient.SendAsync(FanucJ519Protocol.PackInitPacket(), cancellationToken).ConfigureAwait(false);
|
||||||
|
_logger?.LogInformation("J519 初始化包已发送");
|
||||||
|
|
||||||
_cts = new CancellationTokenSource();
|
_cts = new CancellationTokenSource();
|
||||||
_receiveTask = Task.Run(() => ReceiveLoopAsync(_cts.Token), _cts.Token);
|
_receiveTask = Task.Run(() => ReceiveLoopAsync(_cts.Token), _cts.Token);
|
||||||
@@ -70,9 +84,11 @@ public sealed class FanucJ519Client : IDisposable
|
|||||||
|
|
||||||
if (_sendTask is not null)
|
if (_sendTask is not null)
|
||||||
{
|
{
|
||||||
|
_logger?.LogDebug("J519 StartMotion: 发送循环已在运行");
|
||||||
return; // 已在运行。
|
return; // 已在运行。
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_logger?.LogInformation("J519 StartMotion: 启动发送循环");
|
||||||
_sendCts = CancellationTokenSource.CreateLinkedTokenSource(_cts!.Token);
|
_sendCts = CancellationTokenSource.CreateLinkedTokenSource(_cts!.Token);
|
||||||
_sendTask = Task.Run(() => SendLoopAsync(_sendCts.Token), _sendCts.Token);
|
_sendTask = Task.Run(() => SendLoopAsync(_sendCts.Token), _sendCts.Token);
|
||||||
}
|
}
|
||||||
@@ -89,6 +105,8 @@ public sealed class FanucJ519Client : IDisposable
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_logger?.LogInformation("J519 StopMotionAsync: 停止发送循环");
|
||||||
|
|
||||||
if (_sendTask is not null)
|
if (_sendTask is not null)
|
||||||
{
|
{
|
||||||
_sendCts?.Cancel();
|
_sendCts?.Cancel();
|
||||||
@@ -99,7 +117,7 @@ public sealed class FanucJ519Client : IDisposable
|
|||||||
}
|
}
|
||||||
catch (TimeoutException)
|
catch (TimeoutException)
|
||||||
{
|
{
|
||||||
// 发送循环可能未能在 1 秒内结束,继续执行后续清理。
|
_logger?.LogWarning("J519 StopMotionAsync: 发送循环未能在 1 秒内结束");
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
@@ -114,6 +132,7 @@ public sealed class FanucJ519Client : IDisposable
|
|||||||
|
|
||||||
// 发送结束包通知控制器停止伺服流。
|
// 发送结束包通知控制器停止伺服流。
|
||||||
await _udpClient.SendAsync(FanucJ519Protocol.PackEndPacket(), cancellationToken).ConfigureAwait(false);
|
await _udpClient.SendAsync(FanucJ519Protocol.PackEndPacket(), cancellationToken).ConfigureAwait(false);
|
||||||
|
_logger?.LogInformation("J519 StopMotionAsync: 结束包已发送");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -130,6 +149,12 @@ public sealed class FanucJ519Client : IDisposable
|
|||||||
_currentCommand = command;
|
_currentCommand = command;
|
||||||
_commandHistoryForTests?.Add(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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -353,6 +378,10 @@ public sealed class FanucJ519Client : IDisposable
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_logger?.LogInformation("J519 ReceiveLoop 启动");
|
||||||
|
long receiveCount = 0;
|
||||||
|
FanucJ519Response? lastLoggedResponse = null;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
while (!cancellationToken.IsCancellationRequested)
|
while (!cancellationToken.IsCancellationRequested)
|
||||||
@@ -365,16 +394,40 @@ public sealed class FanucJ519Client : IDisposable
|
|||||||
{
|
{
|
||||||
_latestResponse = response;
|
_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)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
// 正常取消,退出循环。
|
_logger?.LogInformation("J519 ReceiveLoop 正常取消,共接收 {Count} 个包", receiveCount);
|
||||||
}
|
}
|
||||||
catch (ObjectDisposedException)
|
catch (ObjectDisposedException)
|
||||||
{
|
{
|
||||||
// UDP 客户端已释放,退出循环。
|
_logger?.LogInformation("J519 ReceiveLoop 因 UDP 释放退出,共接收 {Count} 个包", receiveCount);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Net.Sockets;
|
using System.Net.Sockets;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace Flyshot.Runtime.Fanuc.Protocol;
|
namespace Flyshot.Runtime.Fanuc.Protocol;
|
||||||
|
|
||||||
@@ -114,6 +115,7 @@ public sealed class FanucStateClient : IDisposable
|
|||||||
{
|
{
|
||||||
private readonly object _stateLock = new();
|
private readonly object _stateLock = new();
|
||||||
private readonly FanucStateClientOptions _options;
|
private readonly FanucStateClientOptions _options;
|
||||||
|
private readonly ILogger<FanucStateClient>? _logger;
|
||||||
private TcpClient? _tcpClient;
|
private TcpClient? _tcpClient;
|
||||||
private NetworkStream? _stream;
|
private NetworkStream? _stream;
|
||||||
private CancellationTokenSource? _receiveCts;
|
private CancellationTokenSource? _receiveCts;
|
||||||
@@ -130,7 +132,7 @@ public sealed class FanucStateClient : IDisposable
|
|||||||
/// 使用默认状态通道参数初始化客户端。
|
/// 使用默认状态通道参数初始化客户端。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public FanucStateClient()
|
public FanucStateClient()
|
||||||
: this(new FanucStateClientOptions())
|
: this(new FanucStateClientOptions(), null)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,10 +141,21 @@ public sealed class FanucStateClient : IDisposable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="options">超时和重连参数。</param>
|
/// <param name="options">超时和重连参数。</param>
|
||||||
public FanucStateClient(FanucStateClientOptions options)
|
public FanucStateClient(FanucStateClientOptions options)
|
||||||
|
: this(options, null)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 使用指定状态通道参数和日志记录器初始化客户端。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="options">超时和重连参数。</param>
|
||||||
|
/// <param name="logger">日志记录器;允许 null。</param>
|
||||||
|
public FanucStateClient(FanucStateClientOptions options, ILogger<FanucStateClient>? logger)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(options);
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
ValidateOptions(options);
|
ValidateOptions(options);
|
||||||
_options = options;
|
_options = options;
|
||||||
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -170,6 +183,8 @@ public sealed class FanucStateClient : IDisposable
|
|||||||
throw new InvalidOperationException("状态通道已经连接,请先 Disconnect。");
|
throw new InvalidOperationException("状态通道已经连接,请先 Disconnect。");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_logger?.LogInformation("StateClient ConnectAsync: {Ip}:{Port}", ip, port);
|
||||||
|
|
||||||
_receiveCts = new CancellationTokenSource();
|
_receiveCts = new CancellationTokenSource();
|
||||||
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _receiveCts.Token);
|
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);
|
await OpenConnectionAsync(ip, port, linkedCts.Token).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
catch
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
|
_logger?.LogError(exception, "StateClient 连接失败: {Ip}:{Port}", ip, port);
|
||||||
CloseCurrentConnection();
|
CloseCurrentConnection();
|
||||||
lock (_stateLock)
|
lock (_stateLock)
|
||||||
{
|
{
|
||||||
@@ -203,6 +219,8 @@ public sealed class FanucStateClient : IDisposable
|
|||||||
_receiveTask = Task.Run(
|
_receiveTask = Task.Run(
|
||||||
() => ReceiveAndReconnectLoopAsync(ip, port, _receiveCts.Token),
|
() => ReceiveAndReconnectLoopAsync(ip, port, _receiveCts.Token),
|
||||||
_receiveCts.Token);
|
_receiveCts.Token);
|
||||||
|
|
||||||
|
_logger?.LogInformation("StateClient 已连接并启动接收循环: {Ip}:{Port}", ip, port);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -212,6 +230,7 @@ public sealed class FanucStateClient : IDisposable
|
|||||||
{
|
{
|
||||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||||
|
|
||||||
|
_logger?.LogInformation("StateClient Disconnect");
|
||||||
Shutdown(clearLatestFrame: true);
|
Shutdown(clearLatestFrame: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,6 +287,7 @@ public sealed class FanucStateClient : IDisposable
|
|||||||
private async Task ReceiveAndReconnectLoopAsync(string ip, int port, CancellationToken cancellationToken)
|
private async Task ReceiveAndReconnectLoopAsync(string ip, int port, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var reconnectDelay = _options.ReconnectInitialDelay;
|
var reconnectDelay = _options.ReconnectInitialDelay;
|
||||||
|
_logger?.LogInformation("StateClient 接收循环启动: {Ip}:{Port}", ip, port);
|
||||||
|
|
||||||
while (!cancellationToken.IsCancellationRequested)
|
while (!cancellationToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
@@ -278,14 +298,17 @@ public sealed class FanucStateClient : IDisposable
|
|||||||
}
|
}
|
||||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
|
_logger?.LogInformation("StateClient 接收循环正常取消");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
catch (TimeoutException ex)
|
catch (TimeoutException ex)
|
||||||
{
|
{
|
||||||
|
_logger?.LogWarning(ex, "StateClient 接收超时");
|
||||||
MarkReceiveFailure(FanucStateConnectionState.TimedOut, ex.Message);
|
MarkReceiveFailure(FanucStateConnectionState.TimedOut, ex.Message);
|
||||||
}
|
}
|
||||||
catch (Exception ex) when (ex is IOException or InvalidDataException or SocketException or ObjectDisposedException)
|
catch (Exception ex) when (ex is IOException or InvalidDataException or SocketException or ObjectDisposedException)
|
||||||
{
|
{
|
||||||
|
_logger?.LogWarning(ex, "StateClient 连接异常,准备重连");
|
||||||
MarkReceiveFailure(FanucStateConnectionState.Reconnecting, ex.Message);
|
MarkReceiveFailure(FanucStateConnectionState.Reconnecting, ex.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,6 +335,8 @@ public sealed class FanucStateClient : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
var buffer = new byte[FanucStateProtocol.StateFrameLength];
|
var buffer = new byte[FanucStateProtocol.StateFrameLength];
|
||||||
|
long frameCount = 0;
|
||||||
|
FanucStateFrame? lastLoggedFrame = null;
|
||||||
|
|
||||||
while (!cancellationToken.IsCancellationRequested)
|
while (!cancellationToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
@@ -325,6 +350,26 @@ public sealed class FanucStateClient : IDisposable
|
|||||||
_connectionState = FanucStateConnectionState.Connected;
|
_connectionState = FanucStateConnectionState.Connected;
|
||||||
_lastErrorMessage = null;
|
_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 };
|
var tcpClient = new TcpClient { NoDelay = true };
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
_logger?.LogInformation("StateClient 正在连接 {Ip}:{Port}...", ip, port);
|
||||||
|
|
||||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
timeoutCts.CancelAfter(_options.ConnectTimeout);
|
timeoutCts.CancelAfter(_options.ConnectTimeout);
|
||||||
await tcpClient.ConnectAsync(ip, port, timeoutCts.Token).ConfigureAwait(false);
|
await tcpClient.ConnectAsync(ip, port, timeoutCts.Token).ConfigureAwait(false);
|
||||||
@@ -378,14 +425,18 @@ public sealed class FanucStateClient : IDisposable
|
|||||||
_lastConnectedAt = DateTimeOffset.UtcNow;
|
_lastConnectedAt = DateTimeOffset.UtcNow;
|
||||||
_connectionState = FanucStateConnectionState.Connected;
|
_connectionState = FanucStateConnectionState.Connected;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_logger?.LogInformation("StateClient 已连接到 {Ip}:{Port}", ip, port);
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
|
_logger?.LogWarning("StateClient 连接 {Ip}:{Port} 超时", ip, port);
|
||||||
tcpClient.Dispose();
|
tcpClient.Dispose();
|
||||||
throw new TimeoutException("状态通道建连超时。");
|
throw new TimeoutException("状态通道建连超时。");
|
||||||
}
|
}
|
||||||
catch
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
_logger?.LogWarning(ex, "StateClient 连接 {Ip}:{Port} 失败", ip, port);
|
||||||
tcpClient.Dispose();
|
tcpClient.Dispose();
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
@@ -409,6 +460,12 @@ public sealed class FanucStateClient : IDisposable
|
|||||||
_connectionState = FanucStateConnectionState.Reconnecting;
|
_connectionState = FanucStateConnectionState.Reconnecting;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_logger?.LogInformation(
|
||||||
|
"StateClient 将在 {Delay}ms 后尝试重连 {Ip}:{Port}...",
|
||||||
|
nextDelay.TotalMilliseconds,
|
||||||
|
ip,
|
||||||
|
port);
|
||||||
|
|
||||||
await Task.Delay(nextDelay, cancellationToken).ConfigureAwait(false);
|
await Task.Delay(nextDelay, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
lock (_stateLock)
|
lock (_stateLock)
|
||||||
@@ -419,6 +476,11 @@ public sealed class FanucStateClient : IDisposable
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
await OpenConnectionAsync(ip, port, cancellationToken).ConfigureAwait(false);
|
await OpenConnectionAsync(ip, port, cancellationToken).ConfigureAwait(false);
|
||||||
|
_logger?.LogInformation(
|
||||||
|
"StateClient 重连成功: {Ip}:{Port}, 累计重连次数={Count}",
|
||||||
|
ip,
|
||||||
|
port,
|
||||||
|
_reconnectAttemptCount);
|
||||||
return _options.ReconnectInitialDelay;
|
return _options.ReconnectInitialDelay;
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
@@ -434,6 +496,13 @@ public sealed class FanucStateClient : IDisposable
|
|||||||
_lastErrorMessage = ex.Message;
|
_lastErrorMessage = ex.Message;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_logger?.LogWarning(
|
||||||
|
ex,
|
||||||
|
"StateClient 重连失败: {Ip}:{Port}, 下次等待={NextDelay}ms",
|
||||||
|
ip,
|
||||||
|
port,
|
||||||
|
nextDelay.TotalMilliseconds * 2);
|
||||||
|
|
||||||
nextDelay = IncreaseReconnectDelay(nextDelay);
|
nextDelay = IncreaseReconnectDelay(nextDelay);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -470,6 +539,8 @@ public sealed class FanucStateClient : IDisposable
|
|||||||
_connectionState = state;
|
_connectionState = state;
|
||||||
_lastErrorMessage = message;
|
_lastErrorMessage = message;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_logger?.LogWarning("StateClient 接收失败: state={State}, message={Message}", state, message);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -12,14 +12,17 @@ namespace Flyshot.Server.Host.Controllers;
|
|||||||
public sealed class LegacyHttpApiController : ControllerBase
|
public sealed class LegacyHttpApiController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IControllerClientCompatService _compatService;
|
private readonly IControllerClientCompatService _compatService;
|
||||||
|
private readonly ILogger<LegacyHttpApiController> _logger;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 初始化旧 HTTP 兼容控制器。
|
/// 初始化旧 HTTP 兼容控制器。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="compatService">ControllerClient 兼容服务。</param>
|
/// <param name="compatService">ControllerClient 兼容服务。</param>
|
||||||
public LegacyHttpApiController(IControllerClientCompatService compatService)
|
/// <param name="logger">日志记录器。</param>
|
||||||
|
public LegacyHttpApiController(IControllerClientCompatService compatService, ILogger<LegacyHttpApiController> logger)
|
||||||
{
|
{
|
||||||
_compatService = compatService ?? throw new ArgumentNullException(nameof(compatService));
|
_compatService = compatService ?? throw new ArgumentNullException(nameof(compatService));
|
||||||
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -41,13 +44,16 @@ public sealed class LegacyHttpApiController : ControllerBase
|
|||||||
[HttpPost("/connect_server/")]
|
[HttpPost("/connect_server/")]
|
||||||
public IActionResult ConnectServer([FromQuery] string server_ip, [FromQuery] int port)
|
public IActionResult ConnectServer([FromQuery] string server_ip, [FromQuery] int port)
|
||||||
{
|
{
|
||||||
|
_logger.LogInformation("ConnectServer 调用: server_ip={ServerIp}, port={Port}", server_ip, port);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_compatService.ConnectServer(server_ip, port);
|
_compatService.ConnectServer(server_ip, port);
|
||||||
|
_logger.LogInformation("ConnectServer 成功: server_ip={ServerIp}, port={Port}", server_ip, port);
|
||||||
return Ok(new { status = "connected" });
|
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");
|
return LegacyBadRequest("Connect Server failed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -80,13 +86,16 @@ public sealed class LegacyHttpApiController : ControllerBase
|
|||||||
[HttpPost("/setup_robot/")]
|
[HttpPost("/setup_robot/")]
|
||||||
public IActionResult SetupRobot([FromQuery] string robot_name)
|
public IActionResult SetupRobot([FromQuery] string robot_name)
|
||||||
{
|
{
|
||||||
|
_logger.LogInformation("SetupRobot 调用: robot_name={RobotName}", robot_name);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_compatService.SetUpRobot(robot_name);
|
_compatService.SetUpRobot(robot_name);
|
||||||
|
_logger.LogInformation("SetupRobot 成功: robot_name={RobotName}", robot_name);
|
||||||
return Ok(new { status = "robot setup" });
|
return Ok(new { status = "robot setup" });
|
||||||
}
|
}
|
||||||
catch
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
|
_logger.LogError(exception, "SetupRobot 失败: robot_name={RobotName}", robot_name);
|
||||||
return LegacyBadRequest("SetUpRobot failed");
|
return LegacyBadRequest("SetUpRobot failed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -152,13 +161,16 @@ public sealed class LegacyHttpApiController : ControllerBase
|
|||||||
[HttpGet("/enable_robot/")]
|
[HttpGet("/enable_robot/")]
|
||||||
public IActionResult EnableRobot([FromQuery] int buffer_size = 2)
|
public IActionResult EnableRobot([FromQuery] int buffer_size = 2)
|
||||||
{
|
{
|
||||||
|
_logger.LogInformation("EnableRobot 调用: buffer_size={BufferSize}", buffer_size);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_compatService.EnableRobot(buffer_size);
|
_compatService.EnableRobot(buffer_size);
|
||||||
|
_logger.LogInformation("EnableRobot 成功");
|
||||||
return Ok(new { enable_robot = true });
|
return Ok(new { enable_robot = true });
|
||||||
}
|
}
|
||||||
catch
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
|
_logger.LogError(exception, "EnableRobot 失败");
|
||||||
return LegacyBadRequest("EnableRobot failed");
|
return LegacyBadRequest("EnableRobot failed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -170,13 +182,16 @@ public sealed class LegacyHttpApiController : ControllerBase
|
|||||||
[HttpGet("/disable_robot/")]
|
[HttpGet("/disable_robot/")]
|
||||||
public IActionResult DisableRobot()
|
public IActionResult DisableRobot()
|
||||||
{
|
{
|
||||||
|
_logger.LogInformation("DisableRobot 调用");
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_compatService.DisableRobot();
|
_compatService.DisableRobot();
|
||||||
|
_logger.LogInformation("DisableRobot 成功");
|
||||||
return Ok(new { disable_robot = true });
|
return Ok(new { disable_robot = true });
|
||||||
}
|
}
|
||||||
catch
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
|
_logger.LogError(exception, "DisableRobot 失败");
|
||||||
return LegacyBadRequest("DisableRobot failed");
|
return LegacyBadRequest("DisableRobot failed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -188,13 +203,16 @@ public sealed class LegacyHttpApiController : ControllerBase
|
|||||||
[HttpGet("/stop_move/")]
|
[HttpGet("/stop_move/")]
|
||||||
public IActionResult StopMove()
|
public IActionResult StopMove()
|
||||||
{
|
{
|
||||||
|
_logger.LogInformation("StopMove 调用");
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_compatService.StopMove();
|
_compatService.StopMove();
|
||||||
|
_logger.LogInformation("StopMove 成功");
|
||||||
return Ok(new { status = "move stopped" });
|
return Ok(new { status = "move stopped" });
|
||||||
}
|
}
|
||||||
catch
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
|
_logger.LogError(exception, "StopMove 失败");
|
||||||
return LegacyBadRequest("StopMove failed");
|
return LegacyBadRequest("StopMove failed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -207,13 +225,16 @@ public sealed class LegacyHttpApiController : ControllerBase
|
|||||||
[HttpPost("/set_active_controller/")]
|
[HttpPost("/set_active_controller/")]
|
||||||
public IActionResult SetActiveController([FromQuery] bool sim)
|
public IActionResult SetActiveController([FromQuery] bool sim)
|
||||||
{
|
{
|
||||||
|
_logger.LogInformation("SetActiveController 调用: sim={Sim}", sim);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_compatService.SetActiveController(sim);
|
_compatService.SetActiveController(sim);
|
||||||
|
_logger.LogInformation("SetActiveController 成功: sim={Sim}", sim);
|
||||||
return Ok(new { status = "active controller set" });
|
return Ok(new { status = "active controller set" });
|
||||||
}
|
}
|
||||||
catch
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
|
_logger.LogError(exception, "SetActiveController 失败: sim={Sim}", sim);
|
||||||
return LegacyBadRequest("SetActiveController failed");
|
return LegacyBadRequest("SetActiveController failed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -226,13 +247,16 @@ public sealed class LegacyHttpApiController : ControllerBase
|
|||||||
[HttpPost("/connect_robot/")]
|
[HttpPost("/connect_robot/")]
|
||||||
public IActionResult ConnectRobot([FromQuery] string ip)
|
public IActionResult ConnectRobot([FromQuery] string ip)
|
||||||
{
|
{
|
||||||
|
_logger.LogInformation("ConnectRobot 调用: ip={Ip}", ip);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_compatService.Connect(ip);
|
_compatService.Connect(ip);
|
||||||
|
_logger.LogInformation("ConnectRobot 成功: ip={Ip}", ip);
|
||||||
return Ok(new { status = "robot connected" });
|
return Ok(new { status = "robot connected" });
|
||||||
}
|
}
|
||||||
catch
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
|
_logger.LogError(exception, "ConnectRobot 失败: ip={Ip}", ip);
|
||||||
return LegacyBadRequest("Connect failed");
|
return LegacyBadRequest("Connect failed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -244,13 +268,16 @@ public sealed class LegacyHttpApiController : ControllerBase
|
|||||||
[HttpPost("/disconnect_robot/")]
|
[HttpPost("/disconnect_robot/")]
|
||||||
public IActionResult DisconnectRobot()
|
public IActionResult DisconnectRobot()
|
||||||
{
|
{
|
||||||
|
_logger.LogInformation("DisconnectRobot 调用");
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_compatService.Disconnect();
|
_compatService.Disconnect();
|
||||||
|
_logger.LogInformation("DisconnectRobot 成功");
|
||||||
return Ok(new { status = "robot disconnected" });
|
return Ok(new { status = "robot disconnected" });
|
||||||
}
|
}
|
||||||
catch
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
|
_logger.LogError(exception, "DisconnectRobot 失败");
|
||||||
return LegacyBadRequest("Disconnect failed");
|
return LegacyBadRequest("Disconnect failed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -286,13 +313,16 @@ public sealed class LegacyHttpApiController : ControllerBase
|
|||||||
[HttpPost("/set_tcp/")]
|
[HttpPost("/set_tcp/")]
|
||||||
public IActionResult SetTcp([FromBody] LegacyTcpRequest tcp_data)
|
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
|
try
|
||||||
{
|
{
|
||||||
_compatService.SetTcp(tcp_data.x, tcp_data.y, tcp_data.z);
|
_compatService.SetTcp(tcp_data.x, tcp_data.y, tcp_data.z);
|
||||||
|
_logger.LogInformation("SetTcp 成功");
|
||||||
return Ok(new { status = "TCP set" });
|
return Ok(new { status = "TCP set" });
|
||||||
}
|
}
|
||||||
catch
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
|
_logger.LogError(exception, "SetTcp 失败");
|
||||||
return LegacyBadRequest("SetTCP failed");
|
return LegacyBadRequest("SetTCP failed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -324,13 +354,16 @@ public sealed class LegacyHttpApiController : ControllerBase
|
|||||||
[HttpPost("/set_io/")]
|
[HttpPost("/set_io/")]
|
||||||
public IActionResult SetIo([FromQuery] int port, [FromQuery] bool value, [FromQuery] string io_type)
|
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
|
try
|
||||||
{
|
{
|
||||||
_compatService.SetIo(port, value, io_type);
|
_compatService.SetIo(port, value, io_type);
|
||||||
|
_logger.LogInformation("SetIo 成功: port={Port}, value={Value}", port, value);
|
||||||
return Ok(new { status = "IO set" });
|
return Ok(new { status = "IO set" });
|
||||||
}
|
}
|
||||||
catch
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
|
_logger.LogError(exception, "SetIo 失败: port={Port}, value={Value}", port, value);
|
||||||
return LegacyBadRequest("SetDigitalOutput failed");
|
return LegacyBadRequest("SetDigitalOutput failed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -344,12 +377,16 @@ public sealed class LegacyHttpApiController : ControllerBase
|
|||||||
[HttpGet("/get_io/")]
|
[HttpGet("/get_io/")]
|
||||||
public IActionResult GetIo([FromQuery] int port, [FromQuery] string io_type)
|
public IActionResult GetIo([FromQuery] int port, [FromQuery] string io_type)
|
||||||
{
|
{
|
||||||
|
_logger.LogInformation("GetIo 调用: port={Port}, io_type={IoType}", port, io_type);
|
||||||
try
|
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");
|
return LegacyBadRequest("GetDigitalOutput failed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -379,13 +416,17 @@ public sealed class LegacyHttpApiController : ControllerBase
|
|||||||
[HttpPost("/move_joint/")]
|
[HttpPost("/move_joint/")]
|
||||||
public IActionResult MoveJoint([FromBody] LegacyJointPositionRequest joint_data)
|
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
|
try
|
||||||
{
|
{
|
||||||
_compatService.MoveJoint(joint_data.joints);
|
_compatService.MoveJoint(joint_data.joints);
|
||||||
|
_logger.LogInformation("MoveJoint 成功");
|
||||||
return Ok(new { status = "robot moved" });
|
return Ok(new { status = "robot moved" });
|
||||||
}
|
}
|
||||||
catch
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
|
_logger.LogError(exception, "MoveJoint 失败");
|
||||||
return LegacyBadRequest("MoveJoint failed");
|
return LegacyBadRequest("MoveJoint failed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -441,16 +482,20 @@ public sealed class LegacyHttpApiController : ControllerBase
|
|||||||
[FromQuery] string? method = null,
|
[FromQuery] string? method = null,
|
||||||
[FromQuery] bool? save_traj = null)
|
[FromQuery] bool? save_traj = null)
|
||||||
{
|
{
|
||||||
|
_logger.LogInformation("ExecuteTrajectory 调用: method={Method}, save_traj={SaveTraj}", method ?? "icsp", save_traj ?? false);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var request = ParseExecuteTrajectoryRequest(waypoints, method, save_traj);
|
var request = ParseExecuteTrajectoryRequest(waypoints, method, save_traj);
|
||||||
|
_logger.LogDebug("ExecuteTrajectory 路点数={WaypointCount}, method={Method}", request.Waypoints.Count, request.Method);
|
||||||
_compatService.ExecuteTrajectory(
|
_compatService.ExecuteTrajectory(
|
||||||
request.Waypoints,
|
request.Waypoints,
|
||||||
new TrajectoryExecutionOptions(request.Method, request.SaveTrajectory));
|
new TrajectoryExecutionOptions(request.Method, request.SaveTrajectory));
|
||||||
|
_logger.LogInformation("ExecuteTrajectory 成功: method={Method}", request.Method);
|
||||||
return Ok(new { status = "trajectory executed" });
|
return Ok(new { status = "trajectory executed" });
|
||||||
}
|
}
|
||||||
catch
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
|
_logger.LogError(exception, "ExecuteTrajectory 失败");
|
||||||
return LegacyBadRequest("ExecuteTrajectory failed");
|
return LegacyBadRequest("ExecuteTrajectory failed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -463,18 +508,30 @@ public sealed class LegacyHttpApiController : ControllerBase
|
|||||||
[HttpPost("/upload_flyshot/")]
|
[HttpPost("/upload_flyshot/")]
|
||||||
public IActionResult UploadFlyshot([FromBody] LegacyFlightTrajectoryRequest trajectory_data)
|
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)
|
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长度必须与路点数量相同");
|
return LegacyValidationError("shot_flags长度必须与路点数量相同");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (trajectory_data.offset_values.Count != trajectory_data.waypoints.Count)
|
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长度必须与路点数量相同");
|
return LegacyValidationError("offset_values长度必须与路点数量相同");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (trajectory_data.addrs.Count != trajectory_data.waypoints.Count)
|
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长度必须与路点数量相同");
|
return LegacyValidationError("addrs长度必须与路点数量相同");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -488,10 +545,12 @@ public sealed class LegacyHttpApiController : ControllerBase
|
|||||||
addressGroups: trajectory_data.addrs);
|
addressGroups: trajectory_data.addrs);
|
||||||
|
|
||||||
_compatService.UploadTrajectory(trajectory);
|
_compatService.UploadTrajectory(trajectory);
|
||||||
|
_logger.LogInformation("UploadFlyshot 成功: name={Name}", trajectory_data.name);
|
||||||
return Ok(new { status = "FlyShot uploaded" });
|
return Ok(new { status = "FlyShot uploaded" });
|
||||||
}
|
}
|
||||||
catch
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
|
_logger.LogError(exception, "UploadFlyshot 失败: name={Name}", trajectory_data.name);
|
||||||
return LegacyBadRequest("UploadFlyShotTraj failed");
|
return LegacyBadRequest("UploadFlyShotTraj failed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -504,6 +563,9 @@ public sealed class LegacyHttpApiController : ControllerBase
|
|||||||
[HttpPost("/execute_flyshot/")]
|
[HttpPost("/execute_flyshot/")]
|
||||||
public IActionResult ExecuteFlyshot([FromBody] LegacyExecuteFlyshotRequest data)
|
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
|
try
|
||||||
{
|
{
|
||||||
_compatService.ExecuteTrajectoryByName(
|
_compatService.ExecuteTrajectoryByName(
|
||||||
@@ -513,10 +575,12 @@ public sealed class LegacyHttpApiController : ControllerBase
|
|||||||
method: data.method,
|
method: data.method,
|
||||||
saveTrajectory: data.save_traj,
|
saveTrajectory: data.save_traj,
|
||||||
useCache: data.use_cache));
|
useCache: data.use_cache));
|
||||||
|
_logger.LogInformation("ExecuteFlyshot 成功: name={Name}", data.name);
|
||||||
return Ok(new { status = "FlyShot executed", success = true });
|
return Ok(new { status = "FlyShot executed", success = true });
|
||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
|
_logger.LogError(exception, "ExecuteFlyshot 失败: name={Name}", data.name);
|
||||||
return StatusCode(StatusCodes.Status500InternalServerError, new { detail = exception.Message });
|
return StatusCode(StatusCodes.Status500InternalServerError, new { detail = exception.Message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -529,17 +593,21 @@ public sealed class LegacyHttpApiController : ControllerBase
|
|||||||
[HttpPost("/save_traj_info/")]
|
[HttpPost("/save_traj_info/")]
|
||||||
public IActionResult SaveTrajectoryInfo([FromBody] LegacyTrajectoryInfoRequest request)
|
public IActionResult SaveTrajectoryInfo([FromBody] LegacyTrajectoryInfoRequest request)
|
||||||
{
|
{
|
||||||
|
_logger.LogInformation("SaveTrajectoryInfo 调用: name={Name}, method={Method}", request.name, request.method);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_compatService.SaveTrajectoryInfo(request.name, request.method);
|
_compatService.SaveTrajectoryInfo(request.name, request.method);
|
||||||
|
_logger.LogInformation("SaveTrajectoryInfo 成功: name={Name}", request.name);
|
||||||
return Ok(new { status = "trajectory info saved", success = true });
|
return Ok(new { status = "trajectory info saved", success = true });
|
||||||
}
|
}
|
||||||
catch (NotSupportedException exception)
|
catch (NotSupportedException exception)
|
||||||
{
|
{
|
||||||
|
_logger.LogWarning(exception, "SaveTrajectoryInfo 不支持: name={Name}", request.name);
|
||||||
return StatusCode(StatusCodes.Status501NotImplemented, new { detail = exception.Message });
|
return StatusCode(StatusCodes.Status501NotImplemented, new { detail = exception.Message });
|
||||||
}
|
}
|
||||||
catch
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
|
_logger.LogError(exception, "SaveTrajectoryInfo 失败: name={Name}", request.name);
|
||||||
return LegacyBadRequest("SaveTrajInfo failed");
|
return LegacyBadRequest("SaveTrajInfo failed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -552,6 +620,7 @@ public sealed class LegacyHttpApiController : ControllerBase
|
|||||||
[HttpPost("/is_flyShotTrajValid/")]
|
[HttpPost("/is_flyShotTrajValid/")]
|
||||||
public IActionResult IsFlyshotTrajectoryValid([FromBody] LegacyFlyshotValidationRequest request)
|
public IActionResult IsFlyshotTrajectoryValid([FromBody] LegacyFlyshotValidationRequest request)
|
||||||
{
|
{
|
||||||
|
_logger.LogInformation("IsFlyshotTrajectoryValid 调用: name={Name}, method={Method}", request.name, request.method);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var isValid = _compatService.IsFlyshotTrajectoryValid(
|
var isValid = _compatService.IsFlyshotTrajectoryValid(
|
||||||
@@ -560,14 +629,17 @@ public sealed class LegacyHttpApiController : ControllerBase
|
|||||||
request.method,
|
request.method,
|
||||||
request.save_traj);
|
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 });
|
return Ok(new { success = isValid, valid = isValid, time = duration.TotalSeconds });
|
||||||
}
|
}
|
||||||
catch (NotSupportedException exception)
|
catch (NotSupportedException exception)
|
||||||
{
|
{
|
||||||
|
_logger.LogWarning(exception, "IsFlyshotTrajectoryValid 不支持: name={Name}", request.name);
|
||||||
return StatusCode(StatusCodes.Status501NotImplemented, new { detail = exception.Message });
|
return StatusCode(StatusCodes.Status501NotImplemented, new { detail = exception.Message });
|
||||||
}
|
}
|
||||||
catch
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
|
_logger.LogError(exception, "IsFlyshotTrajectoryValid 失败: name={Name}", request.name);
|
||||||
return LegacyBadRequest("IsFlyShotTrajValid failed");
|
return LegacyBadRequest("IsFlyShotTrajValid failed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -580,13 +652,23 @@ public sealed class LegacyHttpApiController : ControllerBase
|
|||||||
[HttpPost("/set_speedRatio/")]
|
[HttpPost("/set_speedRatio/")]
|
||||||
public IActionResult SetSpeedRatio([FromBody] LegacySpeedRatioRequest data)
|
public IActionResult SetSpeedRatio([FromBody] LegacySpeedRatioRequest data)
|
||||||
{
|
{
|
||||||
|
_logger.LogInformation("SetSpeedRatio 调用: speed={Speed}", data.speed);
|
||||||
try
|
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);
|
_compatService.SetSpeedRatio(data.speed);
|
||||||
|
_logger.LogInformation("SetSpeedRatio 成功: speed={Speed}", data.speed);
|
||||||
return Ok(new { message = "set_speedRatio executed", returnCode = 0 });
|
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");
|
return LegacyBadRequest("set_speedRatio failed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -599,13 +681,16 @@ public sealed class LegacyHttpApiController : ControllerBase
|
|||||||
[HttpPost("/delete_flyshot/")]
|
[HttpPost("/delete_flyshot/")]
|
||||||
public IActionResult DeleteFlyshot([FromBody] LegacyNameRequest request)
|
public IActionResult DeleteFlyshot([FromBody] LegacyNameRequest request)
|
||||||
{
|
{
|
||||||
|
_logger.LogInformation("DeleteFlyshot 调用: name={Name}", request.name);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_compatService.DeleteTrajectory(request.name);
|
_compatService.DeleteTrajectory(request.name);
|
||||||
|
_logger.LogInformation("DeleteFlyshot 成功: name={Name}", request.name);
|
||||||
return Ok(new { status = "FlyShot deleted" });
|
return Ok(new { status = "FlyShot deleted" });
|
||||||
}
|
}
|
||||||
catch
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
|
_logger.LogError(exception, "DeleteFlyshot 失败: name={Name}", request.name);
|
||||||
return LegacyBadRequest("DeleteFlyShotTraj failed");
|
return LegacyBadRequest("DeleteFlyShotTraj failed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -618,22 +703,28 @@ public sealed class LegacyHttpApiController : ControllerBase
|
|||||||
[HttpPost("/init_mpc_robt")]
|
[HttpPost("/init_mpc_robt")]
|
||||||
public IActionResult InitMpcRobot([FromBody] LegacyInitMpcRobotRequest data)
|
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
|
try
|
||||||
{
|
{
|
||||||
_compatService.ConnectServer(data.server_ip, data.port);
|
_compatService.ConnectServer(data.server_ip, data.port);
|
||||||
_compatService.SetUpRobot(data.robot_name);
|
_compatService.SetUpRobot(data.robot_name);
|
||||||
if (!_compatService.IsSetUp)
|
if (!_compatService.IsSetUp)
|
||||||
{
|
{
|
||||||
|
_logger.LogWarning("InitMpcRobot 失败: Robot not setup");
|
||||||
return LegacyBadRequest("Robot not setup");
|
return LegacyBadRequest("Robot not setup");
|
||||||
}
|
}
|
||||||
|
|
||||||
_compatService.SetActiveController(data.sim);
|
_compatService.SetActiveController(data.sim);
|
||||||
_compatService.Connect(data.robot_ip);
|
_compatService.Connect(data.robot_ip);
|
||||||
_compatService.EnableRobot(2);
|
_compatService.EnableRobot(2);
|
||||||
|
_logger.LogInformation("InitMpcRobot 成功: robot_name={RobotName}", data.robot_name);
|
||||||
return Ok(new { message = "init_Success", returnCode = 0 });
|
return Ok(new { message = "init_Success", returnCode = 0 });
|
||||||
}
|
}
|
||||||
catch
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
|
_logger.LogError(exception, "InitMpcRobot 失败");
|
||||||
return LegacyBadRequest("Connect Server failed");
|
return LegacyBadRequest("Connect Server failed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,390 +4,12 @@ using Microsoft.AspNetCore.Mvc;
|
|||||||
namespace Flyshot.Server.Host.Controllers;
|
namespace Flyshot.Server.Host.Controllers;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 提供只读状态监控页面和控制器状态快照 API。
|
/// 提供控制器状态快照 API,状态监控页面由 wwwroot 静态资源承载。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Tags("基础与状态")]
|
[Tags("基础与状态")]
|
||||||
public sealed class StatusController : ControllerBase
|
public sealed class StatusController : ControllerBase
|
||||||
{
|
{
|
||||||
/// <summary>
|
|
||||||
/// 浏览器端状态监控页面,页面逻辑只读取状态快照,不触发控制动作。
|
|
||||||
/// </summary>
|
|
||||||
private const string StatusPageHtml = """
|
|
||||||
<!doctype html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>Flyshot Replacement 状态监控</title>
|
|
||||||
<style>
|
|
||||||
: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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<header>
|
|
||||||
<div class="topbar">
|
|
||||||
<h1>Flyshot Replacement 状态监控</h1>
|
|
||||||
<div class="actions">
|
|
||||||
<a class="link-button" href="/debug" target="_blank" rel="noopener">调试接口</a>
|
|
||||||
<button id="refresh" type="button">刷新</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<main>
|
|
||||||
<div class="summary">
|
|
||||||
<div class="metric">
|
|
||||||
<div class="label">连接状态</div>
|
|
||||||
<div class="value status-row"><span id="state-dot" class="dot"></span><span id="connection-state">--</span></div>
|
|
||||||
</div>
|
|
||||||
<div class="metric">
|
|
||||||
<div class="label">机器人</div>
|
|
||||||
<div id="robot-name" class="value">--</div>
|
|
||||||
</div>
|
|
||||||
<div class="metric">
|
|
||||||
<div class="label">速度倍率</div>
|
|
||||||
<div id="speed-ratio" class="value">--</div>
|
|
||||||
</div>
|
|
||||||
<div class="metric">
|
|
||||||
<div class="label">运动中</div>
|
|
||||||
<div id="motion-state" class="value">--</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="grid">
|
|
||||||
<section>
|
|
||||||
<h2>控制器</h2>
|
|
||||||
<dl>
|
|
||||||
<dt>服务端版本</dt><dd id="server-version">--</dd>
|
|
||||||
<dt>客户端版本</dt><dd id="client-version">--</dd>
|
|
||||||
<dt>已初始化</dt><dd id="setup-state">--</dd>
|
|
||||||
<dt>已使能</dt><dd id="enabled-state">--</dd>
|
|
||||||
<dt>J519 状态</dt><dd id="j519-status">--</dd>
|
|
||||||
<dt>J519 序号</dt><dd id="j519-sequence">--</dd>
|
|
||||||
<dt>采样时间</dt><dd id="captured-at">--</dd>
|
|
||||||
</dl>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<h2>机器人</h2>
|
|
||||||
<dl>
|
|
||||||
<dt>自由度</dt><dd id="dof">--</dd>
|
|
||||||
<dt>关节位置</dt><dd id="joints">--</dd>
|
|
||||||
<dt>TCP 位姿</dt><dd id="pose">--</dd>
|
|
||||||
<dt>已上传轨迹</dt><dd id="trajectories" class="empty">--</dd>
|
|
||||||
</dl>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
<script>
|
|
||||||
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);
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
""";
|
|
||||||
|
|
||||||
private readonly IControllerClientCompatService _compatService;
|
private readonly IControllerClientCompatService _compatService;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -400,13 +22,23 @@ public sealed class StatusController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 返回浏览器可直接打开的状态监控页面。
|
/// 提供短路由 `/status`,跳转到静态状态页。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>HTML 状态页面。</returns>
|
/// <returns>重定向到 <c>/status.html</c>。</returns>
|
||||||
[HttpGet("/status")]
|
[HttpGet("/status")]
|
||||||
public ContentResult GetStatusPage()
|
public IActionResult StatusPage()
|
||||||
{
|
{
|
||||||
return Content(StatusPageHtml, "text/html; charset=utf-8");
|
return Redirect("/status.html");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 提供短路由 `/debug`,跳转到静态调试页。
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>重定向到 <c>/debug.html</c>。</returns>
|
||||||
|
[HttpGet("/debug")]
|
||||||
|
public IActionResult DebugPage()
|
||||||
|
{
|
||||||
|
return Redirect("/debug.html");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -7,9 +7,20 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="NLog.Web.AspNetCore" Version="5.3.11" />
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- 运行时需要把仓库根目录的 NLog.config 带到 Host 输出目录,确保控制台和文件日志目标生效。 -->
|
||||||
|
<Content Include="..\..\NLog.config" Link="NLog.config" CopyToOutputDirectory="PreserveNewest" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- 让静态调试页在构建和发布时都物理复制到输出目录,避免运行时只依赖源码树中的 wwwroot。 -->
|
||||||
|
<Content Update="wwwroot\**\*" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\Flyshot.Core.Domain\Flyshot.Core.Domain.csproj" />
|
<ProjectReference Include="..\Flyshot.Core.Domain\Flyshot.Core.Domain.csproj" />
|
||||||
<ProjectReference Include="..\Flyshot.ControllerClientCompat\Flyshot.ControllerClientCompat.csproj" />
|
<ProjectReference Include="..\Flyshot.ControllerClientCompat\Flyshot.ControllerClientCompat.csproj" />
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace Flyshot.Server.Host.Middleware;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// HTTP 请求与响应日志中间件。
|
||||||
|
/// 记录每个 HTTP 请求的进入时间、方法、路径、查询串、请求体,
|
||||||
|
/// 以及响应的状态码、耗时和响应体(调试级别)。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class RequestResponseLoggingMiddleware
|
||||||
|
{
|
||||||
|
private readonly RequestDelegate _next;
|
||||||
|
private readonly ILogger<RequestResponseLoggingMiddleware> _logger;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 请求体最大日志长度,超出则截断并附加省略标记。
|
||||||
|
/// </summary>
|
||||||
|
private const int MaxBodyLogLength = 4096;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 初始化请求响应日志中间件。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="next">下一个中间件委托。</param>
|
||||||
|
/// <param name="logger">日志记录器。</param>
|
||||||
|
public RequestResponseLoggingMiddleware(RequestDelegate next, ILogger<RequestResponseLoggingMiddleware> logger)
|
||||||
|
{
|
||||||
|
_next = next ?? throw new ArgumentNullException(nameof(next));
|
||||||
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 处理 HTTP 请求并记录输入输出。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="context">HTTP 上下文。</param>
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 从流中读取文本内容。
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<string> 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 截断过长内容,避免日志膨胀。
|
||||||
|
/// </summary>
|
||||||
|
private static string TruncateBody(string body)
|
||||||
|
{
|
||||||
|
if (body.Length <= MaxBodyLogLength)
|
||||||
|
{
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
return body[..MaxBodyLogLength] + " ... [截断,总长度=" + body.Length + "]";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,94 +1,124 @@
|
|||||||
using Flyshot.ControllerClientCompat;
|
using Flyshot.ControllerClientCompat;
|
||||||
using Flyshot.Server.Host;
|
using Flyshot.Server.Host;
|
||||||
|
using Flyshot.Server.Host.Middleware;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using Microsoft.OpenApi.Models;
|
using Microsoft.OpenApi.Models;
|
||||||
|
using NLog.Web;
|
||||||
using Swashbuckle.AspNetCore.SwaggerGen;
|
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<HostSwaggerOptions>(builder.Configuration.GetSection("Swagger"));
|
try
|
||||||
builder.Services.Configure<HostCorsOptions>(builder.Configuration.GetSection("Cors"));
|
|
||||||
builder.Services.AddControllerClientCompat(builder.Configuration);
|
|
||||||
builder.Services.AddControllers();
|
|
||||||
builder.Services.AddEndpointsApiExplorer();
|
|
||||||
|
|
||||||
var swaggerOptions = builder.Configuration.GetSection("Swagger").Get<HostSwaggerOptions>() ?? new HostSwaggerOptions();
|
|
||||||
builder.Services.AddSwaggerGen(options =>
|
|
||||||
{
|
{
|
||||||
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<HostSwaggerOptions>(builder.Configuration.GetSection("Swagger"));
|
||||||
|
builder.Services.Configure<HostCorsOptions>(builder.Configuration.GetSection("Cors"));
|
||||||
|
builder.Services.AddControllerClientCompat(builder.Configuration);
|
||||||
|
builder.Services.AddControllers();
|
||||||
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
|
|
||||||
|
var swaggerOptions = builder.Configuration.GetSection("Swagger").Get<HostSwaggerOptions>() ?? new HostSwaggerOptions();
|
||||||
|
builder.Services.AddSwaggerGen(options =>
|
||||||
{
|
{
|
||||||
Title = swaggerOptions.Title,
|
options.SwaggerDoc(swaggerOptions.DocumentName, new OpenApiInfo
|
||||||
Version = swaggerOptions.Version
|
{
|
||||||
|
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 corsOptions = builder.Configuration.GetSection("Cors").Get<HostCorsOptions>() ?? new HostCorsOptions();
|
||||||
var xmlDocumentationPath = Path.Combine(AppContext.BaseDirectory, $"{typeof(Program).Assembly.GetName().Name}.xml");
|
builder.Services.AddCors(options =>
|
||||||
if (File.Exists(xmlDocumentationPath))
|
|
||||||
{
|
{
|
||||||
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<IOptions<HostSwaggerOptions>>().Value;
|
||||||
|
var resolvedCorsOptions = app.Services.GetRequiredService<IOptions<HostCorsOptions>>().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<HostCorsOptions>() ?? new HostCorsOptions();
|
app.UseCors(resolvedCorsOptions.PolicyName);
|
||||||
builder.Services.AddCors(options =>
|
app.UseStaticFiles();
|
||||||
|
|
||||||
|
// 注册 HTTP 请求/响应日志中间件,记录所有 API 调用的输入输出。
|
||||||
|
app.UseMiddleware<RequestResponseLoggingMiddleware>();
|
||||||
|
|
||||||
|
app.MapControllers();
|
||||||
|
|
||||||
|
logger.Info("Flyshot Server Host 已就绪,开始监听请求。");
|
||||||
|
app.Run();
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
options.AddPolicy(corsOptions.PolicyName, policyBuilder =>
|
logger.Error(exception, "Flyshot Server Host 启动失败。");
|
||||||
{
|
throw;
|
||||||
// 兼容本地调试时最常见的任意源配置,同时保留显式白名单模式。
|
}
|
||||||
if (corsOptions.AllowedOrigins.Length == 1 && string.Equals(corsOptions.AllowedOrigins[0], "*", StringComparison.Ordinal))
|
finally
|
||||||
{
|
|
||||||
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<IOptions<HostSwaggerOptions>>().Value;
|
|
||||||
var resolvedCorsOptions = app.Services.GetRequiredService<IOptions<HostCorsOptions>>().Value;
|
|
||||||
|
|
||||||
if (resolvedSwaggerOptions.Enabled)
|
|
||||||
{
|
{
|
||||||
app.UseSwagger(options =>
|
NLog.LogManager.Shutdown();
|
||||||
{
|
|
||||||
options.RouteTemplate = resolvedSwaggerOptions.JsonRouteTemplate;
|
|
||||||
});
|
|
||||||
|
|
||||||
app.UseSwaggerUI(options =>
|
|
||||||
{
|
|
||||||
options.RoutePrefix = resolvedSwaggerOptions.RoutePrefix;
|
|
||||||
options.SwaggerEndpoint(
|
|
||||||
$"/swagger/{resolvedSwaggerOptions.DocumentName}/swagger.json",
|
|
||||||
$"{resolvedSwaggerOptions.Title} {resolvedSwaggerOptions.Version}");
|
|
||||||
options.DocumentTitle = resolvedSwaggerOptions.Title;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
app.UseCors(resolvedCorsOptions.PolicyName);
|
|
||||||
app.MapControllers();
|
|
||||||
|
|
||||||
app.Run();
|
|
||||||
|
|
||||||
public partial class Program;
|
public partial class Program;
|
||||||
|
|||||||
424
src/Flyshot.Server.Host/wwwroot/assets/debug.css
Normal file
424
src/Flyshot.Server.Host/wwwroot/assets/debug.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
670
src/Flyshot.Server.Host/wwwroot/assets/debug.js
Normal file
670
src/Flyshot.Server.Host/wwwroot/assets/debug.js
Normal file
@@ -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();
|
||||||
214
src/Flyshot.Server.Host/wwwroot/assets/status.css
Normal file
214
src/Flyshot.Server.Host/wwwroot/assets/status.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
92
src/Flyshot.Server.Host/wwwroot/assets/status.js
Normal file
92
src/Flyshot.Server.Host/wwwroot/assets/status.js
Normal file
@@ -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);
|
||||||
44
src/Flyshot.Server.Host/wwwroot/debug.html
Normal file
44
src/Flyshot.Server.Host/wwwroot/debug.html
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Flyshot Replacement 接口调试</title>
|
||||||
|
<link rel="stylesheet" href="/assets/debug.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<div class="topbar">
|
||||||
|
<h1>Flyshot Replacement 接口调试</h1>
|
||||||
|
<div class="actions">
|
||||||
|
<a class="link-button" href="/status.html">回到状态页</a>
|
||||||
|
<a class="link-button" href="/swagger" target="_blank" rel="noopener">Swagger UI</a>
|
||||||
|
<button id="reload-spec" type="button">重新加载 OpenAPI</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<section class="meta">
|
||||||
|
<dl>
|
||||||
|
<dt>OpenAPI 文档</dt>
|
||||||
|
<dd id="meta-spec-url">--</dd>
|
||||||
|
<dt>API 数量</dt>
|
||||||
|
<dd id="meta-operation-count">--</dd>
|
||||||
|
<dt>加载状态</dt>
|
||||||
|
<dd id="meta-status">初始化中...</dd>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
<div id="debug-console-app">
|
||||||
|
<div class="empty-hint">正在加载接口列表...</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<aside class="history" id="history-panel">
|
||||||
|
<h3>
|
||||||
|
<span>调用历史 (本次会话)</span>
|
||||||
|
<button type="button" id="history-clear" class="secondary">清空</button>
|
||||||
|
</h3>
|
||||||
|
<ul id="history-list"></ul>
|
||||||
|
</aside>
|
||||||
|
<script src="/assets/debug.js" defer></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
64
src/Flyshot.Server.Host/wwwroot/status.html
Normal file
64
src/Flyshot.Server.Host/wwwroot/status.html
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Flyshot Replacement 状态监控</title>
|
||||||
|
<link rel="stylesheet" href="/assets/status.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<div class="topbar">
|
||||||
|
<h1>Flyshot Replacement 状态监控</h1>
|
||||||
|
<div class="actions">
|
||||||
|
<a class="link-button" href="/debug.html" target="_blank" rel="noopener">调试接口</a>
|
||||||
|
<button id="refresh" type="button">刷新</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<div class="summary">
|
||||||
|
<div class="metric">
|
||||||
|
<div class="label">连接状态</div>
|
||||||
|
<div class="value status-row"><span id="state-dot" class="dot"></span><span id="connection-state">--</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<div class="label">机器人</div>
|
||||||
|
<div id="robot-name" class="value">--</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<div class="label">速度倍率</div>
|
||||||
|
<div id="speed-ratio" class="value">--</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<div class="label">运动中</div>
|
||||||
|
<div id="motion-state" class="value">--</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid">
|
||||||
|
<section>
|
||||||
|
<h2>控制器</h2>
|
||||||
|
<dl>
|
||||||
|
<dt>服务端版本</dt><dd id="server-version">--</dd>
|
||||||
|
<dt>客户端版本</dt><dd id="client-version">--</dd>
|
||||||
|
<dt>已初始化</dt><dd id="setup-state">--</dd>
|
||||||
|
<dt>已使能</dt><dd id="enabled-state">--</dd>
|
||||||
|
<dt>J519 状态</dt><dd id="j519-status">--</dd>
|
||||||
|
<dt>J519 序号</dt><dd id="j519-sequence">--</dd>
|
||||||
|
<dt>采样时间</dt><dd id="captured-at">--</dd>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h2>机器人</h2>
|
||||||
|
<dl>
|
||||||
|
<dt>自由度</dt><dd id="dof">--</dd>
|
||||||
|
<dt>关节位置</dt><dd id="joints">--</dd>
|
||||||
|
<dt>TCP 位姿</dt><dd id="pose">--</dd>
|
||||||
|
<dt>已上传轨迹</dt><dd id="trajectories" class="empty">--</dd>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script src="/assets/status.js" defer></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -5,54 +5,74 @@ using Microsoft.Extensions.Configuration;
|
|||||||
namespace Flyshot.Server.IntegrationTests;
|
namespace Flyshot.Server.IntegrationTests;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 验证 `/debug` 在线 API 调试页的暴露策略与基础内容契约。
|
/// 验证 `wwwroot` 静态调试页和调试配置 API 的基础内容契约。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// 调试页与 Swagger UI 共用 <c>Swagger:Enabled</c> 开关,开关关闭时
|
/// 调试页自身是静态 HTML,真正的 Swagger 地址由配置 API 下发;
|
||||||
/// 调试页与 `/swagger` 一同下线,避免生产环境意外暴露调试入口。
|
/// 当 Swagger 关闭时,配置 API 返回 404,前端据此显示不可用状态。
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class DebugConsoleEndpointTests(FlyshotServerFactory factory) : IClassFixture<FlyshotServerFactory>
|
public sealed class DebugConsoleEndpointTests(FlyshotServerFactory factory) : IClassFixture<FlyshotServerFactory>
|
||||||
{
|
{
|
||||||
private readonly FlyshotServerFactory _factory = factory;
|
private readonly FlyshotServerFactory _factory = factory;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 当 Swagger 启用时,`/debug` 应当返回完整的 HTML 调试页。
|
/// `debug.html` 应当作为可直接调试的静态页面暴露。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetDebug_WhenSwaggerEnabled_ReturnsConsoleHtml()
|
public async Task GetDebugHtml_ReturnsConsoleStaticPage()
|
||||||
{
|
{
|
||||||
// 默认配置即开启 Swagger,调试页应当作为浏览器可直接打开的 HTML 暴露。
|
|
||||||
using var configuredFactory = CreateFactoryWithSwaggerEnabled(true);
|
using var configuredFactory = CreateFactoryWithSwaggerEnabled(true);
|
||||||
using var client = configuredFactory.CreateClient();
|
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.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
Assert.StartsWith("text/html", response.Content.Headers.ContentType?.MediaType);
|
Assert.StartsWith("text/html", response.Content.Headers.ContentType?.MediaType);
|
||||||
|
|
||||||
var html = await response.Content.ReadAsStringAsync();
|
var html = await response.Content.ReadAsStringAsync();
|
||||||
|
|
||||||
// 页面标题与稳定锚点用于回归保护:调试页骨架一旦丢失,测试立即报警。
|
|
||||||
Assert.Contains("Flyshot Replacement 接口调试", html, StringComparison.Ordinal);
|
Assert.Contains("Flyshot Replacement 接口调试", html, StringComparison.Ordinal);
|
||||||
Assert.Contains("id=\"debug-console-app\"", 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.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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 当 Swagger 关闭时,`/debug` 应当与 `/swagger` 同步下线(404)。
|
/// 当 Swagger 启用时,调试配置 API 应当返回实际 Swagger JSON 地址。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Fact]
|
[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());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 当 Swagger 关闭时,调试配置 API 应当与 Swagger UI 同步下线(404)。
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task GetDebugConfig_WhenSwaggerDisabled_ReturnsNotFound()
|
||||||
{
|
{
|
||||||
// 显式把 Swagger:Enabled 置为 false,此时调试页也不应当被访问到。
|
|
||||||
using var configuredFactory = CreateFactoryWithSwaggerEnabled(false);
|
using var configuredFactory = CreateFactoryWithSwaggerEnabled(false);
|
||||||
using var client = configuredFactory.CreateClient();
|
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);
|
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||||
}
|
}
|
||||||
@@ -81,21 +101,20 @@ public sealed class DebugConsoleEndpointTests(FlyshotServerFactory factory) : IC
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 状态页应当提供跳转到调试页的入口,便于现场顺手跳转。
|
/// 状态页应当提供跳转到静态调试页的入口,便于现场顺手跳转。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetStatusPage_LinksToDebugConsole()
|
public async Task GetStatusHtml_LinksToDebugConsole()
|
||||||
{
|
{
|
||||||
using var configuredFactory = CreateFactoryWithSwaggerEnabled(true);
|
using var configuredFactory = CreateFactoryWithSwaggerEnabled(true);
|
||||||
using var client = configuredFactory.CreateClient();
|
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);
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
var html = await response.Content.ReadAsStringAsync();
|
var html = await response.Content.ReadAsStringAsync();
|
||||||
|
|
||||||
// 状态页头部需要至少一个指向 `/debug` 的链接,文案不强制以保留排版调整空间。
|
Assert.Contains("href=\"/debug.html\"", html, StringComparison.Ordinal);
|
||||||
Assert.Contains("href=\"/debug\"", html, StringComparison.Ordinal);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -10,21 +10,28 @@ namespace Flyshot.Server.IntegrationTests;
|
|||||||
public sealed class StatusEndpointTests(FlyshotServerFactory factory) : IClassFixture<FlyshotServerFactory>
|
public sealed class StatusEndpointTests(FlyshotServerFactory factory) : IClassFixture<FlyshotServerFactory>
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 验证状态页返回可由浏览器直接打开的 HTML,并引用状态快照 API。
|
/// 验证状态页作为 wwwroot 静态 HTML 暴露,并引用状态快照 API。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetStatusPage_ReturnsMonitoringHtml()
|
public async Task GetStatusHtml_ReturnsMonitoringStaticPage()
|
||||||
{
|
{
|
||||||
using var client = factory.CreateClient();
|
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.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
Assert.StartsWith("text/html", response.Content.Headers.ContentType?.MediaType);
|
Assert.StartsWith("text/html", response.Content.Headers.ContentType?.MediaType);
|
||||||
|
|
||||||
var html = await response.Content.ReadAsStringAsync();
|
var html = await response.Content.ReadAsStringAsync();
|
||||||
Assert.Contains("Flyshot Replacement 状态监控", html, StringComparison.Ordinal);
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
Reference in New Issue
Block a user