69e0a622f3
Every 0x0026 position block is self-describing: [i32 raw][u16 base][u16 exponent],
value = raw / base^exponent. The live 31i-B capture (ref-dynamic2-axis1.bin)
reports base 10 / exponent 4 on every block, so axis 1 is 280.1574 mm, not
2801574. The driver ignored the envelope and instead sent 'cnc_getfigure'
(0x00D3) -- a command that does not exist in the FOCAS wire protocol, served only
by the in-tree mock; the real control refuses it with rc=1, so on hardware every
position fell back to PositionDecimalPlaces (default 0) and published raw counts.
- ParsePositionBlock decodes the envelope into FocasPosition; the driver applies
raw / base^exponent at publish. PositionDecimalPlaces is now only the fallback
for a scale-less (4-byte) block.
- A block describing no position (0xFFFF vacant marker, base not 2/10, exponent
> 18 -- raw / 10^400 would read as a Good 0.0) publishes BadNoData on that node
alone; siblings are untouched.
- ReadPositionFiguresAsync / IFocasClient.GetPositionFiguresAsync and the
per-axis figure cache are deleted.
Ported from the sister ignitionoee FOCAS driver v0.17.0 (+ Focas.Wire-014);
live axis-scoped captures live-dynamic2-axis{1,2}-0x0026.bin (Z-34184 X 0.0374,
Y 370.177) added as fixtures.
271 lines
10 KiB
C#
271 lines
10 KiB
C#
using System.Buffers.Binary;
|
|
using System.Net.Sockets;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
|
|
|
/// <summary>
|
|
/// Offline byte-level coverage for the managed FOCAS/2 wire-protocol decode layer
|
|
/// (<see cref="FocasWireProtocol"/>) — the big-endian framing + block-envelope decode that
|
|
/// is this driver's analogue of P/Invoke struct marshalling. Before this suite the entire
|
|
/// wire decode surface ran without a
|
|
/// single byte-level test; every other test drives the <c>IFocasClient</c> seam through the
|
|
/// Fake, which bypasses all framing. Recorded as Driver.FOCAS-013.
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class FocasWireProtocolTests
|
|
{
|
|
// ---- BuildPdu header layout ----
|
|
|
|
[Fact]
|
|
public void BuildPdu_writes_magic_version_type_direction_and_length()
|
|
{
|
|
var body = new byte[] { 0xde, 0xad, 0xbe, 0xef };
|
|
var pdu = FocasWireProtocol.BuildPdu(
|
|
FocasWireProtocol.TypeData, FocasWireProtocol.DirectionRequest, body);
|
|
|
|
pdu.Length.ShouldBe(10 + body.Length);
|
|
pdu[0].ShouldBe((byte)0xa0);
|
|
pdu[1].ShouldBe((byte)0xa0);
|
|
pdu[2].ShouldBe((byte)0xa0);
|
|
pdu[3].ShouldBe((byte)0xa0);
|
|
BinaryPrimitives.ReadUInt16BigEndian(pdu.AsSpan(4, 2)).ShouldBe(FocasWireProtocol.Version);
|
|
pdu[6].ShouldBe(FocasWireProtocol.TypeData);
|
|
pdu[7].ShouldBe(FocasWireProtocol.DirectionRequest);
|
|
BinaryPrimitives.ReadUInt16BigEndian(pdu.AsSpan(8, 2)).ShouldBe((ushort)body.Length);
|
|
pdu.AsSpan(10).ToArray().ShouldBe(body);
|
|
}
|
|
|
|
[Fact]
|
|
public void BuildPdu_rejects_a_body_larger_than_ushort_max()
|
|
{
|
|
var tooBig = new byte[ushort.MaxValue + 1];
|
|
Should.Throw<ArgumentOutOfRangeException>(() =>
|
|
FocasWireProtocol.BuildPdu(FocasWireProtocol.TypeData, FocasWireProtocol.DirectionRequest, tooBig));
|
|
}
|
|
|
|
[Fact]
|
|
public void BuildInitiateBody_writes_the_socket_index_big_endian()
|
|
{
|
|
var body = FocasWireProtocol.BuildInitiateBody(2);
|
|
body.Length.ShouldBe(2);
|
|
BinaryPrimitives.ReadUInt16BigEndian(body).ShouldBe((ushort)2);
|
|
}
|
|
|
|
// ---- BuildPdu -> ReadPduAsync round-trip over a real socket pair ----
|
|
|
|
[Fact]
|
|
public async Task BuildPdu_round_trips_through_ReadPduAsync()
|
|
{
|
|
var body = new byte[] { 1, 2, 3, 4, 5 };
|
|
var pdu = FocasWireProtocol.BuildPdu(
|
|
FocasWireProtocol.TypeData, FocasWireProtocol.DirectionResponse, body);
|
|
|
|
var (client, server) = await ConnectedPairAsync();
|
|
try
|
|
{
|
|
await server.GetStream().WriteAsync(pdu);
|
|
var read = await FocasWireProtocol.ReadPduAsync(client.GetStream(), CancellationToken.None);
|
|
|
|
read.Type.ShouldBe(FocasWireProtocol.TypeData);
|
|
read.Direction.ShouldBe(FocasWireProtocol.DirectionResponse);
|
|
read.Body.ShouldBe(body);
|
|
}
|
|
finally { client.Dispose(); server.Dispose(); }
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReadPduAsync_rejects_bad_magic()
|
|
{
|
|
var bad = new byte[10];
|
|
bad[0] = 0x00; // not 0xa0
|
|
var (client, server) = await ConnectedPairAsync();
|
|
try
|
|
{
|
|
await server.GetStream().WriteAsync(bad);
|
|
await Should.ThrowAsync<FocasWireException>(async () =>
|
|
await FocasWireProtocol.ReadPduAsync(client.GetStream(), CancellationToken.None));
|
|
}
|
|
finally { client.Dispose(); server.Dispose(); }
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReadPduAsync_rejects_unsupported_version()
|
|
{
|
|
var header = new byte[10];
|
|
new byte[] { 0xa0, 0xa0, 0xa0, 0xa0 }.CopyTo(header, 0);
|
|
BinaryPrimitives.WriteUInt16BigEndian(header.AsSpan(4, 2), 99); // unsupported version
|
|
var (client, server) = await ConnectedPairAsync();
|
|
try
|
|
{
|
|
await server.GetStream().WriteAsync(header);
|
|
await Should.ThrowAsync<FocasWireException>(async () =>
|
|
await FocasWireProtocol.ReadPduAsync(client.GetStream(), CancellationToken.None));
|
|
}
|
|
finally { client.Dispose(); server.Dispose(); }
|
|
}
|
|
|
|
// The 10-byte header framing is identical across supported versions (only the version field
|
|
// differs) — older controls + the mock answer v1, modern controls answer v3 (FANUC 30i-B).
|
|
// Validated live 2026-06-25; see docs/plans/2026-06-25-focas-pdu-v3-30i-b-support.md.
|
|
[Theory]
|
|
[InlineData((ushort)1)]
|
|
[InlineData((ushort)3)]
|
|
public async Task ReadPduAsync_accepts_supported_version(ushort version)
|
|
{
|
|
var body = new byte[] { 9, 8, 7 };
|
|
var pdu = new byte[10 + body.Length];
|
|
new byte[] { 0xa0, 0xa0, 0xa0, 0xa0 }.CopyTo(pdu, 0);
|
|
BinaryPrimitives.WriteUInt16BigEndian(pdu.AsSpan(4, 2), version);
|
|
pdu[6] = FocasWireProtocol.TypeData;
|
|
pdu[7] = FocasWireProtocol.DirectionResponse;
|
|
BinaryPrimitives.WriteUInt16BigEndian(pdu.AsSpan(8, 2), (ushort)body.Length);
|
|
body.CopyTo(pdu.AsSpan(10));
|
|
|
|
var (client, server) = await ConnectedPairAsync();
|
|
try
|
|
{
|
|
await server.GetStream().WriteAsync(pdu);
|
|
var read = await FocasWireProtocol.ReadPduAsync(client.GetStream(), CancellationToken.None);
|
|
|
|
read.Type.ShouldBe(FocasWireProtocol.TypeData);
|
|
read.Direction.ShouldBe(FocasWireProtocol.DirectionResponse);
|
|
read.Body.ShouldBe(body);
|
|
}
|
|
finally { client.Dispose(); server.Dispose(); }
|
|
}
|
|
|
|
// ---- BuildRequestBody framing ----
|
|
|
|
[Fact]
|
|
public void BuildRequestBody_prefixes_the_block_count_and_concatenates_blocks()
|
|
{
|
|
var blocks = new[]
|
|
{
|
|
new RequestBlock(0x0018, PathId: 1),
|
|
new RequestBlock(0x0019, Arg1: 7, PathId: 1),
|
|
};
|
|
var body = FocasWireProtocol.BuildRequestBody(blocks);
|
|
|
|
BinaryPrimitives.ReadUInt16BigEndian(body.AsSpan(0, 2)).ShouldBe((ushort)2);
|
|
// First block sits right after the 2-byte count; its declared block length is at +0.
|
|
var firstLen = BinaryPrimitives.ReadUInt16BigEndian(body.AsSpan(2, 2));
|
|
firstLen.ShouldBe((ushort)0x1c); // no extra payload → header-only block
|
|
BinaryPrimitives.ReadUInt16BigEndian(body.AsSpan(2 + 6, 2)).ShouldBe((ushort)0x0018); // command @ +6
|
|
}
|
|
|
|
// ---- ParseResponseBlocks: command / RC / payload extraction ----
|
|
|
|
[Fact]
|
|
public void ParseResponseBlocks_decodes_command_rc_and_payload()
|
|
{
|
|
var payload = new byte[] { 0xAA, 0xBB, 0xCC, 0xDD };
|
|
var body = BuildResponseBody(
|
|
(command: (ushort)0x0018, rc: (short)0, payload: payload),
|
|
(command: (ushort)0x0019, rc: (short)6 /* EW_NOOPT */, payload: Array.Empty<byte>()));
|
|
|
|
var blocks = FocasWireProtocol.ParseResponseBlocks(body);
|
|
|
|
blocks.Count.ShouldBe(2);
|
|
blocks[0].Command.ShouldBe((ushort)0x0018);
|
|
blocks[0].Rc.ShouldBe((short)0);
|
|
blocks[0].Payload.ShouldBe(payload);
|
|
blocks[1].Command.ShouldBe((ushort)0x0019);
|
|
blocks[1].Rc.ShouldBe((short)6);
|
|
blocks[1].Payload.ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public void ParseResponseBlocks_returns_empty_for_a_body_shorter_than_the_count_field()
|
|
{
|
|
FocasWireProtocol.ParseResponseBlocks(new byte[] { 0x00 }).ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public void ParseResponseBlocks_throws_on_a_truncated_block_length_field()
|
|
{
|
|
// count says 1 block, but only one stray byte follows the count.
|
|
var body = new byte[] { 0x00, 0x01, 0x00 };
|
|
Should.Throw<FocasWireException>(() => FocasWireProtocol.ParseResponseBlocks(body));
|
|
}
|
|
|
|
[Fact]
|
|
public void ParseResponseBlocks_throws_when_the_declared_block_length_is_below_the_header()
|
|
{
|
|
var body = new byte[4];
|
|
BinaryPrimitives.WriteUInt16BigEndian(body.AsSpan(0, 2), 1); // 1 block
|
|
BinaryPrimitives.WriteUInt16BigEndian(body.AsSpan(2, 2), 0x08); // block length < 0x10
|
|
Should.Throw<FocasWireException>(() => FocasWireProtocol.ParseResponseBlocks(body));
|
|
}
|
|
|
|
// ---- ReadAscii / ReadNameRecord ----
|
|
|
|
[Fact]
|
|
public void ReadAscii_stops_at_the_first_NUL_and_trims_trailing_space()
|
|
{
|
|
var bytes = "30i \0junkafternul"u8.ToArray();
|
|
FocasWireProtocol.ReadAscii(bytes).ShouldBe("30i");
|
|
}
|
|
|
|
[Fact]
|
|
public void ReadAscii_trims_trailing_spaces_without_a_NUL()
|
|
{
|
|
FocasWireProtocol.ReadAscii("A1.0 "u8.ToArray()).ShouldBe("A1.0");
|
|
}
|
|
|
|
[Fact]
|
|
public void ReadNameRecord_strips_padding()
|
|
{
|
|
// Full record semantics (first NUL, all four bytes) are pinned in FocasNameRecordTests.
|
|
FocasWireProtocol.ReadNameRecord("X \0\0"u8.ToArray()).ShouldBe("X");
|
|
FocasWireProtocol.ReadNameRecord("XY"u8.ToArray()).ShouldBe("XY");
|
|
FocasWireProtocol.ReadNameRecord(ReadOnlySpan<byte>.Empty).ShouldBe(string.Empty);
|
|
}
|
|
|
|
// ---- helpers ----
|
|
|
|
/// <summary>Build a response body in the 0x21 response-block shape ParseResponseBlocks expects.</summary>
|
|
private static byte[] BuildResponseBody(params (ushort command, short rc, byte[] payload)[] blocks)
|
|
{
|
|
var encoded = blocks.Select(b =>
|
|
{
|
|
var blockLen = 0x10 + b.payload.Length;
|
|
var block = new byte[blockLen];
|
|
BinaryPrimitives.WriteUInt16BigEndian(block.AsSpan(0, 2), (ushort)blockLen);
|
|
BinaryPrimitives.WriteUInt16BigEndian(block.AsSpan(6, 2), b.command);
|
|
BinaryPrimitives.WriteInt16BigEndian(block.AsSpan(8, 2), b.rc);
|
|
BinaryPrimitives.WriteUInt16BigEndian(block.AsSpan(14, 2), (ushort)b.payload.Length);
|
|
b.payload.CopyTo(block.AsSpan(16));
|
|
return block;
|
|
}).ToArray();
|
|
|
|
var body = new byte[2 + encoded.Sum(b => b.Length)];
|
|
BinaryPrimitives.WriteUInt16BigEndian(body.AsSpan(0, 2), (ushort)encoded.Length);
|
|
var offset = 2;
|
|
foreach (var block in encoded)
|
|
{
|
|
block.CopyTo(body.AsSpan(offset));
|
|
offset += block.Length;
|
|
}
|
|
return body;
|
|
}
|
|
|
|
private static async Task<(TcpClient client, TcpClient server)> ConnectedPairAsync()
|
|
{
|
|
var listener = new TcpListener(System.Net.IPAddress.Loopback, 0);
|
|
listener.Start();
|
|
try
|
|
{
|
|
var port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port;
|
|
var client = new TcpClient();
|
|
var connect = client.ConnectAsync(System.Net.IPAddress.Loopback, port);
|
|
var server = await listener.AcceptTcpClientAsync();
|
|
await connect;
|
|
return (client, server);
|
|
}
|
|
finally { listener.Stop(); }
|
|
}
|
|
}
|