diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs
index e1aadf3..0b35375 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs
@@ -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;
/// registers under driver-type name
/// "GalaxyMxGateway" so both paths can be live simultaneously during parity testing.
///
-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? logger = null)
- : this(driverInstanceId, options, hierarchySource: null, logger)
+ : this(driverInstanceId, options, hierarchySource: null, dataReader: null, logger)
{
}
///
- /// Test-visible ctor — inject a custom so
- /// can be exercised against canned hierarchies without
- /// building a real gRPC channel.
+ /// Test-visible ctor — inject custom seams so +
+ /// can be exercised against canned data without building
+ /// real gRPC channels.
///
internal GalaxyDriver(
string driverInstanceId,
GalaxyDriverOptions options,
IGalaxyHierarchySource? hierarchySource,
+ IGalaxyDataReader? dataReader = null,
ILogger? 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.Instance;
_hierarchySource = hierarchySource;
+ _dataReader = dataReader;
}
///
@@ -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) =====
+
+ ///
+ public Task> ReadAsync(
+ IReadOnlyList fullReferences, CancellationToken cancellationToken)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ ArgumentNullException.ThrowIfNull(fullReferences);
+ if (fullReferences.Count == 0) return Task.FromResult>([]);
+
+ 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);
+ }
+
///
/// Lazily builds the default from
/// _options.Gateway. Owned is disposed in
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GalaxyMxSession.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GalaxyMxSession.cs
new file mode 100644
index 0000000..dffd69d
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GalaxyMxSession.cs
@@ -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;
+
+///
+/// Driver-side wrapper around the gateway's . Owns the
+/// MXAccess Register 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:
+///
+/// - PR 4.2 (this PR) — skeleton + lifecycle wiring.
+/// - PR 4.3 — write path.
+/// - PR 4.4 — subscription registry + event pump + the production
+/// implementation that drives the read path.
+/// - PR 4.5 — reconnect supervisor.
+///
+///
+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;
+
+ ///
+ /// Server-side handle returned by MXAccess Register. Zero before
+ /// opens the session.
+ ///
+ public int ServerHandle => _serverHandle;
+
+ ///
+ /// Connect the underlying gateway client + open an MXAccess session + register the
+ /// configured client name. Idempotent — second calls are no-ops while
+ /// is true.
+ ///
+ 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);
+ }
+
+ ///
+ /// 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.
+ ///
+ internal void AttachForTests(MxGatewaySession session, int serverHandle)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ _session = session ?? throw new ArgumentNullException(nameof(session));
+ _serverHandle = serverHandle;
+ }
+
+ ///
+ /// Returns the underlying gateway session. Null until or
+ /// runs. PR 4.3 / 4.4 use this to issue commands.
+ ///
+ 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;
+ }
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/IGalaxyDataReader.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/IGalaxyDataReader.cs
new file mode 100644
index 0000000..2bff8cf
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/IGalaxyDataReader.cs
@@ -0,0 +1,27 @@
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
+
+///
+/// Driver-side seam for one-shot reads. Production implementation (PR 4.4) wraps
+/// MxGatewaySession's SubscribeBulk + StreamEvents path to obtain values; tests
+/// substitute a fake returning canned snapshots.
+///
+///
+/// The interface is deliberately minimal — no per-tag overload, no continuation
+/// points. The driver-side IReadable.ReadAsync 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).
+///
+public interface IGalaxyDataReader
+{
+ ///
+ /// Read each entry once and return one
+ /// 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.
+ ///
+ Task> ReadAsync(
+ IReadOnlyList fullReferences, CancellationToken cancellationToken);
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/MxValueDecoder.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/MxValueDecoder.cs
new file mode 100644
index 0000000..9c49d05
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/MxValueDecoder.cs
@@ -0,0 +1,54 @@
+using Google.Protobuf.WellKnownTypes;
+using MxGateway.Contracts.Proto;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
+
+///
+/// Translates gateway-side instances into the boxed CLR objects
+/// DataValueSnapshot.Value carries. Mirrors the seven Galaxy data types in
+/// DataTypeMap (Boolean, Int32, Int64, Float32, Float64, String, DateTime), plus
+/// the array variants exposed by . Unknown / awkward values fall
+/// back to the raw_value bytes so a forward-compatible MXAccess deployment
+/// doesn't lose data on the wire — the consumer can opt to deserialise the bytes.
+///
+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,
+ };
+ }
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/StatusCodeMap.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/StatusCodeMap.cs
new file mode 100644
index 0000000..7e05714
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/StatusCodeMap.cs
@@ -0,0 +1,118 @@
+using Microsoft.Extensions.Logging;
+using MxGateway.Contracts.Proto;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
+
+///
+/// Maps the gateway's (raw MXAccess HRESULT + category bits)
+/// to OPC UA StatusCode uints. Replaces the legacy
+/// MxAccessGalaxyBackend.ToWire heuristic (Quality >= 192 → Good, else Uncertain)
+/// with an explicit table that preserves specific codes (BadNotConnected, OutOfService,
+/// UncertainSubNormal, etc.) instead of collapsing to category buckets.
+///
+///
+/// OPC DA quality bytes are 16-bit values arranged as [QQSSSSSSLLNNNN]:
+/// 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 (Good,
+/// Uncertain, Bad) and emit a single diagnostic log line per session via
+/// the supplied logger so field captures can extend the table.
+///
+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;
+
+ ///
+ /// Map a raw OPC DA quality byte (the low byte of an OPC DA OpcQuality ushort,
+ /// which is what Wonderware Historian + MXAccess surface as OPCITEMSTATE.qLong's
+ /// low byte) to the OPC UA StatusCode uint.
+ ///
+ 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),
+ };
+
+ ///
+ /// Map a gateway-reported 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.
+ ///
+ 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);
+ }
+}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GalaxyDriverReadTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GalaxyDriverReadTests.cs
new file mode 100644
index 0000000..18729e9
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GalaxyDriverReadTests.cs
@@ -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;
+
+///
+/// Tests for 's IReadable wiring. PR 4.2 ships the
+/// abstraction () 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.
+///
+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? LastRequest { get; private set; }
+ public Func, IReadOnlyList> Decide { get; set; } =
+ tags => tags.Select(t => new DataValueSnapshot(
+ Value: t,
+ StatusCode: StatusCodeMap.Good,
+ SourceTimestampUtc: DateTime.UtcNow,
+ ServerTimestampUtc: DateTime.UtcNow)).ToArray();
+
+ public Task> ReadAsync(
+ IReadOnlyList 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(() =>
+ 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(() =>
+ 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);
+ }
+}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/MxValueDecoderTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/MxValueDecoderTests.cs
new file mode 100644
index 0000000..35777ef
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/MxValueDecoderTests.cs
@@ -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;
+
+///
+/// Round-trip tests for . Each scenario constructs a
+/// gateway-style , decodes, and asserts the boxed CLR value
+/// matches the expected type and value.
+///
+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);
+ }
+}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/StatusCodeMapTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/StatusCodeMapTests.cs
new file mode 100644
index 0000000..5b25004
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/StatusCodeMapTests.cs
@@ -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;
+
+///
+/// Exhaustive table-driven tests for . 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 HistorianQualityMapper output.
+///
+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);
+ }
+}