fix(galaxy): #866 map MxStatus failure details by the MxStatusDetail vocabulary, not as OPC DA quality
The gateway copies MXSTATUS_PROXY.detail verbatim, so a failure row's detail is always an MxStatusDetail code. FromMxStatus routed any detail <= 0xFF through the OPC DA quality table: MX_E_PlatformCommunicationError (2) on a stopped platform's ScanState logged "Unrecognised MXAccess quality byte 0x02" and published generic Bad (VD03 live capture), MX_E_InvalidObjectId (8) became BadNotConnected, MX_E_Overflow (20) BadCommunicationError, and a detail in the Uncertain band published Uncertain for a failure. Every non-zero detail now maps per the gateway.md vocabulary (timeout, communication, configuration, type, range, access, initialising), then by category; the category-fallback warning logs once per (detail, category). Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
This commit is contained in:
@@ -110,8 +110,8 @@ internal static class StatusCodeMap
|
||||
/// <see cref="MxStatusProxyExtensions.IsSuccess"/> (category == OK AND success != 0)
|
||||
/// as the canonical success test — the proto contract explicitly documents that
|
||||
/// <c>success</c> 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 <c>MxStatusDetail</c> code drives the specific
|
||||
/// code, then the category, with a transport-error fallback for pre-MXAccess failures.
|
||||
/// </summary>
|
||||
/// <param name="status">The MX status proxy to convert, or null for Good status.</param>
|
||||
/// <param name="logger">Optional logger for diagnostics on unknown status codes.</param>
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// Map a failure row by its <c>MxStatusDetail</c> code — the vocabulary is published in mxaccessgw's
|
||||
/// gateway.md ("MxStatus Detail Vocabulary", lifted from <c>Interop.aaMxDataConsumer.MxStatusDetail</c>)
|
||||
/// — then by the status category for codes with no specific OPC UA counterpart.
|
||||
/// </summary>
|
||||
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();
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
|
||||
@@ -84,12 +84,60 @@ public sealed class StatusCodeMapTests
|
||||
StatusCodeMap.FromMxStatus(s).ShouldNotBe(StatusCodeMap.Good);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that known detail codes map to their specific status codes.</summary>
|
||||
/// <summary>
|
||||
/// #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).
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #866 live capture (VD03 2026-11-11): reading <c>JoePlatform.ScanState</c> 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.
|
||||
/// </summary>
|
||||
[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();
|
||||
}
|
||||
|
||||
/// <summary>Low MxStatusDetail codes map per the gateway.md vocabulary regardless of category.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>The category fallback logs once per (detail, category), not on every data change.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that non-zero DetectedBy maps to communication error when detail is zero.</summary>
|
||||
@@ -155,9 +203,8 @@ public sealed class StatusCodeMapTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FromMxStatus_FailureRow_GoodBandQualityByte_NeverMapsGood()
|
||||
@@ -166,12 +213,12 @@ public sealed class StatusCodeMapTests
|
||||
StatusCodeMap.FromMxStatus(s).ShouldBe(StatusCodeMap.BadUserAccessDenied);
|
||||
}
|
||||
|
||||
/// <summary>An Uncertain-band quality substatus on a failure row still maps to its specific Uncertain code.</summary>
|
||||
/// <summary>A failure row is Bad: a detail in the OPC DA Uncertain band (68) is not a quality byte (#866).</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that top two bits of status codes follow OPC UA convention.</summary>
|
||||
|
||||
Reference in New Issue
Block a user