✨ feat(fanuc): 添加协议编解码与状态页" -m "* 固化 10010 状态帧、10012 命令帧和 60015 J519 包编解码
* 扩展 ControllerClient 兼容层的执行参数和运行时编排 * 新增 /status 页面与 /api/status/snapshot 状态快照接口 * 补充 FANUC 协议、客户端和状态接口的最小验证测试 * 更新 README、兼容要求和真机 Socket 通信实现计划
This commit is contained in:
179
tests/Flyshot.Core.Tests/FanucCommandClientTests.cs
Normal file
179
tests/Flyshot.Core.Tests/FanucCommandClientTests.cs
Normal file
@@ -0,0 +1,179 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Flyshot.Runtime.Fanuc.Protocol;
|
||||
|
||||
namespace Flyshot.Core.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 验证 FANUC TCP 10012 命令客户端的帧收发与响应解析。
|
||||
/// </summary>
|
||||
public sealed class FanucCommandClientTests : IDisposable
|
||||
{
|
||||
private readonly TcpListener _listener;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
|
||||
/// <summary>
|
||||
/// 在随机可用端口启动本地模拟控制器。
|
||||
/// </summary>
|
||||
public FanucCommandClientTests()
|
||||
{
|
||||
_listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
_listener.Start();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取分配给本地模拟控制器的端口。
|
||||
/// </summary>
|
||||
private int Port => ((IPEndPoint)_listener.LocalEndpoint).Port;
|
||||
|
||||
/// <summary>
|
||||
/// 清理模拟控制器和取消源。
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
_cts.Cancel();
|
||||
_listener.Stop();
|
||||
_cts.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证命令客户端可以连接本地模拟控制器。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ConnectAsync_ConnectsToLocalListener()
|
||||
{
|
||||
using var client = new FanucCommandClient();
|
||||
var acceptTask = _listener.AcceptTcpClientAsync();
|
||||
|
||||
await client.ConnectAsync("127.0.0.1", Port, _cts.Token);
|
||||
|
||||
Assert.True(client.IsConnected);
|
||||
// 确保模拟侧也完成握手
|
||||
await acceptTask.WaitAsync(TimeSpan.FromSeconds(2), _cts.Token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证 StopProgram 命令帧与抓包样本一致,并能解析成功响应。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task StopProgramAsync_SendsCorrectFrameAndParsesSuccess()
|
||||
{
|
||||
using var client = new FanucCommandClient();
|
||||
var handlerTask = RunSingleResponseControllerAsync(
|
||||
Convert.FromHexString("646f7a0000001a0000210300000008525642555354534d7a6f64"),
|
||||
Convert.FromHexString("646f7a0000001200002103000000007a6f64"),
|
||||
_cts.Token);
|
||||
|
||||
await client.ConnectAsync("127.0.0.1", Port, _cts.Token);
|
||||
var response = await client.StopProgramAsync("RVBUSTSM", _cts.Token);
|
||||
|
||||
Assert.True(response.IsSuccess);
|
||||
Assert.Equal(FanucCommandMessageIds.StopProgram, response.MessageId);
|
||||
await handlerTask.WaitAsync(TimeSpan.FromSeconds(2), _cts.Token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证 ResetRobot 空命令帧能正确发送并解析结果响应。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ResetRobotAsync_SendsEmptyCommandAndParsesResponse()
|
||||
{
|
||||
using var client = new FanucCommandClient();
|
||||
var expectedFrame = FanucCommandProtocol.PackEmptyCommand(FanucCommandMessageIds.ResetRobot);
|
||||
var responseFrame = Convert.FromHexString("646f7a0000001200002100000000007a6f64");
|
||||
var handlerTask = RunSingleResponseControllerAsync(expectedFrame, responseFrame, _cts.Token);
|
||||
|
||||
await client.ConnectAsync("127.0.0.1", Port, _cts.Token);
|
||||
var response = await client.ResetRobotAsync(_cts.Token);
|
||||
|
||||
Assert.True(response.IsSuccess);
|
||||
Assert.Equal(FanucCommandMessageIds.ResetRobot, response.MessageId);
|
||||
await handlerTask.WaitAsync(TimeSpan.FromSeconds(2), _cts.Token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证 GetProgramStatus 命令帧能正确发送并解析程序状态响应。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetProgramStatusAsync_SendsFrameAndParsesStatusResponse()
|
||||
{
|
||||
using var client = new FanucCommandClient();
|
||||
var expectedFrame = FanucCommandProtocol.PackProgramCommand(FanucCommandMessageIds.GetProgramStatus, "RVBUSTSM");
|
||||
var responseFrame = Convert.FromHexString("646f7a000000160000200300000000000000017a6f64");
|
||||
var handlerTask = RunSingleResponseControllerAsync(expectedFrame, responseFrame, _cts.Token);
|
||||
|
||||
await client.ConnectAsync("127.0.0.1", Port, _cts.Token);
|
||||
var response = await client.GetProgramStatusAsync("RVBUSTSM", _cts.Token);
|
||||
|
||||
Assert.True(response.IsSuccess);
|
||||
Assert.Equal(FanucCommandMessageIds.GetProgramStatus, response.MessageId);
|
||||
Assert.Equal(1u, response.ProgramStatus);
|
||||
await handlerTask.WaitAsync(TimeSpan.FromSeconds(2), _cts.Token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证 StartProgram 命令帧能正确发送并解析成功响应。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task StartProgramAsync_SendsCorrectFrameAndParsesSuccess()
|
||||
{
|
||||
using var client = new FanucCommandClient();
|
||||
var expectedFrame = FanucCommandProtocol.PackProgramCommand(FanucCommandMessageIds.StartProgram, "RVBUSTSM");
|
||||
var responseFrame = Convert.FromHexString("646f7a0000001200002102000000007a6f64");
|
||||
var handlerTask = RunSingleResponseControllerAsync(expectedFrame, responseFrame, _cts.Token);
|
||||
|
||||
await client.ConnectAsync("127.0.0.1", Port, _cts.Token);
|
||||
var response = await client.StartProgramAsync("RVBUSTSM", _cts.Token);
|
||||
|
||||
Assert.True(response.IsSuccess);
|
||||
Assert.Equal(FanucCommandMessageIds.StartProgram, response.MessageId);
|
||||
await handlerTask.WaitAsync(TimeSpan.FromSeconds(2), _cts.Token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证在连接前调用命令会抛出 InvalidOperationException。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SendProgramCommandAsync_BeforeConnect_Throws()
|
||||
{
|
||||
using var client = new FanucCommandClient();
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => client.StopProgramAsync("RVBUSTSM", _cts.Token));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 启动模拟控制器,接收一条请求帧并比对期望内容,然后返回预设响应。
|
||||
/// </summary>
|
||||
private async Task RunSingleResponseControllerAsync(
|
||||
byte[] expectedFrame,
|
||||
byte[] responseFrame,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var controller = await _listener.AcceptTcpClientAsync(cancellationToken);
|
||||
await using var stream = controller.GetStream();
|
||||
|
||||
var buffer = new byte[expectedFrame.Length];
|
||||
await ReadExactAsync(stream, buffer, cancellationToken);
|
||||
Assert.Equal(expectedFrame, buffer);
|
||||
|
||||
await stream.WriteAsync(responseFrame, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从流中精确读取指定长度的字节。
|
||||
/// </summary>
|
||||
private static async Task ReadExactAsync(NetworkStream stream, byte[] buffer, CancellationToken cancellationToken)
|
||||
{
|
||||
var totalRead = 0;
|
||||
while (totalRead < buffer.Length)
|
||||
{
|
||||
var read = await stream.ReadAsync(buffer.AsMemory(totalRead), cancellationToken);
|
||||
if (read == 0)
|
||||
{
|
||||
throw new IOException("模拟控制器读取到 EOF。");
|
||||
}
|
||||
|
||||
totalRead += read;
|
||||
}
|
||||
}
|
||||
}
|
||||
180
tests/Flyshot.Core.Tests/FanucJ519ClientTests.cs
Normal file
180
tests/Flyshot.Core.Tests/FanucJ519ClientTests.cs
Normal file
@@ -0,0 +1,180 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Flyshot.Runtime.Fanuc.Protocol;
|
||||
|
||||
namespace Flyshot.Core.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 验证 FANUC UDP 60015 J519 运动客户端的初始化、周期发送与响应解析。
|
||||
/// </summary>
|
||||
public sealed class FanucJ519ClientTests : IDisposable
|
||||
{
|
||||
private readonly UdpClient _server;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
|
||||
/// <summary>
|
||||
/// 在随机可用端口启动本地 UDP 模拟控制器。
|
||||
/// </summary>
|
||||
public FanucJ519ClientTests()
|
||||
{
|
||||
_server = new UdpClient(0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取分配给本地模拟控制器的端口。
|
||||
/// </summary>
|
||||
private int Port => ((IPEndPoint)_server.Client.LocalEndPoint!).Port;
|
||||
|
||||
/// <summary>
|
||||
/// 清理模拟控制器和取消源。
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
_cts.Cancel();
|
||||
_server.Dispose();
|
||||
_cts.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证连接时会发送初始化包。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ConnectAsync_SendsInitPacket()
|
||||
{
|
||||
using var client = new FanucJ519Client();
|
||||
var receiveTask = _server.ReceiveAsync(_cts.Token);
|
||||
|
||||
await client.ConnectAsync("127.0.0.1", Port, _cts.Token);
|
||||
|
||||
Assert.True(client.IsConnected);
|
||||
var result = await receiveTask.AsTask().WaitAsync(TimeSpan.FromSeconds(2), _cts.Token);
|
||||
Assert.Equal(FanucJ519Protocol.ControlPacketLength, result.Buffer.Length);
|
||||
Assert.Equal(Convert.FromHexString("0000000000000001"), result.Buffer);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证启动运动后能按周期发送命令包。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task StartMotion_SendsPeriodicCommands()
|
||||
{
|
||||
using var client = new FanucJ519Client();
|
||||
await client.ConnectAsync("127.0.0.1", Port, _cts.Token);
|
||||
|
||||
// 接收并丢弃初始化包。
|
||||
var initResult = await _server.ReceiveAsync(_cts.Token);
|
||||
|
||||
var command = new FanucJ519Command(sequence: 1, targetJoints: [1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
|
||||
client.UpdateCommand(command);
|
||||
client.StartMotion();
|
||||
|
||||
// 接收至少一个命令包。
|
||||
var commandResult = await _server.ReceiveAsync(_cts.Token);
|
||||
Assert.Equal(FanucJ519Protocol.CommandPacketLength, commandResult.Buffer.Length);
|
||||
Assert.Equal(1u, BinaryPrimitives.ReadUInt32BigEndian(commandResult.Buffer.AsSpan(0x08, 4)));
|
||||
|
||||
await client.StopMotionAsync(_cts.Token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证停止运动时会发送结束包。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task StopMotionAsync_SendsEndPacket()
|
||||
{
|
||||
using var client = new FanucJ519Client();
|
||||
await client.ConnectAsync("127.0.0.1", Port, _cts.Token);
|
||||
|
||||
// 接收并丢弃初始化包。
|
||||
await _server.ReceiveAsync(_cts.Token);
|
||||
|
||||
await client.StopMotionAsync(_cts.Token);
|
||||
|
||||
// 服务器应该收到结束包。
|
||||
var endResult = await _server.ReceiveAsync(_cts.Token);
|
||||
Assert.Equal(FanucJ519Protocol.ControlPacketLength, endResult.Buffer.Length);
|
||||
Assert.Equal(Convert.FromHexString("0000000200000001"), endResult.Buffer);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证响应解析和最新响应缓存。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetLatestResponse_ParsesIncomingResponse()
|
||||
{
|
||||
using var client = new FanucJ519Client();
|
||||
await client.ConnectAsync("127.0.0.1", Port, _cts.Token);
|
||||
|
||||
// 接收初始化包,获取客户端端点。
|
||||
var initResult = await _server.ReceiveAsync(_cts.Token);
|
||||
var clientEndpoint = initResult.RemoteEndPoint;
|
||||
|
||||
// 构造 132B 响应包并发送回客户端。
|
||||
var responsePacket = new byte[FanucJ519Protocol.ResponsePacketLength];
|
||||
BinaryPrimitives.WriteUInt32BigEndian(responsePacket.AsSpan(0x00, 4), 0);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(responsePacket.AsSpan(0x04, 4), 1);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(responsePacket.AsSpan(0x08, 4), 5);
|
||||
responsePacket[0x0c] = 15; // 所有状态位为真。
|
||||
BinaryPrimitives.WriteUInt32BigEndian(responsePacket.AsSpan(0x14, 4), 999u);
|
||||
BinaryPrimitives.WriteSingleBigEndian(responsePacket.AsSpan(0x18, 4), 10.0f);
|
||||
BinaryPrimitives.WriteSingleBigEndian(responsePacket.AsSpan(0x3c, 4), 0.5f);
|
||||
BinaryPrimitives.WriteSingleBigEndian(responsePacket.AsSpan(0x60, 4), 1.0f);
|
||||
|
||||
await _server.SendAsync(responsePacket, clientEndpoint, _cts.Token);
|
||||
|
||||
// 给接收循环留出时间。
|
||||
await Task.Delay(200, _cts.Token);
|
||||
|
||||
var latest = client.GetLatestResponse();
|
||||
Assert.NotNull(latest);
|
||||
Assert.Equal(5u, latest.Sequence);
|
||||
Assert.True(latest.AcceptsCommand);
|
||||
Assert.True(latest.ReceivedCommand);
|
||||
Assert.True(latest.SystemReady);
|
||||
Assert.True(latest.RobotInMotion);
|
||||
Assert.Equal(999u, latest.Timestamp);
|
||||
Assert.Equal(10.0, latest.Pose[0], precision: 6);
|
||||
Assert.Equal(0.5, latest.JointDegrees[0], precision: 6);
|
||||
Assert.Equal(1.0, latest.MotorCurrents[0], precision: 6);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证 UpdateCommand 替换当前命令后下一周期发送新命令。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task UpdateCommand_ReplacesCurrentCommand()
|
||||
{
|
||||
using var client = new FanucJ519Client();
|
||||
await client.ConnectAsync("127.0.0.1", Port, _cts.Token);
|
||||
await _server.ReceiveAsync(_cts.Token); // init
|
||||
|
||||
var command1 = new FanucJ519Command(sequence: 1, targetJoints: [1.0, 0.0, 0.0, 0.0, 0.0, 0.0]);
|
||||
var command2 = new FanucJ519Command(sequence: 2, targetJoints: [2.0, 0.0, 0.0, 0.0, 0.0, 0.0]);
|
||||
|
||||
client.UpdateCommand(command1);
|
||||
client.StartMotion();
|
||||
|
||||
var result1 = await _server.ReceiveAsync(_cts.Token);
|
||||
Assert.Equal(1u, BinaryPrimitives.ReadUInt32BigEndian(result1.Buffer.AsSpan(0x08, 4)));
|
||||
Assert.Equal(1.0f, BinaryPrimitives.ReadSingleBigEndian(result1.Buffer.AsSpan(0x1c, 4)), precision: 6);
|
||||
|
||||
client.UpdateCommand(command2);
|
||||
|
||||
var result2 = await _server.ReceiveAsync(_cts.Token);
|
||||
Assert.Equal(2u, BinaryPrimitives.ReadUInt32BigEndian(result2.Buffer.AsSpan(0x08, 4)));
|
||||
Assert.Equal(2.0f, BinaryPrimitives.ReadSingleBigEndian(result2.Buffer.AsSpan(0x1c, 4)), precision: 6);
|
||||
|
||||
await client.StopMotionAsync(_cts.Token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证在连接前调用 StartMotion 会抛出 InvalidOperationException。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void StartMotion_BeforeConnect_Throws()
|
||||
{
|
||||
using var client = new FanucJ519Client();
|
||||
Assert.Throws<InvalidOperationException>(() => client.StartMotion());
|
||||
}
|
||||
}
|
||||
116
tests/Flyshot.Core.Tests/FanucProtocolTests.cs
Normal file
116
tests/Flyshot.Core.Tests/FanucProtocolTests.cs
Normal file
@@ -0,0 +1,116 @@
|
||||
using System.Buffers.Binary;
|
||||
using Flyshot.Runtime.Fanuc.Protocol;
|
||||
|
||||
namespace Flyshot.Core.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 验证 FANUC 真机三条通信链路的二进制协议基础与逆向抓包样本一致。
|
||||
/// </summary>
|
||||
public sealed class FanucProtocolTests
|
||||
{
|
||||
/// <summary>
|
||||
/// 验证 TCP 10012 程序命令封包与抓包中的 StopProg("RVBUSTSM") 完全一致。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CommandProtocol_PacksCapturedStopProgramFrame()
|
||||
{
|
||||
var frame = FanucCommandProtocol.PackProgramCommand(FanucCommandMessageIds.StopProgram, "RVBUSTSM");
|
||||
|
||||
Assert.Equal(
|
||||
Convert.FromHexString("646f7a0000001a0000210300000008525642555354534d7a6f64"),
|
||||
frame);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证 TCP 10012 短响应和程序状态响应可以按抓包字段解析。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CommandProtocol_ParsesCapturedResponses()
|
||||
{
|
||||
var stopResponse = FanucCommandProtocol.ParseResultResponse(
|
||||
Convert.FromHexString("646f7a0000001200002103000000007a6f64"));
|
||||
var statusResponse = FanucCommandProtocol.ParseProgramStatusResponse(
|
||||
Convert.FromHexString("646f7a000000160000200300000000000000017a6f64"));
|
||||
|
||||
Assert.Equal(FanucCommandMessageIds.StopProgram, stopResponse.MessageId);
|
||||
Assert.True(stopResponse.IsSuccess);
|
||||
Assert.Equal(FanucCommandMessageIds.GetProgramStatus, statusResponse.MessageId);
|
||||
Assert.True(statusResponse.IsSuccess);
|
||||
Assert.Equal(1u, statusResponse.ProgramStatus);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证 TCP 10010 状态帧可以从抓包样本解析出尾部状态槽位。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void StateProtocol_ParsesCapturedStateFrame()
|
||||
{
|
||||
var frame = FanucStateProtocol.ParseFrame(Convert.FromHexString(
|
||||
"646f7a0000005a000000004388a23243f1ed7f43e9de6bc265031ec2b33cc3c278e0153f8742f53c3f128dbc929529bc7861d63cb0184c3c1ca1a7000000000000000000000000000000020000000000000000000000017a6f64"));
|
||||
|
||||
Assert.Equal(0u, frame.MessageId);
|
||||
Assert.Equal(6, frame.Pose.Count);
|
||||
Assert.Equal(9, frame.JointOrExtensionValues.Count);
|
||||
Assert.Equal([2u, 0u, 0u, 1u], frame.TailWords);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证 UDP 60015 的 J519 初始化、结束和命令包字段布局。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void J519Protocol_PacksControlAndCommandPackets()
|
||||
{
|
||||
var command = new FanucJ519Command(
|
||||
sequence: 2,
|
||||
targetJoints: [1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
|
||||
|
||||
var packet = FanucJ519Protocol.PackCommandPacket(command);
|
||||
|
||||
Assert.Equal(Convert.FromHexString("0000000000000001"), FanucJ519Protocol.PackInitPacket());
|
||||
Assert.Equal(Convert.FromHexString("0000000200000001"), FanucJ519Protocol.PackEndPacket());
|
||||
Assert.Equal(FanucJ519Protocol.CommandPacketLength, packet.Length);
|
||||
Assert.Equal(1u, BinaryPrimitives.ReadUInt32BigEndian(packet.AsSpan(0x00, 4)));
|
||||
Assert.Equal(1u, BinaryPrimitives.ReadUInt32BigEndian(packet.AsSpan(0x04, 4)));
|
||||
Assert.Equal(2u, BinaryPrimitives.ReadUInt32BigEndian(packet.AsSpan(0x08, 4)));
|
||||
Assert.Equal(2, packet[0x0d]);
|
||||
Assert.Equal(1, packet[0x12]);
|
||||
Assert.Equal(1.0f, BinaryPrimitives.ReadSingleBigEndian(packet.AsSpan(0x1c, 4)));
|
||||
Assert.Equal(6.0f, BinaryPrimitives.ReadSingleBigEndian(packet.AsSpan(0x30, 4)));
|
||||
Assert.Equal(0.0f, BinaryPrimitives.ReadSingleBigEndian(packet.AsSpan(0x38, 4)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证 UDP 60015 的 132 字节响应包字段可以被解析成状态位和关节反馈。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void J519Protocol_ParsesResponsePacket()
|
||||
{
|
||||
var packet = new byte[FanucJ519Protocol.ResponsePacketLength];
|
||||
BinaryPrimitives.WriteUInt32BigEndian(packet.AsSpan(0x00, 4), 0);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(packet.AsSpan(0x04, 4), 1);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(packet.AsSpan(0x08, 4), 12);
|
||||
packet[0x0c] = 15;
|
||||
packet[0x0d] = 2;
|
||||
BinaryPrimitives.WriteUInt16BigEndian(packet.AsSpan(0x0e, 2), 1);
|
||||
BinaryPrimitives.WriteUInt16BigEndian(packet.AsSpan(0x10, 2), 255);
|
||||
BinaryPrimitives.WriteUInt16BigEndian(packet.AsSpan(0x12, 2), 10);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(packet.AsSpan(0x14, 4), 1234);
|
||||
BinaryPrimitives.WriteSingleBigEndian(packet.AsSpan(0x18, 4), 100.5f);
|
||||
BinaryPrimitives.WriteSingleBigEndian(packet.AsSpan(0x3c, 4), 1.25f);
|
||||
BinaryPrimitives.WriteSingleBigEndian(packet.AsSpan(0x60, 4), 2.5f);
|
||||
|
||||
var response = FanucJ519Protocol.ParseResponse(packet);
|
||||
|
||||
Assert.Equal(12u, response.Sequence);
|
||||
Assert.Equal(15, response.Status);
|
||||
Assert.True(response.AcceptsCommand);
|
||||
Assert.True(response.ReceivedCommand);
|
||||
Assert.True(response.SystemReady);
|
||||
Assert.True(response.RobotInMotion);
|
||||
Assert.Equal(10, response.ReadIoValue);
|
||||
Assert.Equal(1234u, response.Timestamp);
|
||||
Assert.Equal(100.5, response.Pose[0], precision: 6);
|
||||
Assert.Equal(1.25, response.JointDegrees[0], precision: 6);
|
||||
Assert.Equal(2.5, response.MotorCurrents[0], precision: 6);
|
||||
}
|
||||
}
|
||||
138
tests/Flyshot.Core.Tests/FanucStateClientTests.cs
Normal file
138
tests/Flyshot.Core.Tests/FanucStateClientTests.cs
Normal file
@@ -0,0 +1,138 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Flyshot.Runtime.Fanuc.Protocol;
|
||||
|
||||
namespace Flyshot.Core.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 验证 FANUC TCP 10010 状态通道客户端的后台接收与缓存能力。
|
||||
/// </summary>
|
||||
public sealed class FanucStateClientTests : IDisposable
|
||||
{
|
||||
private readonly TcpListener _listener;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
|
||||
/// <summary>
|
||||
/// 在随机可用端口启动本地模拟控制器。
|
||||
/// </summary>
|
||||
public FanucStateClientTests()
|
||||
{
|
||||
_listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
_listener.Start();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取分配给本地模拟控制器的端口。
|
||||
/// </summary>
|
||||
private int Port => ((IPEndPoint)_listener.LocalEndpoint).Port;
|
||||
|
||||
/// <summary>
|
||||
/// 清理模拟控制器和取消源。
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
_cts.Cancel();
|
||||
_listener.Stop();
|
||||
_cts.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证状态客户端可以连接本地模拟控制器。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ConnectAsync_ConnectsToLocalListener()
|
||||
{
|
||||
using var client = new FanucStateClient();
|
||||
var acceptTask = _listener.AcceptTcpClientAsync();
|
||||
|
||||
await client.ConnectAsync("127.0.0.1", Port, _cts.Token);
|
||||
|
||||
Assert.True(client.IsConnected);
|
||||
await acceptTask.WaitAsync(TimeSpan.FromSeconds(2), _cts.Token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证后台循环能正确解析抓包样本状态帧。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetLatestFrame_ReceivesAndParsesCapturedStateFrame()
|
||||
{
|
||||
using var client = new FanucStateClient();
|
||||
var capturedFrame = Convert.FromHexString(
|
||||
"646f7a0000005a000000004388a23243f1ed7f43e9de6bc265031ec2b33cc3c278e0153f8742f53c3f128dbc929529bc7861d63cb0184c3c1ca1a7000000000000000000000000000000020000000000000000000000017a6f64");
|
||||
|
||||
var handlerTask = RunStreamingControllerAsync(capturedFrame, _cts.Token);
|
||||
|
||||
await client.ConnectAsync("127.0.0.1", Port, _cts.Token);
|
||||
|
||||
// 给后台循环留出接收和解析的时间。
|
||||
await Task.Delay(200, _cts.Token);
|
||||
|
||||
var latest = client.GetLatestFrame();
|
||||
Assert.NotNull(latest);
|
||||
Assert.Equal(0u, latest.MessageId);
|
||||
Assert.Equal(6, latest.Pose.Count);
|
||||
Assert.Equal(9, latest.JointOrExtensionValues.Count);
|
||||
Assert.Equal([2u, 0u, 0u, 1u], latest.TailWords);
|
||||
|
||||
client.Disconnect();
|
||||
await handlerTask.WaitAsync(TimeSpan.FromSeconds(2), _cts.Token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证状态客户端在连接前调用 GetLatestFrame 返回 null。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetLatestFrame_BeforeConnect_ReturnsNull()
|
||||
{
|
||||
using var client = new FanucStateClient();
|
||||
Assert.Null(client.GetLatestFrame());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证 Disconnect 后最新帧被清空。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Disconnect_ClearsLatestFrame()
|
||||
{
|
||||
using var client = new FanucStateClient();
|
||||
var capturedFrame = Convert.FromHexString(
|
||||
"646f7a0000005a000000004388a23243f1ed7f43e9de6bc265031ec2b33cc3c278e0153f8742f53c3f128dbc929529bc7861d63cb0184c3c1ca1a7000000000000000000000000000000020000000000000000000000017a6f64");
|
||||
|
||||
var handlerTask = RunStreamingControllerAsync(capturedFrame, _cts.Token);
|
||||
|
||||
await client.ConnectAsync("127.0.0.1", Port, _cts.Token);
|
||||
await Task.Delay(200, _cts.Token);
|
||||
Assert.NotNull(client.GetLatestFrame());
|
||||
|
||||
client.Disconnect();
|
||||
Assert.Null(client.GetLatestFrame());
|
||||
await handlerTask.WaitAsync(TimeSpan.FromSeconds(2), _cts.Token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 启动模拟控制器,持续发送状态帧流。
|
||||
/// </summary>
|
||||
private async Task RunStreamingControllerAsync(byte[] frames, CancellationToken cancellationToken)
|
||||
{
|
||||
using var controller = await _listener.AcceptTcpClientAsync(cancellationToken);
|
||||
await using var stream = controller.GetStream();
|
||||
|
||||
try
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
await stream.WriteAsync(frames, cancellationToken);
|
||||
await Task.Delay(50, cancellationToken);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// 正常取消。
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// 客户端断开。
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ public sealed class RuntimeOrchestrationTests
|
||||
var runtime = new FanucControllerRuntime();
|
||||
var robot = TestRobotFactory.CreateRobotProfile();
|
||||
runtime.ResetRobot(robot, "FANUC_LR_Mate_200iD");
|
||||
runtime.SetActiveController(sim: false);
|
||||
runtime.SetActiveController(sim: true);
|
||||
runtime.Connect("192.168.10.101");
|
||||
runtime.EnableRobot(bufferSize: 2);
|
||||
|
||||
@@ -90,7 +90,7 @@ public sealed class RuntimeOrchestrationTests
|
||||
{
|
||||
var service = TestRobotFactory.CreateCompatService();
|
||||
service.SetUpRobot("FANUC_LR_Mate_200iD");
|
||||
service.SetActiveController(sim: false);
|
||||
service.SetActiveController(sim: true);
|
||||
service.Connect("192.168.10.101");
|
||||
service.EnableRobot(2);
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ public sealed class LegacyHttpApiCompatibilityTests(FlyshotServerFactory factory
|
||||
Assert.True(isSetupJson.RootElement.GetProperty("is_setup").GetBoolean());
|
||||
}
|
||||
|
||||
using (var activeControllerResponse = await client.PostAsync("/set_active_controller/?sim=false", content: null))
|
||||
using (var activeControllerResponse = await client.PostAsync("/set_active_controller/?sim=true", content: null))
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.OK, activeControllerResponse.StatusCode);
|
||||
using var activeControllerJson = await ReadJsonAsync(activeControllerResponse);
|
||||
@@ -145,6 +145,24 @@ public sealed class LegacyHttpApiCompatibilityTests(FlyshotServerFactory factory
|
||||
Assert.Equal(HttpStatusCode.OK, getPoseResponse.StatusCode);
|
||||
using var getPoseJson = await ReadJsonAsync(getPoseResponse);
|
||||
Assert.Equal(7, getPoseJson.RootElement.GetProperty("pose").GetArrayLength());
|
||||
|
||||
using (var executeTrajectoryResponse = await client.PostAsJsonAsync("/execute_trajectory/", new
|
||||
{
|
||||
method = "icsp",
|
||||
save_traj = true,
|
||||
waypoints = new[]
|
||||
{
|
||||
new[] { 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 },
|
||||
new[] { 0.1, 0.0, 0.0, 0.0, 0.0, 0.0 },
|
||||
new[] { 0.2, 0.0, 0.0, 0.0, 0.0, 0.0 },
|
||||
new[] { 0.3, 0.0, 0.0, 0.0, 0.0, 0.0 }
|
||||
}
|
||||
}))
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.OK, executeTrajectoryResponse.StatusCode);
|
||||
using var executeTrajectoryJson = await ReadJsonAsync(executeTrajectoryResponse);
|
||||
Assert.Equal("trajectory executed", executeTrajectoryJson.RootElement.GetProperty("status").GetString());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -161,15 +179,19 @@ public sealed class LegacyHttpApiCompatibilityTests(FlyshotServerFactory factory
|
||||
addrs = new[]
|
||||
{
|
||||
new[] { 7, 8 },
|
||||
new[] { 7, 8 }
|
||||
new[] { 7, 8 },
|
||||
Array.Empty<int>(),
|
||||
Array.Empty<int>()
|
||||
},
|
||||
name = "demo-http-flyshot",
|
||||
offset_values = new[] { 0.0, 1.0 },
|
||||
shot_flags = new[] { false, true },
|
||||
offset_values = new[] { 0.0, 1.0, 0.0, 0.0 },
|
||||
shot_flags = new[] { false, true, false, false },
|
||||
waypoints = new[]
|
||||
{
|
||||
new[] { 0.1, 0.2, 0.3, 0.4, 0.5, 0.6 },
|
||||
new[] { 1.1, 1.2, 1.3, 1.4, 1.5, 1.6 }
|
||||
new[] { 0.2, 0.2, 0.3, 0.4, 0.5, 0.6 },
|
||||
new[] { 0.3, 0.2, 0.3, 0.4, 0.5, 0.6 },
|
||||
new[] { 0.4, 0.2, 0.3, 0.4, 0.5, 0.6 }
|
||||
}
|
||||
};
|
||||
|
||||
@@ -188,7 +210,27 @@ public sealed class LegacyHttpApiCompatibilityTests(FlyshotServerFactory factory
|
||||
Assert.Contains("demo-http-flyshot", names);
|
||||
}
|
||||
|
||||
using (var executeResponse = await client.PostAsJsonAsync("/execute_flyshot/", new { name = "demo-http-flyshot" }))
|
||||
using (var validResponse = await client.PostAsJsonAsync("/is_flyShotTrajValid/", new
|
||||
{
|
||||
name = "demo-http-flyshot",
|
||||
method = "icsp",
|
||||
save_traj = false
|
||||
}))
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.OK, validResponse.StatusCode);
|
||||
using var validJson = await ReadJsonAsync(validResponse);
|
||||
Assert.True(validJson.RootElement.GetProperty("valid").GetBoolean());
|
||||
Assert.True(validJson.RootElement.GetProperty("time").GetDouble() > 0.0);
|
||||
}
|
||||
|
||||
using (var executeResponse = await client.PostAsJsonAsync("/execute_flyshot/", new
|
||||
{
|
||||
name = "demo-http-flyshot",
|
||||
move_to_start = true,
|
||||
method = "icsp",
|
||||
save_traj = true,
|
||||
use_cache = true
|
||||
}))
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.OK, executeResponse.StatusCode);
|
||||
using var executeJson = await ReadJsonAsync(executeResponse);
|
||||
@@ -197,6 +239,17 @@ public sealed class LegacyHttpApiCompatibilityTests(FlyshotServerFactory factory
|
||||
Assert.True(executeRoot.GetProperty("success").GetBoolean());
|
||||
}
|
||||
|
||||
using (var saveInfoResponse = await client.PostAsJsonAsync("/save_traj_info/", new
|
||||
{
|
||||
name = "demo-http-flyshot",
|
||||
method = "icsp"
|
||||
}))
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.OK, saveInfoResponse.StatusCode);
|
||||
using var saveInfoJson = await ReadJsonAsync(saveInfoResponse);
|
||||
Assert.True(saveInfoJson.RootElement.GetProperty("success").GetBoolean());
|
||||
}
|
||||
|
||||
using (var deleteResponse = await client.PostAsJsonAsync("/delete_flyshot/", new { name = "demo-http-flyshot" }))
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.OK, deleteResponse.StatusCode);
|
||||
@@ -215,7 +268,8 @@ public sealed class LegacyHttpApiCompatibilityTests(FlyshotServerFactory factory
|
||||
server_ip = "127.0.0.1",
|
||||
port = 50001,
|
||||
robot_name = "FANUC_LR_Mate_200iD",
|
||||
robot_ip = "192.168.10.101"
|
||||
robot_ip = "192.168.10.101",
|
||||
sim = true
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, initResponse.StatusCode);
|
||||
|
||||
91
tests/Flyshot.Server.IntegrationTests/StatusEndpointTests.cs
Normal file
91
tests/Flyshot.Server.IntegrationTests/StatusEndpointTests.cs
Normal file
@@ -0,0 +1,91 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Flyshot.Server.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// 验证状态监控页面和状态快照 API 能读取当前 ControllerClient 兼容层状态。
|
||||
/// </summary>
|
||||
public sealed class StatusEndpointTests(FlyshotServerFactory factory) : IClassFixture<FlyshotServerFactory>
|
||||
{
|
||||
/// <summary>
|
||||
/// 验证状态页返回可由浏览器直接打开的 HTML,并引用状态快照 API。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetStatusPage_ReturnsMonitoringHtml()
|
||||
{
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
using var response = await client.GetAsync("/status");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.StartsWith("text/html", response.Content.Headers.ContentType?.MediaType);
|
||||
|
||||
var html = await response.Content.ReadAsStringAsync();
|
||||
Assert.Contains("Flyshot Replacement 状态监控", html, StringComparison.Ordinal);
|
||||
Assert.Contains("/api/status/snapshot", html, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证状态快照 API 会返回运行时连接、使能、速度和机器人元数据。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetStatusSnapshot_ReturnsRuntimeStateAfterLegacyInitialization()
|
||||
{
|
||||
using var client = factory.CreateClient();
|
||||
await InitializeRobotAsync(client);
|
||||
|
||||
using (var speedResponse = await client.PostAsJsonAsync("/set_speedRatio/", new { speed = 0.75 }))
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.OK, speedResponse.StatusCode);
|
||||
}
|
||||
|
||||
using var response = await client.GetAsync("/api/status/snapshot");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
await using var responseStream = await response.Content.ReadAsStreamAsync();
|
||||
using var json = await JsonDocument.ParseAsync(responseStream);
|
||||
var root = json.RootElement;
|
||||
var snapshot = root.GetProperty("snapshot");
|
||||
|
||||
Assert.Equal("ok", root.GetProperty("status").GetString());
|
||||
Assert.True(root.GetProperty("isSetup").GetBoolean());
|
||||
Assert.Equal("FANUC_LR_Mate_200iD", root.GetProperty("robotName").GetString());
|
||||
Assert.Equal(6, root.GetProperty("degreesOfFreedom").GetInt32());
|
||||
Assert.Empty(root.GetProperty("uploadedTrajectories").EnumerateArray());
|
||||
Assert.Equal("Connected", snapshot.GetProperty("connectionState").GetString());
|
||||
Assert.True(snapshot.GetProperty("isEnabled").GetBoolean());
|
||||
Assert.False(snapshot.GetProperty("isInMotion").GetBoolean());
|
||||
Assert.Equal(0.75, snapshot.GetProperty("speedRatio").GetDouble(), precision: 6);
|
||||
Assert.Equal(6, snapshot.GetProperty("jointPositions").GetArrayLength());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 初始化旧 HTTP 兼容链路,使状态页可以读取一个完整的已连接状态。
|
||||
/// </summary>
|
||||
/// <param name="client">测试 HTTP 客户端。</param>
|
||||
private static async Task InitializeRobotAsync(HttpClient client)
|
||||
{
|
||||
using (var setupResponse = await client.PostAsync("/setup_robot/?robot_name=FANUC_LR_Mate_200iD", content: null))
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.OK, setupResponse.StatusCode);
|
||||
}
|
||||
|
||||
using (var activeControllerResponse = await client.PostAsync("/set_active_controller/?sim=true", content: null))
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.OK, activeControllerResponse.StatusCode);
|
||||
}
|
||||
|
||||
using (var connectRobotResponse = await client.PostAsync("/connect_robot/?ip=192.168.10.101", content: null))
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.OK, connectRobotResponse.StatusCode);
|
||||
}
|
||||
|
||||
using (var enableRobotResponse = await client.GetAsync("/enable_robot/"))
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.OK, enableRobotResponse.StatusCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user