5f0a52864c
First real FOCAS hardware contact (Makino Pro 5 / 31i-B @ 10.201.31.5). A full
v3 data-PDU capture corrected the initial diagnosis: the v3 block envelope is
identical to v1, so only specific payload structs / request math / one client
robustness gap were wrong — not "framing rewrites".
Fixes (all re-validated live through the fixed driver):
- version gate: accept inbound PDU {1,3}, keep emitting v1 (FocasWireProtocol).
- cnc_rdtimer: 8-byte {minute,msec} payload is little-endian (ParseTimer) — the
only decode with an in-range msec field.
- pmc_rdpmcrng: request range widened to the data-type byte width
(end = start + width - 1) so a Word/Long isn't truncated to 0 values
(was spurious BadOutOfRange); decode extracted to ParsePmcRange.
- cnc_rdsvmeter: per-axis LOADELM is 8 bytes (not 12) and names come from the
0x0089 block — ParseServoMeters fixes the misaligned 655360 garbage. Also the
"hang" was NetworkStream.ReadAsync not aborting a stalled socket: ReadExactlyAsync
now disposes the stream on cancellation so a stalled peer can't wedge a poll loop.
- cnc_rddynamic2: contract guard rejecting axis < 1 (driver poll already 1-based).
- FocasDriverProbe: run a real wire session (initiate + cnc_statinfo) instead of
degrading to Ok=true "TCP reachability only" when FWLIB is absent — a bare TCP
listener no longer reports HEALTHY.
cnc_rdparam (0x000e) is unsupported on this control — EW_FUNC across 14
request-framing variants x 4 known-present params; needs a reference FWLIB trace
or is restricted. Deferred (deployed config uses macros, not parameters).
Tests: FOCAS suite 234 green (+16), full solution builds 0 errors. Raw v3
captures checked in under tests/.../Fixtures/v3/. Capture tools under scripts/focas/.
Docs: docs/plans/2026-06-25-focas-pdu-v3-{30i-b-support,implementation-plan}.md,
docs/drivers/FOCAS.md, docs/v2/focas-version-matrix.md,
docs/deployments/wonder-app-vd03-makino-z-34184.md.
179 lines
6.9 KiB
C#
179 lines
6.9 KiB
C#
using System.Buffers.Binary;
|
|
using System.Diagnostics;
|
|
using System.Net;
|
|
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>
|
|
/// Byte-level coverage for the FOCAS PDU-v3 fixes derived from a live FANUC 31i-B capture
|
|
/// (2026-06-25). The fixtures under <c>Fixtures/v3/</c> are the raw responses; the specific
|
|
/// payload bytes are inlined here so the tests stay hermetic. See
|
|
/// <c>docs/plans/2026-06-25-focas-pdu-v3-30i-b-support.md</c> +
|
|
/// <c>docs/plans/2026-06-25-focas-pdu-v3-implementation-plan.md</c>.
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class FocasWireV3Tests
|
|
{
|
|
// ---- cnc_rdtimer (0x0120): little-endian {minute, msec} ----
|
|
|
|
// Captured cutting-time (type 2) payload from the live 31i-B.
|
|
private static readonly byte[] CuttingTimerPayload = [0xac, 0xf2, 0x10, 0x00, 0x90, 0xa3, 0x00, 0x00];
|
|
|
|
[Fact]
|
|
public void ParseTimer_decodes_the_live_payload_as_little_endian_minute_and_msec()
|
|
{
|
|
var timer = FocasWireClient.ParseTimer(2, CuttingTimerPayload);
|
|
|
|
timer.Type.ShouldBe((short)2);
|
|
timer.Minutes.ShouldBe(1_110_700); // 0x0010F2AC little-endian
|
|
timer.Milliseconds.ShouldBe(41_872); // 0x0000A390 little-endian
|
|
}
|
|
|
|
[Fact]
|
|
public void ParseTimer_little_endian_is_the_only_decode_with_an_in_range_msec()
|
|
{
|
|
// The whole point: under big-endian the msec field is nonsensical (~2.4e9), so it cannot be
|
|
// the "fractional milliseconds 0..59999" field the model documents. Little-endian is in range.
|
|
var beMsec = BinaryPrimitives.ReadUInt32BigEndian(CuttingTimerPayload.AsSpan(4, 4));
|
|
beMsec.ShouldBeGreaterThan(59_999u);
|
|
|
|
var timer = FocasWireClient.ParseTimer(2, CuttingTimerPayload);
|
|
timer.Milliseconds.ShouldBeInRange(0, 59_999);
|
|
}
|
|
|
|
[Fact]
|
|
public void ParseTimer_handles_a_short_payload_without_throwing()
|
|
{
|
|
var timer = FocasWireClient.ParseTimer(0, []);
|
|
timer.Minutes.ShouldBe(0);
|
|
timer.Milliseconds.ShouldBe(0);
|
|
}
|
|
|
|
// ---- pmc_rdpmcrng (0x8001): byte width + range decode ----
|
|
|
|
[Theory]
|
|
[InlineData((short)0, 1)] // Byte
|
|
[InlineData((short)1, 2)] // Word
|
|
[InlineData((short)2, 4)] // Long
|
|
[InlineData((short)4, 4)] // Real
|
|
[InlineData((short)5, 8)] // Double
|
|
[InlineData((short)99, 1)] // unknown → 1
|
|
public void PmcByteWidth_maps_focas_datatype_codes(short dataType, int expectedWidth)
|
|
{
|
|
FocasWireClient.PmcByteWidth(dataType).ShouldBe(expectedWidth);
|
|
}
|
|
|
|
[Fact]
|
|
public void ParsePmcRange_decodes_a_two_byte_word_into_one_value()
|
|
{
|
|
// A Word read of R100 must request bytes 100..101 (2 bytes); the CNC then returns 2 bytes.
|
|
var range = FocasWireClient.ParsePmcRange(area: 5, dataType: 1, start: 100, end: 101, payload: [0x00, 0x64]);
|
|
range.Values.Count.ShouldBe(1);
|
|
range.Values[0].ShouldBe(100L); // 0x0064 big-endian
|
|
}
|
|
|
|
[Fact]
|
|
public void ParsePmcRange_with_a_single_byte_for_a_word_yields_no_value()
|
|
{
|
|
// This is the pre-fix bug: requesting end==start for a Word returned 1 byte, the 2-byte
|
|
// slot never completed, so the value list was empty → WireFocasClient mapped it BadOutOfRange.
|
|
var range = FocasWireClient.ParsePmcRange(area: 5, dataType: 1, start: 100, end: 100, payload: [0x00]);
|
|
range.Values.ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public void ParsePmcRange_decodes_a_single_byte_value()
|
|
{
|
|
var range = FocasWireClient.ParsePmcRange(area: 5, dataType: 0, start: 0, end: 0, payload: [0x07]);
|
|
range.Values.Count.ShouldBe(1);
|
|
range.Values[0].ShouldBe(7L);
|
|
}
|
|
|
|
// ---- cnc_rdsvmeter: 8-byte LOADELM stride + axis-name correlation ----
|
|
|
|
[Fact]
|
|
public void ParseServoMeters_uses_an_eight_byte_stride_and_names_from_the_axis_block()
|
|
{
|
|
// Three 8-byte records {int32 data; int16 dec=10; int16 unit=0}: data = 0, 50, -3.
|
|
byte[] sv =
|
|
[
|
|
0x00, 0x00, 0x00, 0x00, 0x00, 0x0a, 0x00, 0x00, // axis 1: data=0
|
|
0x00, 0x00, 0x00, 0x32, 0x00, 0x0a, 0x00, 0x00, // axis 2: data=50
|
|
0xff, 0xff, 0xff, 0xfd, 0x00, 0x0a, 0x00, 0x00, // axis 3: data=-3
|
|
];
|
|
// 0x0089 axis-name block: 4-byte records X, Y, Z.
|
|
byte[] names =
|
|
[
|
|
0x58, 0x00, 0x00, 0x00, // "X"
|
|
0x59, 0x00, 0x00, 0x00, // "Y"
|
|
0x5a, 0x00, 0x00, 0x00, // "Z"
|
|
];
|
|
|
|
var meters = FocasWireClient.ParseServoMeters(sv, names, maxCount: 32);
|
|
|
|
meters.Count.ShouldBe(3);
|
|
meters[0].Name.ShouldBe("X");
|
|
meters[0].Value.ShouldBe(0);
|
|
meters[1].Name.ShouldBe("Y");
|
|
meters[1].Value.ShouldBe(50); // 8-byte stride: a 12-byte stride would misread this as 655360
|
|
meters[1].Decimal.ShouldBe((short)10);
|
|
meters[2].Name.ShouldBe("Z");
|
|
meters[2].Value.ShouldBe(-3);
|
|
}
|
|
|
|
// ---- cnc_rddynamic2: 1-based axis guard ----
|
|
|
|
[Theory]
|
|
[InlineData(0)]
|
|
[InlineData(-1)]
|
|
public async Task ReadDynamicAsync_rejects_a_non_positive_axis_index(int axisIndex)
|
|
{
|
|
using var client = new WireFocasClient();
|
|
await Should.ThrowAsync<ArgumentOutOfRangeException>(async () =>
|
|
await client.ReadDynamicAsync(axisIndex, CancellationToken.None));
|
|
}
|
|
|
|
// ---- read-stall hardening: a stalled peer must not wedge the poll loop ----
|
|
|
|
[Fact]
|
|
public async Task ReadPduAsync_aborts_a_stalled_peer_within_the_cancellation_budget()
|
|
{
|
|
var (client, server) = await ConnectedPairAsync();
|
|
try
|
|
{
|
|
// Send only 5 of the 10 header bytes, then stall forever — the cnc_rdsvmeter hang shape.
|
|
await server.GetStream().WriteAsync(new byte[] { 0xa0, 0xa0, 0xa0, 0xa0, 0x00 });
|
|
|
|
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(300));
|
|
var sw = Stopwatch.StartNew();
|
|
await Should.ThrowAsync<OperationCanceledException>(async () =>
|
|
await FocasWireProtocol.ReadPduAsync(client.GetStream(), cts.Token));
|
|
sw.Stop();
|
|
|
|
// Must abort near the 300ms budget, not hang — generous ceiling for CI jitter.
|
|
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(5));
|
|
}
|
|
finally { client.Dispose(); server.Dispose(); }
|
|
}
|
|
|
|
private static async Task<(TcpClient client, TcpClient server)> ConnectedPairAsync()
|
|
{
|
|
var listener = new TcpListener(IPAddress.Loopback, 0);
|
|
listener.Start();
|
|
try
|
|
{
|
|
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
|
var client = new TcpClient();
|
|
var connect = client.ConnectAsync(IPAddress.Loopback, port);
|
|
var server = await listener.AcceptTcpClientAsync();
|
|
await connect;
|
|
return (client, server);
|
|
}
|
|
finally { listener.Stop(); }
|
|
}
|
|
}
|