b95b99ba8e
Every value verified against Opc.Ua.StatusCodes in the pinned SDK (1.5.378.106) by reflection, not by copying siblings. All are client-visible: OPC UA clients branch on status. The three named in #497: - Historian.Gateway SampleMapper "BadNoData" 0x800E0000 -> 0x809B0000 (0x800E0000 is BadServerHalted) - Galaxy "BadTimeout" 0x800B0000 -> 0x800A0000 (0x800B0000 is BadServiceUnsupported) - TwinCAT BadTypeMismatch 0x80730000 -> 0x80740000 (0x80730000 is BadWriteNotSupported) BadTypeMismatch was wrong in three MORE drivers the issue did not name — FOCAS, AbLegacy and AbCip carried the same 0x80730000. Every call site is a FormatException/InvalidCastException conversion failure, so the name was right and the value was wrong in all four. Adding the reflection guard then surfaced nine further defects offline tests had never checked: - Galaxy GoodLocalOverride 0x00D80000 -> 0x00960000 (not a UA code at all) - Galaxy UncertainLastUsableValue 0x40A40000 -> 0x40900000 - Galaxy UncertainSensorNotAccurate 0x408D0000 -> 0x40930000 - Galaxy UncertainEngineeringUnitsExceeded 0x408E0000 -> 0x40940000 - Galaxy UncertainSubNormal 0x408F0000 -> 0x40950000 - TwinCAT BadOutOfService 0x80BE0000 -> 0x808D0000 (was BadProtocolVersionUnsupported) - TwinCAT BadInvalidState 0x80350000 -> 0x80AF0000 (was BadAttributeIdInvalid) - AbCip/AbLegacy GoodMoreData 0x00A70000 -> 0x00A60000 (was GoodCommunicationEvent) - Historian.Gateway GatewayQualityMapper Good_LocalOverride 0x00D80000 -> 0x00960000 Galaxy's whole Uncertain block read as though the OPC DA quality byte could be shifted into the UA substatus position; it cannot — the substatuses are an unrelated enumeration. GatewayQualityMapper already held the correct table, which is what the Galaxy values are now reconciled against. Guard: StatusCodeParityTests (Core.Abstractions.Tests, which already project- references every driver) reflects over every `const uint` in the deployed ZB.MOM.WW.OtOpcUa.Driver.*.dll whose name reads as a status code, and asserts it equals Opc.Ua.StatusCodes.<name>. Discovery is by convention, so a new driver assembly referenced by that project is covered with no edit here. It went red on all nine defects above before the fixes and now checks 109 constants green. Because reflection can only see a NAMED constant, two inline literals were hoisted so the guard can reach them: Galaxy's BadTimeout/Bad into StatusCodeMap, and GatewayQualityMapper's 15-entry table into a new HistorianStatusCodes. An inline `0x800B0000u, // BadTimeout` at a call site is exactly how the Galaxy defect survived. The drivers stay SDK-free by design; only the test project references Opc.Ua.Core, as the oracle. Also corrected: tests that pinned the wrong values (Galaxy StatusCodeMapTests, SampleMapperTests, GatewayQualityMapperTests — all written from the same bad source), a mislabelled comment in DriverInstanceActorTests, and stale constants in two design docs, one of them the unexecuted MTConnect driver design that would have propagated BadNoData's wrong value into a new driver. The CLI's SnapshotFormatter value->name table was checked against the SDK and is correct; no change needed.
166 lines
8.7 KiB
C#
166 lines
8.7 KiB
C#
using Microsoft.Extensions.Logging;
|
|
using ZB.MOM.WW.MxGateway.Client;
|
|
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
|
|
|
/// <summary>
|
|
/// Maps the gateway's <see cref="MxStatusProxy"/> (raw MXAccess HRESULT + category bits)
|
|
/// to OPC UA <c>StatusCode</c> uints. Replaces the legacy
|
|
/// <c>MxAccessGalaxyBackend.ToWire</c> heuristic (Quality >= 192 → Good, else Uncertain)
|
|
/// with an explicit table that preserves specific codes (BadNotConnected, OutOfService,
|
|
/// UncertainSubNormal, etc.) instead of collapsing to category buckets.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// OPC DA quality bytes are 16-bit values arranged as <c>[QQSSSSSSLLNNNN]</c>:
|
|
/// Q = quality category (Bad/Uncertain/Good = 0/1/3), S = substatus, L = limit, N = vendor.
|
|
/// This mapper consumes the LOW byte (where the Q+S bits live) — the same byte the legacy
|
|
/// Wonderware Historian SDK exposed as the raw quality byte. Category-only fallback paths
|
|
/// handle deployment versions of MXAccess that surface unfamiliar substatuses.
|
|
///
|
|
/// Unknown substatus values fall back to the matching category bucket (<c>Good</c>,
|
|
/// <c>Uncertain</c>, <c>Bad</c>) and emit a single diagnostic log line per session via
|
|
/// the supplied logger so field captures can extend the table.
|
|
/// </remarks>
|
|
internal static class StatusCodeMap
|
|
{
|
|
// OPC UA Part 4 standard StatusCodes — top-byte categories are 0x00 (Good),
|
|
// 0x40 (Uncertain), 0x80 (Bad). Specific codes layer onto the category byte.
|
|
//
|
|
// The substatus nibbles are NOT derivable from the OPC DA quality byte this mapper consumes —
|
|
// they are an unrelated OPC UA enumeration and have to be looked up. Five constants here were
|
|
// originally written as though the DA byte could be shifted into the substatus position, which
|
|
// produced values naming entirely different UA codes (Gitea #497): UncertainLastUsableValue held
|
|
// UncertainDataSubNormal's value, UncertainSubNormal held UncertainNoCommunicationLastUsableValue's,
|
|
// and GoodLocalOverride / UncertainSensorNotAccurate / UncertainEngineeringUnitsExceeded held values
|
|
// that are not OPC UA status codes at all. StatusCodeParityTests now checks every constant below
|
|
// against the pinned SDK's Opc.Ua.StatusCodes.
|
|
|
|
public const uint Good = 0x00000000u;
|
|
public const uint GoodLocalOverride = 0x00960000u;
|
|
public const uint Uncertain = 0x40000000u;
|
|
public const uint UncertainLastUsableValue = 0x40900000u;
|
|
public const uint UncertainSensorNotAccurate = 0x40930000u;
|
|
public const uint UncertainEngineeringUnitsExceeded = 0x40940000u;
|
|
public const uint UncertainSubNormal = 0x40950000u;
|
|
public const uint Bad = 0x80000000u;
|
|
public const uint BadConfigurationError = 0x80890000u;
|
|
public const uint BadNotConnected = 0x808A0000u;
|
|
public const uint BadDeviceFailure = 0x808B0000u;
|
|
public const uint BadSensorFailure = 0x808C0000u;
|
|
public const uint BadCommunicationError = 0x80050000u;
|
|
public const uint BadOutOfService = 0x808D0000u;
|
|
public const uint BadWaitingForInitialData = 0x80320000u;
|
|
public const uint BadInternalError = 0x80020000u;
|
|
|
|
/// <summary>
|
|
/// Fills a still-pending read when the caller's token fires before the gateway answers. Named here
|
|
/// rather than written inline at the call site so <c>StatusCodeParityTests</c> can reflect over it —
|
|
/// an inline literal is invisible to that guard, which is exactly how this constant spent its life
|
|
/// as <c>0x800B0000</c> (<c>BadServiceUnsupported</c>) under a <c>// BadTimeout</c> comment.
|
|
/// </summary>
|
|
public const uint BadTimeout = 0x800A0000u;
|
|
|
|
/// <summary>
|
|
/// Map a raw OPC DA quality byte (the low byte of an OPC DA <c>OpcQuality</c> ushort,
|
|
/// which is what Wonderware Historian + MXAccess surface as <c>OPCITEMSTATE.qLong</c>'s
|
|
/// low byte) to the OPC UA StatusCode uint.
|
|
/// </summary>
|
|
/// <param name="q">The OPC DA quality byte to convert.</param>
|
|
/// <param name="logger">Optional logger for diagnostics on unknown quality bytes.</param>
|
|
/// <returns>The OPC UA status code.</returns>
|
|
public static uint FromQualityByte(byte q, ILogger? logger = null) => q switch
|
|
{
|
|
// Good family — top two bits 11b (192-255).
|
|
192 => Good,
|
|
216 => GoodLocalOverride,
|
|
|
|
// Uncertain family — top two bits 01b (64-127).
|
|
64 => Uncertain,
|
|
68 => UncertainLastUsableValue,
|
|
80 => UncertainSensorNotAccurate,
|
|
84 => UncertainEngineeringUnitsExceeded,
|
|
88 => UncertainSubNormal,
|
|
|
|
// Bad family — top two bits 00b (0-63).
|
|
0 => Bad,
|
|
4 => BadConfigurationError,
|
|
8 => BadNotConnected,
|
|
12 => BadDeviceFailure,
|
|
16 => BadSensorFailure,
|
|
20 => BadCommunicationError,
|
|
24 => BadOutOfService,
|
|
32 => BadWaitingForInitialData,
|
|
|
|
_ => Categorize(q, logger),
|
|
};
|
|
|
|
/// <summary>
|
|
/// Map a gateway-reported <see cref="MxStatusProxy"/> to OPC UA StatusCode. Uses
|
|
/// <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.
|
|
/// </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>
|
|
/// <returns>The OPC UA status code.</returns>
|
|
public static uint FromMxStatus(MxStatusProxy? status, ILogger? logger = null)
|
|
{
|
|
if (status is null) return Good;
|
|
if (status.IsSuccess()) return Good;
|
|
|
|
// Detail field carries the substatus when the worker translated MX-style codes;
|
|
// when zero, infer from detected_by.
|
|
var detail = (byte)(status.Detail & 0xFF);
|
|
if (detail != 0) return FromQualityByte(detail, logger);
|
|
|
|
// detected_by != Mxaccess (raw_detected_by != the MXAccess source enum) implies
|
|
// the failure happened pre-call (gateway, worker, transport) — surface as a
|
|
// communication error rather than a generic Bad.
|
|
if (status.RawDetectedBy != 0) return BadCommunicationError;
|
|
|
|
return Bad;
|
|
}
|
|
|
|
/// <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
|
|
/// high word. This is the inverse of the category-bucket arm of
|
|
/// <see cref="FromQualityByte"/>. It is intentionally lossy (substatus bits are not
|
|
/// round-tripped) because the sole consumer
|
|
/// (<see cref="ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Health.PerPlatformProbeWatcher"/>)
|
|
/// only tests <c>qualityByte < 192</c> to distinguish Running from Stopped. Keeping
|
|
/// the round-trip in one place means a future change to the OPC UA bit layout cannot
|
|
/// silently desync the probe-health decode.
|
|
/// </summary>
|
|
/// <param name="statusCode">The OPC UA status code to convert.</param>
|
|
/// <returns>The OPC DA quality category byte.</returns>
|
|
public static byte ToQualityCategoryByte(uint statusCode) =>
|
|
(byte)(((statusCode >> 30) & 0x3u) switch
|
|
{
|
|
0u => 192u, // Good — top two bits 00b → OPC DA 0xC0
|
|
1u => 64u, // Uncertain — top two bits 01b → OPC DA 0x40
|
|
_ => 0u, // Bad — top two bits 10b/11b → OPC DA 0x00
|
|
});
|
|
|
|
private static uint Categorize(byte q, ILogger? logger)
|
|
{
|
|
if (q >= 192) { Log(logger, q, "Good"); return Good; }
|
|
if (q >= 64) { Log(logger, q, "Uncertain"); return Uncertain; }
|
|
Log(logger, q, "Bad");
|
|
return Bad;
|
|
}
|
|
|
|
private static void Log(ILogger? logger, byte q, string bucket)
|
|
{
|
|
// Best-effort diagnostic so field captures can extend the table — once per bucket
|
|
// per session is plenty (the LogWarning level is rate-limited by Serilog filters
|
|
// in production).
|
|
logger?.LogWarning(
|
|
"Unrecognised MXAccess quality byte 0x{Q:X2} — falling back to {Bucket} category. " +
|
|
"Field capture welcome — extend StatusCodeMap.FromQualityByte.", q, bucket);
|
|
}
|
|
}
|