✨ feat(server): 添加服务启动自动初始化机器人功能

* 在 ControllerClientCompatOptions 中新增 AutoInitOptions 配置模型
* 创建 AutoInitRobotHostedService 实现启动时自动初始化链路
* 在 appsettings.json 的 ControllerClientCompat.AutoInit 节配置
  RobotName 和 RobotIp
* 启动时依次执行 SetUpRobot → SetActiveController → Connect →
  EnableRobot;配置缺失或连接失败不阻止服务启动
* 更新 README.md 和现场工作流文档
This commit is contained in:
2026-06-22 13:29:04 +08:00
parent 5c5eaf850d
commit ac41d04a54
12 changed files with 397 additions and 35 deletions

View File

@@ -20,6 +20,11 @@ public sealed class ControllerClientCompatOptions
/// </summary>
public string? WorkspaceRoot { get; set; }
/// <summary>
/// 获取或设置服务启动时自动初始化机器人的配置;为 null 时跳过自动初始化。
/// </summary>
public AutoInitOptions? AutoInit { get; set; }
/// <summary>
/// 解析运行配置根目录,确保运行时默认不再依赖源码仓库位置。
/// </summary>
@@ -44,3 +49,19 @@ public sealed class ControllerClientCompatOptions
: Path.GetFullPath(WorkspaceRoot);
}
}
/// <summary>
/// 表示服务启动时自动初始化机器人的配置项。
/// </summary>
public sealed class AutoInitOptions
{
/// <summary>
/// 获取或设置机器人名称,用于加载机器人配置文件(如 LR_Mate_200iD)。
/// </summary>
public string RobotName { get; set; } = string.Empty;
/// <summary>
/// 获取或设置机器人 IP 地址,用于连接真机或仿真控制器。
/// </summary>
public string RobotIp { get; set; } = string.Empty;
}

View File

@@ -802,6 +802,18 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
sample.TrajectoryTime);
}
}
if (_j519Client.EndCommandStreamAfterQueueDrained && commands.Count > 0)
{
// 正常结束命令流时追加独立结束帧,避免把最后一个真实轨迹采样点和结束语义混在一起。
commands.Add(new FanucJ519Command(
sequence: 0,
targetJoints: commands[^1].TargetJoints,
lastData: 1,
writeIoType: 2,
writeIoIndex: 1,
writeIoMask: 0,
writeIoValue: 0));
}
TryWriteDenseSendArtifacts(result, outputDir, sentJointRows, sentTimingRows, sentJerkRows, triggerBindings);
// 上层只负责生成完整目标序列,真实出队节拍交给 J519 状态包驱动。
@@ -865,6 +877,19 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
commands.Add(command);
sentPoseRows.Add(BuildDenseSendPoseRow(row[0], pose));
}
if (_j519Client.EndCommandStreamAfterQueueDrained && commands.Count > 0)
{
// 直角轨迹结束帧沿用最终目标位姿,并明确清空 IO 输出。
commands.Add(new FanucJ519Command(
sequence: 0,
targetValues: commands[^1].TargetValues,
lastData: 1,
dataStyle: 0,
writeIoType: 2,
writeIoIndex: 1,
writeIoMask: 0,
writeIoValue: 0));
}
TryWriteDenseCartesianSendArtifacts(outputDir, sentPoseRows);

View File

@@ -12,6 +12,10 @@ public sealed class FanucJ519Client : IDisposable
private readonly object _commandLock = new();
private readonly object _responseLock = new();
private readonly ILogger<FanucJ519Client>? _logger;
/// <summary>
/// 指示预装队列发出 LastData 结束帧后是否进入仅监控状态,而不是继续发送保姿命令。
/// </summary>
private readonly bool _endCommandStreamAfterQueueDrained;
private UdpClient? _udpClient;
private CancellationTokenSource? _cts;
private Thread? _receiveThread;
@@ -25,8 +29,14 @@ public sealed class FanucJ519Client : IDisposable
private long _slowSendCount;
private long _maxReceiveToSendTicks;
private uint _sequenceBufferSize;
// 标记 StartMotion 前是否刚装载过新目标,用于区分新命令和上次运动残留目标。
/// <summary>
/// 在 StartMotion 前是否有待发送的新命令;如果为 true 则 StartMotion 不会清空当前目标,而是继续发送新目标。
/// </summary>
private bool _hasPendingCommandForStart;
/// <summary>
/// 标记命令流已通过 LastData 正常结束;该状态下仅接收状态包,不继续回发 hold 命令。
/// </summary>
private bool _monitorOnlyAfterLastData;
private bool _motionStarted;
private bool _disposed;
@@ -35,13 +45,22 @@ public sealed class FanucJ519Client : IDisposable
/// </summary>
public bool IsConnected => _udpClient is not null;
/// <summary>
/// 获取正常队列结束时是否通过 LastData 结束命令流并保留状态监控。
/// </summary>
internal bool EndCommandStreamAfterQueueDrained => _endCommandStreamAfterQueueDrained;
/// <summary>
/// 初始化 FANUC J519 客户端。
/// </summary>
/// <param name="logger">日志记录器;允许 null,供无日志场景使用。</param>
public FanucJ519Client(ILogger<FanucJ519Client>? logger = null)
/// <param name="endCommandStreamAfterQueueDrained">队列结束帧发出后是否停止命令流并保留状态监控。</param>
public FanucJ519Client(
ILogger<FanucJ519Client>? logger = null,
bool endCommandStreamAfterQueueDrained = true)
{
_logger = logger;
_endCommandStreamAfterQueueDrained = endCommandStreamAfterQueueDrained;
}
/// <summary>
@@ -113,6 +132,7 @@ public sealed class FanucJ519Client : IDisposable
}
_hasPendingCommandForStart = false;
_monitorOnlyAfterLastData = false;
_motionStarted = true;
}
@@ -156,6 +176,7 @@ public sealed class FanucJ519Client : IDisposable
{
_motionStarted = false;
_hasPendingCommandForStart = false;
_monitorOnlyAfterLastData = false;
CompleteCommandQueueLocked();
}
@@ -178,6 +199,7 @@ public sealed class FanucJ519Client : IDisposable
CompleteCommandQueueLocked();
_currentCommand = command;
_hasPendingCommandForStart = true;
_monitorOnlyAfterLastData = false;
_commandHistoryForTests?.Add(command);
}
@@ -211,6 +233,8 @@ public sealed class FanucJ519Client : IDisposable
// 队列耗尽后继续保持最后一帧目标,避免运动结束后回落到旧目标或空目标。
_currentCommand = commands[^1];
_hasPendingCommandForStart = true;
_monitorOnlyAfterLastData = false;
_motionStarted = true;
_commandQueueDrainedCompletion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
_commandHistoryForTests?.AddRange(commands);
}
@@ -337,6 +361,7 @@ public sealed class FanucJ519Client : IDisposable
CompleteCommandQueueLocked();
_commandHistoryForTests = null;
_hasPendingCommandForStart = false;
_monitorOnlyAfterLastData = false;
_motionStarted = false;
}
@@ -375,6 +400,7 @@ public sealed class FanucJ519Client : IDisposable
{
CompleteCommandQueueLocked();
_hasPendingCommandForStart = false;
_monitorOnlyAfterLastData = false;
}
}
@@ -526,9 +552,15 @@ public sealed class FanucJ519Client : IDisposable
{
FanucJ519Command? command;
var willDrainQueue = false;
var suppressHoldCommand = false;
lock (_commandLock)
{
if (!_motionStarted)
if (_monitorOnlyAfterLastData)
{
command = null;
suppressHoldCommand = true;
}
else if (!_motionStarted)
{
command = null;
}
@@ -546,7 +578,7 @@ public sealed class FanucJ519Client : IDisposable
}
}
if (command is null)
if (command is null && !suppressHoldCommand)
{
command = TryBuildHoldCommandFromLatestResponse(response);
}
@@ -574,7 +606,14 @@ public sealed class FanucJ519Client : IDisposable
lock (_commandLock)
{
_lastSentCommand = command;
if (willDrainQueue && _commandQueue is { Count: 0 })
if (_endCommandStreamAfterQueueDrained && command.LastData == 1)
{
// LastData 是命令流的正常结束语义;结束后保留状态接收,但不继续回发保姿包。
_monitorOnlyAfterLastData = true;
_motionStarted = false;
CompleteCommandQueueLocked();
}
else if (willDrainQueue && _commandQueue is { Count: 0 })
{
CompleteCommandQueueLocked();
}

View File

@@ -1,6 +1,7 @@
using Flyshot.ControllerClientCompat;
using Flyshot.Server.Host;
using Flyshot.Server.Host.Middleware;
using Flyshot.Server.Host.Services;
using Microsoft.Extensions.Options;
using Microsoft.OpenApi.Models;
using NLog.Web;
@@ -23,6 +24,10 @@ try
builder.Services.Configure<HostSwaggerOptions>(builder.Configuration.GetSection("Swagger"));
builder.Services.Configure<HostCorsOptions>(builder.Configuration.GetSection("Cors"));
builder.Services.AddControllerClientCompat(builder.Configuration);
// 注册服务启动时自动初始化机器人的托管服务;配置缺失时自动跳过。
builder.Services.AddHostedService<AutoInitRobotHostedService>();
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();

View File

@@ -0,0 +1,94 @@
using Flyshot.ControllerClientCompat;
using Microsoft.Extensions.Options;
namespace Flyshot.Server.Host.Services;
/// <summary>
/// 服务启动时自动初始化机器人的托管服务。
/// 从配置文件读取 robot_name 和 robot_ip,依次执行 SetUpRobot、SetActiveController、Connect、EnableRobot。
/// 初始化失败不会阻止服务启动,仅记录警告日志。
/// </summary>
public sealed class AutoInitRobotHostedService : IHostedService
{
private readonly IControllerClientCompatService _compatService;
private readonly ControllerClientCompatOptions _options;
private readonly ILogger<AutoInitRobotHostedService> _logger;
/// <summary>
/// 初始化自动初始化机器人托管服务的实例。
/// </summary>
/// <param name="compatService">控制器兼容服务,用于执行机器人初始化操作。</param>
/// <param name="options">控制器兼容层配置,包含自动初始化参数。</param>
/// <param name="logger">日志记录器。</param>
public AutoInitRobotHostedService(
IControllerClientCompatService compatService,
IOptions<ControllerClientCompatOptions> options,
ILogger<AutoInitRobotHostedService> logger)
{
_compatService = compatService;
_options = options.Value;
_logger = logger;
}
/// <summary>
/// 服务启动时执行自动初始化。若配置中未指定 AutoInit 或参数为空,则跳过。
/// </summary>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>异步任务。</returns>
public Task StartAsync(CancellationToken cancellationToken)
{
var autoInit = _options.AutoInit;
// 未配置 AutoInit 或参数为空时,跳过自动初始化
if (autoInit == null
|| string.IsNullOrWhiteSpace(autoInit.RobotName)
|| string.IsNullOrWhiteSpace(autoInit.RobotIp))
{
_logger.LogInformation("未配置 AutoInit,跳过服务启动自动初始化。");
return Task.CompletedTask;
}
_logger.LogInformation(
"开始自动初始化机器人: robot_name={RobotName}, robot_ip={RobotIp}",
autoInit.RobotName, autoInit.RobotIp);
try
{
// 步骤1:加载机器人配置
_compatService.SetUpRobot(autoInit.RobotName);
if (!_compatService.IsSetUp)
{
_logger.LogWarning("自动初始化失败: SetUpRobot 返回 IsSetUp=false,robot_name={RobotName}", autoInit.RobotName);
return Task.CompletedTask;
}
// 步骤2:设置控制器模式(默认非仿真)
_compatService.SetActiveController(sim: false);
// 步骤3:连接机器人控制器
_compatService.Connect(autoInit.RobotIp);
// 步骤4:使能机器人(上伺服)
_compatService.EnableRobot(8);
_logger.LogInformation("自动初始化成功: robot_name={RobotName}", autoInit.RobotName);
}
catch (Exception ex)
{
// 初始化失败不阻止服务启动,仅记录警告
_logger.LogWarning(ex, "自动初始化机器人失败,服务将继续启动。robot_name={RobotName}", autoInit.RobotName);
}
return Task.CompletedTask;
}
/// <summary>
/// 服务停止时的清理操作。当前无需额外清理。
/// </summary>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>异步任务。</returns>
public Task StopAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
}

View File

@@ -6,7 +6,11 @@
}
},
"ControllerClientCompat": {
"ServerVersion": "flyshot-replacement-controller-client-compat/0.1.0"
"ServerVersion": "flyshot-replacement-controller-client-compat/0.1.0",
"AutoInit": {
"RobotName": "LR_Mate_200iD",
"RobotIp": "127.0.0.1"
}
},
"Swagger": {
"Enabled": true,