feat(fanuc): 添加协议编解码与状态页" -m "* 固化 10010 状态帧、10012 命令帧和 60015 J519 包编解码

* 扩展 ControllerClient 兼容层的执行参数和运行时编排
  * 新增 /status 页面与 /api/status/snapshot 状态快照接口
  * 补充 FANUC 协议、客户端和状态接口的最小验证测试
  * 更新 README、兼容要求和真机 Socket 通信实现计划
This commit is contained in:
2026-04-24 21:26:25 +08:00
parent 8a20d9f507
commit a78e6761cb
25 changed files with 3773 additions and 55 deletions

View File

@@ -0,0 +1,188 @@
using System.Net.Sockets;
namespace Flyshot.Runtime.Fanuc.Protocol;
/// <summary>
/// FANUC TCP 10010 状态通道客户端,持续接收状态帧并缓存最新快照。
/// </summary>
public sealed class FanucStateClient : IDisposable
{
private readonly object _stateLock = new();
private TcpClient? _tcpClient;
private NetworkStream? _stream;
private CancellationTokenSource? _receiveCts;
private Task? _receiveTask;
private FanucStateFrame? _latestFrame;
private bool _disposed;
/// <summary>
/// 获取当前是否已建立连接。
/// </summary>
public bool IsConnected => _tcpClient?.Connected ?? false;
/// <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 (_tcpClient is not null)
{
throw new InvalidOperationException("状态通道已经连接,请先 Disconnect。");
}
_tcpClient = new TcpClient { NoDelay = true };
await _tcpClient.ConnectAsync(ip, port, cancellationToken).ConfigureAwait(false);
_stream = _tcpClient.GetStream();
_receiveCts = new CancellationTokenSource();
_receiveTask = Task.Run(() => ReceiveLoopAsync(_receiveCts.Token), _receiveCts.Token);
}
/// <summary>
/// 断开状态通道并停止后台接收循环。
/// </summary>
public void Disconnect()
{
ObjectDisposedException.ThrowIf(_disposed, this);
_receiveCts?.Cancel();
try
{
_receiveTask?.Wait(TimeSpan.FromSeconds(2));
}
catch (AggregateException)
{
// 后台循环可能因取消而抛出 OperationCanceledException忽略即可。
}
_receiveTask?.Dispose();
_receiveTask = null;
_receiveCts?.Dispose();
_receiveCts = null;
_stream?.Dispose();
_stream = null;
_tcpClient?.Dispose();
_tcpClient = null;
lock (_stateLock)
{
_latestFrame = null;
}
}
/// <summary>
/// 获取最近一次解析的状态帧;若尚未收到任何帧则返回 null。
/// </summary>
/// <returns>最新状态帧或 null。</returns>
public FanucStateFrame? GetLatestFrame()
{
ObjectDisposedException.ThrowIf(_disposed, this);
lock (_stateLock)
{
return _latestFrame;
}
}
/// <summary>
/// 释放客户端资源。
/// </summary>
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
_receiveCts?.Cancel();
try
{
_receiveTask?.Wait(TimeSpan.FromSeconds(2));
}
catch (AggregateException)
{
// 忽略取消异常。
}
_receiveTask?.Dispose();
_receiveCts?.Dispose();
_stream?.Dispose();
_tcpClient?.Dispose();
}
/// <summary>
/// 后台循环:持续从流中读取固定长度状态帧并更新缓存。
/// </summary>
private async Task ReceiveLoopAsync(CancellationToken cancellationToken)
{
if (_stream is null)
{
return;
}
var buffer = new byte[FanucStateProtocol.StateFrameLength];
try
{
while (!cancellationToken.IsCancellationRequested)
{
await ReadExactAsync(buffer, cancellationToken).ConfigureAwait(false);
var frame = FanucStateProtocol.ParseFrame(buffer);
lock (_stateLock)
{
_latestFrame = frame;
}
}
}
catch (OperationCanceledException)
{
// 正常取消,无需处理。
}
catch (IOException)
{
// 连接断开,退出循环。
}
catch (InvalidDataException)
{
// 解析到异常帧,退出循环由上层重连。
}
}
/// <summary>
/// 从流中精确读取固定长度字节。
/// </summary>
private async Task ReadExactAsync(byte[] buffer, CancellationToken cancellationToken)
{
if (_stream is null)
{
throw new InvalidOperationException("状态通道未连接。");
}
var totalRead = 0;
while (totalRead < buffer.Length)
{
var read = await _stream.ReadAsync(buffer.AsMemory(totalRead), cancellationToken).ConfigureAwait(false);
if (read == 0)
{
throw new IOException("状态通道已断开,读取到 EOF。");
}
totalRead += read;
}
}
}