* 在 FanucCommandProtocol/Client 中补齐速度倍率、TCP 位姿和 IO 的封包/解析,并引入 FanucIoTypes 字符串到枚举映射 * FanucControllerRuntime 在非仿真模式下接入真机命令通道,本地 缓存仅作为兜底,TCP 操作扩展为 7 维 Pose * FanucStateClient 增加帧超时检测、退避自动重连和诊断状态接口, 超时或重连期间不再把陈旧帧当作当前机器人状态 * FanucStateProtocol 锁定 90B 帧字段为 pose[6]、joint[6]、 external_axes[3] 和 raw_tail_words[4],并保留状态字诊断槽位 * ICspPlanner 增加 global_scale > 1.0 失败判定,self-adapt-icsp 内部禁用该判定以保留补点重试链路 * 同步更新 README/AGENTS/计划文档的 todo 状态和实现说明
320 lines
13 KiB
C#
320 lines
13 KiB
C#
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>
|
|
/// 验证 GetSpeedRatio 发送空业务体命令,并按 ratio_int / 100.0 解析倍率。
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task GetSpeedRatioAsync_SendsFrameAndParsesRatio()
|
|
{
|
|
using var client = new FanucCommandClient();
|
|
var handlerTask = RunSingleResponseControllerAsync(
|
|
FanucCommandProtocol.PackGetSpeedRatioCommand(),
|
|
FanucCommandProtocol.PackFrame(FanucCommandMessageIds.GetSpeedRatio, Convert.FromHexString("0000005a00000000")),
|
|
_cts.Token);
|
|
|
|
await client.ConnectAsync("127.0.0.1", Port, _cts.Token);
|
|
var response = await client.GetSpeedRatioAsync(_cts.Token);
|
|
|
|
Assert.True(response.IsSuccess);
|
|
Assert.Equal(0.9, response.Ratio, precision: 6);
|
|
await handlerTask.WaitAsync(TimeSpan.FromSeconds(2), _cts.Token);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 验证 SetSpeedRatio 会把 double 倍率夹到 0..100 的整数百分比后下发。
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task SetSpeedRatioAsync_SendsClampedPercentAndParsesSuccess()
|
|
{
|
|
using var client = new FanucCommandClient();
|
|
var handlerTask = RunSingleResponseControllerAsync(
|
|
FanucCommandProtocol.PackSetSpeedRatioCommand(2.0),
|
|
FanucCommandProtocol.PackFrame(FanucCommandMessageIds.SetSpeedRatio, Convert.FromHexString("00000000")),
|
|
_cts.Token);
|
|
|
|
await client.ConnectAsync("127.0.0.1", Port, _cts.Token);
|
|
var response = await client.SetSpeedRatioAsync(2.0, _cts.Token);
|
|
|
|
Assert.True(response.IsSuccess);
|
|
await handlerTask.WaitAsync(TimeSpan.FromSeconds(2), _cts.Token);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 验证 GetTcp 会发送 tcp_id 请求,并解析 result_code + tcp_id + 7 个 float 位姿。
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task GetTcpAsync_SendsFrameAndParsesPose()
|
|
{
|
|
using var client = new FanucCommandClient();
|
|
var handlerTask = RunSingleResponseControllerAsync(
|
|
FanucCommandProtocol.PackGetTcpCommand(1),
|
|
FanucCommandProtocol.PackFrame(
|
|
FanucCommandMessageIds.GetTcp,
|
|
Convert.FromHexString("00000000000000013f80000040000000404000000000000000000000000000003f800000")),
|
|
_cts.Token);
|
|
|
|
await client.ConnectAsync("127.0.0.1", Port, _cts.Token);
|
|
var response = await client.GetTcpAsync(1, _cts.Token);
|
|
|
|
Assert.True(response.IsSuccess);
|
|
Assert.Equal([1.0, 2.0, 3.0, 0.0, 0.0, 0.0, 1.0], response.Pose);
|
|
await handlerTask.WaitAsync(TimeSpan.FromSeconds(2), _cts.Token);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 验证 SetTcp 会按 tcp_id + 7 个 float 位姿下发并解析结果码。
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task SetTcpAsync_SendsFrameAndParsesSuccess()
|
|
{
|
|
using var client = new FanucCommandClient();
|
|
var handlerTask = RunSingleResponseControllerAsync(
|
|
FanucCommandProtocol.PackSetTcpCommand(1, [1.0, 2.0, 3.0, 0.0, 0.0, 0.0, 1.0]),
|
|
FanucCommandProtocol.PackFrame(FanucCommandMessageIds.SetTcp, Convert.FromHexString("00000000")),
|
|
_cts.Token);
|
|
|
|
await client.ConnectAsync("127.0.0.1", Port, _cts.Token);
|
|
var response = await client.SetTcpAsync(1, [1.0, 2.0, 3.0, 0.0, 0.0, 0.0, 1.0], _cts.Token);
|
|
|
|
Assert.True(response.IsSuccess);
|
|
await handlerTask.WaitAsync(TimeSpan.FromSeconds(2), _cts.Token);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 验证 GetIo 会按 io_type、io_index 顺序请求,并解析 float IO 值。
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task GetIoAsync_SendsFrameAndParsesValue()
|
|
{
|
|
using var client = new FanucCommandClient();
|
|
var handlerTask = RunSingleResponseControllerAsync(
|
|
FanucCommandProtocol.PackGetIoCommand(FanucIoTypes.DigitalOutput, 7),
|
|
FanucCommandProtocol.PackFrame(FanucCommandMessageIds.GetIo, Convert.FromHexString("000000003f800000")),
|
|
_cts.Token);
|
|
|
|
await client.ConnectAsync("127.0.0.1", Port, _cts.Token);
|
|
var response = await client.GetIoAsync(7, "DO", _cts.Token);
|
|
|
|
Assert.True(response.IsSuccess);
|
|
Assert.True(response.Value);
|
|
await handlerTask.WaitAsync(TimeSpan.FromSeconds(2), _cts.Token);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 验证 SetIo 会按 io_type、io_index、float value 顺序下发并解析结果码。
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task SetIoAsync_SendsFrameAndParsesSuccess()
|
|
{
|
|
using var client = new FanucCommandClient();
|
|
var handlerTask = RunSingleResponseControllerAsync(
|
|
FanucCommandProtocol.PackSetIoCommand(FanucIoTypes.DigitalOutput, 7, true),
|
|
FanucCommandProtocol.PackFrame(FanucCommandMessageIds.SetIo, Convert.FromHexString("00000000")),
|
|
_cts.Token);
|
|
|
|
await client.ConnectAsync("127.0.0.1", Port, _cts.Token);
|
|
var response = await client.SetIoAsync(7, true, "DO", _cts.Token);
|
|
|
|
Assert.True(response.IsSuccess);
|
|
await handlerTask.WaitAsync(TimeSpan.FromSeconds(2), _cts.Token);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 验证命令响应 result_code 非零时,客户端会抛出可诊断异常而不是让上层误判成功。
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task StopProgramAsync_NonZeroResultCode_ThrowsDiagnosticException()
|
|
{
|
|
using var client = new FanucCommandClient();
|
|
var handlerTask = RunSingleResponseControllerAsync(
|
|
FanucCommandProtocol.PackProgramCommand(FanucCommandMessageIds.StopProgram, "RVBUSTSM"),
|
|
Convert.FromHexString("646f7a00000012000021030000002a7a6f64"),
|
|
_cts.Token);
|
|
|
|
await client.ConnectAsync("127.0.0.1", Port, _cts.Token);
|
|
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => client.StopProgramAsync("RVBUSTSM", _cts.Token));
|
|
|
|
Assert.Contains("0x2103", exception.Message);
|
|
Assert.Contains("42", exception.Message);
|
|
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;
|
|
}
|
|
}
|
|
}
|