using System.Diagnostics; using System.Net.Sockets; using Microsoft.Extensions.Logging; namespace Flyshot.Runtime.Fanuc.Protocol; /// /// FANUC UDP 60015 J519/ICSP 伺服运动客户端,提供状态包驱动的命令发送与响应接收能力。 /// public sealed class FanucJ519Client : IDisposable { private readonly object _commandLock = new(); private readonly object _responseLock = new(); private readonly ILogger? _logger; /// /// 指示预装队列发出 LastData 结束帧后是否进入仅监控状态,而不是继续发送保姿命令。 /// private readonly bool _endCommandStreamAfterQueueDrained; private UdpClient? _udpClient; private CancellationTokenSource? _cts; private Thread? _receiveThread; private FanucJ519Command? _currentCommand; private FanucJ519Command? _lastSentCommand; // 稠密轨迹执行时预装的命令队列,由机器人状态包节拍逐帧出队。 private Queue? _commandQueue; private TaskCompletionSource? _commandQueueDrainedCompletion; private List? _commandHistoryForTests; private FanucJ519Response? _latestResponse; private long _slowSendCount; private long _maxReceiveToSendTicks; private uint _sequenceBufferSize; /// /// 在 StartMotion 前是否有待发送的新命令;如果为 true 则 StartMotion 不会清空当前目标,而是继续发送新目标。 /// private bool _hasPendingCommandForStart; /// /// 标记命令流已通过 LastData 正常结束;该状态下仅接收状态包,不继续回发 hold 命令。 /// private bool _monitorOnlyAfterLastData; private bool _motionStarted; private bool _disposed; /// /// 获取当前是否已创建 UDP 套接字。 /// public bool IsConnected => _udpClient is not null; /// /// 获取正常队列结束时是否通过 LastData 结束命令流并保留状态监控。 /// internal bool EndCommandStreamAfterQueueDrained => _endCommandStreamAfterQueueDrained; /// /// 初始化 FANUC J519 客户端。 /// /// 日志记录器;允许 null,供无日志场景使用。 /// 队列结束帧发出后是否停止命令流并保留状态监控。 public FanucJ519Client( ILogger? logger = null, bool endCommandStreamAfterQueueDrained = true) { _logger = logger; _endCommandStreamAfterQueueDrained = endCommandStreamAfterQueueDrained; } /// /// 建立到 FANUC 控制柜 UDP 60015 运动通道的连接并启动接收循环。 /// /// 控制柜 IP 地址。 /// 运动通道端口,默认 60015。 /// 取消令牌。 public async Task ConnectAsync(string ip, int port = 60015, CancellationToken cancellationToken = default) { ObjectDisposedException.ThrowIf(_disposed, this); if (string.IsNullOrWhiteSpace(ip)) { throw new ArgumentException("IP 不能为空。", nameof(ip)); } if (_udpClient is not null) { throw new InvalidOperationException("J519 通道已经连接,请先 Disconnect。"); } _logger?.LogInformation("J519 ConnectAsync: {Ip}:{Port}", ip, port); _udpClient = new UdpClient(); _udpClient.Connect(ip, port); ConfigureRealtimeSocket(_udpClient.Client); ConfigureProcessPriority(); // 发送初始化包。 await _udpClient.SendAsync(FanucJ519Protocol.PackInitPacket(), cancellationToken).ConfigureAwait(false); _logger?.LogInformation("J519 初始化包已发送"); _cts = new CancellationTokenSource(); _receiveThread = new Thread(ReceiveLoop) { IsBackground = true, Name = "J519 UDP realtime loop", Priority = ThreadPriority.Highest }; _receiveThread.Start(); } /// /// 启动 J519 命令发送许可;实际发包由机器人状态包节拍驱动。 /// public void StartMotion() { ObjectDisposedException.ThrowIf(_disposed, this); if (_udpClient is null) { throw new InvalidOperationException("J519 通道未连接。"); } lock (_commandLock) { _lastSentCommand = null; if (_motionStarted) { _logger?.LogDebug("J519 StartMotion: 状态包驱动发送已启用"); return; } if (!_hasPendingCommandForStart) { _currentCommand = null; CompleteCommandQueueLocked(); } _hasPendingCommandForStart = false; _monitorOnlyAfterLastData = false; _motionStarted = true; } _logger?.LogInformation("J519 StartMotion: 已启用状态包驱动发送"); } /// /// 配置状态包驱动回发时附加到机器人 sequence 的前视缓冲深度。 /// /// 要附加到状态序号上的缓冲深度。 public void SetSequenceBufferSize(int bufferSize) { ObjectDisposedException.ThrowIf(_disposed, this); if (bufferSize < 0) { throw new ArgumentOutOfRangeException(nameof(bufferSize), "J519 sequence buffer size 不能为负数。"); } lock (_commandLock) { _sequenceBufferSize = (uint)bufferSize; } } /// /// 发送状态输出停止包并停止 J519 命令发送。 /// public async Task StopMotionAsync(CancellationToken cancellationToken = default) { ObjectDisposedException.ThrowIf(_disposed, this); if (_udpClient is null) { return; } _logger?.LogInformation("J519 StopMotionAsync: 停止状态包驱动发送"); lock (_commandLock) { _motionStarted = false; _hasPendingCommandForStart = false; _monitorOnlyAfterLastData = false; CompleteCommandQueueLocked(); } // FANUC 手册中 packet type=2 表示停止状态包输出;当前保留现场抓包兼容行为。 await _udpClient.SendAsync(FanucJ519Protocol.PackEndPacket(), cancellationToken).ConfigureAwait(false); _logger?.LogInformation("J519 StopMotionAsync: 状态输出停止包已发送"); } /// /// 原子更新下一周期要发送的 J519 命令。 /// /// 新的 J519 命令。 public void UpdateCommand(FanucJ519Command command) { ArgumentNullException.ThrowIfNull(command); ObjectDisposedException.ThrowIf(_disposed, this); lock (_commandLock) { CompleteCommandQueueLocked(); _currentCommand = command; _hasPendingCommandForStart = true; _monitorOnlyAfterLastData = false; _commandHistoryForTests?.Add(command); } if (_logger?.IsEnabled(LogLevel.Debug) == true) { //_logger.LogDebug( // "J519 UpdateCommand: joints={Joints}, ioMask={IoMask}, ioValue={IoValue}", // command.TargetJoints, // command.WriteIoMask, // command.WriteIoValue); } } /// /// 装载一整段 J519 命令队列;后续每个可接收命令的机器人状态包会自动取出下一帧。 /// /// 按执行顺序排列的 J519 命令列表,至少包含一帧。 public void LoadCommandQueue(IReadOnlyList commands) { ArgumentNullException.ThrowIfNull(commands); ObjectDisposedException.ThrowIf(_disposed, this); if (commands.Count == 0) { throw new ArgumentException("J519 命令队列至少需要包含一帧。", nameof(commands)); } lock (_commandLock) { CompleteCommandQueueLocked(); _commandQueue = new Queue(commands); // 队列耗尽后继续保持最后一帧目标,避免运动结束后回落到旧目标或空目标。 _currentCommand = commands[^1]; _hasPendingCommandForStart = true; _monitorOnlyAfterLastData = false; _motionStarted = true; _commandQueueDrainedCompletion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); _commandHistoryForTests?.AddRange(commands); } _logger?.LogInformation("J519 命令队列已装载: count={Count}", commands.Count); _logger?.LogInformation("开始运动前向机器人发送的sequence={Sequence}",_lastSentCommand?.Sequence ?? 0); } /// /// 等待当前预装命令队列被状态包全部取出;无队列时立即完成。 /// /// 取消令牌。 /// 表示等待过程的任务。 internal Task WaitForCommandQueueDrainedAsync(CancellationToken cancellationToken = default) { ObjectDisposedException.ThrowIf(_disposed, this); Task waitTask; lock (_commandLock) { waitTask = _commandQueueDrainedCompletion?.Task ?? Task.CompletedTask; } return waitTask.WaitAsync(cancellationToken); } /// 判断当前是否没有等待出队的命令;仅供单元测试断言。 /// /// 如果队列为空或尚未装载队列,则返回 true。 internal bool IsCommandQueueDrainedForTests() { ObjectDisposedException.ThrowIf(_disposed, this); lock (_commandLock) { return _commandQueue is null || _commandQueue.Count == 0; } } /// /// 打开命令历史记录,仅供单元测试验证运行时生成的命令序列。 /// internal void EnableCommandHistoryForTests() { ObjectDisposedException.ThrowIf(_disposed, this); lock (_commandLock) { _commandHistoryForTests = []; } } /// /// 获取测试记录的命令历史。 /// /// 命令历史快照。 internal IReadOnlyList GetCommandHistoryForTests() { ObjectDisposedException.ThrowIf(_disposed, this); lock (_commandLock) { return _commandHistoryForTests?.ToArray() ?? Array.Empty(); } } /// /// 获取最近一次通过 UpdateCommand 设置的 J519 命令;供测试断言使用。 /// /// 当前 J519 命令或 null。 internal FanucJ519Command? GetCurrentCommand() { ObjectDisposedException.ThrowIf(_disposed, this); lock (_commandLock) { return _currentCommand; } } /// /// 获取最近一次解析的 J519 响应;若尚未收到任何响应则返回 null。 /// /// 最新 J519 响应或 null。 public FanucJ519Response? GetLatestResponse() { ObjectDisposedException.ThrowIf(_disposed, this); lock (_responseLock) { return _latestResponse; } } /// /// 断开 J519 通道并释放资源。 /// public void Disconnect() { ObjectDisposedException.ThrowIf(_disposed, this); _cts?.Cancel(); try { _udpClient?.Dispose(); _receiveThread?.Join(TimeSpan.FromSeconds(1)); } catch (ObjectDisposedException) { // 忽略释放期间的套接字关闭异常。 } _receiveThread = null; _cts?.Dispose(); _cts = null; _udpClient = null; lock (_commandLock) { _currentCommand = null; _lastSentCommand = null; CompleteCommandQueueLocked(); _commandHistoryForTests = null; _hasPendingCommandForStart = false; _monitorOnlyAfterLastData = false; _motionStarted = false; } lock (_responseLock) { _latestResponse = null; } } /// /// 释放客户端资源。 /// public void Dispose() { if (_disposed) { return; } _disposed = true; _cts?.Cancel(); try { _udpClient?.Dispose(); _receiveThread?.Join(TimeSpan.FromSeconds(1)); } catch (ObjectDisposedException) { // 忽略释放期间的套接字关闭异常。 } _cts?.Dispose(); lock (_commandLock) { CompleteCommandQueueLocked(); _hasPendingCommandForStart = false; _monitorOnlyAfterLastData = false; } } private static void ConfigureProcessPriority() { try { var process = Process.GetCurrentProcess(); if (process.PriorityClass < ProcessPriorityClass.High) { process.PriorityClass = ProcessPriorityClass.High; } } catch (Exception) { // 某些部署环境不允许提升进程优先级;实时链路仍按普通优先级运行。 } } /// /// 配置 J519 UDP 套接字的低延迟参数。 /// /// 已连接 FANUC 60015 的 UDP 套接字。 private void ConfigureRealtimeSocket(Socket socket) { socket.ReceiveBufferSize = 1024 * 1024; socket.SendBufferSize = 1024 * 1024; try { // DSCP EF(46) 标记低延迟流量,是否生效取决于现场网卡、交换机和控制柜网络策略。 socket.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.TypeOfService, 0xB8); } catch (SocketException ex) { _logger?.LogWarning(ex, "J519 UDP 套接字无法设置 DSCP EF 优先级标记"); } } /// /// 后台接收循环:在专用高优先级线程中同步接收 132B 响应并立即回发命令。 /// private void ReceiveLoop() { var udpClient = _udpClient; var cancellationToken = _cts?.Token ?? CancellationToken.None; if (udpClient is null) { return; } _logger?.LogInformation("J519 ReceiveLoop 启动"); long receiveCount = 0; FanucJ519Response? lastLoggedResponse = null; var receiveBuffer = new byte[FanucJ519Protocol.ResponsePacketLength]; var commandBuffer = new byte[FanucJ519Protocol.CommandPacketLength]; try { while (!cancellationToken.IsCancellationRequested) { var received = udpClient.Client.Receive(receiveBuffer); if (received == FanucJ519Protocol.ResponsePacketLength) { var receiveTicks = Stopwatch.GetTimestamp(); var response = FanucJ519Protocol.ParseResponse(receiveBuffer); // 先按状态包节拍回发命令,再做低频日志处理,减少受信周期内的非必要工作。 if (response.AcceptsCommand) { SendCommandForStatus(udpClient.Client, response, commandBuffer, receiveTicks); } lock (_responseLock) { _latestResponse = response; } receiveCount++; // 仅在状态变化时记录 Info,避免高频日志。 if (lastLoggedResponse is null || lastLoggedResponse.Status != response.Status || lastLoggedResponse.RobotInMotion != response.RobotInMotion || lastLoggedResponse.SystemReady != response.SystemReady || lastLoggedResponse.AcceptsCommand != response.AcceptsCommand) { _logger?.LogInformation( "J519 响应: status=0x{Status:X2}, seq={Seq}, accept={Accept}, received={received}, SystemReady={SystemReady}, RobotInMotion={RobotInMotion}, pose=[{Pose}], joints=[{Joints}]", response.Status, response.Sequence, response.AcceptsCommand, response.ReceivedCommand, response.SystemReady, response.RobotInMotion, string.Join(", ", response.Pose.Select(v => v.ToString("F3"))), string.Join(", ", response.JointDegrees.Take(6).Select(v => v.ToString("F3")))); var lastSentTargetJoints = GetLastSentTargetJointsLogText(); _logger?.LogInformation("J519 最后一条发送目标关节轴: joints=[{Joints}]", lastSentTargetJoints); lastLoggedResponse = response; // 如果状态从AcceptsCommand true 变为false,说明机器人报错,清空队列 if (!response.AcceptsCommand) { lock (_commandLock) { _currentCommand = null; CompleteCommandQueueLocked(); } _logger?.LogWarning("J519 接收状态包显示机器人不可接受命令,已清空命令队列"); } } else if (receiveCount % 1000 == 0) { var maxReceiveToSendMs = Stopwatch.GetElapsedTime(0, Interlocked.Read(ref _maxReceiveToSendTicks)).TotalMilliseconds; _logger?.LogDebug( "J519 已接收 {Count} 个响应包,receive-to-send 最大耗时约 {MaxMs:F3}ms,超过 0.5ms 次数 {SlowCount}", receiveCount, maxReceiveToSendMs, Interlocked.Read(ref _slowSendCount)); } } } } catch (OperationCanceledException) { _logger?.LogInformation("J519 ReceiveLoop 正常取消,共接收 {Count} 个包", receiveCount); } catch (ObjectDisposedException) { _logger?.LogInformation("J519 ReceiveLoop 因 UDP 释放退出,共接收 {Count} 个包", receiveCount); } catch (SocketException ex) when (cancellationToken.IsCancellationRequested) { _logger?.LogInformation(ex, "J519 ReceiveLoop 因取消关闭套接字退出,共接收 {Count} 个包", receiveCount); } } /// /// 按机器人状态包的 sequence 立即回发当前 J519 命令。 /// /// 已连接的 UDP 套接字。 /// 刚收到的机器人状态包。 /// 可复用的 64B 命令包缓冲区。 /// 收到状态包后的时间戳。 private void SendCommandForStatus(Socket socket, FanucJ519Response response, byte[] commandBuffer, long receiveTicks) { FanucJ519Command? command; var willDrainQueue = false; var suppressHoldCommand = false; lock (_commandLock) { if (_monitorOnlyAfterLastData) { command = null; suppressHoldCommand = true; } else if (!_motionStarted) { command = null; } else if (_commandQueue is { Count: > 0 } queue) { // 状态包是唯一节拍源:每收到一帧可接收状态,才取出下一条目标。 var queuedCommand = queue.Dequeue(); _currentCommand = queuedCommand; willDrainQueue = queue.Count == 0; command = queuedCommand; } else { command = _currentCommand; } } if (command is null && !suppressHoldCommand) { command = TryBuildHoldCommandFromLatestResponse(response); } if (command is null) { return; } uint sequenceToSend; lock (_commandLock) { sequenceToSend = response.Sequence + _sequenceBufferSize; } FanucJ519Protocol.PackCommandPacket(command, sequenceToSend, commandBuffer); socket.Send(commandBuffer); TrackReceiveToSendLatency(receiveTicks); // _logger?.LogDebug("J519 已回发命令包,seq={Seq}", sequence); // _logger?.LogDebug( // "J519 回发命令详情: joints={Joints}, ioMask={IoMask}, ioValue={IoValue}", // command.TargetJoints, // command.WriteIoMask, // command.WriteIoValue); lock (_commandLock) { _lastSentCommand = command; if (_endCommandStreamAfterQueueDrained && command.LastData == 1) { // LastData 是命令流的正常结束语义;结束后保留状态接收,但不继续回发保姿包。 // _monitorOnlyAfterLastData = true; // _motionStarted = false; CompleteCommandQueueLocked(); } else if (willDrainQueue && _commandQueue is { Count: 0 }) { CompleteCommandQueueLocked(); } } } /// /// 当当前没有显式目标时,使用最近一帧状态反馈关节角构造保姿命令,维持机器人当前位置。 /// /// 当前收到的机器人状态包。 /// 可用于保姿的临时 J519 命令;若反馈关节不足则返回 null。 private static FanucJ519Command? TryBuildHoldCommandFromLatestResponse(FanucJ519Response response) { if (response.JointDegrees.Count < 6) { return null; } // 无运动目标时,持续回发机器人当前反馈关节,保持伺服流与机器人当前位置一致。 return new FanucJ519Command( sequence: response.Sequence, targetJoints: [ response.JointDegrees[0], response.JointDegrees[1], response.JointDegrees[2], response.JointDegrees[3], response.JointDegrees[4], response.JointDegrees[5] ]); } /// /// 记录状态包到命令包发出的最大耗时和慢发送次数,供低频诊断日志观察调度抖动。 /// /// 收到状态包后的时间戳。 private void TrackReceiveToSendLatency(long receiveTicks) { var elapsedTicks = Stopwatch.GetTimestamp() - receiveTicks; var currentMax = Interlocked.Read(ref _maxReceiveToSendTicks); while (elapsedTicks > currentMax && Interlocked.CompareExchange(ref _maxReceiveToSendTicks, elapsedTicks, currentMax) != currentMax) { currentMax = Interlocked.Read(ref _maxReceiveToSendTicks); } if (Stopwatch.GetElapsedTime(0, elapsedTicks) > TimeSpan.FromMilliseconds(0.5)) { Interlocked.Increment(ref _slowSendCount); } } /// /// 清空当前命令队列,并唤醒等待队列结束的运行时任务。 /// private void CompleteCommandQueueLocked() { _commandQueue?.Clear(); _commandQueue = null; _commandQueueDrainedCompletion?.TrySetResult(); _commandQueueDrainedCompletion = null; } /// /// 读取最近一次已成功发送命令的目标关节轴文本,便于状态日志直接对照控制目标。 /// /// 格式化后的目标关节轴文本;如果尚未发送命令则返回占位符。 private string GetLastSentTargetJointsLogText() { lock (_commandLock) { return _lastSentCommand is null ? "n/a" : string.Join(", ", _lastSentCommand.TargetJoints.Take(6).Select(v => v.ToString("F5"))); } } }