fix(drivers): correct 16 wrong OPC UA status-code constants + guard them by reflection (#497)
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.
This commit is contained in:
@@ -77,6 +77,10 @@
|
||||
<PackageVersion Include="Moq" Version="4.20.72" />
|
||||
<PackageVersion Include="Novell.Directory.Ldap.NETStandard" Version="3.6.0" />
|
||||
<PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Client" Version="1.5.378.106" />
|
||||
<!-- Core carries Opc.Ua.StatusCodes, the oracle StatusCodeParityTests checks the drivers'
|
||||
hard-coded uint constants against. Referenced by that TEST project only — the drivers
|
||||
themselves stay SDK-free by design and keep spelling status codes as bare uints. -->
|
||||
<PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Core" Version="1.5.378.106" />
|
||||
<PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Configuration" Version="1.5.378.106" />
|
||||
<PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Server" Version="1.5.378.106" />
|
||||
<!-- OpenTelemetry.Api < 1.15.3 has GHSA-g94r-2vxg-569j (header-parsing memory DoS). The trio
|
||||
|
||||
@@ -271,7 +271,7 @@ public sealed class GatewayQualityMapperTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(192, 0x00000000u)] // Good
|
||||
[InlineData(216, 0x00D80000u)] // Good_LocalOverride
|
||||
[InlineData(216, 0x00960000u)] // Good_LocalOverride
|
||||
[InlineData(64, 0x40000000u)] // Uncertain
|
||||
[InlineData(0, 0x80000000u)] // Bad
|
||||
[InlineData(8, 0x808A0000u)] // Bad_NotConnected
|
||||
@@ -307,7 +307,7 @@ public sealed class SampleMapperTests
|
||||
{
|
||||
var a = new HistorianAggregateSample { Tag = "T", /* Value unset */ EndTime = Ts(2026,1,1,0,0,0) };
|
||||
var snap = SampleMapper.ToAggregateSnapshot(a);
|
||||
Assert.Equal(0x800E0000u, snap.StatusCode); // BadNoData
|
||||
Assert.Equal(0x809B0000u, snap.StatusCode); // BadNoData
|
||||
Assert.Null(snap.Value);
|
||||
}
|
||||
// Ts(...) builds a Google.Protobuf.WellKnownTypes.Timestamp from UTC parts.
|
||||
@@ -323,7 +323,7 @@ public sealed class SampleMapperTests
|
||||
- `StatusCode`: `GatewayQualityMapper.Map((byte)s.OpcQuality)` (prefer `opc_quality`; if zero/unset and `quality` carries the OPC-DA byte, fall back to `quality` — match whatever the gateway populates; document the choice).
|
||||
- `SourceTimestampUtc`: `s.Timestamp.ToDateTime()` (UTC kind).
|
||||
- `ServerTimestampUtc`: `DateTime.UtcNow`.
|
||||
- `SampleMapper.ToAggregateSnapshot(HistorianAggregateSample)` → null aggregate value ⇒ `StatusCode 0x800E0000` (BadNoData), non-null ⇒ Good (`0x00000000`) with the value; `SourceTimestampUtc` ← the bucket end/start timestamp (match the Wonderware `ToAggregateSnapshots` convention — it stamps the bucket timestamp). Provide `IReadOnlyList<>` batch helpers too.
|
||||
- `SampleMapper.ToAggregateSnapshot(HistorianAggregateSample)` → null aggregate value ⇒ `StatusCode 0x809B0000` (BadNoData), non-null ⇒ Good (`0x00000000`) with the value; `SourceTimestampUtc` ← the bucket end/start timestamp (match the Wonderware `ToAggregateSnapshots` convention — it stamps the bucket timestamp). Provide `IReadOnlyList<>` batch helpers too.
|
||||
|
||||
**Step 4: run, expect PASS.**
|
||||
|
||||
|
||||
@@ -98,7 +98,7 @@ Pure function in `.Contracts` (shared by driver, browser-commit, and the typed e
|
||||
| `EVENT` free text (`Program`, `Block`, `Message`) | `String` |
|
||||
| `CONDITION` | `String` (state word; §3.6) |
|
||||
|
||||
**Quality mapping (`DataValueSnapshot.StatusCode`):** an observation value of `UNAVAILABLE` (MTConnect's explicit no-data sentinel) → **`BadNoCommunication` (`0x80310000u`)** with a null value, not the literal string — this is *the* UNAVAILABLE code, used everywhere in this design (read, subscribe, and the §8 fixtures). Rationale + fleet fit: semantically, `UNAVAILABLE` means the Agent is reachable but has no device-backed value (the adapter↔device link is down or the item has never reported) — exactly OPC UA's `BadNoCommunication` ("communication to the data source has failed"). The fleet grep shows no competing convention for this case: every existing driver's `BadCommunicationError` (`0x80050000u`, Modbus/FOCAS/TwinCAT/OpcUaClient/S7/AbCip/AbLegacy/Galaxy) marks the **driver's own transport failure** to its peer — a different failure (and the code this driver also uses when the *Agent* HTTP call fails, per §7) — while `BadNoData` (`0x800E0000u`) is historian-domain only (`Historian.Gateway/Mapping/SampleMapper.cs`). `BadNoCommunication` is already in the CLI's `SnapshotFormatter` name table (`src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/SnapshotFormatter.cs`), so it renders by name, not hex. Declare it as a `private const uint` in `MTConnectDriver` (the Modbus `StatusBadCommunicationError` pattern). Missing dataItem / empty condition → `Bad`. `observation.timestamp` → `SourceTimestamp` (not a separate node).
|
||||
**Quality mapping (`DataValueSnapshot.StatusCode`):** an observation value of `UNAVAILABLE` (MTConnect's explicit no-data sentinel) → **`BadNoCommunication` (`0x80310000u`)** with a null value, not the literal string — this is *the* UNAVAILABLE code, used everywhere in this design (read, subscribe, and the §8 fixtures). Rationale + fleet fit: semantically, `UNAVAILABLE` means the Agent is reachable but has no device-backed value (the adapter↔device link is down or the item has never reported) — exactly OPC UA's `BadNoCommunication` ("communication to the data source has failed"). The fleet grep shows no competing convention for this case: every existing driver's `BadCommunicationError` (`0x80050000u`, Modbus/FOCAS/TwinCAT/OpcUaClient/S7/AbCip/AbLegacy/Galaxy) marks the **driver's own transport failure** to its peer — a different failure (and the code this driver also uses when the *Agent* HTTP call fails, per §7) — while `BadNoData` (`0x809B0000u`) is historian-domain only (`Historian.Gateway/Mapping/SampleMapper.cs`). `BadNoCommunication` is already in the CLI's `SnapshotFormatter` name table (`src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/SnapshotFormatter.cs`), so it renders by name, not hex. Declare it as a `private const uint` in `MTConnectDriver` (the Modbus `StatusBadCommunicationError` pattern). Missing dataItem / empty condition → `Bad`. `observation.timestamp` → `SourceTimestamp` (not a separate node).
|
||||
|
||||
### 3.4 `IReadable` — `/current`
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
public static class AbCipStatusMapper
|
||||
{
|
||||
public const uint Good = 0u;
|
||||
public const uint GoodMoreData = 0x00A70000u;
|
||||
public const uint GoodMoreData = 0x00A60000u;
|
||||
public const uint BadInternalError = 0x80020000u;
|
||||
public const uint BadNodeIdUnknown = 0x80340000u;
|
||||
public const uint BadNotWritable = 0x803B0000u;
|
||||
@@ -44,7 +44,7 @@ public static class AbCipStatusMapper
|
||||
public const uint BadDeviceFailure = 0x808B0000u;
|
||||
public const uint BadCommunicationError = 0x80050000u;
|
||||
public const uint BadTimeout = 0x800A0000u;
|
||||
public const uint BadTypeMismatch = 0x80730000u;
|
||||
public const uint BadTypeMismatch = 0x80740000u;
|
||||
|
||||
/// <summary>Map a CIP general-status byte to an OPC UA StatusCode.</summary>
|
||||
/// <param name="status">The CIP general-status byte value.</param>
|
||||
|
||||
@@ -10,7 +10,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
public static class AbLegacyStatusMapper
|
||||
{
|
||||
public const uint Good = 0u;
|
||||
public const uint GoodMoreData = 0x00A70000u;
|
||||
public const uint GoodMoreData = 0x00A60000u;
|
||||
public const uint BadInternalError = 0x80020000u;
|
||||
public const uint BadNodeIdUnknown = 0x80340000u;
|
||||
public const uint BadNotWritable = 0x803B0000u;
|
||||
@@ -19,7 +19,7 @@ public static class AbLegacyStatusMapper
|
||||
public const uint BadDeviceFailure = 0x808B0000u;
|
||||
public const uint BadCommunicationError = 0x80050000u;
|
||||
public const uint BadTimeout = 0x800A0000u;
|
||||
public const uint BadTypeMismatch = 0x80730000u;
|
||||
public const uint BadTypeMismatch = 0x80740000u;
|
||||
|
||||
/// <summary>
|
||||
/// Map a libplctag return/status code to an OPC UA StatusCode. The integer passed here
|
||||
|
||||
@@ -17,7 +17,7 @@ public static class FocasStatusMapper
|
||||
public const uint BadDeviceFailure = 0x808B0000u;
|
||||
public const uint BadCommunicationError = 0x80050000u;
|
||||
public const uint BadTimeout = 0x800A0000u;
|
||||
public const uint BadTypeMismatch = 0x80730000u;
|
||||
public const uint BadTypeMismatch = 0x80740000u;
|
||||
|
||||
/// <summary>
|
||||
/// Map common FWLIB <c>EW_*</c> return codes. The values below match Fanuc's published
|
||||
|
||||
@@ -856,7 +856,7 @@ public sealed class GalaxyDriver
|
||||
{
|
||||
rejectedTcs.TrySetResult(new DataValueSnapshot(
|
||||
Value: null,
|
||||
StatusCode: 0x80000000u, // Bad
|
||||
StatusCode: StatusCodeMap.Bad,
|
||||
SourceTimestampUtc: null,
|
||||
ServerTimestampUtc: DateTime.UtcNow));
|
||||
}
|
||||
@@ -875,7 +875,7 @@ public sealed class GalaxyDriver
|
||||
{
|
||||
tcs.TrySetResult(new DataValueSnapshot(
|
||||
Value: null,
|
||||
StatusCode: 0x800B0000u, // BadTimeout
|
||||
StatusCode: StatusCodeMap.BadTimeout,
|
||||
SourceTimestampUtc: null,
|
||||
ServerTimestampUtc: DateTime.UtcNow));
|
||||
}
|
||||
|
||||
@@ -26,14 +26,23 @@ 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 = 0x00D80000u;
|
||||
public const uint GoodLocalOverride = 0x00960000u;
|
||||
public const uint Uncertain = 0x40000000u;
|
||||
public const uint UncertainLastUsableValue = 0x40A40000u;
|
||||
public const uint UncertainSensorNotAccurate = 0x408D0000u;
|
||||
public const uint UncertainEngineeringUnitsExceeded = 0x408E0000u;
|
||||
public const uint UncertainSubNormal = 0x408F0000u;
|
||||
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;
|
||||
@@ -44,6 +53,14 @@ internal static class StatusCodeMap
|
||||
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
|
||||
|
||||
+18
-18
@@ -18,29 +18,29 @@ internal static class GatewayQualityMapper
|
||||
public static uint Map(byte q) => q switch
|
||||
{
|
||||
// Good family (192+)
|
||||
192 => 0x00000000u, // Good
|
||||
216 => 0x00D80000u, // Good_LocalOverride
|
||||
192 => HistorianStatusCodes.Good,
|
||||
216 => HistorianStatusCodes.GoodLocalOverride,
|
||||
|
||||
// Uncertain family (64-191)
|
||||
64 => 0x40000000u, // Uncertain
|
||||
68 => 0x40900000u, // Uncertain_LastUsableValue
|
||||
80 => 0x40930000u, // Uncertain_SensorNotAccurate
|
||||
84 => 0x40940000u, // Uncertain_EngineeringUnitsExceeded
|
||||
88 => 0x40950000u, // Uncertain_SubNormal
|
||||
64 => HistorianStatusCodes.Uncertain,
|
||||
68 => HistorianStatusCodes.UncertainLastUsableValue,
|
||||
80 => HistorianStatusCodes.UncertainSensorNotAccurate,
|
||||
84 => HistorianStatusCodes.UncertainEngineeringUnitsExceeded,
|
||||
88 => HistorianStatusCodes.UncertainSubNormal,
|
||||
|
||||
// Bad family (0-63)
|
||||
0 => 0x80000000u, // Bad
|
||||
4 => 0x80890000u, // Bad_ConfigurationError
|
||||
8 => 0x808A0000u, // Bad_NotConnected
|
||||
12 => 0x808B0000u, // Bad_DeviceFailure
|
||||
16 => 0x808C0000u, // Bad_SensorFailure
|
||||
20 => 0x80050000u, // Bad_CommunicationError
|
||||
24 => 0x808D0000u, // Bad_OutOfService
|
||||
32 => 0x80320000u, // Bad_WaitingForInitialData
|
||||
0 => HistorianStatusCodes.Bad,
|
||||
4 => HistorianStatusCodes.BadConfigurationError,
|
||||
8 => HistorianStatusCodes.BadNotConnected,
|
||||
12 => HistorianStatusCodes.BadDeviceFailure,
|
||||
16 => HistorianStatusCodes.BadSensorFailure,
|
||||
20 => HistorianStatusCodes.BadCommunicationError,
|
||||
24 => HistorianStatusCodes.BadOutOfService,
|
||||
32 => HistorianStatusCodes.BadWaitingForInitialData,
|
||||
|
||||
// Unknown — fall back to category bucket so callers still get something usable.
|
||||
_ when q >= 192 => 0x00000000u,
|
||||
_ when q >= 64 => 0x40000000u,
|
||||
_ => 0x80000000u,
|
||||
_ when q >= 192 => HistorianStatusCodes.Good,
|
||||
_ when q >= 64 => HistorianStatusCodes.Uncertain,
|
||||
_ => HistorianStatusCodes.Bad,
|
||||
};
|
||||
}
|
||||
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Mapping;
|
||||
|
||||
/// <summary>
|
||||
/// The OPC UA status codes this driver publishes, as named constants.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>The driver layer is deliberately free of an OPC UA SDK reference, so status codes are spelled
|
||||
/// as bare <c>uint</c>s. The cost of that is a value nobody checks against the name it is written
|
||||
/// under — the defect class Gitea #497 found in six places across four drivers, one of them the
|
||||
/// <c>BadNoData</c> in <see cref="SampleMapper"/> that was really <c>BadServerHalted</c>.</para>
|
||||
/// <para><b>Named, not inline, on purpose.</b> <c>StatusCodeParityTests</c> guards these by reflecting
|
||||
/// over <c>const uint</c> fields and comparing each against <c>Opc.Ua.StatusCodes</c> in the pinned SDK.
|
||||
/// A literal written at a call site is invisible to that guard, which is exactly how
|
||||
/// <see cref="GatewayQualityMapper"/> kept an incorrect <c>Good_LocalOverride</c> through every test it
|
||||
/// had. Add a constant here rather than a literal in a <c>switch</c> arm.</para>
|
||||
/// </remarks>
|
||||
internal static class HistorianStatusCodes
|
||||
{
|
||||
// ---- Good family ----
|
||||
|
||||
/// <summary>The value is good; no qualification.</summary>
|
||||
public const uint Good = 0x00000000u;
|
||||
|
||||
/// <summary>The value has been overridden locally (OPC DA quality 216).</summary>
|
||||
public const uint GoodLocalOverride = 0x00960000u;
|
||||
|
||||
// ---- Uncertain family ----
|
||||
|
||||
/// <summary>The value is uncertain; no specific reason.</summary>
|
||||
public const uint Uncertain = 0x40000000u;
|
||||
|
||||
/// <summary>Communication has failed; the last known value is returned (OPC DA quality 68).</summary>
|
||||
public const uint UncertainLastUsableValue = 0x40900000u;
|
||||
|
||||
/// <summary>The sensor is known not to be accurate (OPC DA quality 80).</summary>
|
||||
public const uint UncertainSensorNotAccurate = 0x40930000u;
|
||||
|
||||
/// <summary>The value is outside the engineering-unit range for the sensor (OPC DA quality 84).</summary>
|
||||
public const uint UncertainEngineeringUnitsExceeded = 0x40940000u;
|
||||
|
||||
/// <summary>The value is derived from fewer sources than required (OPC DA quality 88).</summary>
|
||||
public const uint UncertainSubNormal = 0x40950000u;
|
||||
|
||||
// ---- Bad family ----
|
||||
|
||||
/// <summary>The value is bad; no specific reason.</summary>
|
||||
public const uint Bad = 0x80000000u;
|
||||
|
||||
/// <summary>A configuration problem prevents the value being produced (OPC DA quality 4).</summary>
|
||||
public const uint BadConfigurationError = 0x80890000u;
|
||||
|
||||
/// <summary>The source is not connected (OPC DA quality 8).</summary>
|
||||
public const uint BadNotConnected = 0x808A0000u;
|
||||
|
||||
/// <summary>The device reported a failure (OPC DA quality 12).</summary>
|
||||
public const uint BadDeviceFailure = 0x808B0000u;
|
||||
|
||||
/// <summary>The sensor reported a failure (OPC DA quality 16).</summary>
|
||||
public const uint BadSensorFailure = 0x808C0000u;
|
||||
|
||||
/// <summary>Communication with the source failed (OPC DA quality 20).</summary>
|
||||
public const uint BadCommunicationError = 0x80050000u;
|
||||
|
||||
/// <summary>The source is out of service (OPC DA quality 24).</summary>
|
||||
public const uint BadOutOfService = 0x808D0000u;
|
||||
|
||||
/// <summary>No initial value has arrived from the source yet (OPC DA quality 32).</summary>
|
||||
public const uint BadWaitingForInitialData = 0x80320000u;
|
||||
|
||||
/// <summary>
|
||||
/// The historian returned no data for the requested tag/interval. Distinct from a transport
|
||||
/// failure: the query succeeded and the answer was empty.
|
||||
/// </summary>
|
||||
public const uint BadNoData = 0x809B0000u;
|
||||
}
|
||||
@@ -10,8 +10,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Mapping;
|
||||
/// </summary>
|
||||
internal static class SampleMapper
|
||||
{
|
||||
private const uint StatusGood = 0x00000000u;
|
||||
private const uint StatusBadNoData = 0x800E0000u;
|
||||
private const uint StatusGood = HistorianStatusCodes.Good;
|
||||
private const uint StatusBadNoData = HistorianStatusCodes.BadNoData;
|
||||
|
||||
/// <summary>OPC DA "Good" family floor — a quality byte at/above this carries usable data.</summary>
|
||||
private const byte GoodQualityFloor = 192;
|
||||
|
||||
@@ -19,9 +19,9 @@ public static class TwinCATStatusMapper
|
||||
public const uint BadDeviceFailure = 0x808B0000u;
|
||||
public const uint BadCommunicationError = 0x80050000u;
|
||||
public const uint BadTimeout = 0x800A0000u;
|
||||
public const uint BadTypeMismatch = 0x80730000u;
|
||||
public const uint BadOutOfService = 0x80BE0000u;
|
||||
public const uint BadInvalidState = 0x80350000u;
|
||||
public const uint BadTypeMismatch = 0x80740000u;
|
||||
public const uint BadOutOfService = 0x808D0000u;
|
||||
public const uint BadInvalidState = 0x80AF0000u;
|
||||
|
||||
// ---- AdsErrorCode numeric values (confirmed from Beckhoff.TwinCAT.Ads 7.0.172) ----
|
||||
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
using System.Reflection;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Guards every driver's hard-coded OPC UA status-code constant against
|
||||
/// <see cref="Opc.Ua.StatusCodes"/> — the pinned SDK is the oracle, and a constant whose
|
||||
/// <em>name</em> disagrees with its <em>value</em> fails here.
|
||||
/// <para><b>Why the drivers hard-code these at all.</b> The driver layer is deliberately SDK-free: a
|
||||
/// driver assembly must not drag in the OPC UA stack just to spell a status code, so each one declares
|
||||
/// the handful it needs as bare <c>uint</c> literals. That is a sound layering decision with one
|
||||
/// failure mode — nothing checks the literal against the name — and it is exactly the failure mode
|
||||
/// Gitea #497 found in six places across four drivers.</para>
|
||||
/// <para><b>Discovery is reflection-driven, not a hand-copied list</b> (the same convention
|
||||
/// <see cref="DriverTypeNamesGuardTests"/> uses): the test scans every
|
||||
/// <c>ZB.MOM.WW.OtOpcUa.Driver.*.dll</c> deployed to its output directory for <c>const uint</c> fields
|
||||
/// whose name reads as a status code, so a brand-new driver assembly referenced by this project is
|
||||
/// covered automatically, with no edit here.</para>
|
||||
/// <para><b>An inline literal is invisible to this guard.</b> Reflection can only see a <em>named</em>
|
||||
/// constant, so <c>StatusCode: 0x800B0000u, // BadTimeout</c> written at a call site is unguarded —
|
||||
/// which is how <c>GalaxyDriver</c> shipped <c>BadServiceUnsupported</c> under a <c>BadTimeout</c>
|
||||
/// comment. Hoist a status literal into a named constant rather than writing it at the call site.</para>
|
||||
/// </summary>
|
||||
public sealed class StatusCodeParityTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Name prefixes that mark a <c>uint</c> constant as an OPC UA status code. These are the three
|
||||
/// Part 4 severity categories, so they cover the whole space by construction — a status constant
|
||||
/// that does not start with one of them is not a status constant.
|
||||
/// </summary>
|
||||
private static readonly string[] StatusPrefixes = ["Good", "Uncertain", "Bad"];
|
||||
|
||||
/// <summary>
|
||||
/// Field-name prefixes stripped before the name is looked up on <see cref="Opc.Ua.StatusCodes"/>.
|
||||
/// Drivers vary the spelling (<c>SampleMapper.StatusBadNoData</c> vs
|
||||
/// <c>SqlStatusCodes.BadTimeout</c>); the SDK member is <c>BadNoData</c> either way.
|
||||
/// </summary>
|
||||
private static readonly string[] NamePrefixesToStrip = ["Status"];
|
||||
|
||||
/// <summary>Every <c>Opc.Ua.StatusCodes</c> member, by name — the oracle this test checks against.</summary>
|
||||
private static readonly IReadOnlyDictionary<string, uint> SdkStatusCodes =
|
||||
typeof(Opc.Ua.StatusCodes)
|
||||
.GetFields(BindingFlags.Public | BindingFlags.Static)
|
||||
.Where(f => f.IsLiteral && f.FieldType == typeof(uint))
|
||||
.ToDictionary(f => f.Name, f => (uint)f.GetRawConstantValue()!, StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Every status-shaped <c>const uint</c> declared anywhere in the deployed driver assemblies, as
|
||||
/// <c>(assembly, declaring type, field name, SDK member name, declared value)</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Non-public types and fields are included on purpose — <c>SampleMapper.StatusBadNoData</c> is a
|
||||
/// <c>private const</c> on an <c>internal</c> class, and it carried one of the #497 defects. Access
|
||||
/// modifiers say nothing about whether a value reaches an OPC UA client.
|
||||
/// </remarks>
|
||||
public static TheoryData<string, string, string, string, uint> DeclaredStatusConstants()
|
||||
{
|
||||
var data = new TheoryData<string, string, string, string, uint>();
|
||||
|
||||
var binDir = Path.GetDirectoryName(typeof(StatusCodeParityTests).Assembly.Location)!;
|
||||
foreach (var dll in Directory.GetFiles(binDir, "ZB.MOM.WW.OtOpcUa.Driver.*.dll").OrderBy(p => p, StringComparer.Ordinal))
|
||||
{
|
||||
Assembly asm;
|
||||
try { asm = Assembly.LoadFrom(dll); }
|
||||
catch { continue; }
|
||||
|
||||
Type?[] types;
|
||||
try { types = asm.GetTypes(); }
|
||||
catch (ReflectionTypeLoadException ex) { types = ex.Types; }
|
||||
|
||||
foreach (var type in types)
|
||||
{
|
||||
if (type is null) continue;
|
||||
|
||||
var fields = type.GetFields(
|
||||
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.DeclaredOnly);
|
||||
|
||||
foreach (var field in fields)
|
||||
{
|
||||
if (!field.IsLiteral || field.IsInitOnly || field.FieldType != typeof(uint)) continue;
|
||||
|
||||
var sdkName = StripKnownPrefix(field.Name);
|
||||
if (!StatusPrefixes.Any(p => sdkName.StartsWith(p, StringComparison.Ordinal))) continue;
|
||||
|
||||
data.Add(
|
||||
asm.GetName().Name!,
|
||||
type.FullName ?? type.Name,
|
||||
field.Name,
|
||||
sdkName,
|
||||
(uint)field.GetRawConstantValue()!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/// <summary>Removes a leading <c>Status</c>-style prefix so the remainder can be looked up on the SDK type.</summary>
|
||||
private static string StripKnownPrefix(string fieldName)
|
||||
{
|
||||
foreach (var prefix in NamePrefixesToStrip)
|
||||
{
|
||||
if (fieldName.Length > prefix.Length && fieldName.StartsWith(prefix, StringComparison.Ordinal))
|
||||
return fieldName[prefix.Length..];
|
||||
}
|
||||
|
||||
return fieldName;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A driver's status constant must carry the value the SDK gives the code it is named after.
|
||||
/// </summary>
|
||||
/// <param name="assembly">The declaring driver assembly (failure-message context).</param>
|
||||
/// <param name="type">The declaring type's full name (failure-message context).</param>
|
||||
/// <param name="field">The constant's name as declared.</param>
|
||||
/// <param name="sdkName">The <see cref="Opc.Ua.StatusCodes"/> member the name resolves to.</param>
|
||||
/// <param name="declared">The value the driver declares.</param>
|
||||
[Theory]
|
||||
[MemberData(nameof(DeclaredStatusConstants))]
|
||||
public void Driver_status_constant_matches_the_pinned_SDK(
|
||||
string assembly, string type, string field, string sdkName, uint declared)
|
||||
{
|
||||
SdkStatusCodes.ShouldContainKey(sdkName,
|
||||
$"{type}.{field} (in {assembly}) reads as an OPC UA status code, but '{sdkName}' is not a member " +
|
||||
"of Opc.Ua.StatusCodes. Either the constant is misnamed, or it is not a status code and should " +
|
||||
"not start with Good/Uncertain/Bad.");
|
||||
|
||||
var expected = SdkStatusCodes[sdkName];
|
||||
declared.ShouldBe(expected,
|
||||
$"{type}.{field} (in {assembly}) is declared 0x{declared:X8}, but Opc.Ua.StatusCodes.{sdkName} " +
|
||||
$"is 0x{expected:X8}" +
|
||||
(SdkStatusCodes.FirstOrDefault(kv => kv.Value == declared) is { Key: { } actual } && actual.Length > 0
|
||||
? $" — 0x{declared:X8} is actually {actual}, which is what OPC UA clients would branch on."
|
||||
: ". The declared value is not any OPC UA status code."));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Discovery must find constants in more than one driver assembly. A zero (or near-zero) here means
|
||||
/// the bin scan broke or the drivers stopped declaring these by convention — either way the theory
|
||||
/// above would pass vacuously, which is the one outcome a guard test must never do quietly.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Discovery_finds_status_constants_across_multiple_driver_assemblies()
|
||||
{
|
||||
var found = DeclaredStatusConstants();
|
||||
found.Count.ShouldBeGreaterThan(20,
|
||||
"the driver bin scan found almost no status constants — the assemblies did not deploy to bin, " +
|
||||
"or the naming convention changed");
|
||||
|
||||
var assemblies = found
|
||||
.Select(row => row.Data.Item1)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
assemblies.Length.ShouldBeGreaterThan(3,
|
||||
"status constants were found in fewer than four driver assemblies: " + string.Join(", ", assemblies));
|
||||
}
|
||||
}
|
||||
+6
@@ -12,6 +12,8 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3"/>
|
||||
<PackageReference Include="Shouldly"/>
|
||||
<!-- StatusCodeParityTests only; supplies Opc.Ua.StatusCodes as the oracle. -->
|
||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Core"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
@@ -35,6 +37,10 @@
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Galaxy\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj"/>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Calculation\ZB.MOM.WW.OtOpcUa.Driver.Calculation.csproj"/>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql\ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj"/>
|
||||
<!-- Ships no driver factory (it is a historian backend, not an Equipment driver), so the
|
||||
DriverTypeNames guard skips it — but it does hard-code OPC UA status constants, which
|
||||
puts it in StatusCodeParityTests' scope. -->
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway\ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -19,12 +19,12 @@ public sealed class StatusCodeMapTests
|
||||
/// <param name="expected">The expected OPC UA status code.</param>
|
||||
[Theory]
|
||||
[InlineData((byte)192, 0x00000000u)] // Good
|
||||
[InlineData((byte)216, 0x00D80000u)] // Good_LocalOverride
|
||||
[InlineData((byte)216, 0x00960000u)] // Good_LocalOverride
|
||||
[InlineData((byte)64, 0x40000000u)] // Uncertain
|
||||
[InlineData((byte)68, 0x40A40000u)] // Uncertain_LastUsableValue
|
||||
[InlineData((byte)80, 0x408D0000u)] // Uncertain_SensorNotAccurate
|
||||
[InlineData((byte)84, 0x408E0000u)] // Uncertain_EngineeringUnitsExceeded
|
||||
[InlineData((byte)88, 0x408F0000u)] // Uncertain_SubNormal
|
||||
[InlineData((byte)68, 0x40900000u)] // Uncertain_LastUsableValue
|
||||
[InlineData((byte)80, 0x40930000u)] // Uncertain_SensorNotAccurate
|
||||
[InlineData((byte)84, 0x40940000u)] // Uncertain_EngineeringUnitsExceeded
|
||||
[InlineData((byte)88, 0x40950000u)] // Uncertain_SubNormal
|
||||
[InlineData((byte)0, 0x80000000u)] // Bad
|
||||
[InlineData((byte)4, 0x80890000u)] // Bad_ConfigurationError
|
||||
[InlineData((byte)8, 0x808A0000u)] // Bad_NotConnected
|
||||
@@ -132,9 +132,9 @@ public sealed class StatusCodeMapTests
|
||||
/// <param name="expected">The expected OPC DA category byte.</param>
|
||||
[Theory]
|
||||
[InlineData(0x00000000u, (byte)192)] // Good
|
||||
[InlineData(0x00D80000u, (byte)192)] // GoodLocalOverride — still Good category
|
||||
[InlineData(0x00960000u, (byte)192)] // GoodLocalOverride — still Good category
|
||||
[InlineData(0x40000000u, (byte)64)] // Uncertain
|
||||
[InlineData(0x408F0000u, (byte)64)] // UncertainSubNormal — still Uncertain category
|
||||
[InlineData(0x40950000u, (byte)64)] // UncertainSubNormal — still Uncertain category
|
||||
[InlineData(0x80000000u, (byte)0)] // Bad
|
||||
[InlineData(0x808A0000u, (byte)0)] // BadNotConnected — still Bad category
|
||||
[InlineData(0x80020000u, (byte)0)] // BadInternalError — still Bad category
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ public sealed class GatewayQualityMapperTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(192, 0x00000000u)] // Good
|
||||
[InlineData(216, 0x00D80000u)] // Good_LocalOverride
|
||||
[InlineData(216, 0x00960000u)] // Good_LocalOverride
|
||||
[InlineData(64, 0x40000000u)] // Uncertain
|
||||
[InlineData(0, 0x80000000u)] // Bad
|
||||
[InlineData(8, 0x808A0000u)] // Bad_NotConnected
|
||||
|
||||
+1
-1
@@ -37,7 +37,7 @@ public sealed class SampleMapperTests
|
||||
{
|
||||
var a = new HistorianAggregateSample { Tag = "T", /* Value unset, no Good quality */ EndTime = Ts(2026, 1, 1, 0, 0, 0) };
|
||||
var snap = SampleMapper.ToAggregateSnapshot(a);
|
||||
Assert.Equal(0x800E0000u, snap.StatusCode); // BadNoData
|
||||
Assert.Equal(0x809B0000u, snap.StatusCode); // BadNoData
|
||||
Assert.Null(snap.Value);
|
||||
}
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@ public sealed class DriverInstanceActorTests : RuntimeActorTestBase
|
||||
[Fact]
|
||||
public async Task Write_propagates_status_code_on_Bad_result()
|
||||
{
|
||||
const uint badStatus = 0x80340000; // BadOutOfService — top severity bits = 10b
|
||||
const uint badStatus = 0x80340000; // BadNodeIdUnknown — top severity bits = 10b
|
||||
var driver = new WritableStubDriver { NextStatusCode = badStatus };
|
||||
var actor = Sys.ActorOf(DriverInstanceActor.Props(driver));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user