fix(abcip): only a reply type settles the BOOL-array layout (review r2)
N1: a refusal of the DWORD-addressed request permanently settled the layout as per-element, so a tag deployed before the online edit that creates its BOOL array later read DWORD n and published byte 0 as Good. The native runtime now keeps an explicit layout (Unsettled / Packed / PerElement) that ONLY a reply type changes: 0xD3 settles packed (Bits[n/32], bit n%32), 0xC1 settles per-element (Bits[n]). Refusals and comms faults leave it unsettled, so the next read asks again. A reply whose type disagrees with the addressing it was asked under is discarded and re-read once under the shown layout (it would be some other element), also when an online edit re-types a settled array. A scalar BOOL answered 0xD3 with no element bit, or not settled packed, is refused BadTypeMismatch instead of decoding byte 0. m1: the write-owned Degraded marker is set together with the CAS that installs it, under one lock, so concurrent failing writes cannot leave it naming a different instance (a good write then clears it); a refusal-only read batch still keeps it write-owned. m2/m3/m5: documented (breaker accounting of a superseded init; libplctag throw-only NotImplemented assumption; the TypeMismatch write status word is libplctag-only). m4: test for a write on an unsettled layout (settles by a read first). Refs #737 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
@@ -149,14 +149,15 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
|
||||
/// <summary>Atomically replaces <see cref="_health"/> with <paramref name="update"/> applied to its current value.</summary>
|
||||
/// <param name="update">Computes the new health from the current one.</param>
|
||||
private void UpdateHealth(Func<DriverHealth, DriverHealth> update)
|
||||
/// <returns>The health instance this call installed.</returns>
|
||||
private DriverHealth UpdateHealth(Func<DriverHealth, DriverHealth> update)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var current = _health;
|
||||
var next = update(current);
|
||||
#pragma warning disable CS0420 // a volatile field passed by ref to Interlocked keeps its full-fence semantics
|
||||
if (ReferenceEquals(Interlocked.CompareExchange(ref _health, next, current), current)) return;
|
||||
if (ReferenceEquals(Interlocked.CompareExchange(ref _health, next, current), current)) return next;
|
||||
#pragma warning restore CS0420
|
||||
}
|
||||
}
|
||||
@@ -1505,6 +1506,9 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
else if (status == (int)AbStatus.TypeMismatch)
|
||||
{
|
||||
// Final review I4: deterministic — keep the handle, leave health, warn once per tag.
|
||||
// Only libplctag reaches this status-word branch, where it is a real controller answer: the native
|
||||
// runtime's pre-send type gate THROWS (caught below, no LastSuccessfulRead advance) and never
|
||||
// returns here with the status merely set (m5).
|
||||
results[i] = new WriteResult(AbCipStatusMapper.BadTypeMismatch);
|
||||
RecordWriteAnswered(now);
|
||||
WarnTypeMismatchOnce(def.Name,
|
||||
@@ -1645,23 +1649,35 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
/// replaced it since) is cleared back to <c>Healthy</c>: the device just accepted a write. A Degraded the read
|
||||
/// path recorded is left for the read path to clear.
|
||||
/// </remarks>
|
||||
private void RecordWriteAnswered(DateTime now) =>
|
||||
UpdateHealth(h => h.State == DriverState.Degraded && h.LastError is { } err
|
||||
&& ReferenceEquals(err, Volatile.Read(ref _writeFaultError))
|
||||
? new DriverHealth(DriverState.Healthy, now, null)
|
||||
: h with { LastSuccessfulRead = now });
|
||||
private void RecordWriteAnswered(DateTime now)
|
||||
{
|
||||
lock (_writeHealthLock)
|
||||
{
|
||||
UpdateHealth(h => h.State == DriverState.Degraded && h.LastError is { } err && ReferenceEquals(err, _writeOwnedError)
|
||||
? new DriverHealth(DriverState.Healthy, now, null)
|
||||
: h with { LastSuccessfulRead = now });
|
||||
}
|
||||
}
|
||||
|
||||
// The LastError string the most recent write-side comms fault wrote (reference identity marks "write-owned").
|
||||
private string? _writeFaultError;
|
||||
// The exact LastError string INSTANCE the most recent write-side comms fault installed (a fresh copy, so reference
|
||||
// identity cannot alias a read-side error). A health still carrying it is write-owned: a refusal-only read batch
|
||||
// keeps it (`with` copies the reference), while any read / poll fault installs a new string and takes ownership.
|
||||
// Set only under _writeHealthLock TOGETHER with the CAS that installs it, so two concurrent failing writes can never
|
||||
// leave the marker naming a different instance than the installed health (#737 m1).
|
||||
private string? _writeOwnedError;
|
||||
private readonly object _writeHealthLock = new();
|
||||
|
||||
/// <summary>A write-side comms fault: <c>Degraded</c> (never downgrading <c>Faulted</c>), <c>LastSuccessfulRead</c> preserved.</summary>
|
||||
private void RecordWriteCommsFault(string error)
|
||||
{
|
||||
var owned = new string(error.AsSpan()); // a distinct instance, so reference identity cannot alias a read error
|
||||
Volatile.Write(ref _writeFaultError, owned);
|
||||
UpdateHealth(h => h.State == DriverState.Faulted
|
||||
? h
|
||||
: new DriverHealth(DriverState.Degraded, h.LastSuccessfulRead, owned));
|
||||
var owned = new string(error.AsSpan());
|
||||
lock (_writeHealthLock)
|
||||
{
|
||||
var installed = UpdateHealth(h => h.State == DriverState.Faulted
|
||||
? h
|
||||
: new DriverHealth(DriverState.Degraded, h.LastSuccessfulRead, owned));
|
||||
if (ReferenceEquals(installed.LastError, owned)) _writeOwnedError = owned;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -7,5 +7,12 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
/// "my own token fired" must not mistake a superseded attempt for one. The superseding operation owns the driver's
|
||||
/// state; the caller (normally the runtime, which has already abandoned this attempt) can ignore the result.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Resilience accounting (#737 m2): not being an OCE, it passes the Initialize pipeline's breaker
|
||||
/// <c>ShouldHandle</c> (<c>DriverResiliencePipelineBuilder</c>) and, if its generation still matched, the actor's
|
||||
/// <c>RecordFault</c>. That is practically unreachable — <c>DriverInstanceActor</c> runs initialisation single-flight
|
||||
/// and drops generation-mismatched results — and a superseded attempt is at worst one counted failure. Recognising
|
||||
/// it there is a runtime-side (Lane A) change if it is ever wanted.
|
||||
/// </remarks>
|
||||
/// <param name="message">The detail.</param>
|
||||
public sealed class AbCipInitializationSupersededException(string message) : InvalidOperationException(message);
|
||||
|
||||
@@ -25,6 +25,10 @@ internal static class LibplctagStatusTranslation
|
||||
Status.ErrorTooSmall => AbStatus.TooSmall,
|
||||
// ErrorNotImplemented is the LIBRARY refusing (no I/O); the exception paths raise it as an
|
||||
// AbCipLocalRefusalException so it never counts as "the device answered" (see FromException).
|
||||
// ASSUMPTION (#737 m3): libplctag reports a failed operation by THROWING, so a NotImplemented reaches the driver
|
||||
// only through FromException. AbStatus has no "local" member (it is Driver.Ab.Transport's, Lane K), so a
|
||||
// NotImplemented observed only as a STATUS WORD (GetStatus after a non-throwing op) would still classify as a
|
||||
// device Unsupported refusal; that combination is not produced by libplctag.NET 1.5.2.
|
||||
Status.ErrorUnsupported or Status.ErrorNotImplemented => AbStatus.Unsupported,
|
||||
Status.ErrorPartial => AbStatus.Partial,
|
||||
Status.ErrorAbort => AbStatus.Abort,
|
||||
|
||||
@@ -41,15 +41,34 @@ 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, and cleared (with _path restored to _elementPath) the moment the
|
||||
// controller shows its BOOL array is element-addressed (#737 I1: a 0xC1 reply, or a refusal, to the DWORD request).
|
||||
// U11: a scalar BOOL authored over a BOOL-array element "Bits[n]" — the bit (n % 32) of DWORD n / 32 when the
|
||||
// array is packed. Null for every other tag.
|
||||
private int? _boolElementBit;
|
||||
|
||||
// The ORIGINAL element-addressed path ("Bits[n]") while a DWORD-addressed _path is still unconfirmed; null once
|
||||
// the layout is settled either way.
|
||||
// Both addressings of such a tag: "Bits[n / 32]" (packed: a Logix BOOL array is stored as 0xD3 DWORDs) and the
|
||||
// original "Bits[n]" (per-element: a family that answers 0xC1). _path is whichever _boolLayout selects.
|
||||
private byte[]? _dwordPath;
|
||||
private byte[]? _elementPath;
|
||||
|
||||
/// <summary>How a BOOL-array element tag's array is laid out, as the controller's REPLY TYPE last showed it.</summary>
|
||||
private enum BoolLayout
|
||||
{
|
||||
/// <summary>No reply type seen yet (or only refusals / comms faults): request DWORD-addressed, keep asking.</summary>
|
||||
Unsettled,
|
||||
|
||||
/// <summary>The controller answered 0xD3: address DWORD n / 32, decode bit n % 32.</summary>
|
||||
Packed,
|
||||
|
||||
/// <summary>The controller answered 0xC1: address element n, decode its byte.</summary>
|
||||
PerElement,
|
||||
}
|
||||
|
||||
// #737 fix round 2 (N1): ONLY a reply type settles (or re-settles) the layout — 0xD3 ⇒ Packed, 0xC1 ⇒ PerElement.
|
||||
// A refusal or comms fault says nothing about the layout and leaves it as it was, so a tag deployed before the
|
||||
// online edit that creates its array starts reading correctly once the array exists. A reply whose type disagrees
|
||||
// with the addressing it was asked under is DISCARDED (never decoded) and re-read under the right addressing.
|
||||
private BoolLayout _boolLayout;
|
||||
|
||||
private ConnectionLease? _lease;
|
||||
private RequestScheduler? _privateScheduler; // ForTest only — production always uses the lease's
|
||||
private LogixTagClient? _client;
|
||||
@@ -130,10 +149,15 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
|
||||
&& TrySplitBoolArrayElement(name, out var dwordName, out var bit))
|
||||
{
|
||||
_elementPath = CipPath.EncodeSymbolic(name);
|
||||
name = dwordName;
|
||||
_dwordPath = CipPath.EncodeSymbolic(dwordName);
|
||||
_boolElementBit = bit;
|
||||
_boolLayout = BoolLayout.Unsettled;
|
||||
path = _dwordPath;
|
||||
}
|
||||
else
|
||||
{
|
||||
path = CipPath.EncodeSymbolic(name);
|
||||
}
|
||||
path = CipPath.EncodeSymbolic(name);
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or FormatException or OverflowException)
|
||||
{
|
||||
@@ -161,32 +185,43 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task ReadAsync(CancellationToken cancellationToken)
|
||||
public Task ReadAsync(CancellationToken cancellationToken) => ReadCoreAsync(cancellationToken, mayRelayout: true);
|
||||
|
||||
private async Task ReadCoreAsync(CancellationToken cancellationToken, bool mayRelayout)
|
||||
{
|
||||
var client = Client();
|
||||
var path = Path();
|
||||
try
|
||||
{
|
||||
var (type, handle, data) = await client.ReadAsync(path, _elements, cancellationToken, ExpectedElementBytes(_p.AuthoredType)).ConfigureAwait(false);
|
||||
if (_elementPath is not null)
|
||||
if (_boolElementBit is not null)
|
||||
{
|
||||
// First answer to the DWORD-addressed BOOL-array request settles the layout: 0xD3 confirms it; anything
|
||||
// else means this family addresses BOOL-array ELEMENTS (e.g. a 0xC1 per-element array) — decoding or
|
||||
// writing element n / 32 there would hit the wrong element, so go back to "Bits[n]" and read again.
|
||||
if (type != LogixTypeCode.BoolArray) { FallBackToElementPath(); await ReadAsync(cancellationToken).ConfigureAwait(false); return; }
|
||||
_elementPath = null;
|
||||
BoolLayout? shown = type == LogixTypeCode.BoolArray ? BoolLayout.Packed
|
||||
: type == LogixTypeCode.Bool ? BoolLayout.PerElement
|
||||
: null; // any other type (a mistyped tag): no layout evidence; the type check below reports it
|
||||
if (shown is { } layout)
|
||||
{
|
||||
var askedPacked = ReferenceEquals(path, _dwordPath);
|
||||
SetBoolLayout(layout);
|
||||
if ((layout == BoolLayout.Packed) != askedPacked)
|
||||
{
|
||||
// The reply is for the OTHER addressing (element n / 32 of a per-element array, or DWORD n of a
|
||||
// packed one) — not this element. Discard it and ask again under the layout it just showed.
|
||||
if (mayRelayout)
|
||||
{
|
||||
await ReadCoreAsync(cancellationToken, mayRelayout: false).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
throw new AbTransportException(AbStatus.TypeMismatch,
|
||||
$"'{_p.TagName}': the controller's BOOL-array layout changed between two reads; retrying next read.");
|
||||
}
|
||||
}
|
||||
}
|
||||
_data = data;
|
||||
_typeCode = type;
|
||||
_structHandle = handle;
|
||||
_status = IsTypeMismatch(type) ? AbStatus.TypeMismatch : AbStatus.Ok;
|
||||
}
|
||||
catch (AbTransportException ex) when (_elementPath is not null && AbCipRefusals.IsDeviceRefusal(ex))
|
||||
{
|
||||
// The DWORD index was refused (e.g. out of bounds on an element-addressed array): retry as "Bits[n]".
|
||||
FallBackToElementPath();
|
||||
await ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (AbTransportException ex)
|
||||
{
|
||||
// libplctag reports a failed operation by exception AND leaves the status readable.
|
||||
@@ -195,12 +230,11 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Abandons the DWORD addressing of a BOOL-array element: the original "Bits[n]" path, no bit extraction.</summary>
|
||||
private void FallBackToElementPath()
|
||||
/// <summary>Settles a BOOL-array element tag's layout and selects the matching request path.</summary>
|
||||
private void SetBoolLayout(BoolLayout layout)
|
||||
{
|
||||
_path = _elementPath;
|
||||
_elementPath = null;
|
||||
_boolElementBit = null;
|
||||
_boolLayout = layout;
|
||||
_path = layout == BoolLayout.PerElement ? _elementPath : _dwordPath;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -236,8 +270,9 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
|
||||
try
|
||||
{
|
||||
// An unsettled BOOL-array element layout is settled by a read first, so a write never goes to the
|
||||
// DWORD-addressed element of an element-addressed array (#737 I1).
|
||||
if (_elementPath is not null)
|
||||
// DWORD-addressed element of an element-addressed array (#737 I1). A packed layout's write is refused below
|
||||
// (0xD3 DWORD — it would clobber 31 neighbours); a per-element one writes "Bits[n]" as a BOOL.
|
||||
if (_boolElementBit is not null && _boolLayout == BoolLayout.Unsettled)
|
||||
{
|
||||
await ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
path = Path();
|
||||
@@ -323,9 +358,7 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
|
||||
|
||||
return type switch
|
||||
{
|
||||
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.Bool => bitIndex is int bit ? GetBit(bit) : DecodeScalarBool(offset),
|
||||
AbCipDataType.SInt => (int)(sbyte)Span(offset, 1)[0],
|
||||
AbCipDataType.USInt => (int)Span(offset, 1)[0],
|
||||
AbCipDataType.Int => (int)BinaryPrimitives.ReadInt16LittleEndian(Span(offset, 2)),
|
||||
@@ -471,6 +504,22 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
|
||||
catch (Exception) { /* ForTest only; Dispose must not throw */ }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A scalar BOOL. A packed (0xD3) reply is decoded ONLY as the element's own bit of the DWORD the packed layout
|
||||
/// addressed; any other 0xD3 reply (no element bit, or not settled packed) is refused rather than decoded as
|
||||
/// "byte 0 != 0" — that value would be some other bit, published as Good (#737 N1).
|
||||
/// </summary>
|
||||
private bool DecodeScalarBool(int offset)
|
||||
{
|
||||
if (offset == 0 && _typeCode == LogixTypeCode.BoolArray)
|
||||
{
|
||||
if (_boolElementBit is int element && _boolLayout == BoolLayout.Packed) return GetBit(element);
|
||||
throw new AbTransportException(AbStatus.TypeMismatch,
|
||||
$"'{_p.TagName}' is a scalar BOOL but the controller answered a packed BOOL-array DWORD (0xD3) with no element bit to select.");
|
||||
}
|
||||
return Span(offset, 1)[0] != 0;
|
||||
}
|
||||
|
||||
/// <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
|
||||
|
||||
@@ -128,4 +128,48 @@ public sealed class AbCipWriteHealthTests
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
drv.GetHealth().LastError.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// m1: many concurrent failing writes leave the write-owned marker naming the installed Degraded, so one good
|
||||
/// write still clears it.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Concurrent_failing_writes_then_a_good_write_clear_the_write_side_Degraded()
|
||||
{
|
||||
var fail = true;
|
||||
var (drv, _) = await BuildAsync(p => fail ? new ThrowingWriteTag(p) : new RealEncodingTag(p));
|
||||
for (var round = 0; round < 20; round++)
|
||||
{
|
||||
fail = true;
|
||||
await Task.WhenAll(Enumerable.Range(0, 8).Select(_ => Task.Run(() =>
|
||||
drv.WriteAsync([new WriteRequest("D", 1), new WriteRequest("I", 2)], CancellationToken.None))));
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
|
||||
fail = false;
|
||||
(await drv.WriteAsync([new WriteRequest("D", 3)], CancellationToken.None))[0].StatusCode
|
||||
.ShouldBe(AbCipStatusMapper.Good);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy, $"round {round}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// M3: a refusal-only read batch in between (no read comms fault happened) keeps the Degraded write-owned, so a
|
||||
/// good write still clears it.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_refusal_only_read_between_keeps_the_Degraded_write_owned()
|
||||
{
|
||||
var fail = true;
|
||||
var (drv, _) = await BuildAsync(p => p.TagName == "Broken"
|
||||
? new FakeAbCipTag(p) { Status = (int)ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.AbStatus.NotFound }
|
||||
: fail ? new ThrowingWriteTag(p) : new RealEncodingTag(p));
|
||||
await drv.WriteAsync([new WriteRequest("D", 5)], CancellationToken.None);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
(await drv.ReadAsync(["Broken"], CancellationToken.None))[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown);
|
||||
|
||||
fail = false;
|
||||
await drv.WriteAsync([new WriteRequest("D", 6)], CancellationToken.None);
|
||||
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,6 +159,108 @@ public sealed class NativeAbCipTagRuntimeTests
|
||||
ch.Sent[^1].ShouldBe(Convert.FromHexString("4D04" + "910442697473" + "2825" + "C100" + "0100" + "00")); // Bits[37]
|
||||
}
|
||||
|
||||
/// <summary>A channel whose reply can be switched mid-test (read replies; writes always succeed).</summary>
|
||||
private static FakeCipChannel Switchable(Func<string> readReply) => new()
|
||||
{
|
||||
Respond = req => Task.FromResult(Convert.FromHexString(req[0] == 0x4D ? "CD000000" : readReply())),
|
||||
};
|
||||
|
||||
private const string Dword1 = "5204" + "910442697473" + "2801" + "0100" + "00000000"; // Bits[1] (DWORD 37/32)
|
||||
private const string Element37 = "5204" + "910442697473" + "2825" + "0100" + "00000000"; // Bits[37] (element)
|
||||
|
||||
/// <summary>
|
||||
/// #737 fix round 2 (N1): a REFUSAL of the DWORD request says nothing about the layout — it must not settle it.
|
||||
/// Tag deployed before the online edit that creates <c>Bits : BOOL[2048]</c>: the init read is refused NotFound;
|
||||
/// once the array exists the controller answers 0xD3, and the read must address DWORD n/32 and decode bit n%32
|
||||
/// (the round-1 code had permanently fallen back to <c>Bits[37]</c> = DWORD 37 and decoded byte 0 as Good).
|
||||
/// </summary>
|
||||
/// <param name="refusal">The refusal reply the DWORD request first gets.</param>
|
||||
[Theory]
|
||||
[InlineData("D2000400")] // NotFound
|
||||
[InlineData("D2000F00")] // NotAllowed
|
||||
[InlineData("D2000A00")] // OutOfBounds
|
||||
public async Task A_refusal_leaves_the_BOOL_layout_unsettled_and_a_later_DWORD_reply_decodes_the_right_bit(string refusal)
|
||||
{
|
||||
var reply = refusal;
|
||||
var ch = Switchable(() => reply);
|
||||
using var rt = NativeAbCipTagRuntime.ForTest(ch, Params("Bits[37]", AbCipDataType.Bool));
|
||||
await rt.InitializeAsync(CancellationToken.None); // refused: completes with the refusal status
|
||||
rt.GetStatus().ShouldNotBe((int)AbStatus.Ok);
|
||||
ch.Sent[^1].ShouldBe(Convert.FromHexString(Dword1)); // never fell back to the element path
|
||||
|
||||
reply = "D2000000D300" + "20000000"; // bit 5 of DWORD 1 = element 37
|
||||
await rt.ReadAsync(CancellationToken.None);
|
||||
|
||||
ch.Sent[^1].ShouldBe(Convert.FromHexString(Dword1));
|
||||
rt.DecodeValue(AbCipDataType.Bool, null).ShouldBe(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// N1 positive control: a refusal, then a 0xC1 reply, settles PER-ELEMENT — the n/32 element that came back is
|
||||
/// discarded and <c>Bits[n]</c> is re-read and decoded.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_refusal_then_a_per_element_reply_settles_the_element_path()
|
||||
{
|
||||
var reply = "D2000A00";
|
||||
var ch = Switchable(() => reply);
|
||||
using var rt = NativeAbCipTagRuntime.ForTest(ch, Params("Bits[37]", AbCipDataType.Bool));
|
||||
await rt.InitializeAsync(CancellationToken.None);
|
||||
|
||||
reply = "D2000000C10001";
|
||||
await rt.ReadAsync(CancellationToken.None);
|
||||
|
||||
ch.Sent[^2].ShouldBe(Convert.FromHexString(Dword1)); // asked DWORD-addressed, got a plain BOOL…
|
||||
ch.Sent[^1].ShouldBe(Convert.FromHexString(Element37)); // …so element 37 is re-read and decoded
|
||||
rt.DecodeValue(AbCipDataType.Bool, null).ShouldBe(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// N1: a layout that changes after it was settled (an online edit re-types the array) re-settles from the reply
|
||||
/// type — it never decodes a DWORD reply to the ELEMENT request (DWORD n, byte 0) as the element's value.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_settled_per_element_layout_resettles_when_the_reply_becomes_a_DWORD()
|
||||
{
|
||||
var reply = "D2000000C10000";
|
||||
var ch = Switchable(() => reply);
|
||||
using var rt = NativeAbCipTagRuntime.ForTest(ch, Params("Bits[36]", AbCipDataType.Bool));
|
||||
await rt.InitializeAsync(CancellationToken.None); // settles per-element
|
||||
|
||||
reply = "D2000000D300" + "01000000"; // byte 0 non-zero, bit 4 (element 36) clear
|
||||
await rt.ReadAsync(CancellationToken.None);
|
||||
|
||||
ch.Sent[^1].ShouldBe(Convert.FromHexString("5204" + "910442697473" + "2801" + "0100" + "00000000"));
|
||||
rt.DecodeValue(AbCipDataType.Bool, null).ShouldBe(false); // bit 4 of DWORD 1, not "byte 0 != 0"
|
||||
}
|
||||
|
||||
/// <summary>N1 minimum guard: a scalar BOOL with no element bit answered as a packed DWORD is refused, not byte 0.</summary>
|
||||
[Fact]
|
||||
public async Task A_scalar_BOOL_answered_as_a_packed_DWORD_without_an_element_bit_is_refused()
|
||||
{
|
||||
using var rt = await ReadAsync(Replying("D300", "01000000"), Params("Flag", AbCipDataType.Bool));
|
||||
(Should.Throw<AbTransportException>(() => rt.DecodeValue(AbCipDataType.Bool, null))).Status.ShouldBe(AbStatus.TypeMismatch);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// m4: a runtime whose init read failed on the wire (layout still unsettled) settles the layout by a read BEFORE
|
||||
/// it writes, so the write goes to the right element of a per-element array.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_write_on_an_unsettled_layout_reads_first_and_writes_the_element()
|
||||
{
|
||||
var reply = "D2000100"; // 0x01: not a refusal — init throws, layout unsettled
|
||||
var ch = Switchable(() => reply);
|
||||
using var rt = NativeAbCipTagRuntime.ForTest(ch, Params("Bits[37]", AbCipDataType.Bool));
|
||||
await Should.ThrowAsync<AbTransportException>(() => rt.InitializeAsync(CancellationToken.None));
|
||||
|
||||
reply = "D2000000C10000";
|
||||
rt.EncodeValue(AbCipDataType.Bool, null, true);
|
||||
await rt.WriteAsync(CancellationToken.None);
|
||||
|
||||
ch.Sent[^1].ShouldBe(Convert.FromHexString("4D04" + "910442697473" + "2825" + "C100" + "0100" + "01")); // Bits[37]
|
||||
}
|
||||
|
||||
/// <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()
|
||||
|
||||
Reference in New Issue
Block a user