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