Files
FlyShotHost/tests/Flyshot.Core.Tests/FanucCommandClientTests.cs
yunxiao.zhu 69fa3edd89 feat(runtime): 完善 FANUC 命令参数与状态通道重连
* 在 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 状态和实现说明
2026-04-27 00:18:50 +08:00

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;
}
}
}