diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/StatusCodeMap.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/StatusCodeMap.cs
index 0fe9b7b3a..52f11e472 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/StatusCodeMap.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/StatusCodeMap.cs
@@ -133,6 +133,9 @@ internal static class StatusCodeMap
// the belief that a non-zero source meant a pre-MXAccess transport failure — but every detected_by value
// names an MXAccess layer (0 requesting LMX, 1 responding LMX, 2/3 NMX, 4/5 automation object; the
// gateway's own failures arrive as ProtocolStatus, never as a status row), so the category decides (#872).
+ // The numbering is mxaccessgw's MxStatusProxyConverter.MapSource; NMX (2/3) names the inter-node message
+ // layer that noticed the failure, not a transport fault — a real comms loss is category CommunicationError
+ // or detail 2/5, which FromCategory already maps to BadCommunicationError.
return FromCategory(status, logger);
}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/StatusCodeMapTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/StatusCodeMapTests.cs
index 64f99ced1..9ed5b850c 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/StatusCodeMapTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/StatusCodeMapTests.cs
@@ -140,18 +140,72 @@ public sealed class StatusCodeMapTests
logger.Entries.Count.ShouldBe(1);
}
+ // ===== #872: detected_by never decides the code — the category does =====
+ // raw_detected_by is MXAccess's MxStatusSource, decoded by mxaccessgw's MxStatusProxyConverter.MapSource:
+ // -1 Unknown, 0 RequestingLmx, 1 RespondingLmx, 2 RequestingNmx, 3 RespondingNmx,
+ // 4 RequestingAutomationObject, 5 RespondingAutomationObject, anything else Unknown. Every value names the
+ // MXAccess layer that noticed the failure, not the kind of failure — the live write-refusal capture in
+ // mxaccessgw docs/AlarmProbeFindings.md is an OperationalError detected by RespondingAutomationObject (5).
+ // A transport/network failure shows up as category CommunicationError (or detail 2/5), and the gateway's own
+ // failures arrive as ProtocolStatus, never as a status row. The old rule (detail 0 and raw_detected_by != 0
+ // => BadCommunicationError) turned every one of those object/LMX-detected failures into a comms error.
+
///
- /// #872: detected_by names an MXAccess layer (3 = responding NMX), not a transport failure, so a detail-0
- /// failure row routes on its category — a communication-category row is still BadCommunicationError.
+ /// #872: a detail-0 failure row maps on its category for every detected_by source — including the
+ /// NMX sources, which name the layer that noticed the failure, not a transport failure.
///
+ /// The raw MxStatusSource value.
[Theory]
- [InlineData(MxStatusCategory.Unspecified, 0x80000000u)]
- [InlineData(MxStatusCategory.CommunicationError, 0x80050000u)]
- [InlineData(MxStatusCategory.ConfigurationError, 0x80890000u)]
- public void FromMxStatus_SuccessZero_DetailZero_RoutesByCategory_NotDetectedBy(MxStatusCategory category, uint expected)
+ [InlineData(-1)] // Unknown
+ [InlineData(0)] // RequestingLmx
+ [InlineData(1)] // RespondingLmx
+ [InlineData(2)] // RequestingNmx
+ [InlineData(3)] // RespondingNmx
+ [InlineData(4)] // RequestingAutomationObject
+ [InlineData(5)] // RespondingAutomationObject
+ [InlineData(77)] // outside the documented range -> Unknown
+ public void FromMxStatus_DetailZero_EverySource_RoutesByCategory(int rawDetectedBy)
{
- var s = new MxStatusProxy { Success = 0, Detail = 0, RawDetectedBy = 3, Category = category };
- StatusCodeMap.FromMxStatus(s).ShouldBe(expected);
+ Map(MxStatusCategory.OperationalError).ShouldBe(StatusCodeMap.Bad);
+ Map(MxStatusCategory.SoftwareError).ShouldBe(StatusCodeMap.Bad);
+ Map(MxStatusCategory.OtherError).ShouldBe(StatusCodeMap.Bad);
+ Map(MxStatusCategory.Unspecified).ShouldBe(StatusCodeMap.Bad);
+ Map(MxStatusCategory.ConfigurationError).ShouldBe(StatusCodeMap.BadConfigurationError);
+ Map(MxStatusCategory.SecurityError).ShouldBe(StatusCodeMap.BadUserAccessDenied);
+ Map(MxStatusCategory.CommunicationError).ShouldBe(StatusCodeMap.BadCommunicationError);
+
+ uint Map(MxStatusCategory category) => StatusCodeMap.FromMxStatus(
+ new MxStatusProxy { Success = 0, Detail = 0, RawDetectedBy = rawDetectedBy, Category = category });
+ }
+
+ ///
+ /// #872: the shape the old rule got wrong — an operational failure the responding automation object
+ /// detected (raw 5, the live write-refusal source) is not a communication error.
+ ///
+ [Fact]
+ public void FromMxStatus_DetailZero_RespondingAutomationObject_OperationalError_IsNotCommunicationError()
+ {
+ var s = new MxStatusProxy
+ {
+ Success = 0, Detail = 0, Category = MxStatusCategory.OperationalError,
+ DetectedBy = MxStatusSource.RespondingAutomationObject, RawDetectedBy = 5,
+ };
+ StatusCodeMap.FromMxStatus(s).ShouldBe(StatusCodeMap.Bad);
+ }
+
+ ///
+ /// #872 positive control: a communication-category row stays BadCommunicationError when detected by
+ /// the requesting LMX (raw 0) — the source the old rule treated as "never a comms error".
+ ///
+ [Fact]
+ public void FromMxStatus_DetailZero_RequestingLmx_CommunicationError_IsCommunicationError()
+ {
+ var s = new MxStatusProxy
+ {
+ Success = 0, Detail = 0, Category = MxStatusCategory.CommunicationError,
+ DetectedBy = MxStatusSource.RequestingLmx, RawDetectedBy = 0,
+ };
+ StatusCodeMap.FromMxStatus(s).ShouldBe(StatusCodeMap.BadCommunicationError);
}
/// Verifies that all-zero status maps to Bad status code.