fix(ab-transport): map CIP 0x1C/0x20/0xFF-0x2104 as refusals; remap the PCCC STS table

K-F1 (AbCip #737 F1): FromCip maps general 0x1C and 0x20 to Unsupported and
0xFF/0x2104 to OutOfBounds, so a controller refusal is per-tag Bad instead of a
BadReply comms fault. A Forward Open refused with one of them is still
BadConnection. Every CIP code is now listed with its meaning; 0x01/0x07 stay
opaque on purpose.

K-F3 (AbLegacy F8, review 07 r0 I4): FromPccc follows the DF1 STS table:
0x20/0x30/0x90 -> BadConnection (a bridge reporting an unreachable END node),
0x50 and EXT 0x06 -> NotFound, EXT 0x07 -> OutOfBounds, 0x60 -> NotAllowed,
0x70/0xB0 -> DeviceStateConflict. Golden ParseReply tests per STS, and an
end-to-end AbLegacy test: STS 0x30 degrades with LastSuccessfulRead preserved;
the 0x60 positive control stays Healthy and advances it. Both were red before.
Driver edits are doc remarks and the two runtime tests the old table pinned.

Refs #747
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 16:42:57 -04:00
parent f9fbd4ee0d
commit dfc62cef14
10 changed files with 278 additions and 36 deletions
@@ -1,39 +1,81 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
/// <summary>
/// Maps wire statuses (CIP general/extended, PCCC STS/EXT STS) to the transport-neutral <see cref="AbStatus"/> the
/// drivers classify. The class an <see cref="AbStatus"/> lands in is load-bearing for driver health (#527/#737/#738):
/// an ANSWERED refusal (NotFound / NotAllowed / OutOfBounds / TooLarge / TooSmall / TypeMismatch /
/// DeviceStateConflict, and for AbCip Unsupported) proves the device replied; <see cref="AbStatus.BadConnection"/>
/// is the no-reply comms class; <see cref="AbStatus.BadReply"/> / <see cref="AbStatus.Partial"/> are a reply that
/// cannot be classified. Every code not named below is deliberately <see cref="AbStatus.BadReply"/>.
/// </summary>
public static class AbStatusCodes
{
/// <summary>Maps a CIP general status (and optional extended status words) to an <see cref="AbStatus"/>.</summary>
/// <param name="general">The CIP general status byte.</param>
/// <param name="extended">The extended status words, if any.</param>
/// <returns>The mapped status.</returns>
/// <remarks>
/// 0x01 (connection failure, incl. a routing hop's unconnected-send timeout) and 0x07 (connection lost) stay
/// <see cref="AbStatus.BadReply"/> on purpose: a bridge reports a dead END device that way, so they are neither an
/// answer nor proof of no reply (AbLegacy #738 "opaque"). A Forward Open refused with one of the request-level
/// codes (0x1C / 0x20 / 0xFF-0x2104) stays a connection failure — see
/// <c>ConnectionManagerServices.ParseForwardOpenReply</c>.
/// </remarks>
public static AbStatus FromCip(byte general, ReadOnlySpan<ushort> extended) => general switch
{
0x00 or 0x0B => AbStatus.Ok,
0x04 or 0x05 or 0x16 => AbStatus.NotFound,
0x06 => AbStatus.Partial,
0x08 => AbStatus.Unsupported,
0x0A => AbStatus.OutOfBounds,
0x0C or 0x10 => AbStatus.DeviceStateConflict,
0x0E or 0x0F => AbStatus.NotAllowed,
0x13 => AbStatus.TooSmall,
0x15 => AbStatus.TooLarge,
0xFF when extended.Length > 0 && extended[0] == 0x2105 => AbStatus.OutOfBounds,
0xFF when extended.Length > 0 && extended[0] == 0x2107 => AbStatus.TypeMismatch,
0x00 or 0x0B => AbStatus.Ok, // success / already in requested mode
0x04 or 0x05 or 0x16 => AbStatus.NotFound, // path segment error / path unknown / object does not exist
0x06 => AbStatus.Partial, // partial transfer (fragmented read continues)
0x08 => AbStatus.Unsupported, // service not supported
0x0A => AbStatus.OutOfBounds, // attribute list error
0x0C or 0x10 => AbStatus.DeviceStateConflict, // object state conflict / device state conflict
0x0E or 0x0F => AbStatus.NotAllowed, // attribute not settable / privilege violation
0x13 => AbStatus.TooSmall, // not enough data
0x15 => AbStatus.TooLarge, // too much data
// Lane K F1 (#737): the controller answered and refused this one request — not a garbled reply, so not a
// comms fault that degrades the driver and evicts the runtime.
0x1C => AbStatus.Unsupported, // missing attribute list entry data
0x20 => AbStatus.Unsupported, // invalid parameter
0xFF when extended.Length > 0 && extended[0] == 0x2104 => AbStatus.OutOfBounds, // offset beyond end of tag
0xFF when extended.Length > 0 && extended[0] == 0x2105 => AbStatus.OutOfBounds, // beyond end of the object
0xFF when extended.Length > 0 && extended[0] == 0x2107 => AbStatus.TypeMismatch, // data type mismatch
_ => AbStatus.BadReply,
};
/// <summary>
/// True for the CIP statuses Lane K F1 moved from <see cref="AbStatus.BadReply"/> to a request-level refusal.
/// A Forward Open refused with one of them is still a connection failure.
/// </summary>
/// <param name="general">The CIP general status byte.</param>
/// <param name="extended">The extended status words, if any.</param>
/// <returns><see langword="true"/> for 0x1C, 0x20 and 0xFF/0x2104.</returns>
internal static bool IsRequestLevelRefusal(byte general, ReadOnlySpan<ushort> extended) =>
general is 0x1C or 0x20 || (general == 0xFF && extended.Length > 0 && extended[0] == 0x2104);
/// <summary>Maps a PCCC status byte (and optional extended status) to an <see cref="AbStatus"/>.</summary>
/// <param name="sts">The PCCC STS byte.</param>
/// <param name="extSts">The extended status byte, when present.</param>
/// <returns>The mapped status.</returns>
/// <remarks>
/// Per the DF1/PCCC STS table (1770-6.5.16). Lane K F3 (AbLegacy F8, review 07 r0 I4): 0x20, 0x30 and 0x90 are
/// reported by the HOST or a BRIDGE (a DHRIO, a 1761-NET-ENI) when the END device cannot be reached, so they are
/// the no-reply comms class. Mapping them to an answered refusal made a dead DH+/DH-485 node behind a bridge count
/// as reachable forever — no fail-fast backoff, no comms-loss watchdog.
/// </remarks>
public static AbStatus FromPccc(byte sts, byte? extSts) => sts switch
{
0x00 => AbStatus.Ok,
0x10 => AbStatus.Unsupported,
0x20 => AbStatus.NotFound,
0x30 => AbStatus.NotAllowed,
0x40 or 0x50 => AbStatus.DeviceStateConflict,
0xF0 when extSts is 0x06 or 0x07 => AbStatus.NotFound,
_ => AbStatus.BadReply,
0x10 => AbStatus.Unsupported, // illegal command or format
0x20 => AbStatus.BadConnection, // host has a problem and will not communicate
0x30 => AbStatus.BadConnection, // remote node host missing, disconnected or shut down
0x40 => AbStatus.DeviceStateConflict, // host could not complete: hardware fault
0x50 => AbStatus.NotFound, // addressing problem or memory protect rungs
0x60 => AbStatus.NotAllowed, // function not allowed due to command protection
0x70 => AbStatus.DeviceStateConflict, // processor is in Program mode
0x90 => AbStatus.BadConnection, // remote node cannot buffer command
0xB0 => AbStatus.DeviceStateConflict, // remote node problem due to download
0xF0 when extSts is 0x06 => AbStatus.NotFound, // EXT: address does not point to something usable
0xF0 when extSts is 0x07 => AbStatus.OutOfBounds, // EXT: file is wrong size
_ => AbStatus.BadReply, // 0x80, 0xA0, 0xC0, other EXT STS: unclassified
};
}
@@ -85,8 +85,15 @@ public static class ConnectionManagerServices
public static ForwardOpenReply ParseForwardOpenReply(CipReply reply)
{
if (reply.GeneralStatus != 0)
throw new ForwardOpenRefusedException(reply.Status == AbStatus.BadReply ? AbStatus.BadConnection : reply.Status,
reply.GeneralStatus, reply.ExtendedStatus);
{
// An unclassified refusal, or one of the request-level codes AbStatusCodes maps to a per-request refusal
// for TAG services (0x1C / 0x20 / 0xFF-0x2104), is a connection failure here: no tag can be served.
var status = reply.Status == AbStatus.BadReply
|| AbStatusCodes.IsRequestLevelRefusal(reply.GeneralStatus, reply.ExtendedStatus)
? AbStatus.BadConnection
: reply.Status;
throw new ForwardOpenRefusedException(status, reply.GeneralStatus, reply.ExtendedStatus);
}
var r = new CipReader(reply.Data.Span);
var ot = r.U32(); var to = r.U32(); r.U16(); r.U16(); r.U32();
return new ForwardOpenReply(ot, to, r.U32(), r.U32());
@@ -13,10 +13,10 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// 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.
/// The native transport maps CIP general statuses 0x1C (missing attribute list entry) and 0x20 (invalid
/// parameter) to <see cref="AbStatus.Unsupported"/> and 0xFF/0x2104 (offset beyond end) to
/// <see cref="AbStatus.OutOfBounds"/> (<c>AbStatusCodes.FromCip</c>, Lane K F1), so they classify here as
/// device refusals. A Forward Open refused with one of them stays a connection failure.
/// </para>
/// </remarks>
internal static class AbCipRefusals
@@ -838,10 +838,10 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag
/// reachability (backoff reset, <c>LastSuccessfulRead</c>, Healthy).
/// </summary>
/// <remarks>
/// ⚠️ Inherits the native transport's PCCC STS table (<c>AbStatusCodes.FromPccc</c>, Lane K): it maps STS 0x30
/// ("remote node missing" — a dead DH+/DH-485 node behind a bridge) and 0x20 to <c>NotAllowed</c>/<c>NotFound</c>,
/// so on the NATIVE transport such a dead node still reads as answered. Follow-up F8 (review 07 r0 I4); this
/// classification needs no change once it lands.
/// Inherits the native transport's PCCC STS table (<c>AbStatusCodes.FromPccc</c>). Since Lane K F3 (F8, review
/// 07 r0 I4) STS 0x20 / 0x30 / 0x90 — the host or a bridge reporting an unreachable END device — map to
/// <see cref="AbStatus.BadConnection"/> (no reply), so a dead DH+/DH-485 node behind a bridge is a comms fault,
/// not an answer (<c>NativePcccBridgeStatusHealthTests</c>).
/// </remarks>
/// <param name="status">The transport status.</param>
/// <returns><see langword="true"/> for a device refusal.</returns>
@@ -21,10 +21,15 @@ public sealed class AbStatusCodesTests
[InlineData((byte)0x0E, AbStatus.NotAllowed)]
[InlineData((byte)0x1E, AbStatus.BadReply)]
[InlineData((byte)0x01, AbStatus.BadReply)]
[InlineData((byte)0x07, AbStatus.BadReply)] // connection lost: opaque on purpose (AbLegacy #738)
// Lane K F1 (#737): the controller answered and refused this request — not a garbled reply.
[InlineData((byte)0x1C, AbStatus.Unsupported)] // missing attribute list entry data
[InlineData((byte)0x20, AbStatus.Unsupported)] // invalid parameter
public void Cip_general_status_maps(byte general, AbStatus expected) =>
AbStatusCodes.FromCip(general, ReadOnlySpan<ushort>.Empty).ShouldBe(expected);
[Theory]
[InlineData((ushort)0x2104, AbStatus.OutOfBounds)] // offset beyond end of the tag (Lane K F1)
[InlineData((ushort)0x2105, AbStatus.OutOfBounds)]
[InlineData((ushort)0x2107, AbStatus.TypeMismatch)]
[InlineData((ushort)0x9999, AbStatus.BadReply)]
@@ -37,16 +42,42 @@ public sealed class AbStatusCodesTests
[Theory]
[InlineData((byte)0x00, null, AbStatus.Ok)]
[InlineData((byte)0x10, null, AbStatus.Unsupported)]
[InlineData((byte)0x20, null, AbStatus.NotFound)]
[InlineData((byte)0x30, null, AbStatus.NotAllowed)]
[InlineData((byte)0x40, null, AbStatus.DeviceStateConflict)]
[InlineData((byte)0xF0, (byte)0x06, AbStatus.NotFound)]
[InlineData((byte)0xF0, (byte)0x07, AbStatus.NotFound)]
[InlineData((byte)0x10, null, AbStatus.Unsupported)] // illegal command or format
// Lane K F3 (AbLegacy F8, review 07 r0 I4), per the DF1/PCCC STS table (1770-6.5.16):
[InlineData((byte)0x20, null, AbStatus.BadConnection)] // host has a problem and will not communicate
[InlineData((byte)0x30, null, AbStatus.BadConnection)] // remote node host missing / disconnected / shut down
[InlineData((byte)0x40, null, AbStatus.DeviceStateConflict)] // host hardware fault (the host answered)
[InlineData((byte)0x50, null, AbStatus.NotFound)] // addressing problem or memory protect rungs
[InlineData((byte)0x60, null, AbStatus.NotAllowed)] // function not allowed due to command protection
[InlineData((byte)0x70, null, AbStatus.DeviceStateConflict)] // processor is in Program mode
[InlineData((byte)0x80, null, AbStatus.BadReply)] // compatibility-mode file missing: unclassified
[InlineData((byte)0x90, null, AbStatus.BadConnection)] // remote node cannot buffer command
[InlineData((byte)0xB0, null, AbStatus.DeviceStateConflict)] // remote node problem due to download
[InlineData((byte)0xF0, (byte)0x06, AbStatus.NotFound)] // address does not point to something usable
[InlineData((byte)0xF0, (byte)0x07, AbStatus.OutOfBounds)] // file is wrong size
[InlineData((byte)0xF0, (byte)0x0A, AbStatus.BadReply)]
[InlineData((byte)0xF0, null, AbStatus.BadReply)]
public void Pccc_status_maps(byte sts, byte? ext, AbStatus expected) =>
AbStatusCodes.FromPccc(sts, ext).ShouldBe(expected);
/// <summary>
/// A dead DH+/DH-485 node behind a bridge answers STS 0x30 (and a busy one 0x90). Neither proves the END
/// device answered, so they are the no-reply comms class, never one of the "answered" refusals — that
/// classification made such a device count as reachable forever (no backoff, no comms-loss watchdog).
/// </summary>
/// <param name="sts">The PCCC STS byte.</param>
[Theory]
[InlineData((byte)0x20)]
[InlineData((byte)0x30)]
[InlineData((byte)0x90)]
public void Pccc_no_reply_statuses_are_never_answered_refusals(byte sts)
{
var status = AbStatusCodes.FromPccc(sts, null);
status.ShouldBe(AbStatus.BadConnection);
status.ShouldNotBeOneOf(AbStatus.NotFound, AbStatus.NotAllowed, AbStatus.OutOfBounds,
AbStatus.DeviceStateConflict, AbStatus.TooLarge, AbStatus.TooSmall, AbStatus.TypeMismatch);
}
[Fact]
public void Ok_is_zero_and_every_error_is_negative()
{
@@ -115,6 +115,30 @@ public sealed class ConnectionManagerServicesTests
ex.Status.ShouldBe(AbStatus.BadConnection); // general 0x01 has no specific mapping
}
/// <summary>
/// Lane K F1 remapped general 0x1C / 0x20 and 0xFF/0x2104 for TAG requests (a per-request refusal). A Forward
/// Open refused with one of them is still a connection failure, exactly as before: the whole connection is
/// unusable, so it must not read as a per-tag refusal.
/// </summary>
/// <param name="hex">The refused Forward Open reply.</param>
[Theory]
[InlineData("DB001C00")]
[InlineData("DB002000")]
[InlineData("DB00FF010421")]
public void Forward_open_refused_with_a_request_level_status_is_still_BadConnection(string hex)
{
var ex = Should.Throw<ForwardOpenRefusedException>(() =>
ConnectionManagerServices.ParseForwardOpenReply(CipReply.Parse(Convert.FromHexString(hex))));
ex.Status.ShouldBe(AbStatus.BadConnection);
}
/// <summary>Positive control: a Forward Open refusal that was already specifically mapped keeps its status.</summary>
[Fact]
public void Forward_open_refused_service_not_supported_stays_Unsupported() =>
Should.Throw<ForwardOpenRefusedException>(() =>
ConnectionManagerServices.ParseForwardOpenReply(CipReply.Parse(Convert.FromHexString("DB000800"))))
.Status.ShouldBe(AbStatus.Unsupported);
[Fact]
public void Refused_forward_open_exception_is_not_aliased_to_the_reply_array()
{
@@ -90,6 +90,32 @@ public sealed class PcccServicesTests
[Fact]
public void Plc5_write_is_not_shipped_and_flagged() => PcccServices.Plc5WriteVerified.ShouldBeFalse();
// ---- ParseReply: golden STS replies (Lane K F3 / AbLegacy F8) ----
/// <summary>
/// Wire-level golden replies: the Execute PCCC reply bytes a device (or a bridge in front of it) sends for each
/// STS, through <see cref="PcccServices.ParseReply"/>, to the status the drivers classify.
/// </summary>
/// <param name="stsHex">The reply's STS (+ EXT STS) bytes as hex.</param>
/// <param name="expected">The status ParseReply raises.</param>
[Theory]
[InlineData("20", AbStatus.BadConnection)]
[InlineData("30", AbStatus.BadConnection)]
[InlineData("90", AbStatus.BadConnection)]
[InlineData("50", AbStatus.NotFound)]
[InlineData("60", AbStatus.NotAllowed)]
[InlineData("70", AbStatus.DeviceStateConflict)]
[InlineData("B0", AbStatus.DeviceStateConflict)]
[InlineData("F0|06", AbStatus.NotFound)]
[InlineData("F0|07", AbStatus.OutOfBounds)]
public void ParseReply_golden_sts(string stsHex, AbStatus expected)
{
var parts = stsHex.Split('|');
var hex = Id + "4F" + parts[0] + "9987" + (parts.Length > 1 ? parts[1] : "");
Should.Throw<AbTransportException>(() => PcccServices.ParseReply(Reply(hex), 0x8799))
.Status.ShouldBe(expected);
}
// ---- ParseReply ----
private static CipReply Reply(string hex, byte general = 0) =>
new(0x4B, general, [], Hex(hex));
@@ -297,6 +297,11 @@ public sealed class NativeAbCipTagRuntimeTests
[InlineData("D2000A00", AbStatus.OutOfBounds)]
[InlineData("D2000F00", AbStatus.NotAllowed)]
[InlineData("D200FF010521", AbStatus.OutOfBounds)]
// Lane K F1 (#737): these used to arrive as BadReply, i.e. a comms fault (Degraded + evict) for a tag the
// controller had simply refused. AbCipRefusals.IsDeviceRefusal classifies the new statuses with no driver change.
[InlineData("D2001C00", AbStatus.Unsupported)] // missing attribute list entry data
[InlineData("D2002000", AbStatus.Unsupported)] // invalid parameter
[InlineData("D200FF010421", AbStatus.OutOfBounds)] // offset beyond end of the tag
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(reply)) };
@@ -220,14 +220,30 @@ public sealed class NativeAbLegacyTagRuntimeTests
}
[Fact]
public async Task STS_0x20_on_initialize_is_NotFound()
public async Task STS_0x50_on_initialize_is_NotFound()
{
await using var h = new Harness("slc500", "N99:0").Responder((_, _) => (0x20, null, []));
await using var h = new Harness("slc500", "N99:0").Responder((_, _) => (0x50, null, []));
var ex = await Should.ThrowAsync<AbTransportException>(() => h.Runtime.InitializeAsync(CancellationToken.None));
ex.Status.ShouldBe(AbStatus.NotFound);
h.Runtime.GetStatus().ShouldBe((int)AbStatus.NotFound);
}
/// <summary>
/// Lane K F3 (F8): STS 0x20 / 0x30 come from the host or a bridge when the END device cannot be reached, so they
/// are the no-reply class (BadConnection), not an answered refusal.
/// </summary>
/// <param name="sts">The PCCC STS.</param>
[Theory]
[InlineData((byte)0x20)]
[InlineData((byte)0x30)]
public async Task STS_from_a_bridge_for_an_unreachable_node_is_BadConnection(byte sts)
{
await using var h = new Harness("slc500", "N7:0").Responder((_, _) => (sts, null, []));
var ex = await Should.ThrowAsync<AbTransportException>(() => h.Runtime.InitializeAsync(CancellationToken.None));
ex.Status.ShouldBe(AbStatus.BadConnection);
h.Runtime.GetStatus().ShouldBe((int)AbStatus.BadConnection);
}
[Fact]
public async Task Initialize_performs_exactly_one_read()
{
@@ -305,7 +321,7 @@ public sealed class NativeAbLegacyTagRuntimeTests
public async Task A_failed_second_write_chunk_is_reported_as_failed_not_Good()
{
await using var h = new Harness("slc500", "N7:0", count: 130).Responder((_, n) =>
n == 1 ? (0x30, null, []) : (0x00, null, []));
n == 1 ? (0x60, null, []) : (0x00, null, []));
var ex = await Should.ThrowAsync<AbTransportException>(() => h.Runtime.WriteAsync(CancellationToken.None));
ex.Status.ShouldBe(AbStatus.NotAllowed);
h.Runtime.GetStatus().ShouldBe((int)AbStatus.NotAllowed);
@@ -0,0 +1,91 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
/// <summary>
/// Lane K F3 (AbLegacy F8, review 07 r0 I4), end to end over a real socket with the PRODUCTION
/// <see cref="NativeAbLegacyTagFactory"/>: a bridge (DHRIO / 1761-NET-ENI) in front of a dead DH+ node answers every
/// PCCC request with STS 0x30 "remote node host missing". That is NOT the device answering, so the driver must treat
/// it as a comms fault — Degraded, <c>LastSuccessfulRead</c> not advanced — or the comms-loss watchdog never fires
/// for it. The positive control is a live device refusing a tag (STS 0x60, command protection): per-tag Bad, Healthy,
/// <c>LastSuccessfulRead</c> advanced.
/// </summary>
[Trait("Category", "Unit")]
public sealed class NativePcccBridgeStatusHealthTests
{
private static readonly byte[] FixedId = [0x07, 0x3D, 0xF3, 0x45, 0x43, 0x50, 0x21];
/// <summary>A PCCC fake that answers every request with one fixed STS.</summary>
private static FakeEipServer StsServer(byte sts)
{
var server = new FakeEipServer { ZeroReplyContext = true };
server.Handler = req =>
{
if (FakeEipServer.Command(req) != Ab.Transport.Eip.EncapCommand.SendRRData) return null;
var cip = FakeEipServer.RequestCip(req);
byte[] head = [0xCB, 0x00, 0x00, 0x00, .. FixedId, 0x4F];
return FakeEipServer.RRReply(req, [.. head, sts, cip[15], cip[16]]);
};
return server;
}
#pragma warning disable OTOPCUA0001 // the driver is called directly to pin its OWN classification (no resilience wrap)
private static async Task<AbLegacyDriver> DriverAsync(FakeEipServer server)
{
var host = $"ab://127.0.0.1:{server.Port}/1,0";
var driver = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions(host, AbLegacyPlcFamily.Slc500)],
RawTags = AbLegacyRawTags.Entries([new AbLegacyTagDefinition("T", host, "N7:0", AbLegacyDataType.Int)]),
Timeout = TimeSpan.FromSeconds(5),
Probe = new AbLegacyProbeOptions { Enabled = false },
}, "drv-pccc-bridge", new NativeAbLegacyTagFactory(new DeviceSessionPool()));
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
return driver;
}
/// <summary>STS 0x30 from a bridge: a comms fault, never evidence the device answered.</summary>
[Fact]
public async Task A_dead_node_behind_a_bridge_degrades_and_does_not_advance_LastSuccessfulRead()
{
var ct = TestContext.Current.CancellationToken;
await using var server = StsServer(0x30);
var driver = await DriverAsync(server);
var before = driver.GetHealth().LastSuccessfulRead;
var result = (await driver.ReadAsync(["T"], ct)).Single();
result.StatusCode.ShouldBe(AbLegacyStatusMapper.BadCommunicationError);
var health = driver.GetHealth();
health.State.ShouldBe(DriverState.Degraded);
health.LastSuccessfulRead.ShouldBe(before);
await driver.ShutdownAsync(ct);
server.HandlerErrors.ShouldBeEmpty();
}
/// <summary>Positive control: STS 0x60 is the live device refusing the tag — answered, so Healthy and advanced.</summary>
[Fact]
public async Task A_live_device_refusing_the_tag_stays_Healthy_and_advances_LastSuccessfulRead()
{
var ct = TestContext.Current.CancellationToken;
await using var server = StsServer(0x60);
var driver = await DriverAsync(server);
var before = driver.GetHealth().LastSuccessfulRead;
var result = (await driver.ReadAsync(["T"], ct)).Single();
result.StatusCode.ShouldBe(AbOpcUaStatusMap.BadNotWritable);
var health = driver.GetHealth();
health.State.ShouldBe(DriverState.Healthy);
health.LastSuccessfulRead.ShouldNotBeNull();
health.LastSuccessfulRead.ShouldNotBe(before);
await driver.ShutdownAsync(ct);
server.HandlerErrors.ShouldBeEmpty();
}
#pragma warning restore OTOPCUA0001
}