fix(abcip): a BOOL-array element reads its own bit, not "low byte != 0"

U11: a scalar BOOL authored over Bits[n] requested element n and decoded
"any of bits 0-7 of the reply set", so Bits[n] read Good with the subscript
ignored. A Logix BOOL array is stored as DWORDs (0xD3) and its element segment
addresses them, so the native runtime now requests DWORD n / 32 and decodes bit
n % 32 when the controller answers 0xD3 (a plain BOOL reply still decodes its
byte). libplctag exposes no type code and so cannot say which DWORD it read: a
4-byte reply for such a tag is refused BadNotSupported (warned once) instead of
publishing a wrong value as Good. Real-controller proof is a hardware gate.

Refs #737
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 14:27:34 -04:00
parent 895cc28886
commit 82a485e42e
4 changed files with 87 additions and 3 deletions
@@ -1150,6 +1150,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
// A deterministic local refusal — not a comms fault: BadNotSupported, keep the handle, no
// health signal.
results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadNotSupported, null, now);
WarnRefusalOnce(def.Name, "read", reference, def.DeviceHostAddress, ate.Message);
return ReadOutcome.None;
}
catch (AbCipTagConfigurationException ace)
@@ -29,8 +29,19 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
public LibplctagTagRuntime(AbCipTagCreateParams p)
{
_tag = BuildTag(p);
_boolArrayElement = !p.IsArray && p.AuthoredType == AbCipDataType.Bool
&& NativeAbCipTagRuntime.TrySplitBoolArrayElement(p.TagName, out _, out _);
}
/// <summary>
/// A scalar BOOL authored over a BOOL-array element (<c>Bits[n]</c>). libplctag reads it as whatever the
/// controller returns for that element segment — a packed DWORD — and exposes no type code, so which DWORD
/// came back (and so which bit is element n) cannot be checked here. Review 07 U11: such a read used to decode
/// "low byte != 0" as Good; it is refused (<c>BadNotSupported</c>) when a DWORD came back. The native transport
/// (the default) addresses the right DWORD and decodes the bit.
/// </summary>
private readonly bool _boolArrayElement;
/// <summary>Builds the (uninitialized) libplctag tag for <paramref name="p"/>.</summary>
/// <param name="p">The tag creation parameters.</param>
/// <returns>The new, uninitialised libplctag tag.</returns>
@@ -120,7 +131,10 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
{
AbCipDataType.Bool => bitIndex is int bit
? _tag.GetBit(bit)
: _tag.GetInt8(offset) != 0,
: _boolArrayElement && offset == 0 && _tag.GetSize() == 4
? throw new AbTransportException(AbStatus.Unsupported,
$"'{_tag.Name}' is a BOOL-array element read as a packed DWORD; libplctag cannot say which DWORD it is — use the native transport.")
: _tag.GetInt8(offset) != 0,
AbCipDataType.SInt => (int)(sbyte)_tag.GetInt8(offset),
AbCipDataType.USInt => (int)_tag.GetUInt8(offset),
AbCipDataType.Int => (int)_tag.GetInt16(offset),
@@ -41,6 +41,10 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
private int? _implicitBit;
private byte[]? _path;
// U11: a scalar BOOL authored over a BOOL-array element "Bits[n]" — the bit (n % 32) of the DWORD the request
// addresses (n / 32). Null for every other tag.
private int? _boolElementBit;
private ConnectionLease? _lease;
private RequestScheduler? _privateScheduler; // ForTest only — production always uses the lease's
private LogixTagClient? _client;
@@ -115,7 +119,14 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
try
{
split = CipPath.SplitBitSelector(_p.TagName);
path = CipPath.EncodeSymbolic(split.Name);
var name = split.Name;
if (split.Bit is null && !_p.IsArray && _p.AuthoredType == AbCipDataType.Bool
&& TrySplitBoolArrayElement(name, out var dwordName, out var bit))
{
name = dwordName;
_boolElementBit = bit;
}
path = CipPath.EncodeSymbolic(name);
}
catch (Exception ex) when (ex is ArgumentException or FormatException or OverflowException)
{
@@ -275,7 +286,9 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
return type switch
{
AbCipDataType.Bool => bitIndex is int bit ? GetBit(bit) : Span(offset, 1)[0] != 0,
AbCipDataType.Bool => bitIndex is int bit ? GetBit(bit)
: offset == 0 && _boolElementBit is int element && _typeCode == LogixTypeCode.BoolArray ? GetBit(element)
: Span(offset, 1)[0] != 0,
AbCipDataType.SInt => (int)(sbyte)Span(offset, 1)[0],
AbCipDataType.USInt => (int)Span(offset, 1)[0],
AbCipDataType.Int => (int)BinaryPrimitives.ReadInt16LittleEndian(Span(offset, 2)),
@@ -409,6 +422,31 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
catch (Exception) { /* ForTest only; Dispose must not throw */ }
}
/// <summary>
/// Splits a BOOL-array element reference <c>Name[n]</c> (one non-negative subscript, last segment) into the
/// DWORD it lives in (<c>Name[n / 32]</c>) and its bit within that DWORD (<c>n % 32</c>). A Logix BOOL array
/// is stored as DWORDs (type 0xD3) and its element segment addresses those DWORDs.
/// </summary>
/// <param name="name">The tag name, bit selector already split off.</param>
/// <param name="dwordName">The name addressing the containing DWORD.</param>
/// <param name="bit">The element's bit within that DWORD.</param>
/// <returns><see langword="true"/> when <paramref name="name"/> ends in a single subscript.</returns>
internal static bool TrySplitBoolArrayElement(string name, out string dwordName, out int bit)
{
dwordName = name;
bit = 0;
if (!name.EndsWith(']')) return false;
var open = name.LastIndexOf('[');
if (open <= 0) return false;
var inner = name[(open + 1)..^1];
if (inner.Length == 0 || !inner.All(char.IsAsciiDigit)
|| !int.TryParse(inner, System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out var n))
return false;
dwordName = $"{name[..open]}[{n / 32}]";
bit = n % 32;
return true;
}
private byte[] Path() =>
_path ?? throw new InvalidOperationException("InitializeAsync has not been called.");
@@ -112,6 +112,37 @@ public sealed class NativeAbCipTagRuntimeTests
rt.DecodeValue(AbCipDataType.Bool, null).ShouldBe(true);
}
/// <summary>
/// Review 07 U11: a scalar BOOL authored over a BOOL-array element <c>Bits[n]</c> reads DWORD <c>n / 32</c>
/// (a Logix BOOL array is stored as DWORDs and its CIP element index addresses them) and decodes bit
/// <c>n % 32</c> of it — it used to request element n and report "any of bits 0-7 of the reply set".
/// </summary>
/// <param name="n">The authored BOOL-array element.</param>
/// <param name="dword">The DWORD the request must address.</param>
/// <param name="reply">The DWORD the controller returns (little-endian hex).</param>
/// <param name="expected">The element's value.</param>
[Theory]
[InlineData(37, 1, "20000000", true)] // bit 5 of DWORD 1
[InlineData(36, 1, "20000000", false)] // bit 4 of the same DWORD — the old "low byte != 0" read true
[InlineData(5, 0, "20000000", true)]
[InlineData(1, 0, "01000000", false)] // bit 1, not bit 0
[InlineData(31, 0, "00000080", true)] // top bit of DWORD 0 — outside the low byte entirely
public async Task A_BOOL_array_element_decodes_its_bit_of_its_DWORD(int n, int dword, string reply, bool expected)
{
var ch = Replying("D300", reply);
using var rt = await ReadAsync(ch, Params($"Bits[{n}]", AbCipDataType.Bool));
rt.DecodeValue(AbCipDataType.Bool, null).ShouldBe(expected);
ch.Sent[0].ShouldBe(Convert.FromHexString("5204" + "910442697473" + "28" + dword.ToString("X2") + "0100" + "00000000"));
}
/// <summary>Positive control: a BOOL answered as a plain BOOL (0xC1) still decodes its byte.</summary>
[Fact]
public async Task A_BOOL_element_answered_as_BOOL_decodes_the_byte()
{
using var rt = await ReadAsync(Replying("C100", "01"), Params("Flag", AbCipDataType.Bool));
rt.DecodeValue(AbCipDataType.Bool, null).ShouldBe(true);
}
[Fact]
public async Task Real_returned_for_a_DInt_authored_tag_is_TypeMismatch()
{