fix(abcip): a scalar read whose width disagrees with its authored type is BadTypeMismatch

libplctag decodes whatever bytes the controller returns as the authored type and never
checks the CIP type code. Live read-only check on a production press: WW_ActualVacuum is a
REAL (L5X + the controller's symbol table agree) but the legacy Kepware export in the
ignitionoee repo types it Short; read as INT it returned Good -17346 while the real value
was 5.37. A scalar atomic read (not an array, not a bit-in-DINT) whose buffer size is not
the authored type's width now reads BadTypeMismatch with a warning naming both. Widths
observed live: SINT/BOOL 1, INT 2, DINT/REAL 4, LINT 8. IAbCipTagRuntime.GetSize() is a
default-interface member (-1 = unknown, never checked). Same-width mismatches (DINT vs
REAL) stay undetectable without the type code libplctag strips.
This commit is contained in:
Joseph Doherty
2026-10-07 14:15:19 -04:00
parent 68cf3ae8b7
commit e3d6b76ede
5 changed files with 118 additions and 0 deletions
@@ -799,6 +799,21 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
var tagPath = AbCipTagPath.TryParse(def.TagPath);
var bitIndex = tagPath?.BitIndex;
// libplctag never checks the CIP type the controller returned; it decodes the bytes as
// the authored type. A scalar whose buffer is not that type's width is mis-typed config
// (seen live: a REAL authored as INT read Good -17346), so refuse it rather than publish
// garbage as Good. Same-width mismatches (DINT vs REAL) cannot be seen this way.
if (!IsArrayTag(def) && bitIndex is null
&& ExpectedScalarWidth(def.DataType) is { } width
&& runtime.GetSize() is var size && size >= 0 && size != width)
{
results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadTypeMismatch, null, now);
_logger.LogWarning(
"AbCip read of {Tag} on device {Device} returned {Size} bytes but its authored type {DataType} is {Width} bytes wide; check the tag's dataType",
reference, def.DeviceHostAddress, size, def.DataType, width);
return ReadOutcome.Degraded($"type mismatch reading {reference}: {size} bytes for {def.DataType}");
}
// Review I-1 — an array tag (the EXPLICIT IsArray flag) decodes the whole buffer into an
// element-typed CLR array (int[]/float[]/bool[]/string[]…), INCLUDING a 1-element array
// (ElementCount 1). Scalar tags keep the single-value path.
@@ -1367,6 +1382,21 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
memberName.StartsWith("ZZZZZZZZZZ", StringComparison.Ordinal)
|| memberName.StartsWith("__", StringComparison.Ordinal);
/// <summary>
/// Wire width of a scalar atomic Logix type, or <c>null</c> where the read buffer's size is not
/// a reliable type check (BOOL, STRING, DT, structures).
/// </summary>
/// <param name="type">The authored type.</param>
/// <returns>The width in bytes, or <c>null</c>.</returns>
internal static int? ExpectedScalarWidth(AbCipDataType type) => type switch
{
AbCipDataType.SInt or AbCipDataType.USInt => 1,
AbCipDataType.Int or AbCipDataType.UInt => 2,
AbCipDataType.DInt or AbCipDataType.UDInt or AbCipDataType.Real => 4,
AbCipDataType.LInt or AbCipDataType.ULInt or AbCipDataType.LReal => 8,
_ => null,
};
/// <summary>
/// Recursively fan a controller-discovered UDT shape out into Variable nodes — one per
/// ATOMIC leaf — under the folder produced by <paramref name="folder"/>. Mirrors the
@@ -31,6 +31,13 @@ public interface IAbCipTagRuntime : IDisposable
/// <returns>The raw libplctag status code (zero on success, negative on error).</returns>
int GetStatus();
/// <summary>
/// Bytes in the last read's buffer, or <c>-1</c> when the runtime cannot say. Used to catch an
/// authored type whose width disagrees with what the controller actually returned.
/// </summary>
/// <returns>The buffer size in bytes, or -1 if unknown.</returns>
int GetSize() => -1;
/// <summary>
/// Decode the local buffer into a boxed .NET value per the tag's configured type.
/// <paramref name="bitIndex"/> is non-null only for BOOL-within-DINT tags captured in
@@ -46,6 +46,9 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
/// <inheritdoc />
public int GetStatus() => (int)_tag.GetStatus();
/// <inheritdoc />
public int GetSize() => _tag.GetSize();
/// <inheritdoc />
public object? DecodeValue(AbCipDataType type, int? bitIndex) => DecodeValueAt(type, 0, bitIndex);
@@ -0,0 +1,72 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// libplctag hands back whatever bytes the controller sends and decodes them as the authored type
/// — it never checks the CIP type code. Seen live on a production press: <c>WW_ActualVacuum</c> is a
/// REAL (the L5X and the controller's symbol table agree), the legacy Kepware export types it as
/// Short, and reading it authored as INT returned <c>Good -17346</c> (the low half of a float)
/// where the real value was 5.37. A scalar atomic read whose buffer is not the authored type's
/// width is now <c>BadTypeMismatch</c>. Same-width mismatches (DINT vs REAL) remain undetectable
/// without the type code.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbCipReadWidthMismatchTests
{
private const string Device = "ab://10.0.0.5/1,0";
private static async Task<uint> ReadStatusAsync(AbCipDataType authored, int bufferSize, string path = "WW_ActualVacuum")
{
var factory = new FakeAbCipTagFactory
{
Customise = p => new FakeAbCipTag(p) { Value = 1, Size = bufferSize },
};
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device)],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("T", Device, path, authored)),
Probe = new AbCipProbeOptions { Enabled = false },
}, "abcip-1", factory);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var r = await drv.ReadAsync(["T"], TestContext.Current.CancellationToken);
return r[0].StatusCode;
}
/// <summary>An INT read that comes back 4 bytes wide is a type mismatch, not a Good value.</summary>
[Fact]
public async Task INT_authored_over_a_4_byte_REAL_is_BadTypeMismatch()
{
(await ReadStatusAsync(AbCipDataType.Int, bufferSize: 4)).ShouldBe(AbCipStatusMapper.BadTypeMismatch);
}
/// <summary>Matching widths still read Good — including the live-observed sizes per type.</summary>
/// <param name="type">Authored type.</param>
/// <param name="size">Buffer size the controller returned for that type (observed live).</param>
[Theory]
[InlineData(AbCipDataType.Int, 2)]
[InlineData(AbCipDataType.DInt, 4)]
[InlineData(AbCipDataType.Real, 4)]
[InlineData(AbCipDataType.LInt, 8)]
[InlineData(AbCipDataType.SInt, 1)]
public async Task Matching_width_reads_Good(AbCipDataType type, int size)
{
(await ReadStatusAsync(type, size)).ShouldBe(AbCipStatusMapper.Good);
}
/// <summary>An unknown size (a runtime that cannot report it) never blocks a read.</summary>
[Fact]
public async Task Unknown_size_is_not_checked()
{
(await ReadStatusAsync(AbCipDataType.Int, bufferSize: -1)).ShouldBe(AbCipStatusMapper.Good);
}
/// <summary>A bit-in-DINT read decodes from the parent's 4-byte buffer and is not width-checked.</summary>
[Fact]
public async Task Bit_index_read_is_not_width_checked()
{
(await ReadStatusAsync(AbCipDataType.Bool, bufferSize: 4, path: "Flags.3")).ShouldBe(AbCipStatusMapper.Good);
}
}
@@ -64,6 +64,12 @@ internal class FakeAbCipTag : IAbCipTagRuntime
/// <summary>Returns the simulated status code.</summary>
public virtual int GetStatus() => Status;
/// <summary>Gets or sets the simulated read-buffer size in bytes (-1 = unknown, the default).</summary>
public int Size { get; set; } = -1;
/// <summary>Returns the simulated read-buffer size.</summary>
public virtual int GetSize() => Size;
/// <summary>Returns the mock tag value.</summary>
/// <param name="type">The data type being decoded.</param>
/// <param name="bitIndex">The optional bit index for bit operations.</param>