Files
FlyShotHost/src/Flyshot.Runtime.Fanuc/Protocol/FanucStateClient.cs
yunxiao.zhu 70b0ccd414 feat(fanuc): 优化 J519 实时下发与飞拍起停整形
- 改为高优先级 J519 接收线程与复用缓冲区发送链路
- 增加稠密执行前的 J519 就绪重试与状态诊断
- 修正程序状态响应字段顺序与 EnableRobot 默认参数
- 为飞拍轨迹补充平滑起停时间轴与首尾整形验证
- 补充真实运行配置、报警窗口与边界对比测试
- 同步更新限值文档、分析脚本与 .NET 8 SDK 固定配置
2026-05-06 22:37:31 +08:00

633 lines
21 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Net.Sockets;
using Microsoft.Extensions.Logging;
namespace Flyshot.Runtime.Fanuc.Protocol;
/// <summary>
/// 表示 FANUC TCP 10010 状态通道客户端的连接阶段。
/// </summary>
public enum FanucStateConnectionState
{
/// <summary>
/// 状态通道未连接。
/// </summary>
Disconnected,
/// <summary>
/// 状态通道正在建立连接。
/// </summary>
Connecting,
/// <summary>
/// 状态通道已连接并由后台循环接收状态帧。
/// </summary>
Connected,
/// <summary>
/// 状态通道在限定时间内没有收到完整状态帧。
/// </summary>
TimedOut,
/// <summary>
/// 状态通道正在按退避策略重新连接。
/// </summary>
Reconnecting,
}
/// <summary>
/// 定义 FANUC TCP 10010 状态通道的超时和重连参数。
/// </summary>
public sealed class FanucStateClientOptions
{
/// <summary>
/// 获取或设置接收一帧完整 90B 状态帧允许的最长时间。
/// </summary>
public TimeSpan FrameTimeout { get; init; } = TimeSpan.FromMilliseconds(250);
/// <summary>
/// 获取或设置初始重连等待时间。
/// </summary>
public TimeSpan ReconnectInitialDelay { get; init; } = TimeSpan.FromMilliseconds(100);
/// <summary>
/// 获取或设置重连等待时间的上限。
/// </summary>
public TimeSpan ReconnectMaxDelay { get; init; } = TimeSpan.FromSeconds(5);
/// <summary>
/// 获取或设置单次 TCP 建连允许的最长时间。
/// </summary>
public TimeSpan ConnectTimeout { get; init; } = TimeSpan.FromSeconds(5);
}
/// <summary>
/// 表示 FANUC TCP 10010 状态通道客户端的当前诊断状态。
/// </summary>
public sealed class FanucStateClientStatus
{
/// <summary>
/// 初始化状态通道诊断状态。
/// </summary>
public FanucStateClientStatus(
FanucStateConnectionState state,
bool isFrameStale,
DateTimeOffset? lastFrameAt,
long reconnectAttemptCount,
string? lastErrorMessage)
{
State = state;
IsFrameStale = isFrameStale;
LastFrameAt = lastFrameAt;
ReconnectAttemptCount = reconnectAttemptCount;
LastErrorMessage = lastErrorMessage;
}
/// <summary>
/// 获取状态通道当前连接阶段。
/// </summary>
public FanucStateConnectionState State { get; }
/// <summary>
/// 获取最近缓存状态帧是否已经超过状态帧超时窗口。
/// </summary>
public bool IsFrameStale { get; }
/// <summary>
/// 获取最近一次成功解析状态帧的 UTC 时间。
/// </summary>
public DateTimeOffset? LastFrameAt { get; }
/// <summary>
/// 获取后台循环发起重连的累计次数。
/// </summary>
public long ReconnectAttemptCount { get; }
/// <summary>
/// 获取最近一次状态通道异常的诊断文本。
/// </summary>
public string? LastErrorMessage { get; }
}
/// <summary>
/// FANUC TCP 10010 状态通道客户端,持续接收状态帧并缓存最新快照。
/// </summary>
public sealed class FanucStateClient : IDisposable
{
private readonly object _stateLock = new();
private readonly FanucStateClientOptions _options;
private readonly ILogger<FanucStateClient>? _logger;
private TcpClient? _tcpClient;
private NetworkStream? _stream;
private CancellationTokenSource? _receiveCts;
private Task? _receiveTask;
private FanucStateFrame? _latestFrame;
private FanucStateConnectionState _connectionState = FanucStateConnectionState.Disconnected;
private DateTimeOffset? _lastConnectedAt;
private DateTimeOffset? _lastFrameAt;
private long _reconnectAttemptCount;
private string? _lastErrorMessage;
private bool _disposed;
/// <summary>
/// 使用默认状态通道参数初始化客户端。
/// </summary>
public FanucStateClient()
: this(new FanucStateClientOptions(), null)
{
}
/// <summary>
/// 使用指定状态通道参数初始化客户端。
/// </summary>
/// <param name="options">超时和重连参数。</param>
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);
ValidateOptions(options);
_options = options;
_logger = logger;
}
/// <summary>
/// 获取当前是否已建立连接。
/// </summary>
public bool IsConnected => GetStatus().State == FanucStateConnectionState.Connected;
/// <summary>
/// 建立到 FANUC 控制柜 TCP 10010 状态通道的连接并启动后台接收循环。
/// </summary>
/// <param name="ip">控制柜 IP 地址。</param>
/// <param name="port">状态通道端口,默认 10010。</param>
/// <param name="cancellationToken">取消令牌。</param>
public async Task ConnectAsync(string ip, int port = 10010, CancellationToken cancellationToken = default)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (string.IsNullOrWhiteSpace(ip))
{
throw new ArgumentException("IP 不能为空。", nameof(ip));
}
if (_receiveTask is not null)
{
throw new InvalidOperationException("状态通道已经连接,请先 Disconnect。");
}
_logger?.LogInformation("StateClient ConnectAsync: {Ip}:{Port}", ip, port);
_receiveCts = new CancellationTokenSource();
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _receiveCts.Token);
lock (_stateLock)
{
_connectionState = FanucStateConnectionState.Connecting;
_latestFrame = null;
_lastConnectedAt = null;
_lastFrameAt = null;
_reconnectAttemptCount = 0;
_lastErrorMessage = null;
}
try
{
await OpenConnectionAsync(ip, port, linkedCts.Token).ConfigureAwait(false);
}
catch (Exception exception)
{
_logger?.LogError(exception, "StateClient 连接失败: {Ip}:{Port}", ip, port);
CloseCurrentConnection();
lock (_stateLock)
{
_connectionState = FanucStateConnectionState.Disconnected;
}
_receiveCts.Dispose();
_receiveCts = null;
throw;
}
_receiveTask = Task.Run(
() => ReceiveAndReconnectLoopAsync(ip, port, _receiveCts.Token),
_receiveCts.Token);
_logger?.LogInformation("StateClient 已连接并启动接收循环: {Ip}:{Port}", ip, port);
}
/// <summary>
/// 断开状态通道并停止后台接收循环。
/// </summary>
public void Disconnect()
{
ObjectDisposedException.ThrowIf(_disposed, this);
_logger?.LogInformation("StateClient Disconnect");
Shutdown(clearLatestFrame: true);
}
/// <summary>
/// 获取最近一次解析的状态帧;若尚未收到任何帧则返回 null。
/// </summary>
/// <returns>最新状态帧或 null。</returns>
public FanucStateFrame? GetLatestFrame()
{
ObjectDisposedException.ThrowIf(_disposed, this);
lock (_stateLock)
{
return _latestFrame;
}
}
/// <summary>
/// 获取状态通道当前诊断状态。
/// </summary>
/// <returns>状态通道诊断快照。</returns>
public FanucStateClientStatus GetStatus()
{
ObjectDisposedException.ThrowIf(_disposed, this);
lock (_stateLock)
{
return new FanucStateClientStatus(
_connectionState,
IsFrameStaleLocked(DateTimeOffset.UtcNow),
_lastFrameAt,
_reconnectAttemptCount,
_lastErrorMessage);
}
}
/// <summary>
/// 释放客户端资源。
/// </summary>
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
Shutdown(clearLatestFrame: true);
}
/// <summary>
/// 后台循环:持续接收状态帧;断线、超时或坏帧后进入退避重连。
/// </summary>
private async Task ReceiveAndReconnectLoopAsync(string ip, int port, CancellationToken cancellationToken)
{
var reconnectDelay = _options.ReconnectInitialDelay;
_logger?.LogInformation("StateClient 接收循环启动: {Ip}:{Port}", ip, port);
while (!cancellationToken.IsCancellationRequested)
{
try
{
await ReceiveCurrentConnectionAsync(cancellationToken).ConfigureAwait(false);
reconnectDelay = _options.ReconnectInitialDelay;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
_logger?.LogInformation("StateClient 接收循环正常取消");
return;
}
catch (TimeoutException ex)
{
_logger?.LogWarning(ex, "StateClient 接收超时");
MarkReceiveFailure(FanucStateConnectionState.TimedOut, ex.Message);
}
catch (Exception ex) when (ex is IOException or InvalidDataException or SocketException or ObjectDisposedException)
{
_logger?.LogWarning(ex, "StateClient 连接异常,准备重连");
MarkReceiveFailure(FanucStateConnectionState.Reconnecting, ex.Message);
}
CloseCurrentConnection();
if (cancellationToken.IsCancellationRequested)
{
return;
}
reconnectDelay = await ReconnectWithBackoffAsync(ip, port, reconnectDelay, cancellationToken).ConfigureAwait(false);
}
}
/// <summary>
/// 从当前连接中持续读取状态帧,直到连接异常或被取消。
/// </summary>
private async Task ReceiveCurrentConnectionAsync(CancellationToken cancellationToken)
{
NetworkStream stream;
lock (_stateLock)
{
stream = _stream ?? throw new IOException("状态通道未连接。");
}
var buffer = new byte[FanucStateProtocol.StateFrameLength];
long frameCount = 0;
FanucStateFrame? lastLoggedFrame = null;
while (!cancellationToken.IsCancellationRequested)
{
await ReadExactAsync(stream, buffer, cancellationToken).ConfigureAwait(false);
var frame = FanucStateProtocol.ParseFrame(buffer);
lock (_stateLock)
{
_latestFrame = frame;
_lastFrameAt = DateTimeOffset.UtcNow;
_connectionState = FanucStateConnectionState.Connected;
_lastErrorMessage = null;
}
frameCount++;
// 仅在状态变化或首次接收时记录 Info避免高频日志。
if (lastLoggedFrame is null
|| lastLoggedFrame.CartesianPose[0] != frame.CartesianPose[0]
|| !lastLoggedFrame.RawTailWords.SequenceEqual(frame.RawTailWords))
{
_logger?.LogInformation(
"StateClient 收到状态帧: pose=[{X:F1}, {Y:F1}, {Z:F1}], tail=[{Tail}]",
frame.CartesianPose[0],
frame.CartesianPose[1],
frame.CartesianPose[2],
string.Join(", ", frame.RawTailWords));
lastLoggedFrame = frame;
}
else if (frameCount % 1000 == 0)
{
_logger?.LogDebug("StateClient 已接收 {Count} 个状态帧", frameCount);
}
}
}
/// <summary>
/// 从流中精确读取固定长度字节,超过帧超时窗口则抛出超时异常。
/// </summary>
private async Task ReadExactAsync(NetworkStream stream, byte[] buffer, CancellationToken cancellationToken)
{
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(_options.FrameTimeout);
var totalRead = 0;
try
{
while (totalRead < buffer.Length)
{
var read = await stream.ReadAsync(
buffer.AsMemory(totalRead, buffer.Length - totalRead),
timeoutCts.Token).ConfigureAwait(false);
if (read == 0)
{
throw new IOException("状态通道已断开,读取到 EOF。");
}
totalRead += read;
}
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
throw new TimeoutException("状态通道接收超时,未在限定时间内收到完整 90B 状态帧。");
}
}
/// <summary>
/// 打开 TCP 状态通道并更新连接状态。
/// </summary>
private async Task OpenConnectionAsync(string ip, int port, CancellationToken cancellationToken)
{
var tcpClient = new TcpClient { NoDelay = true };
try
{
_logger?.LogInformation("StateClient 正在连接 {Ip}:{Port}...", ip, port);
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(_options.ConnectTimeout);
await tcpClient.ConnectAsync(ip, port, timeoutCts.Token).ConfigureAwait(false);
lock (_stateLock)
{
_tcpClient = tcpClient;
_stream = tcpClient.GetStream();
_lastConnectedAt = DateTimeOffset.UtcNow;
_connectionState = FanucStateConnectionState.Connected;
}
_logger?.LogInformation("StateClient 已连接到 {Ip}:{Port}", ip, port);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
_logger?.LogWarning("StateClient 连接 {Ip}:{Port} 超时", ip, port);
tcpClient.Dispose();
throw new TimeoutException("状态通道建连超时。");
}
catch (Exception ex)
{
_logger?.LogWarning(ex, "StateClient 连接 {Ip}:{Port} 失败", ip, port);
tcpClient.Dispose();
throw;
}
}
/// <summary>
/// 按退避策略循环尝试重新连接,并返回下一次异常后的退避时间。
/// </summary>
private async Task<TimeSpan> ReconnectWithBackoffAsync(
string ip,
int port,
TimeSpan reconnectDelay,
CancellationToken cancellationToken)
{
var nextDelay = reconnectDelay;
while (!cancellationToken.IsCancellationRequested)
{
lock (_stateLock)
{
_connectionState = FanucStateConnectionState.Reconnecting;
}
_logger?.LogInformation(
"StateClient 将在 {Delay}ms 后尝试重连 {Ip}:{Port}...",
nextDelay.TotalMilliseconds,
ip,
port);
await Task.Delay(nextDelay, cancellationToken).ConfigureAwait(false);
lock (_stateLock)
{
_reconnectAttemptCount++;
}
try
{
await OpenConnectionAsync(ip, port, cancellationToken).ConfigureAwait(false);
_logger?.LogInformation(
"StateClient 重连成功: {Ip}:{Port}, 累计重连次数={Count}",
ip,
port,
_reconnectAttemptCount);
return _options.ReconnectInitialDelay;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception ex) when (ex is SocketException or IOException or TimeoutException)
{
CloseCurrentConnection();
lock (_stateLock)
{
_connectionState = FanucStateConnectionState.Reconnecting;
_lastErrorMessage = ex.Message;
}
_logger?.LogWarning(
ex,
"StateClient 重连失败: {Ip}:{Port}, 下次等待={NextDelay}ms",
ip,
port,
nextDelay.TotalMilliseconds * 2);
nextDelay = IncreaseReconnectDelay(nextDelay);
}
}
return nextDelay;
}
/// <summary>
/// 关闭当前 TCP 连接,不清除最新状态帧,供重连路径保留诊断数据。
/// </summary>
private void CloseCurrentConnection()
{
NetworkStream? stream;
TcpClient? tcpClient;
lock (_stateLock)
{
stream = _stream;
tcpClient = _tcpClient;
_stream = null;
_tcpClient = null;
}
stream?.Dispose();
tcpClient?.Dispose();
}
/// <summary>
/// 记录接收异常并更新状态通道连接阶段。
/// </summary>
private void MarkReceiveFailure(FanucStateConnectionState state, string message)
{
lock (_stateLock)
{
_connectionState = state;
_lastErrorMessage = message;
}
_logger?.LogWarning("StateClient 接收失败: state={State}, message={Message}", state, message);
}
/// <summary>
/// 关闭后台循环和 socket 资源。
/// </summary>
private void Shutdown(bool clearLatestFrame)
{
_receiveCts?.Cancel();
CloseCurrentConnection();
try
{
_receiveTask?.Wait(TimeSpan.FromSeconds(2));
}
catch (AggregateException)
{
// 后台循环可能因取消而抛出 OperationCanceledException忽略即可。
}
_receiveTask = null;
_receiveCts?.Dispose();
_receiveCts = null;
lock (_stateLock)
{
_connectionState = FanucStateConnectionState.Disconnected;
_lastConnectedAt = null;
_lastErrorMessage = null;
_reconnectAttemptCount = 0;
if (clearLatestFrame)
{
_latestFrame = null;
_lastFrameAt = null;
}
}
}
/// <summary>
/// 判断缓存帧是否已经不能代表当前控制柜状态。
/// </summary>
private bool IsFrameStaleLocked(DateTimeOffset now)
{
if (_latestFrame is null)
{
return _connectionState is FanucStateConnectionState.TimedOut or FanucStateConnectionState.Reconnecting
|| _reconnectAttemptCount > 0
|| (_lastConnectedAt.HasValue && now - _lastConnectedAt.Value > _options.FrameTimeout);
}
return _lastFrameAt.HasValue && now - _lastFrameAt.Value > _options.FrameTimeout;
}
/// <summary>
/// 计算下一轮重连等待时间。
/// </summary>
private TimeSpan IncreaseReconnectDelay(TimeSpan currentDelay)
{
var doubledMilliseconds = Math.Max(currentDelay.TotalMilliseconds * 2.0, _options.ReconnectInitialDelay.TotalMilliseconds);
var cappedMilliseconds = Math.Min(doubledMilliseconds, _options.ReconnectMaxDelay.TotalMilliseconds);
return TimeSpan.FromMilliseconds(cappedMilliseconds);
}
/// <summary>
/// 校验状态通道参数,避免后台循环使用无效时间窗口。
/// </summary>
private static void ValidateOptions(FanucStateClientOptions options)
{
ValidatePositive(options.FrameTimeout, nameof(options.FrameTimeout));
ValidatePositive(options.ReconnectInitialDelay, nameof(options.ReconnectInitialDelay));
ValidatePositive(options.ReconnectMaxDelay, nameof(options.ReconnectMaxDelay));
ValidatePositive(options.ConnectTimeout, nameof(options.ConnectTimeout));
if (options.ReconnectMaxDelay < options.ReconnectInitialDelay)
{
throw new ArgumentOutOfRangeException(nameof(options), "最大重连等待时间不能小于初始重连等待时间。");
}
}
/// <summary>
/// 校验时间参数必须为正值。
/// </summary>
private static void ValidatePositive(TimeSpan value, string parameterName)
{
if (value <= TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(parameterName, "时间参数必须大于 0。");
}
}
}