feat(galaxy): per-code OPC UA mapping for the MxStatusDetail vocabulary

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).
This commit is contained in:
Joseph Doherty
2026-08-09 20:16:29 -04:00
parent 20a816dbf4
commit 205cc4a4a3
2 changed files with 62 additions and 28 deletions
@@ -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;
/// <summary>
/// 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
}
/// <summary>
/// Map a failure row by its <see cref="MxStatusCategory"/> — 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 <c>MxStatusDetail</c>
/// vocabulary is published in mxaccessgw's gateway.md ("MxStatus Detail Vocabulary",
/// <c>75c71ad</c>, lifted from <c>Interop.aaMxDataConsumer.MxStatusDetail</c>) — 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.
/// </summary>
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,
@@ -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.
/// <summary>A refused write (operational error, MxStatus detail code) must map to a Bad-family code.</summary>
[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);
}
/// <summary>A security-refused write must map to BadUserAccessDenied, not Good.</summary>
[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);
}
/// <summary>Category-level routing for large details preserves communication/configuration specificity.</summary>
/// <summary>
/// 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.
/// </summary>
[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);
}
/// <summary>
/// Unrecognized large details route by category — including 8000
/// MX_E_AutomationObjectSpecificError, which deliberately stays category-level (the object
/// supplies its own semantics).
/// </summary>
[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);
}