feat(lmxproxy): add MxAccess status detail mapping for richer error messages

- MxStatusMapper: maps all 40+ MxStatusDetail codes, MxStatusCategory,
  and MxStatusSource to human-readable names and client messages
- OnDataChange: checks MXSTATUS_PROXY.success and overrides quality with
  specific OPC UA code when MxAccess reports a failure (e.g., CommFailure,
  ConfigError, WaitingForInitialData)
- OnWriteComplete: uses MxStatusMapper.FormatStatus for structured logging
- Write errors: catches COMException separately with HRESULT in message
- Read errors: distinguishes COM, timeout, and generic failures in logging

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-03-22 05:10:50 -04:00
parent 73b2b2f6d7
commit 5a9574fb95
3 changed files with 217 additions and 19 deletions

View File

@@ -0,0 +1,186 @@
using System.Collections.Generic;
namespace ZB.MOM.WW.LmxProxy.Host.Domain
{
/// <summary>
/// Maps MxAccess MXSTATUS_PROXY fields (detail, category, source) to
/// human-readable messages and OPC UA quality codes.
/// </summary>
public static class MxStatusMapper
{
// ── MxStatusDetail (short) → name + client message ──────────
private static readonly Dictionary<int, (string Name, string Message)> DetailCodes =
new Dictionary<int, (string, string)>
{
{ 0, ("MX_S_Success", "Success") },
{ 1, ("MX_E_RequestTimedOut", "Request to AVEVA System Platform timed out") },
{ 2, ("MX_E_PlatformCommunicationError", "Communication error with System Platform") },
{ 3, ("MX_E_InvalidPlatformId", "Invalid platform identifier") },
{ 4, ("MX_E_InvalidEngineId", "Invalid engine identifier") },
{ 5, ("MX_E_EngineCommunicationError", "Communication error with automation engine") },
{ 6, ("MX_E_InvalidReference", "Tag reference is invalid or could not be resolved") },
{ 7, ("MX_E_NoGalaxyRepository", "Galaxy repository is not available") },
{ 8, ("MX_E_InvalidObjectId", "Invalid object identifier") },
{ 9, ("MX_E_ObjectSignatureMismatch", "Object signature mismatch") },
{ 10, ("MX_E_AttributeSignatureMismatch", "Attribute signature mismatch") },
{ 11, ("MX_E_ResolvingAttribute", "Attribute is still being resolved") },
{ 12, ("MX_E_ResolvingObject", "Object is still being resolved") },
{ 13, ("MX_E_WrongDataType", "Value type does not match attribute data type") },
{ 14, ("MX_E_WrongNumberOfDimensions", "Wrong number of array dimensions") },
{ 15, ("MX_E_InvalidIndex", "Invalid array index") },
{ 16, ("MX_E_IndexOutOfOrder", "Array index out of order") },
{ 17, ("MX_E_DimensionDoesNotExist", "Array dimension does not exist") },
{ 18, ("MX_E_ConversionNotSupported", "Data type conversion not supported") },
{ 19, ("MX_E_UnableToConvertString", "Unable to convert string to target type") },
{ 20, ("MX_E_Overflow", "Numeric overflow during conversion") },
{ 21, ("MX_E_NmxVersionMismatch", "NMX version mismatch") },
{ 22, ("MX_E_NmxInvalidCommand", "NMX invalid command") },
{ 23, ("MX_E_LmxVersionMismatch", "LMX version mismatch") },
{ 24, ("MX_E_LmxInvalidCommand", "LMX invalid command") },
{ 25, ("MX_E_GalaxyRepositoryBusy", "Galaxy repository is busy") },
{ 26, ("MX_E_EngineOverloaded", "Automation engine is overloaded") },
{ 1000, ("MX_E_InvalidPrimitiveId", "Invalid primitive identifier") },
{ 1001, ("MX_E_InvalidAttributeId", "Invalid attribute identifier") },
{ 1002, ("MX_E_InvalidPropertyId", "Invalid property identifier") },
{ 1003, ("MX_E_IndexOutOfRange", "Array index out of range") },
{ 1004, ("MX_E_DataOutOfRange", "Data value out of range") },
{ 1005, ("MX_E_IncorrectDataType", "Incorrect data type for this attribute") },
{ 1006, ("MX_E_NotReadable", "Attribute is not readable") },
{ 1007, ("MX_E_NotWriteable", "Attribute is not writable") },
{ 1008, ("MX_E_WriteAccessDenied", "Write access denied — insufficient security") },
{ 1009, ("MX_E_UnknownError", "Unknown MxAccess error") },
{ 1010, ("MX_E_ObjectInitializing", "Object is still initializing") },
{ 1011, ("MX_E_EngineInitializing", "Automation engine is still initializing") },
{ 1012, ("MX_E_SecuredWrite", "Attribute requires secured write authentication") },
{ 1013, ("MX_E_VerifiedWrite", "Attribute requires verified write (two-user)") },
{ 1014, ("MX_E_NoAlarmAckPrivilege", "No alarm acknowledgment privilege") },
{ 8000, ("MX_E_AutomationObjectSpecificError", "Automation object specific error") },
};
// ── MxStatusCategory (int) → name ──────────
private static readonly Dictionary<int, string> CategoryNames = new Dictionary<int, string>
{
{ -1, "Unknown" },
{ 0, "Ok" },
{ 1, "Pending" },
{ 2, "Warning" },
{ 3, "CommunicationError" },
{ 4, "ConfigurationError" },
{ 5, "OperationalError" },
{ 6, "SecurityError" },
{ 7, "SoftwareError" },
{ 8, "OtherError" },
};
// ── MxStatusSource (int) → name ──────────
private static readonly Dictionary<int, string> SourceNames = new Dictionary<int, string>
{
{ -1, "Unknown" },
{ 0, "RequestingLmx" },
{ 1, "RespondingLmx" },
{ 2, "RequestingNmx" },
{ 3, "RespondingNmx" },
{ 4, "RequestingAutomationObject" },
{ 5, "RespondingAutomationObject" },
};
/// <summary>
/// Gets the symbolic name for an MxStatusDetail code (e.g., "MX_E_WrongDataType").
/// </summary>
public static string GetDetailName(int detailCode)
{
return DetailCodes.TryGetValue(detailCode, out var entry) ? entry.Name : string.Format("MX_E_Unknown({0})", detailCode);
}
/// <summary>
/// Gets a human-readable client message for an MxStatusDetail code.
/// </summary>
public static string GetDetailMessage(int detailCode)
{
return DetailCodes.TryGetValue(detailCode, out var entry) ? entry.Message : string.Format("MxAccess error code {0}", detailCode);
}
/// <summary>
/// Gets the symbolic name for an MxStatusCategory value.
/// </summary>
public static string GetCategoryName(int category)
{
return CategoryNames.TryGetValue(category, out var name) ? name : string.Format("Unknown({0})", category);
}
/// <summary>
/// Gets the symbolic name for an MxStatusSource value.
/// </summary>
public static string GetSourceName(int source)
{
return SourceNames.TryGetValue(source, out var name) ? name : string.Format("Unknown({0})", source);
}
/// <summary>
/// Builds a detailed error string from all MXSTATUS_PROXY fields.
/// Format: "MX_E_WrongDataType: Value type does not match attribute data type [Category=OperationalError, Source=RespondingAutomationObject]"
/// </summary>
public static string FormatStatus(int detail, int category, int source)
{
return string.Format("{0}: {1} [Category={2}, Source={3}]",
GetDetailName(detail),
GetDetailMessage(detail),
GetCategoryName(category),
GetSourceName(source));
}
/// <summary>
/// Maps an MxStatusCategory to the most appropriate OPC UA QualityCode.
/// Used when MXSTATUS_PROXY.success is false in an OnDataChange callback
/// to override the raw OPC DA quality byte.
/// </summary>
public static Quality CategoryToQuality(int category, int detail)
{
// Specific detail codes take priority
switch (detail)
{
case 6: // MX_E_InvalidReference
case 1001: // MX_E_InvalidAttributeId
return Quality.Bad_ConfigError;
case 2: // MX_E_PlatformCommunicationError
case 5: // MX_E_EngineCommunicationError
return Quality.Bad_CommFailure;
case 11: // MX_E_ResolvingAttribute
case 12: // MX_E_ResolvingObject
case 1010: // MX_E_ObjectInitializing
case 1011: // MX_E_EngineInitializing
return Quality.Bad_WaitingForInitialData;
case 1006: // MX_E_NotReadable
return Quality.Bad_OutOfService;
case 1: // MX_E_RequestTimedOut
return Quality.Bad_CommFailure;
}
// Fall back to category
switch (category)
{
case 0: // MxCategoryOk
return Quality.Good;
case 1: // MxCategoryPending
return Quality.Uncertain;
case 2: // MxCategoryWarning
return Quality.Uncertain;
case 3: // MxCategoryCommunicationError
return Quality.Bad_CommFailure;
case 4: // MxCategoryConfigurationError
return Quality.Bad_ConfigError;
case 5: // MxCategoryOperationalError
return Quality.Bad;
case 6: // MxCategorySecurityError
return Quality.Bad;
case 7: // MxCategorySoftwareError
return Quality.Bad;
default:
return Quality.Bad;
}
}
}
}

View File

@@ -1,6 +1,4 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using ArchestrA.MxAccess;
using Serilog;
using ZB.MOM.WW.LmxProxy.Host.Domain;
@@ -31,6 +29,17 @@ namespace ZB.MOM.WW.LmxProxy.Host.MxAccess
{
var quality = MapQuality(pwItemQuality);
var timestamp = ConvertTimestamp(pftItemTimeStamp);
// Check MXSTATUS_PROXY — if success is false, override quality
// with a more specific code derived from the MxAccess status fields
if (ItemStatus != null && ItemStatus.Length > 0 && ItemStatus[0].success == 0)
{
var status = ItemStatus[0];
quality = MxStatusMapper.CategoryToQuality((int)status.category, status.detail);
Log.Debug("OnDataChange status failure for handle {Handle}: {Status}",
phItemHandle, MxStatusMapper.FormatStatus(status.detail, (int)status.category, (int)status.detectedBy));
}
var vtq = new Vtq(pvItemValue, timestamp, quality);
// Resolve address from handle map
@@ -84,9 +93,8 @@ namespace ZB.MOM.WW.LmxProxy.Host.MxAccess
var status = ItemStatus[0];
if (status.success == 0)
{
string errorMsg = GetWriteErrorMessage(status.detail);
Log.Warning("OnWriteComplete callback: write failed for handle {Handle}: {Error} (Category={Category}, Detail={Detail})",
phItemHandle, errorMsg, status.category, status.detail);
Log.Warning("OnWriteComplete callback: write failed for handle {Handle}: {Status}",
phItemHandle, MxStatusMapper.FormatStatus(status.detail, (int)status.category, (int)status.detectedBy));
}
else
{
@@ -104,20 +112,6 @@ namespace ZB.MOM.WW.LmxProxy.Host.MxAccess
}
}
/// <summary>
/// Gets a human-readable error message for a write error code.
/// </summary>
private static string GetWriteErrorMessage(int errorCode)
{
switch (errorCode)
{
case 1008: return "User lacks proper security for write operation";
case 1012: return "Secured write required";
case 1013: return "Verified write required";
default: return string.Format("Unknown error code: {0}", errorCode);
}
}
/// <summary>
/// Converts a timestamp object to DateTime in UTC.
/// </summary>

View File

@@ -24,6 +24,16 @@ namespace ZB.MOM.WW.LmxProxy.Host.MxAccess
{
return await ReadSingleValueAsync(address, ct);
}
catch (System.Runtime.InteropServices.COMException comEx)
{
Log.Error(comEx, "COM read error for tag {Address}: HRESULT=0x{ErrorCode:X8}", address, comEx.ErrorCode);
return Vtq.New(null, Quality.Bad_CommFailure);
}
catch (TimeoutException)
{
Log.Warning("Read timed out for tag {Address}", address);
return Vtq.New(null, Quality.Bad_CommFailure);
}
catch (Exception ex)
{
Log.Error(ex, "ReadAsync failed for tag {Address}", address);
@@ -203,6 +213,14 @@ namespace ZB.MOM.WW.LmxProxy.Host.MxAccess
Log.Debug("Write completed synchronously for {Address} (handle={Handle})", address, itemHandle);
}
catch (System.Runtime.InteropServices.COMException comEx)
{
string enriched = string.Format("Write failed for '{0}': COM error 0x{1:X8} — {2}",
address, comEx.ErrorCode, comEx.Message);
Log.Error(comEx, "COM write error for {Address}: HRESULT=0x{ErrorCode:X8}",
address, comEx.ErrorCode);
throw new InvalidOperationException(enriched, comEx);
}
catch (Exception ex)
{
Log.Error(ex, "Failed to write value to {Address}", address);