fix(focas): decode cnc_rdmacro dec_val at offset 6; vacant/fractional macros go Bad
The ODBM wire payload is [i32 mantissa][i16 length, a constant 10][i16 dec_val]. ReadMacroAsync read the decimal at offset 4, so every macro decoded without failing but wrongly: the captured #500 = 1.0 (05 f5 e1 00 00 0a 00 08) published 0.01. FANUC normalises macros to a 9-significant-digit mantissa, so the error was never small. - dec_val is read at [6..7]; a payload < 8 bytes is refused, not guessed at. - A vacant variable (dec_val 0xFFFF) publishes BadNoData, never a Good 0. - Integer-typed tags get the exact whole value (integer arithmetic); a fractional macro on an integer tag is BadTypeMismatch, a whole value that does not fit the type BadOutOfRange. Float tags get mantissa / 10^dec. Ported from the sister ignitionoee FOCAS driver v0.15.0 (Focas.Wire-013, Focas-017/018); live vectors from Makino Z-34184 (31i-B, G431). Adds a WireFixtures helper that loads the raw 31i-B captures in Fixtures/v3.
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
using System.Buffers.Binary;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <c>cnc_rdmacro</c> (0x0015) decode, pinned to real FANUC 31i-B payloads. Ported from the
|
||||
/// sister ignitionoee FOCAS driver's v0.15.0 fix (review finding Focas.Wire-013 / Focas-017).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The on-wire <c>ODBM</c> payload is 8 bytes: <c>[0..3] i32 mantissa</c>,
|
||||
/// <c>[4..5] i16</c> — a <b>constant 10</b> on every observed response (a length field, NOT the
|
||||
/// decimal), and <c>[6..7] i16 dec_val</c> (<c>0xFFFF</c> = vacant). Reading the decimal at
|
||||
/// offset 4 decodes every macro on every control without ever failing — just wrongly: the
|
||||
/// captured <c>#500 = 1.0</c> (<c>05 f5 e1 00 00 0a 00 08</c>) read as <c>100000000 / 10^10 =
|
||||
/// 0.01</c>. FANUC normalises a macro to a 9-significant-digit mantissa, so the live vectors
|
||||
/// below (Makino Z-34184, 2026-08-05) all carry large mantissas.
|
||||
/// </remarks>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasMacroDecodeTests
|
||||
{
|
||||
private static byte[] Odbm(int mantissa, int decVal, int lengthField = 10)
|
||||
{
|
||||
var p = new byte[8];
|
||||
BinaryPrimitives.WriteInt32BigEndian(p.AsSpan(0, 4), mantissa);
|
||||
BinaryPrimitives.WriteUInt16BigEndian(p.AsSpan(4, 2), (ushort)lengthField);
|
||||
BinaryPrimitives.WriteUInt16BigEndian(p.AsSpan(6, 2), (ushort)(decVal & 0xFFFF));
|
||||
return p;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseMacro_decodes_the_live_capture_with_the_decimal_at_offset_6()
|
||||
{
|
||||
var payload = WireFixtures.Payload("ref-macro-500-0x0015.bin", 0x0015);
|
||||
|
||||
var m = FocasWireClient.ParseMacro(500, payload);
|
||||
|
||||
m.Number.ShouldBe((short)500);
|
||||
m.Value.ShouldBe(100_000_000);
|
||||
m.Decimal.ShouldBe((short)8); // offset 6 — NOT the constant 10 at offset 4
|
||||
m.IsVacant.ShouldBeFalse();
|
||||
m.Scaled!.Value.ShouldBe(1.0, 1e-12);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseMacro_ignores_the_length_field_at_offset_4()
|
||||
{
|
||||
var a = FocasWireClient.ParseMacro(508, Odbm(148_500_000, 6));
|
||||
var b = FocasWireClient.ParseMacro(508, Odbm(148_500_000, 6, lengthField: 0));
|
||||
|
||||
b.ShouldBe(a);
|
||||
b.Scaled!.Value.ShouldBe(148.5, 1e-9);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(270_000_000, 6, 270.0)] // #502
|
||||
[InlineData(148_500_000, 6, 148.5)] // #508
|
||||
[InlineData(208_850_000, 4, 20885.0)] // #610
|
||||
[InlineData(202_608_050, 1, 20_260_805.0)] // #3011 date
|
||||
[InlineData(-100_000_000, 12, -1.0e-4)] // #802
|
||||
[InlineData(969_479_000, 9, 0.969479)] // #801
|
||||
[InlineData(1234, 0, 1234.0)]
|
||||
public void ParseMacro_scales_the_live_vectors(int mantissa, int dec, double expected)
|
||||
{
|
||||
FocasWireClient.ParseMacro(1, Odbm(mantissa, dec)).Scaled!.Value.ShouldBe(expected, 1e-9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_vacant_macro_has_no_value_not_zero()
|
||||
{
|
||||
var m = FocasWireClient.ParseMacro(611, Odbm(0, 0xFFFF));
|
||||
|
||||
m.Decimal.ShouldBe((short)-1);
|
||||
m.IsVacant.ShouldBeTrue();
|
||||
m.Scaled.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_genuine_zero_is_not_vacant()
|
||||
{
|
||||
var m = FocasWireClient.ParseMacro(3901, Odbm(0, 0));
|
||||
m.IsVacant.ShouldBeFalse();
|
||||
m.Scaled.ShouldBe(0.0);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(4)]
|
||||
[InlineData(6)]
|
||||
[InlineData(7)]
|
||||
public void ParseMacro_refuses_a_payload_too_short_to_carry_dec_val(int length)
|
||||
{
|
||||
Should.Throw<FocasWireException>(() => FocasWireClient.ParseMacro(1, new byte[length]));
|
||||
}
|
||||
|
||||
// ---- published value / quality ----
|
||||
|
||||
[Fact]
|
||||
public void Float64_publishes_the_scaled_value_good()
|
||||
{
|
||||
var (value, status) = WireFocasClient.MapMacro(FocasWireClient.ParseMacro(508, Odbm(148_500_000, 6)), FocasDataType.Float64, null);
|
||||
status.ShouldBe(FocasStatusMapper.Good);
|
||||
value.ShouldBe(148.5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Int32_publishes_the_exact_whole_value_not_the_mantissa()
|
||||
{
|
||||
var (value, status) = WireFocasClient.MapMacro(FocasWireClient.ParseMacro(502, Odbm(270_000_000, 6)), FocasDataType.Int32, null);
|
||||
status.ShouldBe(FocasStatusMapper.Good);
|
||||
value.ShouldBe(270);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_fractional_macro_on_an_integer_tag_is_bad_not_truncated()
|
||||
{
|
||||
var (value, status) = WireFocasClient.MapMacro(FocasWireClient.ParseMacro(508, Odbm(148_500_000, 6)), FocasDataType.Int32, null);
|
||||
status.ShouldBe(FocasStatusMapper.BadTypeMismatch);
|
||||
value.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(FocasDataType.Float64)]
|
||||
[InlineData(FocasDataType.Float32)]
|
||||
[InlineData(FocasDataType.Int32)]
|
||||
public void A_vacant_macro_is_bad_for_every_type(FocasDataType type)
|
||||
{
|
||||
var (value, status) = WireFocasClient.MapMacro(FocasWireClient.ParseMacro(611, Odbm(0, 0xFFFF)), type, null);
|
||||
status.ShouldBe(FocasStatusMapper.BadNoData);
|
||||
value.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_whole_macro_out_of_range_for_its_integer_type_is_bad()
|
||||
{
|
||||
// 70000 is whole, but does not fit Int16.
|
||||
var (value, status) = WireFocasClient.MapMacro(FocasWireClient.ParseMacro(1, Odbm(700_000_000, 4)), FocasDataType.Int16, null);
|
||||
status.ShouldBe(FocasStatusMapper.BadOutOfRange);
|
||||
value.ShouldBeNull();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Loads the raw FOCAS response PDUs under <c>Fixtures/v3/</c> — byte-for-byte captures from a
|
||||
/// real FANUC 31i-B (Makino, series G431) — and splits them into response blocks with the
|
||||
/// production <see cref="FocasWireProtocol.ParseResponseBlocks"/>. The <c>live-*</c> captures
|
||||
/// were taken by the sister ignitionoee FOCAS driver's read-only probes of Z-34184 (2026-08-12)
|
||||
/// and are shared verbatim; the <c>ref-*</c> ones are this repo's own 2026-06-25 capture.
|
||||
/// </summary>
|
||||
internal static class WireFixtures
|
||||
{
|
||||
private const int PduHeaderLength = 10;
|
||||
|
||||
/// <summary>Every response block of the named capture, in wire order.</summary>
|
||||
/// <param name="name">File name under <c>Fixtures/v3/</c>.</param>
|
||||
/// <returns>The parsed response blocks.</returns>
|
||||
public static IReadOnlyList<ResponseBlock> Blocks(string name)
|
||||
{
|
||||
var raw = File.ReadAllBytes(Path.Combine(AppContext.BaseDirectory, "Fixtures", "v3", name));
|
||||
return FocasWireProtocol.ParseResponseBlocks(raw.AsSpan(PduHeaderLength));
|
||||
}
|
||||
|
||||
/// <summary>The payload of the first block carrying <paramref name="command"/>.</summary>
|
||||
/// <param name="name">File name under <c>Fixtures/v3/</c>.</param>
|
||||
/// <param name="command">The FOCAS command id to locate.</param>
|
||||
/// <returns>That block's payload bytes.</returns>
|
||||
public static byte[] Payload(string name, ushort command) =>
|
||||
Blocks(name).First(b => b.Command == command).Payload;
|
||||
}
|
||||
+5
@@ -19,6 +19,11 @@
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Real FANUC 31i-B response captures (raw PDUs); read by WireFixtures. -->
|
||||
<None Include="Fixtures\v3\*.bin" CopyToOutputDirectory="PreserveNewest"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.FOCAS\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
Reference in New Issue
Block a user