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:
Joseph Doherty
2026-04-29 15:15:42 -04:00
parent ecba5cedf9
commit 85bdf0d58b
8 changed files with 651 additions and 5 deletions

View File

@@ -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

View 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;
}
}

View File

@@ -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);
}

View File

@@ -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,
};
}
}

View 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 &gt;= 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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}