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 99dde2880..7303a8591 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 @@ -110,8 +110,8 @@ internal static class StatusCodeMap /// (category == OK AND success != 0) /// as the canonical success test — the proto contract explicitly documents that /// success is NOT a boolean and must not be checked in isolation; category is - /// the authoritative indicator. On failure, the detail byte (OPC DA quality substatus) - /// drives the specific code, with a transport-error fallback for pre-MXAccess failures. + /// the authoritative indicator. On failure, the MxStatusDetail code drives the specific + /// code, then the category, with a transport-error fallback for pre-MXAccess failures. /// /// The MX status proxy to convert, or null for Good status. /// Optional logger for diagnostics on unknown status codes. @@ -121,21 +121,14 @@ internal static class StatusCodeMap if (status is null) return Good; if (status.IsSuccess()) return Good; - // A detail in [1, 255] is an OPC DA quality substatus (read/data-change rows). Anything - // larger is an MXAccess MxStatus detail code — the write-completion rows the gateway - // correlates onto write replies carry these (e.g. 1007 operational, 1008 security) — and - // truncating one into the quality table maps a REFUSED WRITE to Good whenever the low byte - // lands in the Good band (1007 → 239 ≥ 192). Route those on the status category instead. - var detail = status.Detail; - if (detail is > 0 and <= 0xFF) - { - var mapped = FromQualityByte((byte)detail, logger); - // Invariant: a row that is not IsSuccess() must never surface as Good — a - // contradictory Good-band substatus on a failure row falls back to the category. - if ((mapped >> 30) != 0u) return mapped; - return FromCategory(status, logger); - } - if (detail > 0xFF) return FromCategory(status, logger); + // The detail is MXAccess's MxStatusDetail code verbatim (mxaccessgw's MxStatusProxyConverter copies + // MXSTATUS_PROXY.detail straight through; vocabulary in its gateway.md) — NEVER an OPC DA quality + // byte. It used to be routed through the quality table whenever it was <= 0xFF, which mis-named + // every low code: MX_E_PlatformCommunicationError (2) logged as "quality byte 0x02" and fell to + // generic Bad (#866, a stopped platform's ScanState on VD03), MX_E_InvalidObjectId (8) surfaced as + // BadNotConnected, MX_E_Overflow (20) as BadCommunicationError. A failure row is also never Good or + // Uncertain, whatever its detail. + if (status.Detail != 0) return FromCategory(status, logger); // detected_by != Mxaccess (raw_detected_by != the MXAccess source enum) implies // the failure happened pre-call (gateway, worker, transport) — surface as a @@ -146,33 +139,47 @@ internal static class StatusCodeMap } /// - /// Map a failure row whose detail field is an MxStatus detail code rather than an OPC DA - /// quality byte (or contradicts the failure). Per-code first — the MxStatusDetail - /// vocabulary is published in mxaccessgw's gateway.md ("MxStatus Detail Vocabulary", - /// 75c71ad, lifted from Interop.aaMxDataConsumer.MxStatusDetail) — then the - /// category bucket. Codes ≤ 0xFF (e.g. 1 = MX_E_RequestTimedOut) never reach here: they are - /// value-ambiguous with the quality-byte space and take the quality path, which still lands - /// them in the Bad family. + /// Map a failure row by its MxStatusDetail code — the vocabulary is published in mxaccessgw's + /// gateway.md ("MxStatus Detail Vocabulary", lifted from Interop.aaMxDataConsumer.MxStatusDetail) + /// — then by the status category for codes with no specific OPC UA counterpart. /// private static uint FromCategory(MxStatusProxy status, ILogger? logger) { switch (status.Detail) { - case 1004: return BadOutOfRange; // MX_E_DataOutOfRange + case 1: return BadTimeout; // MX_E_RequestTimedOut + case 2: // MX_E_PlatformCommunicationError (live: stopped platform, #866) + case 5: return BadCommunicationError; // MX_E_EngineCommunicationError + case 3: // MX_E_InvalidPlatformId + case 4: // MX_E_InvalidEngineId + case 6: // MX_E_InvalidReference + case 8: // MX_E_InvalidObjectId + case 9: // MX_E_ObjectSignatureMismatch + case 10: // MX_E_AttributeSignatureMismatch + case 1000: // MX_E_InvalidPrimitiveId + case 1001: // MX_E_InvalidAttributeId + case 1002: return BadConfigurationError; // MX_E_InvalidPropertyId + case 13: // MX_E_WrongDataType + case 18: // MX_E_ConversionNotSupported + case 19: // MX_E_UnableToConvertString case 1005: return BadTypeMismatch; // MX_E_IncorrectDataType + case 20: // MX_E_Overflow + case 1004: return BadOutOfRange; // MX_E_DataOutOfRange case 1006: return BadNotReadable; // MX_E_NotReadable case 1007: return BadNotWritable; // MX_E_NotWriteable (live-captured: read-only attr) case 1008: // MX_E_WriteAccessDenied (live-captured: unadvised no-login write) case 1012: // MX_E_SecuredWrite (attribute requires a secured write) case 1013: // MX_E_VerifiedWrite (attribute requires a verified write) + case 1014: // MX_E_NoAlarmAckPrivilege case 1016: // MX_E_UserNotHavingAccessRights case 1017: return BadUserAccessDenied; // MX_E_VerifierNotHavingVerifyRights case 1010: // MX_E_ObjectInitializing (transient) case 1011: return BadOutOfService; // MX_E_EngineInitializing (transient) } - // Everything else — including 8000 MX_E_AutomationObjectSpecificError, deliberately, since - // the object supplies its own semantics — falls back to the category bucket. + // Everything else — resolving (11/12), repository/engine busy (7/25/26), version/command mismatches, + // index errors, 1009 unknown and, deliberately, 8000 MX_E_AutomationObjectSpecificError since the + // object supplies its own semantics — falls back to the category bucket. var code = status.Category switch { MxStatusCategory.SecurityError => BadUserAccessDenied, @@ -180,13 +187,19 @@ internal static class StatusCodeMap MxStatusCategory.ConfigurationError => BadConfigurationError, _ => Bad, }; - logger?.LogWarning( - "MxStatus failure row category {Category} detail {Detail} mapped to 0x{Code:X8} at category " + - "level. Field capture welcome — extend StatusCodeMap.FromCategory with the detail code.", - status.Category, status.Detail, code); + // Once per (detail, category) per process: these rows arrive on the data-change path, so a stopped + // engine's tags would otherwise log on every update. + if (logger is not null && UnmappedDetailsLogged.TryAdd((status.Detail, status.Category), 0)) + logger.LogWarning( + "MxStatus failure row category {Category} detail {Detail} ({DiagnosticText}) mapped to 0x{Code:X8} at " + + "category level. Field capture welcome — extend StatusCodeMap.FromCategory with the detail code.", + status.Category, status.Detail, status.DiagnosticText, code); return code; } + private static readonly System.Collections.Concurrent.ConcurrentDictionary<(int, MxStatusCategory), byte> + UnmappedDetailsLogged = new(); + /// /// Convert an OPC UA status-code uint back to the OPC DA quality category /// byte — Good=192, Uncertain=64, Bad=0 — by extracting the top-two bits of the 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 4b4d9084a..83c24ae09 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 @@ -84,12 +84,60 @@ public sealed class StatusCodeMapTests StatusCodeMap.FromMxStatus(s).ShouldNotBe(StatusCodeMap.Good); } - /// Verifies that known detail codes map to their specific status codes. + /// + /// #866: a low detail is an MxStatusDetail code, not an OPC DA quality byte. Detail 8 is + /// MX_E_InvalidObjectId — it used to surface as BadNotConnected (quality byte 8). + /// [Fact] public void FromMxStatus_SuccessZeroAndCategoryNotOk_DetailKnown_MapsToSpecificCode() { - var s = new MxStatusProxy { Success = 0, Category = MxStatusCategory.OperationalError, Detail = 8 /* Bad_NotConnected */ }; - StatusCodeMap.FromMxStatus(s).ShouldBe(StatusCodeMap.BadNotConnected); + var s = new MxStatusProxy { Success = 0, Category = MxStatusCategory.OperationalError, Detail = 8 }; + StatusCodeMap.FromMxStatus(s).ShouldBe(StatusCodeMap.BadConfigurationError); + } + + /// + /// #866 live capture (VD03 2026-11-11): reading JoePlatform.ScanState on a deployed but + /// not-running platform returns a failure row with detail 2 = MX_E_PlatformCommunicationError, + /// which logged "Unrecognised MXAccess quality byte 0x02" and published generic Bad. + /// + [Fact] + public void FromMxStatus_PlatformCommunicationError_IsCommunicationError_AndDoesNotLogAsQuality() + { + var logger = new CapturingLogger(); + var s = new MxStatusProxy { Success = 0, Category = MxStatusCategory.CommunicationError, Detail = 2 }; + StatusCodeMap.FromMxStatus(s, logger).ShouldBe(StatusCodeMap.BadCommunicationError); + logger.Entries.ShouldBeEmpty(); + } + + /// Low MxStatusDetail codes map per the gateway.md vocabulary regardless of category. + [Theory] + [InlineData(1, 0x800A0000u)] // MX_E_RequestTimedOut → BadTimeout + [InlineData(2, 0x80050000u)] // MX_E_PlatformCommunicationError → BadCommunicationError + [InlineData(5, 0x80050000u)] // MX_E_EngineCommunicationError → BadCommunicationError + [InlineData(3, 0x80890000u)] // MX_E_InvalidPlatformId → BadConfigurationError + [InlineData(4, 0x80890000u)] // MX_E_InvalidEngineId (was BadConfigurationError by quality-byte accident) + [InlineData(6, 0x80890000u)] // MX_E_InvalidReference + [InlineData(12, 0x80000000u)] // MX_E_ResolvingObject → category (was BadDeviceFailure) + [InlineData(13, 0x80740000u)] // MX_E_WrongDataType → BadTypeMismatch + [InlineData(16, 0x80000000u)] // MX_E_IndexOutOfOrder → category (was BadSensorFailure) + [InlineData(19, 0x80740000u)] // MX_E_UnableToConvertString → BadTypeMismatch + [InlineData(20, 0x803C0000u)] // MX_E_Overflow → BadOutOfRange (was BadCommunicationError) + [InlineData(24, 0x80000000u)] // MX_E_LmxInvalidCommand → category (was BadOutOfService) + [InlineData(32, 0x80000000u)] // not a vocabulary code → category (was BadWaitingForInitialData) + public void FromMxStatus_LowDetailCodes_MapByVocabulary(int detail, uint expected) + { + var s = new MxStatusProxy { Success = 0, Category = MxStatusCategory.OperationalError, Detail = detail }; + StatusCodeMap.FromMxStatus(s).ShouldBe(expected); + } + + /// The category fallback logs once per (detail, category), not on every data change. + [Fact] + public void FromMxStatus_CategoryFallback_LogsOncePerDetailAndCategory() + { + var logger = new CapturingLogger(); + var s = new MxStatusProxy { Success = 0, Category = MxStatusCategory.SoftwareError, Detail = 23 /* MX_E_LmxVersionMismatch */ }; + for (var i = 0; i < 5; i++) StatusCodeMap.FromMxStatus(s, logger).ShouldBe(StatusCodeMap.Bad); + logger.Entries.Count.ShouldBe(1); } /// Verifies that non-zero DetectedBy maps to communication error when detail is zero. @@ -155,9 +203,8 @@ public sealed class StatusCodeMapTests } /// - /// Invariant: a row that is not IsSuccess() must NEVER map to Good — a Good-band quality - /// byte (e.g. 216 = GoodLocalOverride) on a failure row is contradictory and falls back to - /// the category route. Uncertain-band substatuses remain allowed (uncertain is not Good). + /// Invariant: a row that is not IsSuccess() must NEVER map to Good — a detail that happens to sit in + /// the OPC DA Good band (216) is not a quality byte and routes on the category. /// [Fact] public void FromMxStatus_FailureRow_GoodBandQualityByte_NeverMapsGood() @@ -166,12 +213,12 @@ public sealed class StatusCodeMapTests StatusCodeMap.FromMxStatus(s).ShouldBe(StatusCodeMap.BadUserAccessDenied); } - /// An Uncertain-band quality substatus on a failure row still maps to its specific Uncertain code. + /// A failure row is Bad: a detail in the OPC DA Uncertain band (68) is not a quality byte (#866). [Fact] - public void FromMxStatus_FailureRow_UncertainBandQualityByte_KeepsUncertainCode() + public void FromMxStatus_FailureRow_UncertainBandDetail_IsBad() { var s = new MxStatusProxy { Success = 0, Category = MxStatusCategory.OperationalError, Detail = 68 }; - StatusCodeMap.FromMxStatus(s).ShouldBe(StatusCodeMap.UncertainLastUsableValue); + StatusCodeMap.FromMxStatus(s).ShouldBe(StatusCodeMap.Bad); } /// Verifies that top two bits of status codes follow OPC UA convention.