fix(abcip): translate decode/encode-time libplctag exceptions; doc and probe-test cleanups

This commit is contained in:
Joseph Doherty
2026-10-07 15:44:02 -04:00
parent a57d61c484
commit 10c909cbc2
8 changed files with 89 additions and 20 deletions
@@ -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,
@@ -18,19 +18,10 @@ 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: transport statuses (<c>AbStatus</c>) that indicate the controller answered
/// the CIP session but could not resolve the probe tag
/// (<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 (<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).
/// 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".
/// </para>
/// </summary>
public sealed class AbCipDriverProbe : IDriverProbe
@@ -28,7 +28,7 @@ public interface IAbCipTagRuntime : IDisposable
/// 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>
/// <returns>The <c>AbStatus</c> value (zero on success, negative on error).</returns>
int GetStatus();
/// <summary>
@@ -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); }
}
/// <summary>Runs a synchronous libplctag call, rethrowing <see cref="LibPlcTagException"/> as <see cref="AbTransportException"/>.</summary>
/// <typeparam name="T">The result type.</typeparam>
/// <param name="op">The libplctag call.</param>
/// <returns>The call result.</returns>
public static T WrapSync<T>(Func<T> op)
{
try { return op(); }
catch (LibPlcTagException ex) { throw new AbTransportException(ToAbStatus(ex), ex.Message, ex); }
}
/// <summary>Runs a synchronous libplctag call, rethrowing <see cref="LibPlcTagException"/> as <see cref="AbTransportException"/>.</summary>
/// <param name="op">The libplctag call.</param>
public static void WrapSync(Action op)
{
try { op(); }
catch (LibPlcTagException ex) { throw new AbTransportException(ToAbStatus(ex), ex.Message, ex); }
}
}
@@ -51,13 +51,16 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
public int GetStatus() => (int)LibplctagStatusTranslation.ToAbStatus(_tag.GetStatus());
/// <inheritdoc />
public int GetSize() => _tag.GetSize();
public int GetSize() => LibplctagStatusTranslation.WrapSync(() => _tag.GetSize());
/// <inheritdoc />
public object? DecodeValue(AbCipDataType type, int? bitIndex) => DecodeValueAt(type, 0, bitIndex);
/// <inheritdoc />
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
};
/// <inheritdoc />
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
};
/// <inheritdoc />
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)
{
@@ -181,4 +181,25 @@ public sealed class AbCipDriverProbeTests
public void AnUnrecognisedExceptionMessageIsNotTreatedAsReachable()
=> AbCipDriverProbe.IsReachableException(
new AbTransportException(AbStatus.BadConnection, "something the wrapper has never said")).ShouldBeFalse();
/// <summary>A fake runtime reporting NotFound is reachable ("probe tag not found").</summary>
[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");
}
}
@@ -224,4 +224,24 @@ public sealed class AbCipDriverReadTests
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadCommunicationError);
factory.Tags["Nope"].Disposed.ShouldBeTrue();
}
/// <summary>A decode that throws OutOfBounds reads BadOutOfRange through the driver read path.</summary>
[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");
}
}
@@ -104,4 +104,14 @@ public sealed class AbCipStatusMapperTests
Status.ErrorUnsupported or Status.ErrorNotImplemented => AbCipStatusMapper.BadNotSupported,
_ => AbCipStatusMapper.BadCommunicationError, // incl. BadConnection/BadGateway/BadReply/Partial/Abort/...
};
/// <summary>A decode past the buffer end surfaces as BadOutOfRange once the adapter wraps it.</summary>
[Fact]
public void Wrapped_decode_OutOfBounds_maps_to_BadOutOfRange()
{
var ex = Should.Throw<AbTransportException>(() =>
LibplctagStatusTranslation.WrapSync<int>(() => throw new LibPlcTagException("ErrorOutOfBounds")));
AbCipStatusMapper.MapException(ex).ShouldBe(AbCipStatusMapper.BadOutOfRange);
}
}