PR 4.2 — IReadable abstraction + StatusCodeMap + MxValueDecoder
Read path scaffold + the byte→uint quality mapping table that the parity matrix (PR 5.x) pins. PR 4.4 supplies the production GW-backed reader; this PR ships the abstraction and the supporting infrastructure so 4.4 just plugs the implementation in. Files: - Runtime/StatusCodeMap.cs — explicit OPC DA quality byte → OPC UA StatusCode uint mapping. Extends the legacy Galaxy.Host HistorianQualityMapper with named constants (Good / GoodLocalOverride, Uncertain + 4 substatuses, Bad + 7 substatuses, BadInternalError) and an MxStatusProxy → uint helper that honors success flag → detail byte → detected_by transport-error fallback. Unknown bytes fall back to category bucket with a once-per-session diagnostic log so field captures can extend the table. - Runtime/MxValueDecoder.cs — gateway MxValue → boxed CLR value for the seven Galaxy data types (Boolean, Int32, Int64, Float32, Float64, String, DateTime) plus their array variants. Honors MxValue.IsNull and RawValue passthrough. - Runtime/IGalaxyDataReader.cs — driver-side seam for one-shot reads. PR 4.4 ships the production wrapper around MxGatewaySession.SubscribeBulk + StreamEvents + UnsubscribeBulk; this PR exposes the contract so GalaxyDriver.ReadAsync wires through it. - Runtime/GalaxyMxSession.cs — wrapper around MxGatewaySession that owns the Register handle. ConnectAsync opens session + Register; AttachForTests lets tests bypass real gw construction. PR 4.3/4.4/4.5 add write, subscribe, and reconnect surfaces. GalaxyDriver: - Implements IReadable. ReadAsync routes through the injected IGalaxyDataReader (test seam) when present; production path throws NotSupportedException pointing at PR 4.4 — protects deployments running this PR from silent wrong reads while signaling that the legacy-host backend (Galaxy:Backend=legacy-host) handles reads in the meantime. - Internal ctor extended with optional dataReader parameter (default null, preserves PR 4.0/4.1 callers). Tests: 42 new — exhaustive byte→uint table for StatusCodeMap (15 known codes + category-bucket fallback for unknowns + MxStatusProxy precedence rules + OPC UA top-byte invariants), every MxValue oneof case for the decoder (bool/int32/int64/float/double/string/timestamp/3 array variants/ raw bytes/null), GalaxyDriver IReadable wiring (route-through, empty- request, no-reader-throws, post-dispose-throws, status-code preservation). 62 Galaxy tests total pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@ using MxGateway.Client;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy;
|
||||
|
||||
@@ -22,7 +23,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy;
|
||||
/// <see cref="GalaxyDriverFactoryExtensions"/> registers under driver-type name
|
||||
/// "GalaxyMxGateway" so both paths can be live simultaneously during parity testing.
|
||||
/// </remarks>
|
||||
public sealed class GalaxyDriver : IDriver, ITagDiscovery, IDisposable
|
||||
public sealed class GalaxyDriver : IDriver, ITagDiscovery, IReadable, IDisposable
|
||||
{
|
||||
private readonly string _driverInstanceId;
|
||||
private readonly GalaxyDriverOptions _options;
|
||||
@@ -35,6 +36,13 @@ public sealed class GalaxyDriver : IDriver, ITagDiscovery, IDisposable
|
||||
private IGalaxyHierarchySource? _hierarchySource;
|
||||
private GalaxyRepositoryClient? _ownedRepositoryClient;
|
||||
|
||||
// PR 4.2 — IGalaxyDataReader is the test seam for IReadable. PR 4.4 supplies the
|
||||
// production implementation that wraps GalaxyMxSession's SubscribeBulk + StreamEvents
|
||||
// pump; until then ReadAsync throws NotSupportedException when the reader is null
|
||||
// (legacy-host backend handles reads in production via DriverNodeManager's
|
||||
// capability-routing).
|
||||
private readonly IGalaxyDataReader? _dataReader;
|
||||
|
||||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||
private bool _disposed;
|
||||
|
||||
@@ -42,19 +50,20 @@ public sealed class GalaxyDriver : IDriver, ITagDiscovery, IDisposable
|
||||
string driverInstanceId,
|
||||
GalaxyDriverOptions options,
|
||||
ILogger<GalaxyDriver>? logger = null)
|
||||
: this(driverInstanceId, options, hierarchySource: null, logger)
|
||||
: this(driverInstanceId, options, hierarchySource: null, dataReader: null, logger)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test-visible ctor — inject a custom <see cref="IGalaxyHierarchySource"/> so
|
||||
/// <see cref="DiscoverAsync"/> can be exercised against canned hierarchies without
|
||||
/// building a real gRPC channel.
|
||||
/// Test-visible ctor — inject custom seams so <see cref="DiscoverAsync"/> +
|
||||
/// <see cref="ReadAsync"/> can be exercised against canned data without building
|
||||
/// real gRPC channels.
|
||||
/// </summary>
|
||||
internal GalaxyDriver(
|
||||
string driverInstanceId,
|
||||
GalaxyDriverOptions options,
|
||||
IGalaxyHierarchySource? hierarchySource,
|
||||
IGalaxyDataReader? dataReader = null,
|
||||
ILogger<GalaxyDriver>? logger = null)
|
||||
{
|
||||
_driverInstanceId = !string.IsNullOrWhiteSpace(driverInstanceId)
|
||||
@@ -63,6 +72,7 @@ public sealed class GalaxyDriver : IDriver, ITagDiscovery, IDisposable
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? NullLogger<GalaxyDriver>.Instance;
|
||||
_hierarchySource = hierarchySource;
|
||||
_dataReader = dataReader;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -130,6 +140,31 @@ public sealed class GalaxyDriver : IDriver, ITagDiscovery, IDisposable
|
||||
await discoverer.DiscoverAsync(builder, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// ===== IReadable (PR 4.2 — abstraction; PR 4.4 supplies production reader) =====
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
||||
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
ArgumentNullException.ThrowIfNull(fullReferences);
|
||||
if (fullReferences.Count == 0) return Task.FromResult<IReadOnlyList<DataValueSnapshot>>([]);
|
||||
|
||||
if (_dataReader is null)
|
||||
{
|
||||
// The production GW-backed reader builds on the StreamEvents pump that PR 4.4
|
||||
// ships; until then a real gateway-driver instance can't fulfill reads.
|
||||
// Tests that need to exercise IReadable inject a fake reader via the internal
|
||||
// ctor; production deployments running on this PR should keep the
|
||||
// legacy-host backend selected via the Galaxy:Backend flag (PR 4.W).
|
||||
throw new NotSupportedException(
|
||||
"GalaxyDriver.ReadAsync requires the StreamEvents-backed reader from PR 4.4. " +
|
||||
"Until that lands, route reads through the legacy-host backend (Galaxy:Backend=legacy-host).");
|
||||
}
|
||||
|
||||
return _dataReader.ReadAsync(fullReferences, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lazily builds the default <see cref="IGalaxyHierarchySource"/> from
|
||||
/// <c>_options.Gateway</c>. Owned <see cref="GalaxyRepositoryClient"/> is disposed in
|
||||
|
||||
102
src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GalaxyMxSession.cs
Normal file
102
src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GalaxyMxSession.cs
Normal file
@@ -0,0 +1,102 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using MxGateway.Client;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||||
|
||||
/// <summary>
|
||||
/// Driver-side wrapper around the gateway's <see cref="MxGatewaySession"/>. Owns the
|
||||
/// MXAccess <c>Register</c> handle, caches the per-tag item handles AddItem returns,
|
||||
/// and coordinates the read / write / subscribe call paths. PRs 4.2-4.5 fill this in
|
||||
/// incrementally:
|
||||
/// <list type="bullet">
|
||||
/// <item>PR 4.2 (this PR) — skeleton + lifecycle wiring.</item>
|
||||
/// <item>PR 4.3 — write path.</item>
|
||||
/// <item>PR 4.4 — subscription registry + event pump + the production
|
||||
/// <see cref="IGalaxyDataReader"/> implementation that drives the read path.</item>
|
||||
/// <item>PR 4.5 — reconnect supervisor.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public sealed class GalaxyMxSession : IAsyncDisposable
|
||||
{
|
||||
private readonly GalaxyMxAccessOptions _options;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
// Owned gateway client + session — populated when ConnectAsync runs. Tests can leave
|
||||
// them null and exercise the surface via injected IGalaxyDataReader fakes.
|
||||
private MxGatewayClient? _ownedClient;
|
||||
private MxGatewaySession? _session;
|
||||
private int _serverHandle;
|
||||
private bool _disposed;
|
||||
|
||||
public GalaxyMxSession(GalaxyMxAccessOptions options, ILogger? logger = null)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? NullLogger.Instance;
|
||||
}
|
||||
|
||||
public bool IsConnected => _session is not null;
|
||||
|
||||
/// <summary>
|
||||
/// Server-side handle returned by MXAccess <c>Register</c>. Zero before
|
||||
/// <see cref="ConnectAsync"/> opens the session.
|
||||
/// </summary>
|
||||
public int ServerHandle => _serverHandle;
|
||||
|
||||
/// <summary>
|
||||
/// Connect the underlying gateway client + open an MXAccess session + register the
|
||||
/// configured client name. Idempotent — second calls are no-ops while
|
||||
/// <see cref="IsConnected"/> is true.
|
||||
/// </summary>
|
||||
public async Task ConnectAsync(MxGatewayClientOptions clientOptions, CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
if (_session is not null) return;
|
||||
|
||||
_ownedClient = MxGatewayClient.Create(clientOptions);
|
||||
_session = await _ownedClient.OpenSessionAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
_serverHandle = await _session.RegisterAsync(_options.ClientName, cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogInformation(
|
||||
"GalaxyMxSession connected — clientName={ClientName} serverHandle={Handle}",
|
||||
_options.ClientName, _serverHandle);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test seam — attach a session opened externally (e.g. against an in-process gw
|
||||
/// fake). Skips the gateway-client construction so tests can drive the session
|
||||
/// surface without spinning a real gRPC channel. Caller retains client ownership.
|
||||
/// </summary>
|
||||
internal void AttachForTests(MxGatewaySession session, int serverHandle)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
_session = session ?? throw new ArgumentNullException(nameof(session));
|
||||
_serverHandle = serverHandle;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the underlying gateway session. Null until <see cref="ConnectAsync"/> or
|
||||
/// <see cref="AttachForTests"/> runs. PR 4.3 / 4.4 use this to issue commands.
|
||||
/// </summary>
|
||||
public MxGatewaySession? Session => _session;
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
if (_session is not null)
|
||||
{
|
||||
try { await _session.DisposeAsync().ConfigureAwait(false); }
|
||||
catch (Exception ex) { _logger.LogWarning(ex, "GalaxyMxSession session dispose failed (best-effort)"); }
|
||||
}
|
||||
_session = null;
|
||||
|
||||
if (_ownedClient is not null)
|
||||
{
|
||||
try { await _ownedClient.DisposeAsync().ConfigureAwait(false); }
|
||||
catch (Exception ex) { _logger.LogWarning(ex, "GalaxyMxSession client dispose failed (best-effort)"); }
|
||||
}
|
||||
_ownedClient = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||||
|
||||
/// <summary>
|
||||
/// Driver-side seam for one-shot reads. Production implementation (PR 4.4) wraps
|
||||
/// <c>MxGatewaySession</c>'s SubscribeBulk + StreamEvents path to obtain values; tests
|
||||
/// substitute a fake returning canned snapshots.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The interface is deliberately minimal — no per-tag overload, no continuation
|
||||
/// points. The driver-side <c>IReadable.ReadAsync</c> contract guarantees a value per
|
||||
/// requested tag in input order, with status codes carrying the per-tag failure mode
|
||||
/// (e.g. BadInternalError for transport failure on a single tag, BadOutOfService for
|
||||
/// a tag the gateway didn't recognise).
|
||||
/// </remarks>
|
||||
public interface IGalaxyDataReader
|
||||
{
|
||||
/// <summary>
|
||||
/// Read each <paramref name="fullReferences"/> entry once and return one
|
||||
/// <see cref="DataValueSnapshot"/> per request entry, in input order.
|
||||
/// Implementations MUST return the same length as the input — partial-tag
|
||||
/// failures are encoded as Bad-quality snapshots, not omitted.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
||||
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using MxGateway.Contracts.Proto;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||||
|
||||
/// <summary>
|
||||
/// Translates gateway-side <see cref="MxValue"/> instances into the boxed CLR objects
|
||||
/// <c>DataValueSnapshot.Value</c> carries. Mirrors the seven Galaxy data types in
|
||||
/// <c>DataTypeMap</c> (Boolean, Int32, Int64, Float32, Float64, String, DateTime), plus
|
||||
/// the array variants exposed by <see cref="MxArray"/>. Unknown / awkward values fall
|
||||
/// back to the <c>raw_value</c> bytes so a forward-compatible MXAccess deployment
|
||||
/// doesn't lose data on the wire — the consumer can opt to deserialise the bytes.
|
||||
/// </summary>
|
||||
internal static class MxValueDecoder
|
||||
{
|
||||
public static object? Decode(MxValue? value)
|
||||
{
|
||||
if (value is null) return null;
|
||||
if (value.IsNull) return null;
|
||||
|
||||
return value.KindCase switch
|
||||
{
|
||||
MxValue.KindOneofCase.BoolValue => value.BoolValue,
|
||||
MxValue.KindOneofCase.Int32Value => value.Int32Value,
|
||||
MxValue.KindOneofCase.Int64Value => value.Int64Value,
|
||||
MxValue.KindOneofCase.FloatValue => value.FloatValue,
|
||||
MxValue.KindOneofCase.DoubleValue => value.DoubleValue,
|
||||
MxValue.KindOneofCase.StringValue => value.StringValue,
|
||||
MxValue.KindOneofCase.TimestampValue => DecodeTimestamp(value.TimestampValue),
|
||||
MxValue.KindOneofCase.ArrayValue => DecodeArray(value.ArrayValue),
|
||||
MxValue.KindOneofCase.RawValue => value.RawValue.ToByteArray(),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
private static DateTime? DecodeTimestamp(Timestamp? ts) => ts?.ToDateTime();
|
||||
|
||||
private static object? DecodeArray(MxArray? array)
|
||||
{
|
||||
if (array is null) return null;
|
||||
|
||||
return array.ValuesCase switch
|
||||
{
|
||||
MxArray.ValuesOneofCase.BoolValues => array.BoolValues.Values.ToArray(),
|
||||
MxArray.ValuesOneofCase.Int32Values => array.Int32Values.Values.ToArray(),
|
||||
MxArray.ValuesOneofCase.Int64Values => array.Int64Values.Values.ToArray(),
|
||||
MxArray.ValuesOneofCase.FloatValues => array.FloatValues.Values.ToArray(),
|
||||
MxArray.ValuesOneofCase.DoubleValues => array.DoubleValues.Values.ToArray(),
|
||||
MxArray.ValuesOneofCase.StringValues => array.StringValues.Values.ToArray(),
|
||||
MxArray.ValuesOneofCase.TimestampValues => array.TimestampValues.Values.Select(t => t.ToDateTime()).ToArray(),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
118
src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/StatusCodeMap.cs
Normal file
118
src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/StatusCodeMap.cs
Normal file
@@ -0,0 +1,118 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using 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.
|
||||
|
||||
public const uint Good = 0x00000000u;
|
||||
public const uint GoodLocalOverride = 0x00D80000u;
|
||||
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 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>
|
||||
/// 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>
|
||||
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. Honors
|
||||
/// the success flag, then the detail byte (treated as a quality substatus), with a
|
||||
/// transport-error fallback for status rows whose detected_by indicates the failure
|
||||
/// happened before the MXAccess call ran.
|
||||
/// </summary>
|
||||
public static uint FromMxStatus(MxStatusProxy? status, ILogger? logger = null)
|
||||
{
|
||||
if (status is null) return Good;
|
||||
if (status.Success != 0) return Good;
|
||||
|
||||
// Detail field carries the substatus when the worker translated MX-style codes;
|
||||
// when zero, infer from category + 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;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="GalaxyDriver"/>'s <c>IReadable</c> wiring. PR 4.2 ships the
|
||||
/// abstraction (<see cref="IGalaxyDataReader"/>) and the wiring; PR 4.4 supplies the
|
||||
/// production gateway-backed reader. These tests verify the wiring against a fake
|
||||
/// reader plus the explicit "no reader → NotSupportedException" fallback that protects
|
||||
/// deployments running on this PR from silently producing wrong reads.
|
||||
/// </summary>
|
||||
public sealed class GalaxyDriverReadTests
|
||||
{
|
||||
private static GalaxyDriverOptions Opts() => new(
|
||||
new GalaxyGatewayOptions("https://mxgw.test:5001", "key"),
|
||||
new GalaxyMxAccessOptions("OtOpcUa-A"),
|
||||
new GalaxyRepositoryOptions(),
|
||||
new GalaxyReconnectOptions());
|
||||
|
||||
private sealed class FakeReader : IGalaxyDataReader
|
||||
{
|
||||
public IReadOnlyList<string>? LastRequest { get; private set; }
|
||||
public Func<IReadOnlyList<string>, IReadOnlyList<DataValueSnapshot>> Decide { get; set; } =
|
||||
tags => tags.Select(t => new DataValueSnapshot(
|
||||
Value: t,
|
||||
StatusCode: StatusCodeMap.Good,
|
||||
SourceTimestampUtc: DateTime.UtcNow,
|
||||
ServerTimestampUtc: DateTime.UtcNow)).ToArray();
|
||||
|
||||
public Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
||||
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
||||
{
|
||||
LastRequest = fullReferences;
|
||||
return Task.FromResult(Decide(fullReferences));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_RoutesThroughInjectedReader()
|
||||
{
|
||||
var reader = new FakeReader();
|
||||
var driver = new GalaxyDriver("g", Opts(), hierarchySource: null, dataReader: reader);
|
||||
|
||||
var result = await driver.ReadAsync(["Tank1.Level", "Tank2.Level"], CancellationToken.None);
|
||||
|
||||
reader.LastRequest.ShouldBe(new[] { "Tank1.Level", "Tank2.Level" });
|
||||
result.Count.ShouldBe(2);
|
||||
result[0].Value.ShouldBe("Tank1.Level");
|
||||
result[0].StatusCode.ShouldBe(StatusCodeMap.Good);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_EmptyRequest_ReturnsEmpty_WithoutCallingReader()
|
||||
{
|
||||
var reader = new FakeReader();
|
||||
var driver = new GalaxyDriver("g", Opts(), hierarchySource: null, dataReader: reader);
|
||||
|
||||
var result = await driver.ReadAsync([], CancellationToken.None);
|
||||
|
||||
result.ShouldBeEmpty();
|
||||
reader.LastRequest.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_NoReader_Throws_PointingAtPR44()
|
||||
{
|
||||
var driver = new GalaxyDriver("g", Opts());
|
||||
|
||||
var ex = await Should.ThrowAsync<NotSupportedException>(() =>
|
||||
driver.ReadAsync(["x"], CancellationToken.None));
|
||||
ex.Message.ShouldContain("PR 4.4");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_AfterDispose_Throws()
|
||||
{
|
||||
var driver = new GalaxyDriver("g", Opts(), hierarchySource: null, dataReader: new FakeReader());
|
||||
driver.Dispose();
|
||||
await Should.ThrowAsync<ObjectDisposedException>(() =>
|
||||
driver.ReadAsync(["x"], CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_PreservesReaderStatusCodes()
|
||||
{
|
||||
var reader = new FakeReader
|
||||
{
|
||||
Decide = tags => new DataValueSnapshot[]
|
||||
{
|
||||
new(42.0, StatusCodeMap.Good, DateTime.UtcNow, DateTime.UtcNow),
|
||||
new(null, StatusCodeMap.BadNotConnected, null, DateTime.UtcNow),
|
||||
},
|
||||
};
|
||||
var driver = new GalaxyDriver("g", Opts(), hierarchySource: null, dataReader: reader);
|
||||
|
||||
var result = await driver.ReadAsync(["a", "b"], CancellationToken.None);
|
||||
|
||||
result[0].StatusCode.ShouldBe(StatusCodeMap.Good);
|
||||
result[1].StatusCode.ShouldBe(StatusCodeMap.BadNotConnected);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
using Google.Protobuf;
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using MxGateway.Contracts.Proto;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime;
|
||||
|
||||
/// <summary>
|
||||
/// Round-trip tests for <see cref="MxValueDecoder"/>. Each scenario constructs a
|
||||
/// gateway-style <see cref="MxValue"/>, decodes, and asserts the boxed CLR value
|
||||
/// matches the expected type and value.
|
||||
/// </summary>
|
||||
public sealed class MxValueDecoderTests
|
||||
{
|
||||
[Fact]
|
||||
public void Decode_Null_ReturnsNull()
|
||||
{
|
||||
MxValueDecoder.Decode(null).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Decode_IsNullFlag_ReturnsNull()
|
||||
{
|
||||
var v = new MxValue { IsNull = true };
|
||||
MxValueDecoder.Decode(v).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Decode_Bool() => MxValueDecoder.Decode(new MxValue { BoolValue = true }).ShouldBe(true);
|
||||
|
||||
[Fact]
|
||||
public void Decode_Int32() => MxValueDecoder.Decode(new MxValue { Int32Value = -42 }).ShouldBe(-42);
|
||||
|
||||
[Fact]
|
||||
public void Decode_Int64() => MxValueDecoder.Decode(new MxValue { Int64Value = 123456789012L }).ShouldBe(123456789012L);
|
||||
|
||||
[Fact]
|
||||
public void Decode_Float() => MxValueDecoder.Decode(new MxValue { FloatValue = 3.14f }).ShouldBe(3.14f);
|
||||
|
||||
[Fact]
|
||||
public void Decode_Double() => MxValueDecoder.Decode(new MxValue { DoubleValue = 2.71828 }).ShouldBe(2.71828);
|
||||
|
||||
[Fact]
|
||||
public void Decode_String() => MxValueDecoder.Decode(new MxValue { StringValue = "hello" }).ShouldBe("hello");
|
||||
|
||||
[Fact]
|
||||
public void Decode_Timestamp_ReturnsUtcDateTime()
|
||||
{
|
||||
var when = new DateTime(2026, 4, 29, 12, 0, 0, DateTimeKind.Utc);
|
||||
var v = new MxValue { TimestampValue = Timestamp.FromDateTime(when) };
|
||||
MxValueDecoder.Decode(v).ShouldBe(when);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Decode_BoolArray()
|
||||
{
|
||||
var v = new MxValue
|
||||
{
|
||||
ArrayValue = new MxArray
|
||||
{
|
||||
BoolValues = new BoolArray(),
|
||||
},
|
||||
};
|
||||
v.ArrayValue.BoolValues.Values.AddRange(new[] { true, false, true });
|
||||
|
||||
var decoded = MxValueDecoder.Decode(v);
|
||||
decoded.ShouldBe(new[] { true, false, true });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Decode_DoubleArray()
|
||||
{
|
||||
var v = new MxValue
|
||||
{
|
||||
ArrayValue = new MxArray { DoubleValues = new DoubleArray() },
|
||||
};
|
||||
v.ArrayValue.DoubleValues.Values.AddRange(new[] { 1.0, 2.0, 3.5 });
|
||||
|
||||
var decoded = MxValueDecoder.Decode(v);
|
||||
decoded.ShouldBe(new[] { 1.0, 2.0, 3.5 });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Decode_StringArray()
|
||||
{
|
||||
var v = new MxValue
|
||||
{
|
||||
ArrayValue = new MxArray { StringValues = new StringArray() },
|
||||
};
|
||||
v.ArrayValue.StringValues.Values.AddRange(new[] { "a", "b" });
|
||||
|
||||
var decoded = MxValueDecoder.Decode(v);
|
||||
decoded.ShouldBe(new[] { "a", "b" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Decode_RawValue_ReturnsBytes()
|
||||
{
|
||||
var bytes = new byte[] { 0xDE, 0xAD, 0xBE, 0xEF };
|
||||
var v = new MxValue { RawValue = ByteString.CopyFrom(bytes) };
|
||||
|
||||
var decoded = (byte[])MxValueDecoder.Decode(v)!;
|
||||
decoded.ShouldBe(bytes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
using MxGateway.Contracts.Proto;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime;
|
||||
|
||||
/// <summary>
|
||||
/// Exhaustive table-driven tests for <see cref="StatusCodeMap"/>. Pinning the byte→uint
|
||||
/// mapping here protects against accidental drift — every Galaxy deployment that
|
||||
/// reaches the parity matrix in PR 5.2 depends on these specific OPC UA StatusCode
|
||||
/// values matching the legacy <c>HistorianQualityMapper</c> output.
|
||||
/// </summary>
|
||||
public sealed class StatusCodeMapTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData((byte)192, 0x00000000u)] // Good
|
||||
[InlineData((byte)216, 0x00D80000u)] // 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)0, 0x80000000u)] // Bad
|
||||
[InlineData((byte)4, 0x80890000u)] // Bad_ConfigurationError
|
||||
[InlineData((byte)8, 0x808A0000u)] // Bad_NotConnected
|
||||
[InlineData((byte)12, 0x808B0000u)] // Bad_DeviceFailure
|
||||
[InlineData((byte)16, 0x808C0000u)] // Bad_SensorFailure
|
||||
[InlineData((byte)20, 0x80050000u)] // Bad_CommunicationError
|
||||
[InlineData((byte)24, 0x808D0000u)] // Bad_OutOfService
|
||||
[InlineData((byte)32, 0x80320000u)] // Bad_WaitingForInitialData
|
||||
public void FromQualityByte_KnownValues_MapToOpcUaStatusCode(byte input, uint expected)
|
||||
{
|
||||
StatusCodeMap.FromQualityByte(input).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData((byte)200)] // Unknown Good — falls back to category bucket
|
||||
[InlineData((byte)100)] // Unknown Uncertain
|
||||
[InlineData((byte)40)] // Unknown Bad
|
||||
public void FromQualityByte_UnknownValues_FallBackToCategoryBucket(byte input)
|
||||
{
|
||||
var mapped = StatusCodeMap.FromQualityByte(input);
|
||||
if (input >= 192) mapped.ShouldBe(StatusCodeMap.Good);
|
||||
else if (input >= 64) mapped.ShouldBe(StatusCodeMap.Uncertain);
|
||||
else mapped.ShouldBe(StatusCodeMap.Bad);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromMxStatus_NullStatus_IsGood()
|
||||
{
|
||||
StatusCodeMap.FromMxStatus(null).ShouldBe(StatusCodeMap.Good);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromMxStatus_SuccessNonZero_IsGood()
|
||||
{
|
||||
var s = new MxStatusProxy { Success = 1 };
|
||||
StatusCodeMap.FromMxStatus(s).ShouldBe(StatusCodeMap.Good);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromMxStatus_SuccessZero_DetailKnown_MapsToSpecificCode()
|
||||
{
|
||||
var s = new MxStatusProxy { Success = 0, Detail = 8 /* Bad_NotConnected */ };
|
||||
StatusCodeMap.FromMxStatus(s).ShouldBe(StatusCodeMap.BadNotConnected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromMxStatus_SuccessZero_DetailZero_DetectedByNonZero_IsCommunicationError()
|
||||
{
|
||||
var s = new MxStatusProxy { Success = 0, Detail = 0, RawDetectedBy = 3 };
|
||||
StatusCodeMap.FromMxStatus(s).ShouldBe(StatusCodeMap.BadCommunicationError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromMxStatus_SuccessZero_AllZero_IsBad()
|
||||
{
|
||||
var s = new MxStatusProxy();
|
||||
StatusCodeMap.FromMxStatus(s).ShouldBe(StatusCodeMap.Bad);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TopByteCategoryBits_StayWithinOpcUaConvention()
|
||||
{
|
||||
// Sanity check that every Bad code we mint actually has the Bad top byte (0x80…),
|
||||
// every Uncertain has 0x40…, every Good has 0x00…. Pins the OPC UA Part 4 invariant.
|
||||
StatusCodeMap.Good.ShouldBeLessThan(0x40000000u);
|
||||
StatusCodeMap.GoodLocalOverride.ShouldBeLessThan(0x40000000u);
|
||||
|
||||
((StatusCodeMap.Uncertain >> 30) & 0x3u).ShouldBe(1u);
|
||||
((StatusCodeMap.UncertainLastUsableValue >> 30) & 0x3u).ShouldBe(1u);
|
||||
|
||||
((StatusCodeMap.Bad >> 30) & 0x3u).ShouldBe(2u);
|
||||
((StatusCodeMap.BadNotConnected >> 30) & 0x3u).ShouldBe(2u);
|
||||
((StatusCodeMap.BadOutOfService >> 30) & 0x3u).ShouldBe(2u);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user