diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/AbStatusCodes.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/AbStatusCodes.cs
index b9d8d674..39c5b41d 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/AbStatusCodes.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/AbStatusCodes.cs
@@ -1,39 +1,81 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
+///
+/// Maps wire statuses (CIP general/extended, PCCC STS/EXT STS) to the transport-neutral the
+/// drivers classify. The class an 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;
+/// is the no-reply comms class; / are a reply that
+/// cannot be classified. Every code not named below is deliberately .
+///
public static class AbStatusCodes
{
/// Maps a CIP general status (and optional extended status words) to an .
/// The CIP general status byte.
/// The extended status words, if any.
/// The mapped status.
+ ///
+ /// 0x01 (connection failure, incl. a routing hop's unconnected-send timeout) and 0x07 (connection lost) stay
+ /// 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
+ /// ConnectionManagerServices.ParseForwardOpenReply.
+ ///
public static AbStatus FromCip(byte general, ReadOnlySpan 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,
};
+ ///
+ /// True for the CIP statuses Lane K F1 moved from to a request-level refusal.
+ /// A Forward Open refused with one of them is still a connection failure.
+ ///
+ /// The CIP general status byte.
+ /// The extended status words, if any.
+ /// for 0x1C, 0x20 and 0xFF/0x2104.
+ internal static bool IsRequestLevelRefusal(byte general, ReadOnlySpan extended) =>
+ general is 0x1C or 0x20 || (general == 0xFF && extended.Length > 0 && extended[0] == 0x2104);
+
/// Maps a PCCC status byte (and optional extended status) to an .
/// The PCCC STS byte.
/// The extended status byte, when present.
/// The mapped status.
+ ///
+ /// 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.
+ ///
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
};
}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ConnectionManagerServices.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ConnectionManagerServices.cs
index 95d2dee7..91d59600 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ConnectionManagerServices.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ConnectionManagerServices.cs
@@ -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());
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipRefusals.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipRefusals.cs
index 334568a1..2bded247 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipRefusals.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipRefusals.cs
@@ -13,10 +13,10 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// never writes Degraded, 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.
///
-/// The native transport currently folds CIP general statuses 0x1C, 0x20 and 0xFF/0x2104 into
-/// (AbStatusCodes.FromCip, owned by Driver.Ab.Transport), 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 and 0xFF/0x2104 (offset beyond end) to
+/// (AbStatusCodes.FromCip, Lane K F1), so they classify here as
+/// device refusals. A Forward Open refused with one of them stays a connection failure.
///
///
internal static class AbCipRefusals
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs
index 2444f3c1..4d7657d7 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs
@@ -838,10 +838,10 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag
/// reachability (backoff reset, LastSuccessfulRead, Healthy).
///
///
- /// ⚠️ Inherits the native transport's PCCC STS table (AbStatusCodes.FromPccc, Lane K): it maps STS 0x30
- /// ("remote node missing" — a dead DH+/DH-485 node behind a bridge) and 0x20 to NotAllowed/NotFound,
- /// 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 (AbStatusCodes.FromPccc). 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
+ /// (no reply), so a dead DH+/DH-485 node behind a bridge is a comms fault,
+ /// not an answer (NativePcccBridgeStatusHealthTests).
///
/// The transport status.
/// for a device refusal.
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/AbStatusCodesTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/AbStatusCodesTests.cs
index 905585bc..76ee84fb 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/AbStatusCodesTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/AbStatusCodesTests.cs
@@ -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.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);
+ ///
+ /// 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).
+ ///
+ /// The PCCC STS byte.
+ [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()
{
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/ConnectionManagerServicesTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/ConnectionManagerServicesTests.cs
index 0d4c2003..ec362206 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/ConnectionManagerServicesTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/ConnectionManagerServicesTests.cs
@@ -115,6 +115,30 @@ public sealed class ConnectionManagerServicesTests
ex.Status.ShouldBe(AbStatus.BadConnection); // general 0x01 has no specific mapping
}
+ ///
+ /// 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.
+ ///
+ /// The refused Forward Open reply.
+ [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(() =>
+ ConnectionManagerServices.ParseForwardOpenReply(CipReply.Parse(Convert.FromHexString(hex))));
+ ex.Status.ShouldBe(AbStatus.BadConnection);
+ }
+
+ /// Positive control: a Forward Open refusal that was already specifically mapped keeps its status.
+ [Fact]
+ public void Forward_open_refused_service_not_supported_stays_Unsupported() =>
+ Should.Throw(() =>
+ 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()
{
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Pccc/PcccServicesTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Pccc/PcccServicesTests.cs
index b8d36f68..d8c6edb7 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Pccc/PcccServicesTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Pccc/PcccServicesTests.cs
@@ -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) ----
+
+ ///
+ /// Wire-level golden replies: the Execute PCCC reply bytes a device (or a bridge in front of it) sends for each
+ /// STS, through , to the status the drivers classify.
+ ///
+ /// The reply's STS (+ EXT STS) bytes as hex.
+ /// The status ParseReply raises.
+ [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(() => PcccServices.ParseReply(Reply(hex), 0x8799))
+ .Status.ShouldBe(expected);
+ }
+
// ---- ParseReply ----
private static CipReply Reply(string hex, byte general = 0) =>
new(0x4B, general, [], Hex(hex));
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/NativeAbCipTagRuntimeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/NativeAbCipTagRuntimeTests.cs
index 028d6e27..03434b0a 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/NativeAbCipTagRuntimeTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/NativeAbCipTagRuntimeTests.cs
@@ -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)) };
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/NativeAbLegacyTagRuntimeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/NativeAbLegacyTagRuntimeTests.cs
index 8d5185fe..845a3a07 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/NativeAbLegacyTagRuntimeTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/NativeAbLegacyTagRuntimeTests.cs
@@ -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(() => h.Runtime.InitializeAsync(CancellationToken.None));
ex.Status.ShouldBe(AbStatus.NotFound);
h.Runtime.GetStatus().ShouldBe((int)AbStatus.NotFound);
}
+ ///
+ /// 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.
+ ///
+ /// The PCCC STS.
+ [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(() => 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(() => h.Runtime.WriteAsync(CancellationToken.None));
ex.Status.ShouldBe(AbStatus.NotAllowed);
h.Runtime.GetStatus().ShouldBe((int)AbStatus.NotAllowed);
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/NativePcccBridgeStatusHealthTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/NativePcccBridgeStatusHealthTests.cs
new file mode 100644
index 00000000..4829c654
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/NativePcccBridgeStatusHealthTests.cs
@@ -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;
+
+///
+/// Lane K F3 (AbLegacy F8, review 07 r0 I4), end to end over a real socket with the PRODUCTION
+/// : 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, LastSuccessfulRead 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,
+/// LastSuccessfulRead advanced.
+///
+[Trait("Category", "Unit")]
+public sealed class NativePcccBridgeStatusHealthTests
+{
+ private static readonly byte[] FixedId = [0x07, 0x3D, 0xF3, 0x45, 0x43, 0x50, 0x21];
+
+ /// A PCCC fake that answers every request with one fixed STS.
+ 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 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;
+ }
+
+ /// STS 0x30 from a bridge: a comms fault, never evidence the device answered.
+ [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();
+ }
+
+ /// Positive control: STS 0x60 is the live device refusing the tag — answered, so Healthy and advanced.
+ [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
+}