From 205cc4a4a347fc63d09a4628bb93aad60c05664f Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 9 Aug 2026 20:16:29 -0400 Subject: [PATCH] feat(galaxy): per-code OPC UA mapping for the MxStatusDetail vocabulary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to 20a816db: mxaccessgw published the full MxStatusDetail enum (gateway.md @ 75c71ad, "MxStatus Detail Vocabulary", lifted from Interop.aaMxDataConsumer.MxStatusDetail), confirming the live-captured codes (1007 = MX_E_NotWriteable, 1008 = MX_E_WriteAccessDenied). FromCategory now maps the write-relevant band per code before falling back to the category bucket: 1004 -> BadOutOfRange, 1005 -> BadTypeMismatch, 1006 -> BadNotReadable, 1007 -> BadNotWritable, 1008/1012/1013/1016/1017 -> BadUserAccessDenied, 1010/1011 (initializing, transient) -> BadOutOfService. 8000 (MX_E_AutomationObjectSpecificError) deliberately stays category-level — the object supplies its own semantics. Codes <= 0xFF are value-ambiguous with the OPC DA quality-byte space and keep the quality path (still Bad-family). Four new named constants, all validated by StatusCodeParityTests against the pinned SDK. Live refusal smoke re-run green (read-only refusal now surfaces BadNotWritable instead of generic Bad). --- .../Runtime/StatusCodeMap.cs | 32 ++++++++-- .../Runtime/StatusCodeMapTests.cs | 58 +++++++++++-------- 2 files changed, 62 insertions(+), 28 deletions(-) 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 d5dcb754..a9b59bf6 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 @@ -53,6 +53,10 @@ internal static class StatusCodeMap public const uint BadWaitingForInitialData = 0x80320000u; public const uint BadInternalError = 0x80020000u; public const uint BadUserAccessDenied = 0x801F0000u; + public const uint BadOutOfRange = 0x803C0000u; + public const uint BadTypeMismatch = 0x80740000u; + public const uint BadNotReadable = 0x803A0000u; + public const uint BadNotWritable = 0x803B0000u; /// /// Fills a still-pending read when the caller's token fires before the gateway answers. Named here @@ -137,13 +141,33 @@ internal static class StatusCodeMap } /// - /// Map a failure row by its — used when the detail field is - /// an MxStatus detail code rather than an OPC DA quality byte (or contradicts the failure). - /// Category-level only: the MxStatus detail vocabulary (1007, 1008, …) is not mapped - /// per-code yet, so the log line carries the raw detail for field capture. + /// 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. /// private static uint FromCategory(MxStatusProxy status, ILogger? logger) { + switch (status.Detail) + { + case 1004: return BadOutOfRange; // MX_E_DataOutOfRange + case 1005: return BadTypeMismatch; // MX_E_IncorrectDataType + 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 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. var code = status.Category switch { MxStatusCategory.SecurityError => BadUserAccessDenied, 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 f3c67194..4b4d9084 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 @@ -115,32 +115,42 @@ public sealed class StatusCodeMapTests // truncation `(byte)(Detail & 0xFF)` pushed 1007 → 239 and 1008 → 240 into the OPC DA Good band, // mapping a REFUSED write to Good — no node revert, silently-lost write reported committed. - /// A refused write (operational error, MxStatus detail code) must map to a Bad-family code. - [Fact] - public void FromMxStatus_WriteRefusal_OperationalError_Detail1007_IsBad() - { - // Live shape: writeRaw to read-only TestMachine_002.Tagname. - var s = new MxStatusProxy { Success = 0, Category = MxStatusCategory.OperationalError, Detail = 1007, RawDetectedBy = 5 }; - StatusCodeMap.FromMxStatus(s).ShouldBe(StatusCodeMap.Bad); - } - - /// A security-refused write must map to BadUserAccessDenied, not Good. - [Fact] - public void FromMxStatus_WriteRefusal_SecurityError_Detail1008_IsUserAccessDenied() - { - // Live shape: writeRaw through a plain-advised (subscription) handle — no supervisory advise. - var s = new MxStatusProxy { Success = 0, Category = MxStatusCategory.SecurityError, Detail = 1008, RawDetectedBy = 5 }; - StatusCodeMap.FromMxStatus(s).ShouldBe(StatusCodeMap.BadUserAccessDenied); - } - - /// Category-level routing for large details preserves communication/configuration specificity. + /// + /// Per-code mapping from the MxStatusDetail vocabulary (mxaccessgw gateway.md @ 75c71ad). + /// The 1007/1008 rows are the exact live-captured shapes from the 06/S-1 gate. + /// [Theory] - [InlineData(MxStatusCategory.CommunicationError, 0x80050000u)] // BadCommunicationError - [InlineData(MxStatusCategory.ConfigurationError, 0x80890000u)] // BadConfigurationError - [InlineData(MxStatusCategory.SoftwareError, 0x80000000u)] // unmapped category → generic Bad - public void FromMxStatus_LargeDetail_RoutesByCategory(MxStatusCategory category, uint expected) + [InlineData(1004, MxStatusCategory.OperationalError, 0x803C0000u)] // MX_E_DataOutOfRange → BadOutOfRange + [InlineData(1005, MxStatusCategory.OperationalError, 0x80740000u)] // MX_E_IncorrectDataType → BadTypeMismatch + [InlineData(1006, MxStatusCategory.OperationalError, 0x803A0000u)] // MX_E_NotReadable → BadNotReadable + [InlineData(1007, MxStatusCategory.OperationalError, 0x803B0000u)] // MX_E_NotWriteable → BadNotWritable (live: read-only Tagname) + [InlineData(1008, MxStatusCategory.SecurityError, 0x801F0000u)] // MX_E_WriteAccessDenied → BadUserAccessDenied (live: plain-advised write) + [InlineData(1012, MxStatusCategory.SecurityError, 0x801F0000u)] // MX_E_SecuredWrite → BadUserAccessDenied + [InlineData(1013, MxStatusCategory.SecurityError, 0x801F0000u)] // MX_E_VerifiedWrite → BadUserAccessDenied + [InlineData(1016, MxStatusCategory.SecurityError, 0x801F0000u)] // MX_E_UserNotHavingAccessRights → BadUserAccessDenied + [InlineData(1017, MxStatusCategory.SecurityError, 0x801F0000u)] // MX_E_VerifierNotHavingVerifyRights → BadUserAccessDenied + [InlineData(1010, MxStatusCategory.OperationalError, 0x808D0000u)] // MX_E_ObjectInitializing → BadOutOfService (transient) + [InlineData(1011, MxStatusCategory.OperationalError, 0x808D0000u)] // MX_E_EngineInitializing → BadOutOfService (transient) + public void FromMxStatus_KnownDetailCodes_MapPerCode(int detail, MxStatusCategory category, uint expected) { - var s = new MxStatusProxy { Success = 0, Category = category, Detail = 2001 }; + var s = new MxStatusProxy { Success = 0, Category = category, Detail = detail, RawDetectedBy = 5 }; + StatusCodeMap.FromMxStatus(s).ShouldBe(expected); + } + + /// + /// Unrecognized large details route by category — including 8000 + /// MX_E_AutomationObjectSpecificError, which deliberately stays category-level (the object + /// supplies its own semantics). + /// + [Theory] + [InlineData(2001, MxStatusCategory.CommunicationError, 0x80050000u)] // BadCommunicationError + [InlineData(2001, MxStatusCategory.ConfigurationError, 0x80890000u)] // BadConfigurationError + [InlineData(2001, MxStatusCategory.SoftwareError, 0x80000000u)] // unmapped category → generic Bad + [InlineData(8000, MxStatusCategory.OperationalError, 0x80000000u)] // MX_E_AutomationObjectSpecificError → category-level + [InlineData(8000, MxStatusCategory.SecurityError, 0x801F0000u)] // ... which for a security row is BadUserAccessDenied + public void FromMxStatus_UnmappedLargeDetail_RoutesByCategory(int detail, MxStatusCategory category, uint expected) + { + var s = new MxStatusProxy { Success = 0, Category = category, Detail = detail }; StatusCodeMap.FromMxStatus(s).ShouldBe(expected); }