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.