diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs
index df97d4a9..fd10e654 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs
@@ -834,8 +834,8 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
}
catch (Exception ex)
{
- // Transport exception — evict so the next read creates a fresh handle. A libplctag
- // exception carries its Status, so a tag the controller does not have reads
+ // Transport exception — evict so the next read creates a fresh handle. An AbTransportException
+ // carries its AbStatus, so a tag the controller does not have reads
// BadNodeIdUnknown and a decode past the buffer BadOutOfRange, not a comms error.
EvictRuntime(device, def.Name);
results[fb.OriginalIndex] = new DataValueSnapshot(null,
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs
index 6973cd04..84bf5979 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs
@@ -18,19 +18,10 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// non-CIP server) returns Ok = false with a "handshake failed" message instead of
/// a false-positive green tick.
///
-/// Status-code discrimination: transport statuses (AbStatus) that indicate the controller answered
-/// the CIP session but could not resolve the probe tag
-/// (ErrorNotFound, ErrorNoMatch,
-/// ErrorBadDevice) are treated as "reachable — Ok = true" because
-/// a non-existent tag name proves CIP connectivity. Statuses that indicate a transport or
-/// session failure (ErrorTimeout, ErrorBadConnection,
-/// ErrorBadGateway, ErrorWinsock,
-/// ErrorOpen, ErrorClose,
-/// ErrorRead, ErrorWrite,
-/// ErrorBadReply, ErrorRemoteErr,
-/// ErrorPartial, ErrorAbort) are treated as
-/// "handshake failed — Ok = false". All other error statuses default to
-/// "handshake failed" (conservative).
+/// Status-code discrimination: AbStatus.Ok and AbStatus.NotFound 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".
///
///
public sealed class AbCipDriverProbe : IDriverProbe
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs
index 61b6a016..ed0c781d 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs
@@ -28,7 +28,7 @@ public interface IAbCipTagRuntime : IDisposable
/// Raw transport status code (an AbStatus value) — mapped to an OPC UA StatusCode via
/// . Zero on success, negative on error.
///
- /// The raw libplctag status code (zero on success, negative on error).
+ /// The AbStatus value (zero on success, negative on error).
int GetStatus();
///
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagStatusTranslation.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagStatusTranslation.cs
index 4e938c5b..3d695bdd 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagStatusTranslation.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagStatusTranslation.cs
@@ -55,4 +55,22 @@ internal static class LibplctagStatusTranslation
try { return await op().ConfigureAwait(false); }
catch (LibPlcTagException ex) { throw new AbTransportException(ToAbStatus(ex), ex.Message, ex); }
}
+
+ /// Runs a synchronous libplctag call, rethrowing as .
+ /// The result type.
+ /// The libplctag call.
+ /// The call result.
+ public static T WrapSync(Func op)
+ {
+ try { return op(); }
+ catch (LibPlcTagException ex) { throw new AbTransportException(ToAbStatus(ex), ex.Message, ex); }
+ }
+
+ /// Runs a synchronous libplctag call, rethrowing as .
+ /// The libplctag call.
+ public static void WrapSync(Action op)
+ {
+ try { op(); }
+ catch (LibPlcTagException ex) { throw new AbTransportException(ToAbStatus(ex), ex.Message, ex); }
+ }
}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs
index 62d8a914..3087492a 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs
@@ -51,13 +51,16 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
public int GetStatus() => (int)LibplctagStatusTranslation.ToAbStatus(_tag.GetStatus());
///
- public int GetSize() => _tag.GetSize();
+ public int GetSize() => LibplctagStatusTranslation.WrapSync(() => _tag.GetSize());
///
public object? DecodeValue(AbCipDataType type, int? bitIndex) => DecodeValueAt(type, 0, bitIndex);
///
- public object? DecodeValueAt(AbCipDataType type, int offset, int? bitIndex) => type switch
+ public object? DecodeValueAt(AbCipDataType type, int offset, int? bitIndex) =>
+ LibplctagStatusTranslation.WrapSync(() => DecodeValueAtCore(type, offset, bitIndex));
+
+ private object? DecodeValueAtCore(AbCipDataType type, int offset, int? bitIndex) => type switch
{
AbCipDataType.Bool => bitIndex is int bit
? _tag.GetBit(bit)
@@ -79,7 +82,10 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
};
///
- public object? DecodeArray(AbCipDataType type, int count)
+ public object? DecodeArray(AbCipDataType type, int count) =>
+ LibplctagStatusTranslation.WrapSync(() => DecodeArrayCore(type, count));
+
+ private object? DecodeArrayCore(AbCipDataType type, int count)
{
if (count < 1) count = 1;
// libplctag reports the per-element byte size once the tag has been read; it correctly
@@ -160,7 +166,10 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
};
///
- public void EncodeValue(AbCipDataType type, int? bitIndex, object? value)
+ public void EncodeValue(AbCipDataType type, int? bitIndex, object? value) =>
+ LibplctagStatusTranslation.WrapSync(() => EncodeValueCore(type, bitIndex, value));
+
+ private void EncodeValueCore(AbCipDataType type, int? bitIndex, object? value)
{
switch (type)
{
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverProbeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverProbeTests.cs
index 5707aea4..90c4f4cf 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverProbeTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverProbeTests.cs
@@ -181,4 +181,25 @@ public sealed class AbCipDriverProbeTests
public void AnUnrecognisedExceptionMessageIsNotTreatedAsReachable()
=> AbCipDriverProbe.IsReachableException(
new AbTransportException(AbStatus.BadConnection, "something the wrapper has never said")).ShouldBeFalse();
+
+ /// A fake runtime reporting NotFound is reachable ("probe tag not found").
+ [Fact]
+ public async Task Injected_factory_NotFound_status_is_reported_reachable()
+ {
+ using var listener = new TcpListener(IPAddress.Loopback, 0);
+ listener.Start();
+ var port = ((IPEndPoint)listener.LocalEndpoint).Port;
+ var factory = new FakeAbCipTagFactory
+ {
+ Customise = p => new FakeAbCipTag(p) { Status = (int)AbStatus.NotFound },
+ };
+
+ var result = await new AbCipDriverProbe(factory).ProbeAsync(
+ $"{{\"devices\":[{{\"hostAddress\":\"ab://127.0.0.1:{port}/1,0\"}}]}}",
+ TimeSpan.FromSeconds(3),
+ TestContext.Current.CancellationToken);
+
+ result.Ok.ShouldBeTrue();
+ result.Message!.ShouldContain("probe tag not found");
+ }
}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverReadTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverReadTests.cs
index fa46b20d..61a78199 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverReadTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverReadTests.cs
@@ -224,4 +224,24 @@ public sealed class AbCipDriverReadTests
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadCommunicationError);
factory.Tags["Nope"].Disposed.ShouldBeTrue();
}
+
+ /// A decode that throws OutOfBounds reads BadOutOfRange through the driver read path.
+ [Fact]
+ public async Task Decode_OutOfBounds_exception_maps_to_BadOutOfRange()
+ {
+ var (drv, factory) = NewDriver(
+ new AbCipTagDefinition("Over", "ab://10.0.0.5/1,0", "Over", AbCipDataType.DInt));
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ factory.Customise = p => new ThrowingDecodeTag(p);
+
+ var snapshots = await drv.ReadAsync(["Over"], CancellationToken.None);
+
+ snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadOutOfRange);
+ }
+
+ private sealed class ThrowingDecodeTag(AbCipTagCreateParams p) : FakeAbCipTag(p)
+ {
+ public override object? DecodeValue(AbCipDataType type, int? bitIndex) =>
+ throw new AbTransportException(AbStatus.OutOfBounds, "decode past buffer");
+ }
}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipStatusMapperTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipStatusMapperTests.cs
index e8cf140b..8c813c63 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipStatusMapperTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipStatusMapperTests.cs
@@ -104,4 +104,14 @@ public sealed class AbCipStatusMapperTests
Status.ErrorUnsupported or Status.ErrorNotImplemented => AbCipStatusMapper.BadNotSupported,
_ => AbCipStatusMapper.BadCommunicationError, // incl. BadConnection/BadGateway/BadReply/Partial/Abort/...
};
+
+ /// A decode past the buffer end surfaces as BadOutOfRange once the adapter wraps it.
+ [Fact]
+ public void Wrapped_decode_OutOfBounds_maps_to_BadOutOfRange()
+ {
+ var ex = Should.Throw(() =>
+ LibplctagStatusTranslation.WrapSync(() => throw new LibPlcTagException("ErrorOutOfBounds")));
+
+ AbCipStatusMapper.MapException(ex).ShouldBe(AbCipStatusMapper.BadOutOfRange);
+ }
}