fix(abcip): review fix round 1 - BOOL-array fallback, local refusals, minors
I1: the U11 Bits[n] -> Bits[n/32] rewrite was unconditional. The native runtime now keeps the original element path until the first reply settles the layout: 0xD3 confirms DWORD addressing (bit n%32); any other reply type (e.g. a 0xC1 per-element BOOL array) or a refusal of the DWORD index falls back to Bits[n] and re-reads, and a write settles the layout by a read first, so it can never decode or write element n/32 of an element-addressed array. I2: libplctag ErrorNoMatch/ErrorBadDevice no longer fold into NotFound (they now read as BadConnection: comms, Degraded, LastSuccessfulRead preserved). ErrorNotImplemented - and the native runtime's own pre-send refusals - raise AbCipLocalRefusalException: BadNotSupported, no health, LastSuccessfulRead NOT advanced, no backoff record, not reachable for the probe or Test Connect. M1: the bit RMW's parent read/write status words follow the health contract (refusal: per-tag Bad + Warning; comms: evict the parent + Degraded). M2: device Unsupported read refusals return Refused (advance LastSuccessfulRead); device write refusals call RecordWriteAnswered (not the pre-send native type gate). M3: a good write clears a Degraded a write comms fault recorded (a read-side Degraded is still left for the read path). M4: a superseded init throws AbCipInitializationSupersededException (not an OCE); ShutdownAsync stamps Unknown only if no newer init published meanwhile. M5: the sync-Dispose timing assert is 2 s against a 4 s probe read; the unencodable-array test asserts health. Refs #737 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
@@ -515,7 +515,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
lock (_lifecycleLock)
|
||||
{
|
||||
if (generation != _lifecycleGeneration)
|
||||
throw new OperationCanceledException(
|
||||
throw new AbCipInitializationSupersededException(
|
||||
$"AbCip driver {_driverInstanceId} initialization was superseded by a shutdown or a newer initialization.");
|
||||
|
||||
_options = options;
|
||||
@@ -562,7 +562,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
// Never-published states hold no probe loop and no runtime yet; dispose for symmetry.
|
||||
if (builtDevices is not null)
|
||||
foreach (var state in builtDevices.Values) state.Retire();
|
||||
if (ex is OperationCanceledException)
|
||||
if (ex is AbCipInitializationSupersededException)
|
||||
{
|
||||
_logger.LogInformation("AbCip driver {DriverInstanceId} initialization abandoned: {Reason}",
|
||||
_driverInstanceId, ex.Message);
|
||||
@@ -652,9 +652,15 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
public async Task ShutdownAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await _poll.DisposeAsync().ConfigureAwait(false);
|
||||
await ReleaseDeviceStatesAsync().ConfigureAwait(false);
|
||||
var generation = await ReleaseDeviceStatesAsync().ConfigureAwait(false);
|
||||
_resolver.Clear(); // no-op in v3 (the resolver reads the live table by closure); kept for symmetry
|
||||
UpdateHealth(h => new DriverHealth(DriverState.Unknown, h.LastSuccessfulRead, null));
|
||||
lock (_lifecycleLock)
|
||||
{
|
||||
// M4: a newer InitializeAsync that published while this shutdown was still joining its probe loops owns the
|
||||
// health now — stamping Unknown over its Healthy would misreport a live driver.
|
||||
if (generation == _lifecycleGeneration)
|
||||
UpdateHealth(h => new DriverHealth(DriverState.Unknown, h.LastSuccessfulRead, null));
|
||||
}
|
||||
}
|
||||
|
||||
// ---- ISubscribable (polling overlay via shared engine) ----
|
||||
@@ -764,10 +770,10 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
probeRuntime = null;
|
||||
}
|
||||
}
|
||||
catch (AbCipTagConfigurationException ace)
|
||||
catch (Exception ace) when (ace is AbCipTagConfigurationException or AbCipLocalRefusalException)
|
||||
{
|
||||
// The probe tag's own name cannot become a request — it never reached the device, so it is no evidence
|
||||
// either way: no backoff signal, no host transition, one Warning.
|
||||
// The probe tag's own name cannot become a request (or the library refused it locally) — it never
|
||||
// reached the device, so it is no evidence either way: no backoff signal, no host transition, one Warning.
|
||||
try { probeRuntime?.Dispose(); } catch { }
|
||||
probeRuntime = null;
|
||||
state.ProbeInitialized = false;
|
||||
@@ -1145,13 +1151,21 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (AbCipLocalRefusalException local)
|
||||
{
|
||||
// #737 I2: refused by the library / driver itself — no I/O, so no proof the device answered: BadNotSupported,
|
||||
// keep the handle, no health signal, LastSuccessfulRead untouched.
|
||||
results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadNotSupported, null, now);
|
||||
WarnRefusalOnce(def.Name, "read", reference, def.DeviceHostAddress, local.Message);
|
||||
return ReadOutcome.None;
|
||||
}
|
||||
catch (AbTransportException ate) when (ate.Status == AbStatus.Unsupported)
|
||||
{
|
||||
// A deterministic local refusal — not a comms fault: BadNotSupported, keep the handle, no
|
||||
// health signal.
|
||||
// The DEVICE refused (CIP 0x08, or a decode refusal of data it returned) — not a comms fault:
|
||||
// BadNotSupported, keep the handle, no state change; it answered, so LastSuccessfulRead advances (M2).
|
||||
results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadNotSupported, null, now);
|
||||
WarnRefusalOnce(def.Name, "read", reference, def.DeviceHostAddress, ate.Message);
|
||||
return ReadOutcome.None;
|
||||
return ReadOutcome.Refused;
|
||||
}
|
||||
catch (AbCipTagConfigurationException ace)
|
||||
{
|
||||
@@ -1169,7 +1183,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
reference, def.DeviceHostAddress, ate.Message);
|
||||
return ReadOutcome.Refused;
|
||||
}
|
||||
catch (AbTransportException ate) when (AbCipRefusals.IsDeviceRefusal(ate.Status))
|
||||
catch (AbTransportException ate) when (AbCipRefusals.IsDeviceRefusal(ate))
|
||||
{
|
||||
// H1/L4: the controller answered "no" (renamed tag, Arr[999], read-protected …) — a per-tag config
|
||||
// error, not a comms fault. Keep the handle (no evict → no lease / Forward-Open churn), no Degraded,
|
||||
@@ -1284,10 +1298,17 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (AbCipLocalRefusalException local)
|
||||
{
|
||||
StampGroupStatus(group, results, now, AbCipStatusMapper.BadNotSupported);
|
||||
WarnRefusalOnce(parent.Name, "whole-UDT read", group.ParentName, parent.DeviceHostAddress, local.Message);
|
||||
return ReadOutcome.None;
|
||||
}
|
||||
catch (AbTransportException ate) when (ate.Status == AbStatus.Unsupported)
|
||||
{
|
||||
StampGroupStatus(group, results, now, AbCipStatusMapper.BadNotSupported);
|
||||
return ReadOutcome.None;
|
||||
WarnRefusalOnce(parent.Name, "whole-UDT read", group.ParentName, parent.DeviceHostAddress, ate.Message);
|
||||
return ReadOutcome.Refused;
|
||||
}
|
||||
catch (AbCipTagConfigurationException ace)
|
||||
{
|
||||
@@ -1485,14 +1506,17 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
{
|
||||
// Final review I4: deterministic — keep the handle, leave health, warn once per tag.
|
||||
results[i] = new WriteResult(AbCipStatusMapper.BadTypeMismatch);
|
||||
RecordWriteAnswered(now);
|
||||
WarnTypeMismatchOnce(def.Name,
|
||||
"AbCip write to {Tag} on device {Device} refused: the controller's type does not match the authored {DataType} (logged once per tag)",
|
||||
w.FullReference, def.DeviceHostAddress, def.DataType);
|
||||
}
|
||||
else if (AbCipRefusals.IsDeviceRefusal(status))
|
||||
{
|
||||
// The controller refused this write (read-only, out of bounds …): keep the handle, no health.
|
||||
// The controller refused this write (read-only, out of bounds …): keep the handle, no state change;
|
||||
// it answered, so LastSuccessfulRead advances (M2).
|
||||
results[i] = new WriteResult(AbCipStatusMapper.MapStatus(status));
|
||||
RecordWriteAnswered(now);
|
||||
WarnRefusalOnce(def.Name, "write", w.FullReference, def.DeviceHostAddress, ((AbStatus)status).ToString());
|
||||
}
|
||||
else if (status != 0)
|
||||
@@ -1550,14 +1574,20 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
"AbCip write to {Tag} on device {Device}: the value is out of range for {DataType}: {Reason} (logged once per tag)",
|
||||
w.FullReference, def.DeviceHostAddress, def.DataType, oe.Message);
|
||||
}
|
||||
catch (AbCipLocalRefusalException local)
|
||||
{
|
||||
// Refused locally (e.g. the native runtime cannot type the write; libplctag NotImplemented) — nothing
|
||||
// sent: BadNotSupported, keep the handle, leave health alone.
|
||||
results[i] = new WriteResult(AbCipStatusMapper.BadNotSupported);
|
||||
WarnRefusalOnce(def.Name, "write", w.FullReference, def.DeviceHostAddress, local.Message);
|
||||
}
|
||||
catch (AbTransportException ate) when (ate.Status == AbStatus.Unsupported)
|
||||
{
|
||||
// A deterministic local refusal (e.g. the native runtime cannot type the write) — not a comms
|
||||
// fault: BadNotSupported, keep the handle, leave health alone.
|
||||
// The device refused the write as unsupported — BadNotSupported, keep the handle, no state change;
|
||||
// it answered, so LastSuccessfulRead advances (M2).
|
||||
results[i] = new WriteResult(AbCipStatusMapper.BadNotSupported);
|
||||
_logger.LogWarning(ate,
|
||||
"AbCip write refused (not supported) for tag {Tag} on device {Device}",
|
||||
w.FullReference, def.DeviceHostAddress);
|
||||
RecordWriteAnswered(now);
|
||||
WarnRefusalOnce(def.Name, "write", w.FullReference, def.DeviceHostAddress, ate.Message);
|
||||
}
|
||||
catch (AbCipTagConfigurationException ace)
|
||||
{
|
||||
@@ -1570,15 +1600,18 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
// Final review I4: the controller's type disagrees with the authored one (native type gate, or
|
||||
// CIP 0xFF/0x2107 from the controller) — deterministic: BadTypeMismatch, keep the handle, leave
|
||||
// health alone, warn once per tag. Retrying cannot succeed until the config is corrected.
|
||||
// No LastSuccessfulRead advance: the native type gate throws this BEFORE sending.
|
||||
results[i] = new WriteResult(AbCipStatusMapper.BadTypeMismatch);
|
||||
WarnTypeMismatchOnce(def.Name,
|
||||
"AbCip write to {Tag} on device {Device} refused: {Reason} (logged once per tag)",
|
||||
w.FullReference, def.DeviceHostAddress, ate.Message);
|
||||
}
|
||||
catch (AbTransportException ate) when (AbCipRefusals.IsDeviceRefusal(ate.Status))
|
||||
catch (AbTransportException ate) when (AbCipRefusals.IsDeviceRefusal(ate))
|
||||
{
|
||||
// H1: the controller answered "no" to this write — keep the handle, leave health alone, warn once.
|
||||
// H1: the controller answered "no" to this write — keep the handle, no state change, warn once; it
|
||||
// answered, so LastSuccessfulRead advances (M2).
|
||||
results[i] = new WriteResult(AbCipStatusMapper.MapStatus(ate.Status));
|
||||
RecordWriteAnswered(now);
|
||||
WarnRefusalOnce(def.Name, "write", w.FullReference, def.DeviceHostAddress, ate.Message);
|
||||
}
|
||||
catch (Exception) when (device.Retired)
|
||||
@@ -1607,14 +1640,29 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
/// A good write proves the device answered: advance <c>LastSuccessfulRead</c> but leave the STATE alone. Writing
|
||||
/// <c>Healthy</c> here (H19) overwrote a <c>Degraded</c> the poll loop had just recorded for other tags.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// M3: a <c>Degraded</c> that a WRITE comms fault recorded (still the current health — same error, no read has
|
||||
/// 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 with { LastSuccessfulRead = 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 });
|
||||
|
||||
// The LastError string the most recent write-side comms fault wrote (reference identity marks "write-owned").
|
||||
private string? _writeFaultError;
|
||||
|
||||
/// <summary>A write-side comms fault: <c>Degraded</c> (never downgrading <c>Faulted</c>), <c>LastSuccessfulRead</c> preserved.</summary>
|
||||
private void RecordWriteCommsFault(string error) =>
|
||||
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, error));
|
||||
: new DriverHealth(DriverState.Degraded, h.LastSuccessfulRead, owned));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read-modify-write one bit within an integer parent (SINT / INT / DINT / LINT — Logix allows <c>.n</c> on
|
||||
@@ -1636,7 +1684,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
var parentRuntime = await EnsureParentRuntimeAsync(device, parentName, ct).ConfigureAwait(false);
|
||||
await parentRuntime.ReadAsync(ct).ConfigureAwait(false);
|
||||
var readStatus = parentRuntime.GetStatus();
|
||||
if (readStatus != 0) return AbCipStatusMapper.MapStatus(readStatus);
|
||||
if (readStatus != 0) return BitWriteStatusWord(device, parentName, readStatus, "read");
|
||||
|
||||
// Unsigned at the narrow widths so a set top bit (SINT bit 7, INT bit 15) encodes without overflowing the
|
||||
// signed type; DINT keeps its signed int arithmetic (1 << 31 is int.MinValue, which a DINT encodes as is).
|
||||
@@ -1685,7 +1733,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
var writeStatus = parentRuntime.GetStatus();
|
||||
return writeStatus == 0
|
||||
? AbCipStatusMapper.Good
|
||||
: AbCipStatusMapper.MapStatus(writeStatus);
|
||||
: BitWriteStatusWord(device, parentName, writeStatus, "write");
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -1693,6 +1741,28 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// M1: a non-zero status word on the bit write's parent read / write follows the health contract — a device
|
||||
/// refusal is per-tag Bad with one Warning and no state change (it answered: LastSuccessfulRead advances); any
|
||||
/// other status is a comms fault: evict the PARENT runtime and write Degraded (LastSuccessfulRead preserved).
|
||||
/// </summary>
|
||||
private uint BitWriteStatusWord(DeviceState device, string parentName, int status, string phase)
|
||||
{
|
||||
var mapped = AbCipStatusMapper.MapStatus(status);
|
||||
if (AbCipRefusals.IsDeviceRefusal(status))
|
||||
{
|
||||
RecordWriteAnswered(DateTime.UtcNow);
|
||||
WarnRefusalOnce(parentName, $"bit-write parent {phase}", parentName, device.Options.HostAddress, ((AbStatus)status).ToString());
|
||||
return mapped;
|
||||
}
|
||||
EvictParentRuntime(device, parentName);
|
||||
RecordWriteCommsFault($"transport status {status} on the bit-write parent {phase} of {parentName}");
|
||||
_logger.LogWarning(
|
||||
"AbCip bit-write parent {Phase} of {Parent} on device {Device} returned transport status {Status}; evicting the parent runtime",
|
||||
phase, parentName, device.Options.HostAddress, status);
|
||||
return mapped;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get or lazily create a parent-DINT runtime for a parent tag path, cached per-device
|
||||
/// so repeated bit writes against the same DINT share one handle.
|
||||
@@ -1802,10 +1872,10 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
if (!device.Runtimes.TryAdd(def.Name, runtime)) runtime.Dispose();
|
||||
throw;
|
||||
}
|
||||
catch (AbCipTagConfigurationException)
|
||||
catch (Exception ex) when (ex is AbCipTagConfigurationException or AbCipLocalRefusalException)
|
||||
{
|
||||
// The tag's own name cannot become a request — no I/O happened, so it says nothing
|
||||
// about the device: leave the backoff window exactly as it was.
|
||||
// The tag's own name cannot become a request, or the library refused the tag locally — no I/O happened,
|
||||
// so it says nothing about the device: leave the backoff window exactly as it was.
|
||||
runtime.Dispose();
|
||||
throw;
|
||||
}
|
||||
|
||||
@@ -206,7 +206,8 @@ public sealed class AbCipDriverProbe : IDriverProbe
|
||||
internal static bool IsReachableException(AbTransportException ex)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(ex);
|
||||
return IsReachableStatus(ex.Status);
|
||||
// A local refusal (the library / driver refused without any I/O) proves nothing about the controller.
|
||||
return ex is not AbCipLocalRefusalException && IsReachableStatus(ex.Status);
|
||||
}
|
||||
|
||||
private static (AbCipHostAddress? parsed, AbCipDeviceOptions? device) ExtractTarget(AbCipDriverOptions opts)
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// Thrown by <see cref="AbCipDriver.InitializeAsync"/> when a shutdown, dispose or newer initialization retired the
|
||||
/// driver's tables while this initialization was building, so it published nothing. Deliberately NOT an
|
||||
/// <see cref="OperationCanceledException"/>: nobody cancelled this call, and a caller that treats cancellation as
|
||||
/// "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>
|
||||
/// <param name="message">The detail.</param>
|
||||
public sealed class AbCipInitializationSupersededException(string message) : InvalidOperationException(message);
|
||||
@@ -0,0 +1,15 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// A refusal raised LOCALLY — by the driver's own runtime or by libplctag (<c>ErrorNotImplemented</c>) — before or
|
||||
/// without any reply from the controller. It carries <see cref="AbStatus.Unsupported"/> (so it maps to
|
||||
/// <c>BadNotSupported</c>) but, unlike a device refusal, proves nothing about the device: it keeps the runtime and
|
||||
/// writes no health, does NOT advance <c>LastSuccessfulRead</c>, records nothing in the connect backoff, and is not
|
||||
/// "reachable" for the probe or Test Connect (review 07 / Gitea #737 I2).
|
||||
/// </summary>
|
||||
/// <param name="message">The refusal detail.</param>
|
||||
/// <param name="inner">The underlying exception, if any.</param>
|
||||
public sealed class AbCipLocalRefusalException(string message, Exception? inner = null)
|
||||
: AbTransportException(AbStatus.Unsupported, message, inner);
|
||||
@@ -36,5 +36,7 @@ internal static class AbCipRefusals
|
||||
/// <summary>True when <paramref name="ex"/> is an <see cref="AbTransportException"/> carrying a device refusal.</summary>
|
||||
/// <param name="ex">The exception.</param>
|
||||
/// <returns><see langword="true"/> for a deterministic device refusal.</returns>
|
||||
public static bool IsDeviceRefusal(Exception ex) => ex is AbTransportException ate && IsDeviceRefusal(ate.Status);
|
||||
/// <remarks>An <see cref="AbCipLocalRefusalException"/> is never a device refusal: nothing reached the device.</remarks>
|
||||
public static bool IsDeviceRefusal(Exception ex) =>
|
||||
ex is AbTransportException ate and not AbCipLocalRefusalException && IsDeviceRefusal(ate.Status);
|
||||
}
|
||||
|
||||
@@ -14,11 +14,17 @@ internal static class LibplctagStatusTranslation
|
||||
Status.Ok => AbStatus.Ok,
|
||||
Status.Pending => AbStatus.Pending,
|
||||
Status.ErrorTimeout => AbStatus.Timeout,
|
||||
Status.ErrorNotFound or Status.ErrorNoMatch or Status.ErrorBadDevice => AbStatus.NotFound,
|
||||
// #737 I2: only ErrorNotFound is the controller saying "no such tag". ErrorNoMatch / ErrorBadDevice describe a
|
||||
// session or device-level failure (no proof the controller answered this tag), so they are comms-class: with
|
||||
// the widened refusal classification, folding them into NotFound made a broken session read as "reachable".
|
||||
Status.ErrorNotFound => AbStatus.NotFound,
|
||||
Status.ErrorNoMatch or Status.ErrorBadDevice => AbStatus.BadConnection,
|
||||
Status.ErrorNotAllowed => AbStatus.NotAllowed,
|
||||
Status.ErrorOutOfBounds => AbStatus.OutOfBounds,
|
||||
Status.ErrorTooLarge => AbStatus.TooLarge,
|
||||
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).
|
||||
Status.ErrorUnsupported or Status.ErrorNotImplemented => AbStatus.Unsupported,
|
||||
Status.ErrorPartial => AbStatus.Partial,
|
||||
Status.ErrorAbort => AbStatus.Abort,
|
||||
@@ -70,9 +76,21 @@ internal static class LibplctagStatusTranslation
|
||||
public static async Task Wrap(Func<Task> op)
|
||||
{
|
||||
try { await op().ConfigureAwait(false); }
|
||||
catch (LibPlcTagException ex) { throw new AbTransportException(ToAbStatus(ex), ex.Message, ex); }
|
||||
catch (LibPlcTagException ex) { throw FromException(ex); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The transport exception for a libplctag failure: <c>ErrorNotImplemented</c> becomes an
|
||||
/// <see cref="AbCipLocalRefusalException"/> (a library refusal, no I/O); everything else an
|
||||
/// <see cref="AbTransportException"/> carrying the translated status.
|
||||
/// </summary>
|
||||
/// <param name="ex">The libplctag exception.</param>
|
||||
/// <returns>The exception to throw.</returns>
|
||||
public static AbTransportException FromException(LibPlcTagException ex) =>
|
||||
Enum.TryParse<Status>((ex.Message ?? string.Empty).Trim(), ignoreCase: true, out var s) && s == Status.ErrorNotImplemented
|
||||
? new AbCipLocalRefusalException($"libplctag does not implement this operation: {ex.Message}", ex)
|
||||
: new AbTransportException(ToAbStatus(ex), ex.Message ?? string.Empty, ex);
|
||||
|
||||
/// <summary>Runs <paramref name="op"/>, rethrowing any <see cref="LibPlcTagException"/> as <see cref="AbTransportException"/>.</summary>
|
||||
/// <typeparam name="T">The result type.</typeparam>
|
||||
/// <param name="op">The libplctag operation.</param>
|
||||
@@ -80,7 +98,7 @@ internal static class LibplctagStatusTranslation
|
||||
public static async Task<T> Wrap<T>(Func<Task<T>> op)
|
||||
{
|
||||
try { return await op().ConfigureAwait(false); }
|
||||
catch (LibPlcTagException ex) { throw new AbTransportException(ToAbStatus(ex), ex.Message, ex); }
|
||||
catch (LibPlcTagException ex) { throw FromException(ex); }
|
||||
}
|
||||
|
||||
/// <summary>Runs a synchronous libplctag call, rethrowing <see cref="LibPlcTagException"/> as <see cref="AbTransportException"/>.</summary>
|
||||
@@ -90,7 +108,7 @@ internal static class LibplctagStatusTranslation
|
||||
public static T WrapSync<T>(Func<T> op)
|
||||
{
|
||||
try { return op(); }
|
||||
catch (LibPlcTagException ex) { throw new AbTransportException(ToAbStatus(ex), ex.Message, ex); }
|
||||
catch (LibPlcTagException ex) { throw FromException(ex); }
|
||||
}
|
||||
|
||||
/// <summary>Runs a synchronous libplctag call, rethrowing <see cref="LibPlcTagException"/> as <see cref="AbTransportException"/>.</summary>
|
||||
@@ -98,6 +116,6 @@ internal static class LibplctagStatusTranslation
|
||||
public static void WrapSync(Action op)
|
||||
{
|
||||
try { op(); }
|
||||
catch (LibPlcTagException ex) { throw new AbTransportException(ToAbStatus(ex), ex.Message, ex); }
|
||||
catch (LibPlcTagException ex) { throw FromException(ex); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,9 +42,14 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
|
||||
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.
|
||||
// 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).
|
||||
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.
|
||||
private byte[]? _elementPath;
|
||||
|
||||
private ConnectionLease? _lease;
|
||||
private RequestScheduler? _privateScheduler; // ForTest only — production always uses the lease's
|
||||
private LogixTagClient? _client;
|
||||
@@ -124,6 +129,7 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
|
||||
if (split.Bit is null && !_p.IsArray && _p.AuthoredType == AbCipDataType.Bool
|
||||
&& TrySplitBoolArrayElement(name, out var dwordName, out var bit))
|
||||
{
|
||||
_elementPath = CipPath.EncodeSymbolic(name);
|
||||
name = dwordName;
|
||||
_boolElementBit = bit;
|
||||
}
|
||||
@@ -148,7 +154,7 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
|
||||
{
|
||||
await ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (AbTransportException ex) when (AbCipRefusals.IsDeviceRefusal(ex.Status))
|
||||
catch (AbTransportException ex) when (AbCipRefusals.IsDeviceRefusal(ex))
|
||||
{
|
||||
// The controller answered. _status already holds the refusal.
|
||||
}
|
||||
@@ -162,11 +168,25 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
|
||||
try
|
||||
{
|
||||
var (type, handle, data) = await client.ReadAsync(path, _elements, cancellationToken, ExpectedElementBytes(_p.AuthoredType)).ConfigureAwait(false);
|
||||
if (_elementPath 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;
|
||||
}
|
||||
_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.
|
||||
@@ -175,6 +195,14 @@ 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()
|
||||
{
|
||||
_path = _elementPath;
|
||||
_elementPath = null;
|
||||
_boolElementBit = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Upper bound on one element's wire bytes for the scheduler's reply budget; 0 when the
|
||||
/// authored type is unknown or a structure, which sends the read alone rather than guess.
|
||||
@@ -207,6 +235,13 @@ 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)
|
||||
{
|
||||
await ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
path = Path();
|
||||
}
|
||||
if (_pendingString is { } text)
|
||||
{
|
||||
// A STRING writes as the type the controller reported — a Logix STRING is a structure
|
||||
@@ -215,7 +250,7 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
|
||||
if (_typeCode is null || (_typeCode == LogixTypeCode.Struct && _structHandle is null))
|
||||
await ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (_typeCode == LogixTypeCode.Struct && _structHandle is null)
|
||||
throw new AbTransportException(AbStatus.Unsupported,
|
||||
throw new AbCipLocalRefusalException(
|
||||
$"STRING write to '{_p.TagName}': the controller returned no structure handle.");
|
||||
var payload = EncodeStringPayload(text);
|
||||
await client.WriteAsync(path, _typeCode!.Value, _structHandle, 1, payload, cancellationToken)
|
||||
@@ -224,7 +259,7 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
|
||||
else if (_writeBuffer is { } buffer)
|
||||
{
|
||||
var type = _typeCode ?? AtomicCode(_p.AuthoredType)
|
||||
?? throw new AbTransportException(AbStatus.Unsupported,
|
||||
?? throw new AbCipLocalRefusalException(
|
||||
$"Write to '{_p.TagName}': type unknown — no prior read and no authored atomic type.");
|
||||
// The read rule decides first: a same-width mismatch (REAL under a DINT-authored tag)
|
||||
// would otherwise send the int bit pattern as a REAL and report Good.
|
||||
|
||||
@@ -123,6 +123,7 @@ public sealed class AbCipArrayWriteTests
|
||||
|
||||
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNotSupported);
|
||||
factory.Tags.ShouldNotContainKey("Arr");
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy); // M5: a refusal writes no health
|
||||
}
|
||||
|
||||
/// <summary>The controller holding a different shape than authored (size ≠ width × count) is BadTypeMismatch, nothing sent.</summary>
|
||||
|
||||
@@ -124,4 +124,38 @@ public sealed class AbCipBitInWordWidthTests
|
||||
await drv.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// M1: a parent read that fails with a COMMS status evicts the parent runtime and degrades (LastSuccessfulRead
|
||||
/// preserved); one the controller REFUSES is per-tag Bad, keeps the runtime and writes no health.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_failed_parent_read_follows_the_health_contract()
|
||||
{
|
||||
var (drv, factory) = await BuildAsync(4, 0, "Motor.Flags.3");
|
||||
factory.Customise = p => new FakeAbCipTag(p) { Status = (int)AbStatus.BadConnection };
|
||||
var before = drv.GetHealth();
|
||||
|
||||
var r = await drv.WriteAsync([new WriteRequest("Bit", true)], CancellationToken.None);
|
||||
|
||||
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadCommunicationError);
|
||||
factory.Tags["Motor.Flags"].Disposed.ShouldBeTrue();
|
||||
factory.Tags["Motor.Flags"].WriteCount.ShouldBe(0);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
drv.GetHealth().LastSuccessfulRead.ShouldBe(before.LastSuccessfulRead);
|
||||
}
|
||||
|
||||
/// <summary>M1 positive control: a refused parent read keeps the runtime, no health write.</summary>
|
||||
[Fact]
|
||||
public async Task A_refused_parent_read_keeps_the_runtime_and_health()
|
||||
{
|
||||
var (drv, factory) = await BuildAsync(4, 0, "Motor.Flags.3");
|
||||
factory.Customise = p => new FakeAbCipTag(p) { Status = (int)AbStatus.NotAllowed };
|
||||
|
||||
var r = await drv.WriteAsync([new WriteRequest("Bit", true)], CancellationToken.None);
|
||||
|
||||
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNotWritable);
|
||||
factory.Tags["Motor.Flags"].Disposed.ShouldBeFalse();
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,4 +225,74 @@ public sealed class AbCipDeviceRefusalTests
|
||||
{
|
||||
public IAbCipTagRuntime Create(AbCipTagCreateParams createParams) => create(createParams);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #737 I2: a LOCAL refusal (libplctag ErrorNotImplemented — no I/O) is per-tag BadNotSupported, keeps health
|
||||
/// state, and does NOT advance LastSuccessfulRead (nothing proves the device answered). Positive control: the
|
||||
/// device's own Unsupported refusal does advance it (M2).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_local_refusal_does_not_advance_LastSuccessfulRead_but_a_device_one_does()
|
||||
{
|
||||
var (drv, _) = Build(p => p.TagName == "Arr[999]"
|
||||
? new LocalRefusingTag(p)
|
||||
: new RefusingTag(p) { ReadThrows = AbStatus.Unsupported });
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
var before = drv.GetHealth().LastSuccessfulRead!.Value;
|
||||
await Task.Delay(20);
|
||||
|
||||
(await drv.ReadAsync(["Bad"], CancellationToken.None))[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNotSupported);
|
||||
drv.GetHealth().LastSuccessfulRead.ShouldBe(before);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
|
||||
(await drv.ReadAsync(["Good"], CancellationToken.None))[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNotSupported);
|
||||
drv.GetHealth().LastSuccessfulRead!.Value.ShouldBeGreaterThan(before);
|
||||
}
|
||||
|
||||
/// <summary>#737 I2: a local refusal at create opens no backoff — a sibling cold create still connects.</summary>
|
||||
[Fact]
|
||||
public async Task A_local_refusal_at_create_does_not_throttle_or_count_as_answered()
|
||||
{
|
||||
var (drv, _) = Build(p => p.TagName == "Arr[999]"
|
||||
? new FakeAbCipTag(p) { ThrowOnInitialize = true, Exception = new AbCipLocalRefusalException("ErrorNotImplemented") }
|
||||
: new FakeAbCipTag(p) { Value = 3 });
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var r = await drv.ReadAsync(["Bad", "Good"], CancellationToken.None);
|
||||
|
||||
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNotSupported);
|
||||
r[1].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
/// <summary>M2: a write the device refuses proves it answered — LastSuccessfulRead advances.</summary>
|
||||
[Fact]
|
||||
public async Task A_refused_write_advances_LastSuccessfulRead()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory { Customise = p => new RefusingTag(p) { WriteThrows = AbStatus.NotAllowed } };
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(Device)],
|
||||
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Ro", Device, "ReadOnlyTag", AbCipDataType.DInt)),
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
}, "abcip-refusal", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
var before = drv.GetHealth().LastSuccessfulRead!.Value;
|
||||
await Task.Delay(20);
|
||||
|
||||
(await drv.WriteAsync([new WriteRequest("Ro", 5)], CancellationToken.None))[0].StatusCode
|
||||
.ShouldBe(AbCipStatusMapper.BadNotWritable);
|
||||
|
||||
drv.GetHealth().LastSuccessfulRead!.Value.ShouldBeGreaterThan(before);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
private sealed class LocalRefusingTag(AbCipTagCreateParams p) : FakeAbCipTag(p)
|
||||
{
|
||||
public override async Task ReadAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await base.ReadAsync(cancellationToken);
|
||||
throw new AbCipLocalRefusalException("ErrorNotImplemented");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+30
-7
@@ -56,9 +56,9 @@ public sealed class AbCipLifecycleConcurrencyTests
|
||||
return (drv, factory);
|
||||
}
|
||||
|
||||
private static async Task WaitUntilAsync(Func<bool> condition, string what)
|
||||
private static async Task WaitUntilAsync(Func<bool> condition, string what, int seconds = 5)
|
||||
{
|
||||
var deadline = DateTime.UtcNow.AddSeconds(5);
|
||||
var deadline = DateTime.UtcNow.AddSeconds(seconds);
|
||||
while (!condition())
|
||||
{
|
||||
if (DateTime.UtcNow > deadline) throw new TimeoutException(what);
|
||||
@@ -106,7 +106,8 @@ public sealed class AbCipLifecycleConcurrencyTests
|
||||
var init = drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await Task.Delay(50);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
await Should.ThrowAsync<OperationCanceledException>(() => init);
|
||||
var superseded = await Should.ThrowAsync<AbCipInitializationSupersededException>(() => init);
|
||||
superseded.ShouldNotBeAssignableTo<OperationCanceledException>(); // M4: distinct from a caller cancellation
|
||||
|
||||
drv.GetDeviceState(Host).ShouldBeNull();
|
||||
drv.GetHostStatuses().ShouldBeEmpty();
|
||||
@@ -199,18 +200,40 @@ public sealed class AbCipLifecycleConcurrencyTests
|
||||
[Fact]
|
||||
public async Task Sync_Dispose_does_not_block_on_the_teardown_but_still_completes_it()
|
||||
{
|
||||
var (drv, factory) = Build(probeReadDelay: TimeSpan.FromMilliseconds(500));
|
||||
var (drv, factory) = Build(probeReadDelay: TimeSpan.FromSeconds(4));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await WaitUntilAsync(() => factory.Tags.ContainsKey("ProbeTag"), "probe never started");
|
||||
var loop = drv.GetDeviceState(Host)!.ProbeTask!;
|
||||
await Task.Delay(20); // the probe is inside its slow, cancellation-deaf read
|
||||
await Task.Delay(50); // the probe is inside its slow, cancellation-deaf read
|
||||
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
((IDisposable)drv).Dispose();
|
||||
sw.Stop();
|
||||
|
||||
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromMilliseconds(250)); // did not wait out the 500 ms probe read
|
||||
await WaitUntilAsync(() => loop.IsCompleted && factory.Tags["ProbeTag"].Disposed, "teardown never completed");
|
||||
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(2)); // did not wait out the 4 s probe read (CI-tolerant margin)
|
||||
await WaitUntilAsync(() => loop.IsCompleted && factory.Tags["ProbeTag"].Disposed, "teardown never completed", seconds: 15);
|
||||
drv.GetDeviceState(Host).ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// M4: a ShutdownAsync overtaken by a NEWER InitializeAsync (its probe join is slow) must not stamp Unknown over
|
||||
/// the newer init's Healthy when it finally finishes.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_slow_shutdown_does_not_overwrite_a_newer_inits_health()
|
||||
{
|
||||
var (drv, factory) = Build(probeReadDelay: TimeSpan.FromMilliseconds(400));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await WaitUntilAsync(() => factory.Tags.ContainsKey("ProbeTag"), "probe never started");
|
||||
await Task.Delay(30); // inside the slow probe read
|
||||
|
||||
var shutdown = drv.ShutdownAsync(CancellationToken.None);
|
||||
await Task.Delay(30);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None); // newer: publishes while the shutdown still joins
|
||||
await shutdown;
|
||||
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
drv.GetDeviceState(Host).ShouldNotBeNull();
|
||||
await drv.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Task 22 review r1: a runtime that refuses an operation with <see cref="AbStatus.Unsupported"/> (the native
|
||||
/// transport does this before the wire, e.g. a write it cannot type) reaches the client as BadNotSupported —
|
||||
/// transport does this before the wire, e.g. a write it cannot type — an <see cref="AbCipLocalRefusalException"/>
|
||||
/// since #737 I2) reaches the client as BadNotSupported —
|
||||
/// NOT as a comms fault. The handle is kept (one create, never disposed) and driver health is untouched.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
@@ -19,12 +20,12 @@ public sealed class AbCipLocalRefusalTests
|
||||
{
|
||||
public override Task ReadAsync(CancellationToken cancellationToken) =>
|
||||
refuseRead
|
||||
? Task.FromException(new AbTransportException(AbStatus.Unsupported, "refused read"))
|
||||
? Task.FromException(new AbCipLocalRefusalException("refused read"))
|
||||
: base.ReadAsync(cancellationToken);
|
||||
|
||||
public override Task WriteAsync(CancellationToken cancellationToken) =>
|
||||
refuseWrite
|
||||
? Task.FromException(new AbTransportException(AbStatus.Unsupported, "refused write"))
|
||||
? Task.FromException(new AbCipLocalRefusalException("refused write"))
|
||||
: base.WriteAsync(cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ public sealed class AbCipStatusMapperTests
|
||||
Status.Ok => AbCipStatusMapper.Good,
|
||||
Status.Pending => AbCipStatusMapper.GoodMoreData,
|
||||
Status.ErrorTimeout => AbCipStatusMapper.BadTimeout,
|
||||
Status.ErrorNotFound or Status.ErrorNoMatch or Status.ErrorBadDevice => AbCipStatusMapper.BadNodeIdUnknown,
|
||||
Status.ErrorNotFound => AbCipStatusMapper.BadNodeIdUnknown, // #737 I2: NoMatch/BadDevice are comms
|
||||
Status.ErrorNotAllowed => AbCipStatusMapper.BadNotWritable,
|
||||
Status.ErrorOutOfBounds or Status.ErrorTooLarge or Status.ErrorTooSmall => AbCipStatusMapper.BadOutOfRange,
|
||||
Status.ErrorUnsupported or Status.ErrorNotImplemented => AbCipStatusMapper.BadNotSupported,
|
||||
|
||||
@@ -110,4 +110,22 @@ public sealed class AbCipWriteHealthTests
|
||||
after.LastSuccessfulRead.ShouldBe(before.LastSuccessfulRead);
|
||||
factory.Tags["D"].Disposed.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>M3: a good write clears a Degraded that a WRITE comms fault recorded.</summary>
|
||||
[Fact]
|
||||
public async Task A_good_write_clears_a_write_side_Degraded()
|
||||
{
|
||||
var fail = true;
|
||||
var (drv, _) = await BuildAsync(p => fail ? new ThrowingWriteTag(p) : new RealEncodingTag(p));
|
||||
(await drv.WriteAsync([new WriteRequest("D", 5)], CancellationToken.None))[0].StatusCode
|
||||
.ShouldBe(AbCipStatusMapper.BadCommunicationError);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
|
||||
fail = false; // the evicted runtime is re-created healthy
|
||||
(await drv.WriteAsync([new WriteRequest("D", 6)], CancellationToken.None))[0].StatusCode
|
||||
.ShouldBe(AbCipStatusMapper.Good);
|
||||
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
drv.GetHealth().LastError.ShouldBeNull();
|
||||
}
|
||||
}
|
||||
|
||||
+22
-2
@@ -16,8 +16,8 @@ public sealed class LibplctagStatusTranslationTests
|
||||
[InlineData(Status.Pending, AbStatus.Pending)]
|
||||
[InlineData(Status.ErrorTimeout, AbStatus.Timeout)]
|
||||
[InlineData(Status.ErrorNotFound, AbStatus.NotFound)]
|
||||
[InlineData(Status.ErrorNoMatch, AbStatus.NotFound)]
|
||||
[InlineData(Status.ErrorBadDevice, AbStatus.NotFound)]
|
||||
[InlineData(Status.ErrorNoMatch, AbStatus.BadConnection)] // #737 I2: not proof the controller answered
|
||||
[InlineData(Status.ErrorBadDevice, AbStatus.BadConnection)]
|
||||
[InlineData(Status.ErrorNotAllowed, AbStatus.NotAllowed)]
|
||||
[InlineData(Status.ErrorOutOfBounds, AbStatus.OutOfBounds)]
|
||||
[InlineData(Status.ErrorTooLarge, AbStatus.TooLarge)]
|
||||
@@ -148,4 +148,24 @@ public sealed class LibplctagStatusTranslationTests
|
||||
LibplctagStatusTranslation.Wrap(() => throw new LibPlcTagException(Status.ErrorBadParam)));
|
||||
thrown.Status.ShouldBe(AbStatus.BadConnection);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #737 I2: libplctag's <c>ErrorNotImplemented</c> is the LIBRARY refusing (no I/O): a local refusal, not a device
|
||||
/// refusal — it must not count as "the controller answered".
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task NotImplemented_is_a_local_refusal_not_a_device_refusal()
|
||||
{
|
||||
var ex = await Should.ThrowAsync<AbTransportException>(() =>
|
||||
LibplctagStatusTranslation.Wrap(() => throw new LibPlcTagException("ErrorNotImplemented")));
|
||||
ex.ShouldBeOfType<AbCipLocalRefusalException>();
|
||||
ex.Status.ShouldBe(AbStatus.Unsupported);
|
||||
AbCipRefusals.IsDeviceRefusal(ex).ShouldBeFalse();
|
||||
AbCipDriverProbe.IsReachableException(ex).ShouldBeFalse();
|
||||
|
||||
// Positive control: the device's own Unsupported refusal still is one.
|
||||
var device = await Should.ThrowAsync<AbTransportException>(() =>
|
||||
LibplctagStatusTranslation.Wrap(() => throw new LibPlcTagException("ErrorUnsupported")));
|
||||
AbCipRefusals.IsDeviceRefusal(device).ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,6 +135,30 @@ public sealed class NativeAbCipTagRuntimeTests
|
||||
ch.Sent[0].ShouldBe(Convert.FromHexString("5204" + "910442697473" + "28" + dword.ToString("X2") + "0100" + "00000000"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #737 I1: a family whose BOOL array is element-addressed (the controller answers the DWORD-index request with a
|
||||
/// plain BOOL, 0xC1) falls back to the ORIGINAL element path <c>Bits[n]</c> — it must neither decode nor write
|
||||
/// element <c>n / 32</c>. Positive control: the 0xD3 theory above still decodes bit <c>n % 32</c>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_per_element_BOOL_array_falls_back_to_the_element_path_for_read_and_write()
|
||||
{
|
||||
var ch = new FakeCipChannel
|
||||
{
|
||||
Respond = req => Task.FromResult(Convert.FromHexString(req[0] == 0x4D ? "CD000000" : "D2000000C10001")),
|
||||
};
|
||||
using var rt = NativeAbCipTagRuntime.ForTest(ch, Params("Bits[37]", AbCipDataType.Bool));
|
||||
await rt.InitializeAsync(CancellationToken.None);
|
||||
await rt.ReadAsync(CancellationToken.None);
|
||||
|
||||
rt.DecodeValue(AbCipDataType.Bool, null).ShouldBe(true);
|
||||
ch.Sent[^1].ShouldBe(Convert.FromHexString("5204" + "910442697473" + "2825" + "0100" + "00000000")); // Bits[37]
|
||||
|
||||
rt.EncodeValue(AbCipDataType.Bool, null, false);
|
||||
await rt.WriteAsync(CancellationToken.None);
|
||||
ch.Sent[^1].ShouldBe(Convert.FromHexString("4D04" + "910442697473" + "2825" + "C100" + "0100" + "00")); // 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