fix(abcip): classify every controller refusal as per-tag Bad, keep the runtime
H1/L4: one classification (AbCipRefusals.IsDeviceRefusal) of the statuses
that prove the controller answered - NotFound, OutOfBounds, NotAllowed,
TooLarge, TooSmall, TypeMismatch, Unsupported - shared by the data path, the
Test-Connect probe (IsReachableStatus widened) and the native runtime.
- A refused read/write (thrown or as a status word) is per-tag Bad with a
once-per-tag Warning, keeps the runtime (no evict, so no lease/Forward-Open
churn for a sole lease holder), writes no Degraded and, since the device
answered, advances LastSuccessfulRead without touching the state.
- A refused cold create records the backoff as reachable, so siblings are no
longer throttled for up to 30 s by one Arr[999].
- The native runtime completes InitializeAsync on any refusal (it used to on
TypeMismatch/Unsupported only) and reports it on every later read.
- A refused READ of NotAllowed reads BadNotReadable, not BadNotWritable.
- Test Connect names the refusal ("probe tag refused: OutOfBounds").
0x1C/0x20/0xFF-0x2104 still fold into BadReply inside Driver.Ab.Transport
(Lane K follow-up); they remain comms faults until mapped there.
Refs #737
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
@@ -266,6 +266,21 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
_logger.LogWarning(message, args);
|
||||
}
|
||||
|
||||
// Tags whose device-refusal Warning has already been logged, per operation ("read"/"write"/…): a refused tag is
|
||||
// polled (or retried) forever, so the Warning is once per tag per operation per driver instance.
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, byte> _refusalWarned =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Logs a device-refusal Warning the first time <paramref name="tagName"/> is refused for <paramref name="operation"/>.</summary>
|
||||
private void WarnRefusalOnce(string tagName, string operation, string reference, string device, string reason)
|
||||
{
|
||||
if (_refusalWarned.TryAdd($"{operation}\0{tagName}", 0))
|
||||
_logger.LogWarning(
|
||||
"AbCip {Operation} of {Tag} on device {Device} refused by the controller: {Reason}; reporting it Bad " +
|
||||
"for this tag only — the device and its other tags are unaffected (logged once per tag)",
|
||||
operation, reference, device, reason);
|
||||
}
|
||||
|
||||
// Tags whose malformed-name Warning has already been logged — polled forever, warned once.
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, byte> _configErrorWarned =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
@@ -884,11 +899,17 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
/// A <c>null</c> <see cref="State"/> means "no health signal" — a config-level miss such as an
|
||||
/// unknown reference or unknown device, which never touched the wire and never touched health.
|
||||
/// </summary>
|
||||
private readonly record struct ReadOutcome(DriverState? State, string? Error)
|
||||
private readonly record struct ReadOutcome(DriverState? State, string? Error, bool Answered = false)
|
||||
{
|
||||
/// <summary>The item produced no health signal at all.</summary>
|
||||
public static ReadOutcome None => new(null, null);
|
||||
|
||||
/// <summary>
|
||||
/// The controller answered with a deterministic refusal: no health STATE signal (a refusal is not a comms
|
||||
/// fault), but proof the device is reachable, so <c>LastSuccessfulRead</c> advances.
|
||||
/// </summary>
|
||||
public static ReadOutcome Refused => new(null, null, Answered: true);
|
||||
|
||||
/// <summary>The item read successfully.</summary>
|
||||
public static ReadOutcome Success => new(DriverState.Healthy, null);
|
||||
|
||||
@@ -917,10 +938,12 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
private void ApplyBatchHealth(ReadOnlySpan<ReadOutcome> outcomes, DateTime now)
|
||||
{
|
||||
var anySuccess = false;
|
||||
var anyAnswered = false;
|
||||
DriverState? worst = null;
|
||||
string? worstError = null;
|
||||
foreach (var outcome in outcomes)
|
||||
{
|
||||
if (outcome.Answered) anyAnswered = true;
|
||||
if (outcome.State is not { } state) continue;
|
||||
if (state == DriverState.Healthy) anySuccess = true;
|
||||
if (worst is null || HealthRank(state) > HealthRank(worst.Value))
|
||||
@@ -930,13 +953,19 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing reached the wire (every reference was unknown / unroutable) — leave health alone,
|
||||
// exactly as the per-item writes did before.
|
||||
if (worst is not { } worstState) return;
|
||||
if (worst is not { } worstState)
|
||||
{
|
||||
// No state signal. If a controller answered (a refusal), that still proves it reachable: advance
|
||||
// LastSuccessfulRead and leave the state alone. Otherwise nothing reached the wire (every reference was
|
||||
// unknown / unroutable / misconfigured) — leave health alone entirely.
|
||||
if (anyAnswered)
|
||||
UpdateHealth(current => current with { LastSuccessfulRead = now });
|
||||
return;
|
||||
}
|
||||
|
||||
UpdateHealth(current => new DriverHealth(
|
||||
worstState,
|
||||
anySuccess ? now : current.LastSuccessfulRead,
|
||||
anySuccess || anyAnswered ? now : current.LastSuccessfulRead,
|
||||
worstState == DriverState.Healthy ? null : worstError));
|
||||
}
|
||||
|
||||
@@ -987,7 +1016,14 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
"AbCip read of {Tag} on device {Device}: the controller's type does not match the authored " +
|
||||
"{DataType}; reporting BadTypeMismatch until the tag's dataType is corrected (logged once per tag)",
|
||||
reference, def.DeviceHostAddress, def.DataType);
|
||||
return ReadOutcome.None;
|
||||
return ReadOutcome.Refused;
|
||||
}
|
||||
if (AbCipRefusals.IsDeviceRefusal(status))
|
||||
{
|
||||
// The controller answered with a deterministic refusal: per-tag Bad, keep the handle.
|
||||
results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.MapReadStatus((AbStatus)status), null, now);
|
||||
WarnRefusalOnce(def.Name, "read", reference, def.DeviceHostAddress, ((AbStatus)status).ToString());
|
||||
return ReadOutcome.Refused;
|
||||
}
|
||||
if (status != 0)
|
||||
{
|
||||
@@ -1022,7 +1058,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
WarnTypeMismatchOnce(def.Name,
|
||||
"AbCip read of {Tag} on device {Device} returned {Size} bytes but its authored type {DataType} is {Width} bytes wide; check the tag's dataType (logged once per tag)",
|
||||
reference, def.DeviceHostAddress, size, def.DataType, width);
|
||||
return ReadOutcome.None;
|
||||
return ReadOutcome.Refused;
|
||||
}
|
||||
// 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
|
||||
@@ -1063,7 +1099,16 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadTypeMismatch, null, now);
|
||||
WarnTypeMismatchOnce(def.Name, "AbCip read of {Tag} on device {Device} refused: {Reason} (logged once per tag)",
|
||||
reference, def.DeviceHostAddress, ate.Message);
|
||||
return ReadOutcome.None;
|
||||
return ReadOutcome.Refused;
|
||||
}
|
||||
catch (AbTransportException ate) when (AbCipRefusals.IsDeviceRefusal(ate.Status))
|
||||
{
|
||||
// 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,
|
||||
// no backoff (EnsureTagRuntimeAsync already recorded the answer as reachable), warn once per tag.
|
||||
results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.MapReadStatus(ate.Status), null, now);
|
||||
WarnRefusalOnce(def.Name, "read", reference, def.DeviceHostAddress, ate.Message);
|
||||
return ReadOutcome.Refused;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -1119,7 +1164,13 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
"AbCip whole-UDT read of {Parent} on device {Device}: the controller's type does not match " +
|
||||
"the authored type; {MemberCount} members report BadTypeMismatch (logged once per tag)",
|
||||
group.ParentName, parent.DeviceHostAddress, group.Members.Count);
|
||||
return ReadOutcome.None;
|
||||
return ReadOutcome.Refused;
|
||||
}
|
||||
if (AbCipRefusals.IsDeviceRefusal(status))
|
||||
{
|
||||
StampGroupStatus(group, results, now, AbCipStatusMapper.MapReadStatus((AbStatus)status));
|
||||
WarnRefusalOnce(parent.Name, "whole-UDT read", group.ParentName, parent.DeviceHostAddress, ((AbStatus)status).ToString());
|
||||
return ReadOutcome.Refused;
|
||||
}
|
||||
if (status != 0)
|
||||
{
|
||||
@@ -1167,7 +1218,13 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
WarnTypeMismatchOnce(parent.Name,
|
||||
"AbCip whole-UDT read of {Parent} on device {Device} refused: {Reason} (logged once per tag)",
|
||||
group.ParentName, parent.DeviceHostAddress, ate.Message);
|
||||
return ReadOutcome.None;
|
||||
return ReadOutcome.Refused;
|
||||
}
|
||||
catch (AbTransportException ate) when (AbCipRefusals.IsDeviceRefusal(ate.Status))
|
||||
{
|
||||
StampGroupStatus(group, results, now, AbCipStatusMapper.MapReadStatus(ate.Status));
|
||||
WarnRefusalOnce(parent.Name, "whole-UDT read", group.ParentName, parent.DeviceHostAddress, ate.Message);
|
||||
return ReadOutcome.Refused;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -1298,6 +1355,12 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
"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.
|
||||
results[i] = new WriteResult(AbCipStatusMapper.MapStatus(status));
|
||||
WarnRefusalOnce(def.Name, "write", w.FullReference, def.DeviceHostAddress, ((AbStatus)status).ToString());
|
||||
}
|
||||
else if (status != 0)
|
||||
{
|
||||
EvictRuntime(device, def.Name);
|
||||
@@ -1378,6 +1441,12 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
"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))
|
||||
{
|
||||
// H1: the controller answered "no" to this write — keep the handle, leave health alone, warn once.
|
||||
results[i] = new WriteResult(AbCipStatusMapper.MapStatus(ate.Status));
|
||||
WarnRefusalOnce(def.Name, "write", w.FullReference, def.DeviceHostAddress, ate.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Transport / wire error — evict so the next write creates a fresh handle.
|
||||
@@ -1538,9 +1607,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
// not answer. "Tag not found" means the controller replied over a working session; it
|
||||
// is a config error on this one tag, and counting it as a failed Forward-Open throttled
|
||||
// every sibling tag on the device (seen live: one typo blacked out the whole device).
|
||||
if (ex is AbTransportException ate
|
||||
&& ate.Status != AbStatus.Ok
|
||||
&& AbCipDriverProbe.IsReachableStatus(ate.Status))
|
||||
if (AbCipRefusals.IsDeviceRefusal(ex))
|
||||
device.Backoff.RecordSuccess();
|
||||
else
|
||||
device.Backoff.RecordFailure(DateTime.UtcNow);
|
||||
|
||||
@@ -18,10 +18,11 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
/// non-CIP server) returns <c>Ok = false</c> with a "handshake failed" message instead of
|
||||
/// a false-positive green tick.
|
||||
/// <para>
|
||||
/// Status-code discrimination: <c>AbStatus.Ok</c> and <c>AbStatus.NotFound</c> mean the controller
|
||||
/// answered the CIP session (a non-existent probe tag name still proves CIP connectivity) and are
|
||||
/// treated as "reachable - Ok = true". Every other status (timeout, bad connection, bad reply,
|
||||
/// partial, abort, ...) is a transport or session failure: "handshake failed - Ok = false".
|
||||
/// Status-code discrimination: <c>AbStatus.Ok</c> and every device refusal (not found, out of bounds,
|
||||
/// not allowed, too large/small, type mismatch, unsupported) mean the controller answered the CIP session
|
||||
/// (a non-existent probe tag name still proves CIP connectivity) and are treated as "reachable - Ok = true".
|
||||
/// Every other status (timeout, bad connection, bad reply, partial, abort, ...) is a transport or session
|
||||
/// failure: "handshake failed - Ok = false".
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class AbCipDriverProbe : IDriverProbe
|
||||
@@ -147,7 +148,7 @@ public sealed class AbCipDriverProbe : IDriverProbe
|
||||
{
|
||||
var msg = statusCode == AbStatus.Ok
|
||||
? "CIP session OK"
|
||||
: "CIP session OK (controller reachable; probe tag not found)";
|
||||
: RefusedProbeMessage(statusCode);
|
||||
return new(true, msg, sw.Elapsed);
|
||||
}
|
||||
|
||||
@@ -164,7 +165,7 @@ public sealed class AbCipDriverProbe : IDriverProbe
|
||||
if (IsReachableException(ex))
|
||||
{
|
||||
sw.Stop();
|
||||
return new(true, "CIP session OK (controller reachable; probe tag not found)", sw.Elapsed);
|
||||
return new(true, RefusedProbeMessage(ex.Status), sw.Elapsed);
|
||||
}
|
||||
|
||||
return new(false, $"Reachable at {host}:{port} but CIP handshake failed: {ex.Message}", null);
|
||||
@@ -180,13 +181,20 @@ public sealed class AbCipDriverProbe : IDriverProbe
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns <c>true</c> for transport statuses that indicate the CIP controller answered
|
||||
/// but the probe tag name was not found or did not match — sufficient to confirm CIP
|
||||
/// reachability. Returns <c>true</c> for <c>Ok</c> (tag was found).
|
||||
/// Returns <c>true</c> for transport statuses that indicate the CIP controller answered — <c>Ok</c>, or any
|
||||
/// device refusal (<see cref="AbCipRefusals.IsDeviceRefusal(AbStatus)"/>: not found, out of bounds, not
|
||||
/// allowed, too large/small, type mismatch, unsupported) — sufficient to confirm CIP reachability.
|
||||
/// </summary>
|
||||
/// <param name="s">The transport status to classify.</param>
|
||||
/// <returns><see langword="true"/> if the status confirms CIP reachability.</returns>
|
||||
internal static bool IsReachableStatus(AbStatus s) => s is AbStatus.Ok or AbStatus.NotFound;
|
||||
internal static bool IsReachableStatus(AbStatus s) => s == AbStatus.Ok || AbCipRefusals.IsDeviceRefusal(s);
|
||||
|
||||
/// <summary>The Test-Connect message for a controller that answered but refused the probe tag.</summary>
|
||||
/// <param name="status">The refusal.</param>
|
||||
/// <returns>The message.</returns>
|
||||
private static string RefusedProbeMessage(AbStatus status) => status == AbStatus.NotFound
|
||||
? "CIP session OK (controller reachable; probe tag not found)"
|
||||
: $"CIP session OK (controller reachable; probe tag refused: {status})";
|
||||
|
||||
/// <summary>
|
||||
/// Decides whether an <see cref="AbTransportException"/> is a "controller answered CIP but the
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// The ONE classification of a transport status into "the controller answered" vs "the wire failed", shared by
|
||||
/// the data path, the probe loop, the Test-Connect probe and the native runtime's initialisation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A device refusal is deterministic: the session works and the controller said no to this one tag (a renamed
|
||||
/// tag, <c>Arr[999]</c>, a read-protected tag, a type the authored config does not match). It is per-tag Bad,
|
||||
/// keeps the tag runtime (evicting re-creates it — and, for a sole lease holder, re-Forward-Opens — on every poll),
|
||||
/// never writes <c>Degraded</c>, never opens the device-wide connect backoff, and warns once per tag. Everything
|
||||
/// else (timeout, bad connection, a reply that failed to parse, partial, abort, …) is a comms fault.
|
||||
/// <para>
|
||||
/// The native transport currently folds CIP general statuses 0x1C, 0x20 and 0xFF/0x2104 into
|
||||
/// <see cref="AbStatus.BadReply"/> (<c>AbStatusCodes.FromCip</c>, owned by <c>Driver.Ab.Transport</c>), which
|
||||
/// this classification cannot tell from a garbled reply; until the transport maps them explicitly they stay
|
||||
/// comms faults.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class AbCipRefusals
|
||||
{
|
||||
/// <summary>True when <paramref name="status"/> proves the controller answered but refused this tag.</summary>
|
||||
/// <param name="status">The transport status.</param>
|
||||
/// <returns><see langword="true"/> for a deterministic device refusal.</returns>
|
||||
public static bool IsDeviceRefusal(AbStatus status) => status is AbStatus.NotFound or AbStatus.OutOfBounds
|
||||
or AbStatus.NotAllowed or AbStatus.TooLarge or AbStatus.TooSmall or AbStatus.TypeMismatch
|
||||
or AbStatus.Unsupported;
|
||||
|
||||
/// <summary>True when <paramref name="status"/> (an <see cref="AbStatus"/> value) is a device refusal.</summary>
|
||||
/// <param name="status">The runtime status word.</param>
|
||||
/// <returns><see langword="true"/> for a deterministic device refusal.</returns>
|
||||
public static bool IsDeviceRefusal(int status) => IsDeviceRefusal((AbStatus)status);
|
||||
|
||||
/// <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);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ public static class AbCipStatusMapper
|
||||
public const uint GoodMoreData = 0x00A60000u;
|
||||
public const uint BadInternalError = 0x80020000u;
|
||||
public const uint BadNodeIdUnknown = 0x80340000u;
|
||||
public const uint BadNotReadable = 0x803A0000u;
|
||||
public const uint BadNotWritable = 0x803B0000u;
|
||||
public const uint BadOutOfRange = 0x803C0000u;
|
||||
public const uint BadNotSupported = 0x803D0000u;
|
||||
@@ -51,4 +52,14 @@ public static class AbCipStatusMapper
|
||||
/// <returns>The OPC UA status code.</returns>
|
||||
public static uint MapException(Exception ex) =>
|
||||
AbOpcUaStatusMap.MapException(ex);
|
||||
|
||||
/// <summary>
|
||||
/// Status for a READ the controller refused: as <see cref="MapStatus(AbStatus)"/>, except that a
|
||||
/// <see cref="AbStatus.NotAllowed"/> answer to a read is <see cref="BadNotReadable"/> (the shared table's
|
||||
/// <c>BadNotWritable</c> describes a refused write).
|
||||
/// </summary>
|
||||
/// <param name="status">The refusal status.</param>
|
||||
/// <returns>The OPC UA status code.</returns>
|
||||
public static uint MapReadStatus(AbStatus status) =>
|
||||
status == AbStatus.NotAllowed ? BadNotReadable : MapStatus(status);
|
||||
}
|
||||
|
||||
@@ -94,16 +94,15 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
|
||||
/// <summary>The trailing bit selector split off the tag name (<c>Tag.5</c>), if any.</summary>
|
||||
internal int? ImplicitBit => _implicitBit;
|
||||
|
||||
// Performs one read to prove the tag exists: libplctag's InitializeAsync errors for an unknown
|
||||
// tag, and the driver's backoff classification depends on create throwing AbStatus.NotFound.
|
||||
// Performs one read to prove the session works and learn the tag's type.
|
||||
//
|
||||
// A name the encoder refuses throws AbCipTagConfigurationException before any lease: it is this
|
||||
// tag's configuration, not the device, and must not black out the device's other tags. A read the
|
||||
// controller answers with a deterministic refusal (AbStatus.TypeMismatch / AbStatus.Unsupported)
|
||||
// proves the tag exists, so it completes the initialization with that status rather than throwing:
|
||||
// a throw here makes the driver dispose the runtime and re-create it - and, for a sole lease
|
||||
// holder, re-Forward-Open - on every poll (mistype churn). The driver's own read reports the
|
||||
// refusal.
|
||||
// controller answers with a deterministic refusal (AbCipRefusals.IsDeviceRefusal: NotFound, OutOfBounds,
|
||||
// NotAllowed, TooLarge/TooSmall, TypeMismatch, Unsupported) proves the session works, so it completes the
|
||||
// initialization with that status rather than throwing: a throw here makes the driver dispose the runtime
|
||||
// and re-create it - and, for a sole lease holder, re-Forward-Open - on every poll (a renamed tag or a
|
||||
// mistype churned the lease). The driver's own read reports the refusal, per tag, on the kept runtime.
|
||||
/// <inheritdoc />
|
||||
public async Task InitializeAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -137,9 +136,9 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
|
||||
{
|
||||
await ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (AbTransportException ex) when (ex.Status is AbStatus.TypeMismatch or AbStatus.Unsupported)
|
||||
catch (AbTransportException ex) when (AbCipRefusals.IsDeviceRefusal(ex.Status))
|
||||
{
|
||||
// The controller answered — the tag exists. _status already holds the refusal.
|
||||
// The controller answered. _status already holds the refusal.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -311,8 +311,9 @@ public sealed class AbCipAlmaProjectionTests
|
||||
// A non-zero libplctag status ⇒ Bad on every member read; the driver evicts the handle and re-creates it, so
|
||||
// every re-created runtime must fail too (a fresh fake would read as "all clear").
|
||||
var recreations = 0;
|
||||
r.Factory.Customise = p => { Interlocked.Increment(ref recreations); return new FakeAbCipTag(p) { Status = (int)AbStatus.NotFound }; };
|
||||
foreach (var t in r.Factory.Tags.Values.ToList()) t.Status = (int)AbStatus.NotFound;
|
||||
// A comms status (not a device refusal, which keeps the handle since review 07 H1) so the handle is evicted.
|
||||
r.Factory.Customise = p => { Interlocked.Increment(ref recreations); return new FakeAbCipTag(p) { Status = (int)AbStatus.BadConnection }; };
|
||||
foreach (var t in r.Factory.Tags.Values.ToList()) t.Status = (int)AbStatus.BadConnection;
|
||||
await WaitFor(() => Volatile.Read(ref recreations) >= 6);
|
||||
await r.StopAsync();
|
||||
Snapshot(r).Any(x => x.Kind == AlarmTransitionKind.Clear).ShouldBeFalse();
|
||||
@@ -334,9 +335,9 @@ public sealed class AbCipAlmaProjectionTests
|
||||
{
|
||||
if (!p.TagName.EndsWith("HAcked", StringComparison.Ordinal)) return null!;
|
||||
Interlocked.Increment(ref bad);
|
||||
return new FakeAbCipTag(p) { Status = (int)AbStatus.NotFound };
|
||||
return new FakeAbCipTag(p) { Status = (int)AbStatus.BadConnection }; // comms ⇒ evicted + re-created
|
||||
};
|
||||
r.Factory.Tags[$"{Tag}.HAcked"].Status = (int)AbStatus.NotFound;
|
||||
r.Factory.Tags[$"{Tag}.HAcked"].Status = (int)AbStatus.BadConnection;
|
||||
await WaitFor(() => Volatile.Read(ref bad) >= 4);
|
||||
// The link recovers and HAcked reads true again.
|
||||
r.Factory.Customise = p => new FakeAbCipTag(p) { Value = true };
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 07 H1 / L4: a status that proves the controller ANSWERED (NotFound, OutOfBounds, NotAllowed,
|
||||
/// TooLarge, TooSmall, TypeMismatch, Unsupported) is a deterministic per-tag refusal, not a comms fault. It
|
||||
/// keeps the tag runtime (no evict → no lease / Forward-Open churn), writes no Degraded, opens no device-wide
|
||||
/// backoff window for siblings, warns once, and — since the device answered — advances LastSuccessfulRead.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipDeviceRefusalTests
|
||||
{
|
||||
private const string Device = "ab://10.0.0.5/1,0";
|
||||
|
||||
/// <summary>A fake whose reads/writes throw a chosen transport status (init succeeds unless told otherwise).</summary>
|
||||
private sealed class RefusingTag(AbCipTagCreateParams p) : FakeAbCipTag(p)
|
||||
{
|
||||
public AbStatus? ReadThrows { get; set; }
|
||||
public AbStatus? WriteThrows { get; set; }
|
||||
|
||||
public override async Task ReadAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await base.ReadAsync(cancellationToken);
|
||||
if (ReadThrows is { } s) throw new AbTransportException(s, s.ToString());
|
||||
}
|
||||
|
||||
public override async Task WriteAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await base.WriteAsync(cancellationToken);
|
||||
if (WriteThrows is { } s) throw new AbTransportException(s, s.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
private static (AbCipDriver Driver, FakeAbCipTagFactory Factory) Build(
|
||||
Func<AbCipTagCreateParams, FakeAbCipTag> make)
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory { Customise = make };
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(Device)],
|
||||
RawTags = AbCipRawTags.From(
|
||||
new AbCipTagDefinition("Bad", Device, "Arr[999]", AbCipDataType.DInt),
|
||||
new AbCipTagDefinition("Good", Device, "Real1", AbCipDataType.DInt)),
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
MaxConcurrentReads = 1,
|
||||
}, "abcip-refusal", factory);
|
||||
return (drv, factory);
|
||||
}
|
||||
|
||||
/// <summary>The classification itself: every answered refusal is reachable; transport failures are not.</summary>
|
||||
/// <param name="status">The transport status.</param>
|
||||
/// <param name="refusal">Whether it is a device refusal.</param>
|
||||
[Theory]
|
||||
[InlineData(AbStatus.NotFound, true)]
|
||||
[InlineData(AbStatus.OutOfBounds, true)]
|
||||
[InlineData(AbStatus.NotAllowed, true)]
|
||||
[InlineData(AbStatus.TooLarge, true)]
|
||||
[InlineData(AbStatus.TooSmall, true)]
|
||||
[InlineData(AbStatus.TypeMismatch, true)]
|
||||
[InlineData(AbStatus.Unsupported, true)]
|
||||
[InlineData(AbStatus.Ok, false)]
|
||||
[InlineData(AbStatus.Timeout, false)]
|
||||
[InlineData(AbStatus.BadConnection, false)]
|
||||
[InlineData(AbStatus.BadReply, false)]
|
||||
[InlineData(AbStatus.Partial, false)]
|
||||
[InlineData(AbStatus.Abort, false)]
|
||||
public void Answered_statuses_are_device_refusals(AbStatus status, bool refusal)
|
||||
{
|
||||
AbCipRefusals.IsDeviceRefusal(status).ShouldBe(refusal);
|
||||
AbCipDriverProbe.IsReachableStatus(status).ShouldBe(refusal || status == AbStatus.Ok);
|
||||
}
|
||||
|
||||
/// <summary>A read the controller refuses keeps its runtime, stays Healthy, and advances LastSuccessfulRead.</summary>
|
||||
/// <param name="status">The refusal the controller answers with.</param>
|
||||
/// <param name="expected">The OPC UA status the tag reads.</param>
|
||||
[Theory]
|
||||
[InlineData(AbStatus.NotFound, AbCipStatusMapper.BadNodeIdUnknown)]
|
||||
[InlineData(AbStatus.OutOfBounds, AbCipStatusMapper.BadOutOfRange)]
|
||||
[InlineData(AbStatus.TooLarge, AbCipStatusMapper.BadOutOfRange)]
|
||||
[InlineData(AbStatus.TooSmall, AbCipStatusMapper.BadOutOfRange)]
|
||||
[InlineData(AbStatus.NotAllowed, AbCipStatusMapper.BadNotReadable)]
|
||||
public async Task A_refused_read_keeps_the_runtime_and_writes_no_Degraded(AbStatus status, uint expected)
|
||||
{
|
||||
var (drv, factory) = Build(p => new RefusingTag(p) { ReadThrows = p.TagName == "Arr[999]" ? status : null, Value = 1 });
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
var before = drv.GetHealth().LastSuccessfulRead!.Value;
|
||||
await Task.Delay(20);
|
||||
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
var r = await drv.ReadAsync(["Bad"], CancellationToken.None);
|
||||
r[0].StatusCode.ShouldBe(expected);
|
||||
}
|
||||
|
||||
var bad = factory.Tags["Arr[999]"];
|
||||
bad.InitializeCount.ShouldBe(1); // never evicted → never re-created
|
||||
bad.Disposed.ShouldBeFalse();
|
||||
bad.ReadCount.ShouldBe(3);
|
||||
var health = drv.GetHealth();
|
||||
health.State.ShouldBe(DriverState.Healthy);
|
||||
health.LastSuccessfulRead!.Value.ShouldBeGreaterThan(before); // the device answered
|
||||
}
|
||||
|
||||
/// <summary>Positive control: a genuine transport failure on the same tag still evicts and degrades.</summary>
|
||||
[Fact]
|
||||
public async Task A_transport_failure_still_evicts_and_degrades()
|
||||
{
|
||||
var (drv, factory) = Build(p => new RefusingTag(p) { ReadThrows = p.TagName == "Arr[999]" ? AbStatus.Timeout : null });
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var r = await drv.ReadAsync(["Bad"], CancellationToken.None);
|
||||
|
||||
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadTimeout);
|
||||
factory.Tags["Arr[999]"].Disposed.ShouldBeTrue();
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
}
|
||||
|
||||
/// <summary>A refusal returned as a status word (no throw) is classified the same way.</summary>
|
||||
[Fact]
|
||||
public async Task A_refusal_status_word_keeps_the_runtime()
|
||||
{
|
||||
var (drv, factory) = Build(p => new FakeAbCipTag(p) { Status = p.TagName == "Arr[999]" ? (int)AbStatus.OutOfBounds : 0, Value = 1 });
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.ReadAsync(["Bad"], CancellationToken.None);
|
||||
var r = await drv.ReadAsync(["Bad", "Good"], CancellationToken.None);
|
||||
|
||||
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadOutOfRange);
|
||||
r[1].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
factory.Tags["Arr[999]"].InitializeCount.ShouldBe(1);
|
||||
factory.Tags["Arr[999]"].Disposed.ShouldBeFalse();
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A cold create the controller refuses with OutOfBounds proves the session works: it must not open the
|
||||
/// device-wide backoff window, so a sibling cold tag still connects and reads Good.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_refused_create_does_not_throttle_sibling_cold_creates()
|
||||
{
|
||||
var (drv, _) = Build(p => p.TagName == "Arr[999]"
|
||||
? new FakeAbCipTag(p) { ThrowOnInitialize = true, Exception = new AbTransportException(AbStatus.OutOfBounds, "OutOfBounds") }
|
||||
: new FakeAbCipTag(p) { Value = 42 });
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var r = await drv.ReadAsync(["Bad", "Good"], CancellationToken.None);
|
||||
|
||||
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadOutOfRange);
|
||||
r[1].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
r[1].Value.ShouldBe(42);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
/// <summary>A write the controller refuses keeps the runtime and writes no health.</summary>
|
||||
[Fact]
|
||||
public async Task A_refused_write_keeps_the_runtime_and_writes_no_Degraded()
|
||||
{
|
||||
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 w1 = await drv.WriteAsync([new WriteRequest("Ro", 5)], CancellationToken.None);
|
||||
var w2 = await drv.WriteAsync([new WriteRequest("Ro", 6)], CancellationToken.None);
|
||||
|
||||
w1[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNotWritable);
|
||||
w2[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNotWritable);
|
||||
factory.Tags["ReadOnlyTag"].InitializeCount.ShouldBe(1);
|
||||
factory.Tags["ReadOnlyTag"].Disposed.ShouldBeFalse();
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
}
|
||||
@@ -73,7 +73,7 @@ public sealed class AbCipTypeMismatchTests
|
||||
creates().ShouldBe(1);
|
||||
factory.Tags["T"].Disposed.ShouldBeFalse();
|
||||
factory.Tags["T"].ReadCount.ShouldBe(Polls); // positive control: every poll did reach the runtime
|
||||
drv.GetHealth().ShouldBe(health);
|
||||
AssertStateUnchanged(drv.GetHealth(), health);
|
||||
MismatchWarnings(log).ShouldBe(1);
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@ public sealed class AbCipTypeMismatchTests
|
||||
Volatile.Read(ref creates).ShouldBe(1);
|
||||
factory.Tags["T"].Disposed.ShouldBeFalse();
|
||||
factory.Tags["T"].ReadCount.ShouldBe(Polls); // positive control: every poll did reach the runtime
|
||||
drv.GetHealth().ShouldBe(health);
|
||||
AssertStateUnchanged(drv.GetHealth(), health);
|
||||
drv.GetHealth().State.ShouldNotBe(DriverState.Degraded);
|
||||
MismatchWarnings(log).ShouldBe(1);
|
||||
}
|
||||
@@ -121,7 +121,7 @@ public sealed class AbCipTypeMismatchTests
|
||||
|
||||
creates().ShouldBe(1);
|
||||
factory.Tags["T"].Disposed.ShouldBeFalse();
|
||||
drv.GetHealth().ShouldBe(health);
|
||||
AssertStateUnchanged(drv.GetHealth(), health);
|
||||
MismatchWarnings(log).ShouldBe(1);
|
||||
}
|
||||
|
||||
@@ -179,7 +179,7 @@ public sealed class AbCipTypeMismatchTests
|
||||
target.ForwardOpens.ShouldBe(1);
|
||||
target.ForwardCloses.ShouldBe(0);
|
||||
target.ConnectedRequests.ShouldBeGreaterThanOrEqualTo(Polls); // positive control: polls hit the wire
|
||||
drv.GetHealth().ShouldBe(health);
|
||||
AssertStateUnchanged(drv.GetHealth(), health);
|
||||
MismatchWarnings(log).ShouldBe(1);
|
||||
}
|
||||
finally
|
||||
@@ -226,7 +226,7 @@ public sealed class AbCipTypeMismatchTests
|
||||
target.ForwardOpens.ShouldBe(1);
|
||||
target.ForwardCloses.ShouldBe(0);
|
||||
target.ConnectedRequests.ShouldBeGreaterThanOrEqualTo(Polls); // positive control: polls hit the wire
|
||||
drv.GetHealth().ShouldBe(health);
|
||||
AssertStateUnchanged(drv.GetHealth(), health);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -267,12 +267,23 @@ public sealed class AbCipTypeMismatchTests
|
||||
Volatile.Read(ref creates).ShouldBe(1);
|
||||
factory.Tags["T"].Disposed.ShouldBeFalse();
|
||||
factory.Tags["T"].ReadCount.ShouldBe(Polls - 1); // positive control: later polls reached the kept runtime
|
||||
drv.GetHealth().ShouldBe(health);
|
||||
AssertStateUnchanged(drv.GetHealth(), health);
|
||||
}
|
||||
|
||||
private sealed class CountingFactory(Func<AbCipTagCreateParams, IAbCipTagRuntime> create) : IAbCipTagFactory
|
||||
{
|
||||
public IAbCipTagRuntime Create(AbCipTagCreateParams createParams) => create(createParams);
|
||||
}
|
||||
/// <summary>
|
||||
/// A refusal leaves the health STATE and error alone; since the controller answered, LastSuccessfulRead may
|
||||
/// only move forward (review 07 H1 — any data proving the device answered advances it).
|
||||
/// </summary>
|
||||
private static void AssertStateUnchanged(DriverHealth actual, DriverHealth before)
|
||||
{
|
||||
actual.State.ShouldBe(before.State);
|
||||
actual.LastError.ShouldBe(before.LastError);
|
||||
(actual.LastSuccessfulRead ?? DateTime.MinValue).ShouldBeGreaterThanOrEqualTo(before.LastSuccessfulRead ?? DateTime.MinValue);
|
||||
}
|
||||
|
||||
#pragma warning restore OTOPCUA0001
|
||||
}
|
||||
|
||||
@@ -129,14 +129,35 @@ public sealed class NativeAbCipTagRuntimeTests
|
||||
rt.GetStatus().ShouldBe((int)AbStatus.Ok);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unknown_tag_fails_initialize_with_NotFound()
|
||||
/// <summary>
|
||||
/// Review 07 L4: a controller refusal on the existence read completes the initialization (the session works —
|
||||
/// throwing made the driver dispose + re-create the runtime, re-leasing the connection, every poll); the
|
||||
/// refusal stays readable and every later read reports it again.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("D2000400", AbStatus.NotFound)]
|
||||
[InlineData("D2000500", AbStatus.NotFound)]
|
||||
[InlineData("D2000A00", AbStatus.OutOfBounds)]
|
||||
[InlineData("D2000F00", AbStatus.NotAllowed)]
|
||||
[InlineData("D200FF010521", AbStatus.OutOfBounds)]
|
||||
public async Task A_refused_tag_completes_initialize_and_reports_the_refusal_on_read(string reply, AbStatus expected)
|
||||
{
|
||||
var ch = new FakeCipChannel { Respond = _ => Task.FromResult(Convert.FromHexString("D2000400")) };
|
||||
var ch = new FakeCipChannel { Respond = _ => Task.FromResult(Convert.FromHexString(reply)) };
|
||||
using var rt = NativeAbCipTagRuntime.ForTest(ch, Params("Nope", AbCipDataType.DInt));
|
||||
await rt.InitializeAsync(CancellationToken.None);
|
||||
rt.GetStatus().ShouldBe((int)expected);
|
||||
(await Should.ThrowAsync<AbTransportException>(() => rt.ReadAsync(CancellationToken.None)))
|
||||
.Status.ShouldBe(expected);
|
||||
}
|
||||
|
||||
/// <summary>Positive control: a reply the transport cannot classify as a refusal still fails initialize.</summary>
|
||||
[Fact]
|
||||
public async Task An_unclassified_reply_still_fails_initialize()
|
||||
{
|
||||
var ch = new FakeCipChannel { Respond = _ => Task.FromResult(Convert.FromHexString("D2000100")) };
|
||||
using var rt = NativeAbCipTagRuntime.ForTest(ch, Params("Nope", AbCipDataType.DInt));
|
||||
(await Should.ThrowAsync<AbTransportException>(() => rt.InitializeAsync(CancellationToken.None)))
|
||||
.Status.ShouldBe(AbStatus.NotFound);
|
||||
rt.GetStatus().ShouldBe((int)AbStatus.NotFound);
|
||||
.Status.ShouldBe(AbStatus.BadReply);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
Reference in New Issue
Block a user