refactor(abcip): confine libplctag to its adapters behind AbStatus/AbTransportException

This commit is contained in:
Joseph Doherty
2026-10-07 15:40:07 -04:00
parent a0897fda96
commit a57d61c484
17 changed files with 307 additions and 187 deletions
@@ -1,4 +1,4 @@
using libplctag;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
@@ -789,12 +789,12 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
// mirrors the probe loop's recreate-on-failure behaviour.
EvictRuntime(device, def.Name);
results[fb.OriginalIndex] = new DataValueSnapshot(null,
AbCipStatusMapper.MapLibplctagStatus(status), null, now);
AbCipStatusMapper.MapStatus(status), null, now);
_logger.LogWarning(
"AbCip read returned non-zero libplctag status {LibplctagStatus} for tag {Tag} on device {Device}; " +
"AbCip read returned non-zero transport status {Status} for tag {Tag} on device {Device}; " +
"evicting cached runtime so next call re-creates it",
status, reference, def.DeviceHostAddress);
return ReadOutcome.Degraded($"libplctag status {status} reading {reference}");
return ReadOutcome.Degraded($"transport status {status} reading {reference}");
}
var tagPath = AbCipTagPath.TryParse(def.TagPath);
@@ -850,7 +850,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// <summary>
/// Perform one whole-UDT read on the parent tag, then decode each
/// grouped member from the runtime's buffer at its computed byte offset. A per-group
/// failure (parent read raised, non-zero libplctag status, or missing device) stamps
/// failure (parent read raised, non-zero transport status, or missing device) stamps
/// the mapped fault across every grouped member only — sibling groups + the
/// per-tag fallback list are unaffected.
/// </summary>
@@ -881,13 +881,13 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
if (status != 0)
{
EvictRuntime(device, parent.Name);
var mapped = AbCipStatusMapper.MapLibplctagStatus(status);
var mapped = AbCipStatusMapper.MapStatus(status);
StampGroupStatus(group, results, now, mapped);
_logger.LogWarning(
"AbCip whole-UDT read returned non-zero libplctag status {LibplctagStatus} for parent {Parent} " +
"AbCip whole-UDT read returned non-zero transport status {Status} for parent {Parent} " +
"on device {Device}; {MemberCount} member values stamped with mapped status",
status, group.ParentName, parent.DeviceHostAddress, group.Members.Count);
return ReadOutcome.Degraded($"libplctag status {status} reading UDT {group.ParentName}");
return ReadOutcome.Degraded($"transport status {status} reading UDT {group.ParentName}");
}
foreach (var member in group.Members)
@@ -992,9 +992,9 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
if (status != 0)
{
EvictRuntime(device, def.Name);
results[i] = new WriteResult(AbCipStatusMapper.MapLibplctagStatus(status));
results[i] = new WriteResult(AbCipStatusMapper.MapStatus(status));
_logger.LogWarning(
"AbCip write returned non-zero libplctag status {LibplctagStatus} for tag {Tag} on device {Device}; " +
"AbCip write returned non-zero transport status {Status} for tag {Tag} on device {Device}; " +
"evicting cached runtime so next call re-creates it",
status, w.FullReference, def.DeviceHostAddress);
}
@@ -1076,7 +1076,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.MapLibplctagStatus(readStatus);
if (readStatus != 0) return AbCipStatusMapper.MapStatus(readStatus);
var current = Convert.ToInt32(parentRuntime.DecodeValue(AbCipDataType.DInt, bitIndex: null) ?? 0);
var updated = Convert.ToBoolean(value)
@@ -1088,7 +1088,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
var writeStatus = parentRuntime.GetStatus();
return writeStatus == 0
? AbCipStatusMapper.Good
: AbCipStatusMapper.MapLibplctagStatus(writeStatus);
: AbCipStatusMapper.MapStatus(writeStatus);
}
finally
{
@@ -1183,10 +1183,9 @@ 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 LibPlcTagException lpe
&& AbCipStatusMapper.TryGetStatus(lpe, out var status)
&& status != Status.Ok
&& AbCipDriverProbe.IsReachableStatus(status))
if (ex is AbTransportException ate
&& ate.Status != AbStatus.Ok
&& AbCipDriverProbe.IsReachableStatus(ate.Status))
device.Backoff.RecordSuccess();
else
device.Backoff.RecordFailure(DateTime.UtcNow);
@@ -1211,7 +1210,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// Evict the runtime for <paramref name="tagName"/> from the device's cache and dispose
/// it so the next read/write call re-creates and re-initializes a fresh handle.
/// Called from <see cref="ReadSingleAsync"/>, <see cref="ReadGroupAsync"/>, and
/// <see cref="WriteAsync"/> after a non-zero libplctag status or transport exception —
/// <see cref="WriteAsync"/> after a non-zero transport status or transport exception —
/// mirroring the probe loop's recreate-on-failure behaviour.
/// </summary>
private static void EvictRuntime(DeviceState device, string tagName)
@@ -2,7 +2,7 @@ using System.Diagnostics;
using System.Net.Sockets;
using System.Text.Json;
using System.Text.Json.Serialization;
using libplctag;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.PlcFamilies;
@@ -11,24 +11,24 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// Two-phase Test-Connect probe for the <see cref="AbCipDriverOptions"/>-shaped driver config.
/// Phase 1: bare TCP connect to the first device's gateway host + EtherNet/IP port to
/// quickly reject unreachable targets. Phase 2: initialises a libplctag <see cref="Tag"/>
/// quickly reject unreachable targets. Phase 2: initialises a tag runtime
/// against the same device to open a real EIP session and perform a CIP Forward Open —
/// confirming the remote endpoint actually speaks EtherNet/IP CIP, not just accepts TCP.
/// A device that accepts the TCP connection but is not a CIP controller (wrong CIP path,
/// non-CIP server) returns <c>Ok = false</c> with a "handshake failed" message instead of
/// a false-positive green tick.
/// <para>
/// Status-code discrimination: libplctag statuses that indicate the controller answered
/// Status-code discrimination: transport statuses (<c>AbStatus</c>) that indicate the controller answered
/// the CIP session but could not resolve the probe tag
/// (<see cref="Status.ErrorNotFound"/>, <see cref="Status.ErrorNoMatch"/>,
/// <see cref="Status.ErrorBadDevice"/>) are treated as "reachable — Ok = true" because
/// (<c>ErrorNotFound</c>, <c>ErrorNoMatch</c>,
/// <c>ErrorBadDevice</c>) are treated as "reachable — Ok = true" because
/// a non-existent tag name proves CIP connectivity. Statuses that indicate a transport or
/// session failure (<see cref="Status.ErrorTimeout"/>, <see cref="Status.ErrorBadConnection"/>,
/// <see cref="Status.ErrorBadGateway"/>, <see cref="Status.ErrorWinsock"/>,
/// <see cref="Status.ErrorOpen"/>, <see cref="Status.ErrorClose"/>,
/// <see cref="Status.ErrorRead"/>, <see cref="Status.ErrorWrite"/>,
/// <see cref="Status.ErrorBadReply"/>, <see cref="Status.ErrorRemoteErr"/>,
/// <see cref="Status.ErrorPartial"/>, <see cref="Status.ErrorAbort"/>) are treated as
/// session failure (<c>ErrorTimeout</c>, <c>ErrorBadConnection</c>,
/// <c>ErrorBadGateway</c>, <c>ErrorWinsock</c>,
/// <c>ErrorOpen</c>, <c>ErrorClose</c>,
/// <c>ErrorRead</c>, <c>ErrorWrite</c>,
/// <c>ErrorBadReply</c>, <c>ErrorRemoteErr</c>,
/// <c>ErrorPartial</c>, <c>ErrorAbort</c>) are treated as
/// "handshake failed — Ok = false". All other error statuses default to
/// "handshake failed" (conservative).
/// </para>
@@ -42,6 +42,12 @@ public sealed class AbCipDriverProbe : IDriverProbe
Converters = { new JsonStringEnumConverter() },
};
private readonly IAbCipTagFactory _tagFactory;
/// <summary>Initializes a new instance of the <see cref="AbCipDriverProbe"/> class.</summary>
/// <param name="tagFactory">Factory for the probe's tag runtime; defaults to the libplctag-backed one.</param>
public AbCipDriverProbe(IAbCipTagFactory? tagFactory = null) => _tagFactory = tagFactory ?? new LibplctagTagFactory();
/// <inheritdoc />
public string DriverType => "AbCip";
@@ -124,7 +130,7 @@ public sealed class AbCipDriverProbe : IDriverProbe
AllowPacking: firstDevice?.AllowPacking ?? profile.SupportsRequestPacking,
ConnectionSize: firstDevice?.ConnectionSize ?? profile.DefaultConnectionSize);
var rt = new LibplctagTagRuntime(p);
var rt = _tagFactory.Create(p);
try
{
await rt.InitializeAsync(cts.Token);
@@ -132,10 +138,10 @@ public sealed class AbCipDriverProbe : IDriverProbe
// InitializeAsync completed without throwing — either the tag was found (Ok) or
// libplctag returned a non-exception status. Check via GetStatus().
var statusCode = (Status)rt.GetStatus();
var statusCode = (AbStatus)rt.GetStatus();
if (IsReachableStatus(statusCode))
{
var msg = statusCode == Status.Ok
var msg = statusCode == AbStatus.Ok
? "CIP session OK"
: "CIP session OK (controller reachable; probe tag not found)";
return new(true, msg, sw.Elapsed);
@@ -148,9 +154,9 @@ public sealed class AbCipDriverProbe : IDriverProbe
{
return new(false, $"Probe timed out after {timeout.TotalSeconds:F0}s.", null);
}
catch (LibPlcTagException ex)
catch (AbTransportException ex)
{
// LibPlcTagException carries the Status; classify as reachable vs transport failure.
// AbTransportException carries the Status; classify as reachable vs transport failure.
if (IsReachableException(ex))
{
sw.Stop();
@@ -170,42 +176,23 @@ public sealed class AbCipDriverProbe : IDriverProbe
}
/// <summary>
/// Returns <c>true</c> for libplctag statuses that indicate the CIP controller answered
/// 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 <see cref="Status.Ok"/> (tag was found).
/// reachability. Returns <c>true</c> for <c>Ok</c> (tag was found).
/// </summary>
internal static bool IsReachableStatus(Status s) => s is
Status.Ok or
Status.ErrorNotFound or
Status.ErrorNoMatch or
Status.ErrorBadDevice;
internal static bool IsReachableStatus(AbStatus s) => s is AbStatus.Ok or AbStatus.NotFound;
/// <summary>
/// Decides whether a <see cref="LibPlcTagException"/> is a "controller answered CIP but the
/// Decides whether an <see cref="AbTransportException"/> is a "controller answered CIP but the
/// probe tag did not resolve" condition (reachable) or a session / transport failure
/// (unreachable), by recovering the <see cref="Status"/> the wrapper threw on and deferring
/// to <see cref="IsReachableStatus"/> — one classification, not two that can drift.
/// (unreachable), deferring to <see cref="IsReachableStatus"/> — one classification, not two.
/// </summary>
/// <param name="ex">The exception libplctag threw out of <c>InitializeAsync</c>.</param>
/// <param name="ex">The exception thrown out of <c>InitializeAsync</c>.</param>
/// <returns><c>true</c> when the controller answered; <c>false</c> for a transport/session failure.</returns>
/// <remarks>
/// <b>The status arrives as the managed enum NAME.</b> <c>NativeTagWrapper.ThrowIfStatusNotOk</c>
/// constructs the exception with <c>Status.ToString()</c>, so the message is exactly
/// <c>"ErrorNotFound"</c> — never the native C spelling <c>"STATUS_NOT_FOUND"</c> this method
/// used to look for. Those substrings could not match, so the reachable classification was
/// unreachable on this path — and this IS the path a probe takes whenever its tag does not
/// resolve, which is every Test Connect against a device with no authored tags. The native
/// spellings are kept as a secondary match in case a future wrapper reports them.
/// </remarks>
internal static bool IsReachableException(LibPlcTagException ex)
internal static bool IsReachableException(AbTransportException ex)
{
var msg = ex.Message ?? string.Empty;
if (AbCipStatusMapper.TryGetStatus(ex, out var status))
return IsReachableStatus(status);
return msg.Contains("NOT_FOUND", StringComparison.OrdinalIgnoreCase)
|| msg.Contains("NO_MATCH", StringComparison.OrdinalIgnoreCase)
|| msg.Contains("BAD_DEVICE", StringComparison.OrdinalIgnoreCase);
ArgumentNullException.ThrowIfNull(ex);
return IsReachableStatus(ex.Status);
}
private static (AbCipHostAddress? parsed, AbCipDeviceOptions? device) ExtractTarget(AbCipDriverOptions opts)
@@ -1,4 +1,4 @@
using libplctag;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
@@ -26,8 +26,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// writes during download / test-mode transitions).</item>
/// <item>0x16 object does not exist — <c>BadNodeIdUnknown</c>.</item>
/// <item>0x1E embedded service error — unwrap to the extended status when possible.</item>
/// <item>libplctag.NET <see cref="Status"/> errors — mapped per-member by
/// <see cref="MapLibplctagStatus(Status)"/>: timeout, not-found, not-allowed, and
/// <item>Transport <see cref="AbStatus"/> errors — mapped per-member by
/// <see cref="MapStatus(AbStatus)"/>: timeout, not-found, not-allowed, and
/// out-of-bounds get their specific OPC UA codes; the remaining transport errors
/// fold into <c>BadCommunicationError</c>.</item>
/// </list>
@@ -64,66 +64,42 @@ public static class AbCipStatusMapper
};
/// <summary>
/// Map a libplctag return/status code to an OPC UA StatusCode. The integer passed here
/// is <c>(int)Tag.GetStatus()</c> — i.e. the underlying value of the libplctag.NET
/// <see cref="Status"/> enum, NOT a raw native <c>PLCTAG_ERR_*</c> constant. The wrapper
/// renumbers the native codes into a contiguous enum, so this method switches on the
/// <see cref="Status"/> members directly to stay correct if the wrapper renumbers again.
/// <see cref="Status.Ok"/> is success; <see cref="Status.Pending"/> is an in-flight
/// operation; every other (negative) member is an error.
/// Map a transport status code to an OPC UA StatusCode. The integer is
/// <see cref="IAbCipTagRuntime.GetStatus"/>, i.e. the underlying value of <see cref="AbStatus"/>.
/// </summary>
/// <param name="status">The libplctag status code as an integer.</param>
/// <param name="status">The <see cref="AbStatus"/> as an integer.</param>
/// <returns>The mapped OPC UA StatusCode value.</returns>
public static uint MapLibplctagStatus(int status) => MapLibplctagStatus((Status)status);
public static uint MapStatus(int status) => MapStatus((AbStatus)status);
/// <summary>
/// Recover the libplctag <see cref="Status"/> a <see cref="LibPlcTagException"/> was thrown on.
/// The wrapper builds the exception from <c>Status.ToString()</c>, so the message is the enum
/// name (<c>"ErrorNotFound"</c>).
/// Map an <see cref="AbStatus"/> to an OPC UA StatusCode. Timeout, not-found, not-allowed and
/// out-of-bounds get their specific codes; the remaining transport errors fold into
/// <see cref="BadCommunicationError"/>.
/// </summary>
/// <param name="ex">The exception.</param>
/// <param name="status">The recovered status.</param>
/// <returns><c>true</c> when the message names a defined <see cref="Status"/>.</returns>
public static bool TryGetStatus(LibPlcTagException ex, out Status status)
/// <param name="status">The transport status.</param>
/// <returns>The mapped OPC UA StatusCode value.</returns>
public static uint MapStatus(AbStatus status) => status switch
{
ArgumentNullException.ThrowIfNull(ex);
return Enum.TryParse((ex.Message ?? string.Empty).Trim(), ignoreCase: true, out status)
&& Enum.IsDefined(status);
}
AbStatus.Ok => Good,
AbStatus.Pending => GoodMoreData,
AbStatus.Timeout => BadTimeout,
AbStatus.NotFound => BadNodeIdUnknown,
AbStatus.NotAllowed => BadNotWritable,
AbStatus.OutOfBounds or AbStatus.TooLarge or AbStatus.TooSmall => BadOutOfRange,
AbStatus.Unsupported => BadNotSupported,
AbStatus.TypeMismatch => BadTypeMismatch,
AbStatus.DeviceStateConflict => BadDeviceFailure,
_ => BadCommunicationError, // BadConnection, BadReply, Partial, Abort
};
/// <summary>
/// Status for an exception thrown while creating / reading a tag: a libplctag exception maps
/// through its <see cref="Status"/> (so a tag the controller does not have reads
/// Status for an exception thrown while creating / reading a tag: a transport exception maps
/// through its <see cref="AbStatus"/> (so a tag the controller does not have reads
/// <see cref="BadNodeIdUnknown"/>, not a comms error); anything else is
/// <see cref="BadCommunicationError"/>.
/// </summary>
/// <param name="ex">The exception.</param>
/// <returns>The OPC UA status code.</returns>
public static uint MapException(Exception ex) =>
ex is LibPlcTagException lpe && TryGetStatus(lpe, out var status)
? MapLibplctagStatus(status)
: BadCommunicationError;
/// <summary>
/// Map a libplctag.NET <see cref="Status"/> enum value to an OPC UA StatusCode. This is
/// the strongly-typed core of the mapper; the <c>int</c> overload exists only for the
/// <see cref="IAbCipTagRuntime.GetStatus"/> seam, which returns the boxed-as-int value.
/// </summary>
/// <param name="status">The libplctag Status enum value.</param>
/// <returns>The mapped OPC UA StatusCode value.</returns>
public static uint MapLibplctagStatus(Status status) => status switch
{
Status.Ok => Good,
Status.Pending => GoodMoreData,
Status.ErrorTimeout => BadTimeout,
Status.ErrorNotFound or Status.ErrorNoMatch or Status.ErrorBadDevice => BadNodeIdUnknown,
Status.ErrorNotAllowed => BadNotWritable,
Status.ErrorOutOfBounds or Status.ErrorTooLarge or Status.ErrorTooSmall => BadOutOfRange,
Status.ErrorUnsupported or Status.ErrorNotImplemented => BadNotSupported,
Status.ErrorBadConnection or Status.ErrorBadGateway or Status.ErrorBadReply
or Status.ErrorWinsock or Status.ErrorOpen or Status.ErrorClose
or Status.ErrorRead or Status.ErrorWrite or Status.ErrorRemoteErr
or Status.ErrorPartial or Status.ErrorAbort => BadCommunicationError,
_ => BadCommunicationError,
};
ex is AbTransportException ate ? MapStatus(ate.Status) : BadCommunicationError;
}
@@ -25,8 +25,8 @@ public interface IAbCipTagRuntime : IDisposable
Task WriteAsync(CancellationToken cancellationToken);
/// <summary>
/// Raw libplctag status code — mapped to an OPC UA StatusCode via
/// <see cref="AbCipStatusMapper.MapLibplctagStatus(int)"/>. Zero on success, negative on error.
/// Raw transport status code (an <c>AbStatus</c> value) — mapped to an OPC UA StatusCode via
/// <see cref="AbCipStatusMapper.MapStatus(int)"/>. Zero on success, negative on error.
/// </summary>
/// <returns>The raw libplctag status code (zero on success, negative on error).</returns>
int GetStatus();
@@ -0,0 +1,58 @@
using libplctag;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>The ONLY place libplctag's <see cref="Status"/> meets <see cref="AbStatus"/>.</summary>
internal static class LibplctagStatusTranslation
{
/// <summary>Translates a libplctag status into the driver-owned <see cref="AbStatus"/>.</summary>
/// <param name="s">The libplctag status.</param>
/// <returns>The equivalent transport-neutral status.</returns>
public static AbStatus ToAbStatus(Status s) => s switch
{
Status.Ok => AbStatus.Ok,
Status.Pending => AbStatus.Pending,
Status.ErrorTimeout => AbStatus.Timeout,
Status.ErrorNotFound or Status.ErrorNoMatch or Status.ErrorBadDevice => AbStatus.NotFound,
Status.ErrorNotAllowed => AbStatus.NotAllowed,
Status.ErrorOutOfBounds => AbStatus.OutOfBounds,
Status.ErrorTooLarge => AbStatus.TooLarge,
Status.ErrorTooSmall => AbStatus.TooSmall,
Status.ErrorUnsupported or Status.ErrorNotImplemented => AbStatus.Unsupported,
Status.ErrorPartial => AbStatus.Partial,
Status.ErrorAbort => AbStatus.Abort,
Status.ErrorBadReply => AbStatus.BadReply,
_ => AbStatus.BadConnection,
};
/// <summary>
/// Recovers the status a <see cref="LibPlcTagException"/> was thrown on. libplctag builds the
/// exception from <c>Status.ToString()</c>, so the message is the enum name.
/// </summary>
/// <param name="ex">The exception.</param>
/// <returns>The translated status, or <see cref="AbStatus.BadConnection"/> when the message names no status.</returns>
public static AbStatus ToAbStatus(LibPlcTagException ex) =>
Enum.TryParse<Status>((ex.Message ?? string.Empty).Trim(), ignoreCase: true, out var s) && Enum.IsDefined(s)
? ToAbStatus(s)
: AbStatus.BadConnection;
/// <summary>Runs <paramref name="op"/>, rethrowing any <see cref="LibPlcTagException"/> as <see cref="AbTransportException"/>.</summary>
/// <param name="op">The libplctag operation.</param>
/// <returns>A task completing when the operation does.</returns>
public static async Task Wrap(Func<Task> op)
{
try { await op().ConfigureAwait(false); }
catch (LibPlcTagException ex) { throw new AbTransportException(ToAbStatus(ex), ex.Message, 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>
/// <returns>The operation result.</returns>
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); }
}
}
@@ -77,9 +77,12 @@ internal sealed class LibplctagTagEnumerator : IAbCipTagEnumerator
AbCipTagCreateParams deviceParams, string listingName, CancellationToken cancellationToken)
{
using var tag = BuildSymbolListTag(deviceParams, listingName);
await tag.InitializeAsync(cancellationToken).ConfigureAwait(false);
await tag.ReadAsync(cancellationToken).ConfigureAwait(false);
return tag.GetBuffer();
return await LibplctagStatusTranslation.Wrap(async () =>
{
await tag.InitializeAsync(cancellationToken).ConfigureAwait(false);
await tag.ReadAsync(cancellationToken).ConfigureAwait(false);
return tag.GetBuffer();
}).ConfigureAwait(false);
}
/// <summary>
@@ -1,4 +1,5 @@
using libplctag;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
@@ -35,16 +36,19 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
}
/// <inheritdoc />
public Task InitializeAsync(CancellationToken cancellationToken) => _tag.InitializeAsync(cancellationToken);
public Task InitializeAsync(CancellationToken cancellationToken) =>
LibplctagStatusTranslation.Wrap(() => _tag.InitializeAsync(cancellationToken));
/// <inheritdoc />
public Task ReadAsync(CancellationToken cancellationToken) => _tag.ReadAsync(cancellationToken);
public Task ReadAsync(CancellationToken cancellationToken) =>
LibplctagStatusTranslation.Wrap(() => _tag.ReadAsync(cancellationToken));
/// <inheritdoc />
public Task WriteAsync(CancellationToken cancellationToken) => _tag.WriteAsync(cancellationToken);
public Task WriteAsync(CancellationToken cancellationToken) =>
LibplctagStatusTranslation.Wrap(() => _tag.WriteAsync(cancellationToken));
/// <inheritdoc />
public int GetStatus() => (int)_tag.GetStatus();
public int GetStatus() => (int)LibplctagStatusTranslation.ToAbStatus(_tag.GetStatus());
/// <inheritdoc />
public int GetSize() => _tag.GetSize();
@@ -20,9 +20,13 @@ internal sealed class LibplctagTemplateReader : IAbCipTemplateReader
{
_tag?.Dispose();
_tag = BuildTemplateTag(deviceParams, templateInstanceId);
await _tag.InitializeAsync(cancellationToken).ConfigureAwait(false);
await _tag.ReadAsync(cancellationToken).ConfigureAwait(false);
return _tag.GetBuffer();
var tag = _tag;
return await LibplctagStatusTranslation.Wrap(async () =>
{
await tag.InitializeAsync(cancellationToken).ConfigureAwait(false);
await tag.ReadAsync(cancellationToken).ConfigureAwait(false);
return tag.GetBuffer();
}).ConfigureAwait(false);
}
/// <summary>Builds the (uninitialized) libplctag tag for the <c>@udt/{id}</c> pseudo.</summary>
@@ -1,4 +1,4 @@
using libplctag;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
@@ -323,7 +323,7 @@ public sealed class AbCipDriverCodeReviewRegressionTests
// First tag creation → returns error; second → returns success.
callCount++;
return callCount == 1
? new FakeAbCipTag(p) { Status = (int)libplctag.Status.ErrorBadConnection }
? new FakeAbCipTag(p) { Status = (int)AbStatus.BadConnection }
: new FakeAbCipTag(p) { Status = 0, Value = 42 };
};
var drv = new AbCipDriver(new AbCipDriverOptions
@@ -1,6 +1,6 @@
using System.Net;
using System.Net.Sockets;
using libplctag;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
using Shouldly;
using Xunit;
@@ -151,27 +151,22 @@ public sealed class AbCipDriverProbeTests
// -------------------------------------------------------------------------
/// <summary>
/// The exception path classifies by the SAME rule as the status path. libplctag.NET builds
/// <see cref="LibPlcTagException"/> with <c>Status.ToString()</c>, so its message is
/// "ErrorNotFound" — this method used to look for the native C spelling "STATUS_NOT_FOUND",
/// which that message can never contain, so a controller that answered "tag does not exist"
/// was reported as a failed handshake. That is the answer every Test Connect gets on a device
/// with no authored tags, i.e. every device at the moment an operator first presses the button.
/// The exception path classifies by the SAME rule as the status path: a controller that answered
/// "tag does not exist" is reachable (the answer every Test Connect gets on a device with no
/// authored tags); a session or transport failure is not.
/// </summary>
/// <param name="status">The status libplctag threw on.</param>
/// <param name="status">The status the transport threw on.</param>
/// <param name="expectedReachable">Whether the controller should be reported reachable.</param>
[Theory]
[InlineData(Status.ErrorNotFound, true)]
[InlineData(Status.ErrorNoMatch, true)]
[InlineData(Status.ErrorBadDevice, true)]
[InlineData(Status.ErrorTimeout, false)]
[InlineData(Status.ErrorBadConnection, false)]
[InlineData(Status.ErrorBadGateway, false)]
[InlineData(Status.ErrorBadParam, false)]
public void ThrownStatus_IsClassifiedTheSameWayAsAReturnedStatus(Status status, bool expectedReachable)
[InlineData(AbStatus.NotFound, true)]
[InlineData(AbStatus.Timeout, false)]
[InlineData(AbStatus.BadConnection, false)]
[InlineData(AbStatus.BadReply, false)]
[InlineData(AbStatus.Partial, false)]
[InlineData(AbStatus.Abort, false)]
public void ThrownStatus_IsClassifiedTheSameWayAsAReturnedStatus(AbStatus status, bool expectedReachable)
{
// Exactly how NativeTagWrapper.ThrowIfStatusNotOk constructs it.
var ex = new LibPlcTagException(status.ToString());
var ex = new AbTransportException(status, status.ToString());
AbCipDriverProbe.IsReachableException(ex).ShouldBe(expectedReachable);
AbCipDriverProbe.IsReachableStatus(status).ShouldBe(expectedReachable);
@@ -185,5 +180,5 @@ public sealed class AbCipDriverProbeTests
[Fact]
public void AnUnrecognisedExceptionMessageIsNotTreatedAsReachable()
=> AbCipDriverProbe.IsReachableException(
new LibPlcTagException("something the wrapper has never said")).ShouldBeFalse();
new AbTransportException(AbStatus.BadConnection, "something the wrapper has never said")).ShouldBeFalse();
}
@@ -1,4 +1,4 @@
using libplctag;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
@@ -87,14 +87,14 @@ public sealed class AbCipDriverReadTests
factory.Tags["Motor1.Speed"].ReadCount.ShouldBe(3);
}
/// <summary>Verifies that non-zero libplctag status is mapped via AbCipStatusMapper.</summary>
/// <summary>Verifies that non-zero transport status is mapped via AbCipStatusMapper.</summary>
[Fact]
public async Task NonZero_libplctag_status_maps_via_AbCipStatusMapper()
public async Task NonZero_transport_status_maps_via_AbCipStatusMapper()
{
var (drv, factory) = NewDriver(
new AbCipTagDefinition("Ghost", "ab://10.0.0.5/1,0", "Missing.Tag", AbCipDataType.DInt));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new FakeAbCipTag(p) { Status = (int)Status.ErrorNotFound };
factory.Customise = p => new FakeAbCipTag(p) { Status = (int)AbStatus.NotFound };
var snapshots = await drv.ReadAsync(["Ghost"], CancellationToken.None);
@@ -1,3 +1,4 @@
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
using Shouldly;
using Xunit;
@@ -89,7 +90,7 @@ public sealed class AbCipDriverWholeUdtReadTests
// Prime runtime existence via a first (successful) read so we can flip it to error.
await drv.ReadAsync(["Motor.Speed", "Motor.Torque"], CancellationToken.None);
factory.Tags["Motor"].Status = -3; // libplctag BadTimeout — mapped in AbCipStatusMapper
factory.Tags["Motor"].Status = (int)AbStatus.Timeout; // mapped in AbCipStatusMapper
var snapshots = await drv.ReadAsync(["Motor.Speed", "Motor.Torque"], CancellationToken.None);
@@ -1,4 +1,4 @@
using libplctag;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
@@ -84,14 +84,14 @@ public sealed class AbCipDriverWriteTests
results.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good);
}
/// <summary>Verifies that non-zero libplctag status after write maps correctly.</summary>
/// <summary>Verifies that non-zero transport status after write maps correctly.</summary>
[Fact]
public async Task Non_zero_libplctag_status_after_write_maps_via_AbCipStatusMapper()
public async Task Non_zero_transport_status_after_write_maps_via_AbCipStatusMapper()
{
var (drv, factory) = NewDriver(
new AbCipTagDefinition("Broken", "ab://10.0.0.5/1,0", "Broken", AbCipDataType.DInt));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new FakeAbCipTag(p) { Status = (int)Status.ErrorTimeout };
factory.Customise = p => new FakeAbCipTag(p) { Status = (int)AbStatus.Timeout };
var results = await drv.WriteAsync(
[new WriteRequest("Broken", 1)], CancellationToken.None);
@@ -1,3 +1,4 @@
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
using Microsoft.Extensions.Logging;
using Shouldly;
using Xunit;
@@ -88,7 +89,7 @@ public sealed class AbCipLoggingTests
var logger = new CapturingLogger<AbCipDriver>();
var factory = new FakeAbCipTagFactory
{
Customise = p => new FakeAbCipTag(p) { Status = (int)libplctag.Status.ErrorBadConnection },
Customise = p => new FakeAbCipTag(p) { Status = (int)AbStatus.BadConnection },
};
var drv = new AbCipDriver(new AbCipDriverOptions
{
@@ -1,4 +1,5 @@
using libplctag;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
@@ -29,41 +30,78 @@ public sealed class AbCipStatusMapperTests
AbCipStatusMapper.MapCipGeneralStatus(status).ShouldBe(expected);
}
// Driver.AbCip-002 — the integers here are the underlying values of the libplctag.NET
// Status enum (what (int)Tag.GetStatus() actually returns), NOT raw native PLCTAG_ERR_*
// constants. The libplctag.NET wrapper renumbers the native codes into a contiguous enum.
/// <summary>Verifies that libplctag Status enum values are mapped to OPC UA status codes.</summary>
/// <param name="status">The libplctag Status enum value to map.</param>
/// <summary>Verifies that transport status values are mapped to OPC UA status codes.</summary>
/// <param name="status">The transport status to map.</param>
/// <param name="expected">The expected OPC UA status code.</param>
[Theory]
[InlineData(Status.Ok, AbCipStatusMapper.Good)]
[InlineData(Status.Pending, AbCipStatusMapper.GoodMoreData)]
[InlineData(Status.ErrorTimeout, AbCipStatusMapper.BadTimeout)]
[InlineData(Status.ErrorNotFound, AbCipStatusMapper.BadNodeIdUnknown)]
[InlineData(Status.ErrorNotAllowed, AbCipStatusMapper.BadNotWritable)]
[InlineData(Status.ErrorOutOfBounds, AbCipStatusMapper.BadOutOfRange)]
[InlineData(Status.ErrorBadConnection, AbCipStatusMapper.BadCommunicationError)]
[InlineData(Status.ErrorBadGateway, AbCipStatusMapper.BadCommunicationError)]
[InlineData(Status.ErrorUnsupported, AbCipStatusMapper.BadNotSupported)]
[InlineData(Status.ErrorNoMem, AbCipStatusMapper.BadCommunicationError)] // unmapped → generic comms
public void MapLibplctagStatus_maps_real_enum_members(Status status, uint expected)
[InlineData(AbStatus.Ok, AbCipStatusMapper.Good)]
[InlineData(AbStatus.Pending, AbCipStatusMapper.GoodMoreData)]
[InlineData(AbStatus.Timeout, AbCipStatusMapper.BadTimeout)]
[InlineData(AbStatus.NotFound, AbCipStatusMapper.BadNodeIdUnknown)]
[InlineData(AbStatus.NotAllowed, AbCipStatusMapper.BadNotWritable)]
[InlineData(AbStatus.OutOfBounds, AbCipStatusMapper.BadOutOfRange)]
[InlineData(AbStatus.TooLarge, AbCipStatusMapper.BadOutOfRange)]
[InlineData(AbStatus.TooSmall, AbCipStatusMapper.BadOutOfRange)]
[InlineData(AbStatus.Unsupported, AbCipStatusMapper.BadNotSupported)]
[InlineData(AbStatus.TypeMismatch, AbCipStatusMapper.BadTypeMismatch)]
[InlineData(AbStatus.DeviceStateConflict, AbCipStatusMapper.BadDeviceFailure)]
[InlineData(AbStatus.BadConnection, AbCipStatusMapper.BadCommunicationError)]
[InlineData(AbStatus.BadReply, AbCipStatusMapper.BadCommunicationError)]
[InlineData(AbStatus.Partial, AbCipStatusMapper.BadCommunicationError)]
[InlineData(AbStatus.Abort, AbCipStatusMapper.BadCommunicationError)]
public void MapStatus_maps_every_AbStatus(AbStatus status, uint expected)
{
AbCipStatusMapper.MapLibplctagStatus(status).ShouldBe(expected);
// The int overload must agree — it is the seam IAbCipTagRuntime.GetStatus() drives.
AbCipStatusMapper.MapLibplctagStatus((int)status).ShouldBe(expected);
AbCipStatusMapper.MapStatus(status).ShouldBe(expected);
// The int overload must agree - it is the seam IAbCipTagRuntime.GetStatus() drives.
AbCipStatusMapper.MapStatus((int)status).ShouldBe(expected);
}
/// <summary>Verifies that timeout is distinguished from generic communication error.</summary>
[Fact]
public void MapLibplctagStatus_distinguishes_timeout_from_generic_comms_error()
public void MapStatus_distinguishes_timeout_from_generic_comms_error()
{
// Regression for Driver.AbCip-002: a real timeout used to fall through to
// BadCommunicationError because the code matched the wrong integer (-5).
AbCipStatusMapper.MapLibplctagStatus((int)Status.ErrorTimeout)
.ShouldBe(AbCipStatusMapper.BadTimeout);
AbCipStatusMapper.MapLibplctagStatus((int)Status.ErrorNotFound)
.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown);
AbCipStatusMapper.MapLibplctagStatus((int)Status.ErrorNotFound)
.ShouldNotBe(AbCipStatusMapper.BadCommunicationError);
AbCipStatusMapper.MapStatus((int)AbStatus.Timeout).ShouldBe(AbCipStatusMapper.BadTimeout);
AbCipStatusMapper.MapStatus((int)AbStatus.NotFound).ShouldBe(AbCipStatusMapper.BadNodeIdUnknown);
AbCipStatusMapper.MapStatus((int)AbStatus.NotFound).ShouldNotBe(AbCipStatusMapper.BadCommunicationError);
}
/// <summary>Verifies MapException maps transport exceptions by status and everything else to comms error.</summary>
[Fact]
public void MapException_uses_the_transport_status()
{
AbCipStatusMapper.MapException(new AbTransportException(AbStatus.NotFound, "x"))
.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown);
AbCipStatusMapper.MapException(new InvalidOperationException("x"))
.ShouldBe(AbCipStatusMapper.BadCommunicationError);
}
/// <summary>
/// Regression oracle: the OPC UA code the pre-AbStatus <c>MapLibplctagStatus</c> returned for every
/// libplctag member (table copied from the deleted switch) must equal
/// <c>MapStatus(ToAbStatus(member))</c>.
/// </summary>
/// <param name="status">A libplctag status.</param>
[Theory]
[MemberData(nameof(AllLibplctagStatuses))]
public void Translation_plus_MapStatus_matches_the_old_libplctag_mapping(Status status)
{
AbCipStatusMapper.MapStatus(LibplctagStatusTranslation.ToAbStatus(status)).ShouldBe(OldMapLibplctagStatus(status));
}
/// <summary>Every defined libplctag <see cref="Status"/> member.</summary>
/// <returns>One row per member.</returns>
public static IEnumerable<object[]> AllLibplctagStatuses() =>
Enum.GetValues<Status>().Select(s => new object[] { s });
private static uint OldMapLibplctagStatus(Status status) => status switch
{
Status.Ok => AbCipStatusMapper.Good,
Status.Pending => AbCipStatusMapper.GoodMoreData,
Status.ErrorTimeout => AbCipStatusMapper.BadTimeout,
Status.ErrorNotFound or Status.ErrorNoMatch or Status.ErrorBadDevice => AbCipStatusMapper.BadNodeIdUnknown,
Status.ErrorNotAllowed => AbCipStatusMapper.BadNotWritable,
Status.ErrorOutOfBounds or Status.ErrorTooLarge or Status.ErrorTooSmall => AbCipStatusMapper.BadOutOfRange,
Status.ErrorUnsupported or Status.ErrorNotImplemented => AbCipStatusMapper.BadNotSupported,
_ => AbCipStatusMapper.BadCommunicationError, // incl. BadConnection/BadGateway/BadReply/Partial/Abort/...
};
}
@@ -1,4 +1,4 @@
using libplctag;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
@@ -8,7 +8,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// A tag the controller does not have is a configuration error, not a lost connection. Seen live
/// against a production CompactLogix: with one misspelled tag first in the read list, libplctag's
/// create threw <c>LibPlcTagException("ErrorNotFound")</c>, the driver counted that as a failed
/// create threw <c>AbTransportException(AbStatus.NotFound)</c>, the driver counted that as a failed
/// Forward-Open, opened the device's connect-backoff window, and every other not-yet-connected tag
/// on the device — all of them real — came back <c>BadCommunicationError</c> (throttled). The bad
/// tag itself also read <c>BadCommunicationError</c> instead of <c>BadNodeIdUnknown</c>.
@@ -43,7 +43,7 @@ public sealed class AbCipUnknownTagReadTests
[Fact]
public async Task Unknown_tag_does_not_throttle_sibling_tags_and_reads_BadNodeIdUnknown()
{
var (drv, _) = Build(name => name == "NoSuchTag_xyz" ? new LibPlcTagException("ErrorNotFound") : null);
var (drv, _) = Build(name => name == "NoSuchTag_xyz" ? new AbTransportException(AbStatus.NotFound, "NotFound") : null);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var r = await drv.ReadAsync(["Typo", "Real1", "Real2"], TestContext.Current.CancellationToken);
@@ -61,7 +61,7 @@ public sealed class AbCipUnknownTagReadTests
[Fact]
public async Task A_connection_failure_still_opens_the_backoff_window()
{
var (drv, factory) = Build(name => name == "NoSuchTag_xyz" ? new LibPlcTagException("ErrorTimeout") : null);
var (drv, factory) = Build(name => name == "NoSuchTag_xyz" ? new AbTransportException(AbStatus.Timeout, "Timeout") : null);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var r = await drv.ReadAsync(["Typo", "Real1"], TestContext.Current.CancellationToken);
@@ -0,0 +1,54 @@
using libplctag;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>Pins the libplctag <see cref="Status"/> to <see cref="AbStatus"/> translation table.</summary>
public sealed class LibplctagStatusTranslationTests
{
/// <summary>Each libplctag status lands in the documented bucket.</summary>
/// <param name="from">The libplctag status.</param>
/// <param name="to">The expected transport status.</param>
[Theory]
[InlineData(Status.Ok, AbStatus.Ok)]
[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.ErrorNotAllowed, AbStatus.NotAllowed)]
[InlineData(Status.ErrorOutOfBounds, AbStatus.OutOfBounds)]
[InlineData(Status.ErrorTooLarge, AbStatus.TooLarge)]
[InlineData(Status.ErrorTooSmall, AbStatus.TooSmall)]
[InlineData(Status.ErrorUnsupported, AbStatus.Unsupported)]
[InlineData(Status.ErrorNotImplemented, AbStatus.Unsupported)]
[InlineData(Status.ErrorPartial, AbStatus.Partial)]
[InlineData(Status.ErrorAbort, AbStatus.Abort)]
[InlineData(Status.ErrorBadReply, AbStatus.BadReply)]
[InlineData(Status.ErrorBadGateway, AbStatus.BadConnection)]
[InlineData(Status.ErrorWinsock, AbStatus.BadConnection)]
public void Translates(Status from, AbStatus to) => LibplctagStatusTranslation.ToAbStatus(from).ShouldBe(to);
/// <summary>The exception message is recovered as the enum name.</summary>
[Fact]
public void Exception_message_is_recovered_as_the_enum_name()
{
LibplctagStatusTranslation.ToAbStatus(new LibPlcTagException("ErrorNotFound")).ShouldBe(AbStatus.NotFound);
LibplctagStatusTranslation.ToAbStatus(new LibPlcTagException("gibberish")).ShouldBe(AbStatus.BadConnection);
}
/// <summary>Wrap rethrows as a transport exception keeping the original as InnerException.</summary>
[Fact]
public async Task Wrap_rethrows_as_AbTransportException_with_inner()
{
var inner = new LibPlcTagException("ErrorTimeout");
var ex = await Should.ThrowAsync<AbTransportException>(
() => LibplctagStatusTranslation.Wrap(() => throw inner));
ex.Status.ShouldBe(AbStatus.Timeout);
ex.InnerException.ShouldBeSameAs(inner);
}
}