From a617086da165ee06a751b4049c92b85a4cded0ec Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 29 Apr 2026 15:24:22 -0400 Subject: [PATCH] =?UTF-8?q?PR=204.3=20=E2=80=94=20IWritable=20+=20secured-?= =?UTF-8?q?write=20routing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Write path online. GalaxyDriver implements IWritable; routes by SecurityClassification — SecuredWrite / VerifiedWrite tags go through MxCommandKind.WriteSecured, everything else through MxGatewaySession. WriteAsync. Per-tag classifications are captured during ITagDiscovery via a SecurityCapturingBuilder wrapper that intercepts Variable() calls without the discoverer needing to know about the driver's internal state. Files: - Runtime/MxValueEncoder.cs — boxed CLR value → MxValue. Covers seven Galaxy scalar types (bool/int8-32/uint8-32 → Int32, int64/uint64 → Int64, float, double, string, DateTime/DateTimeOffset → Timestamp) and 1-D array variants. Inverse of MxValueDecoder; round-trip pinned by tests. DateTime.Local converts to UTC; unsupported types throw ArgumentException. - Runtime/IGalaxyDataWriter.cs — driver-side seam. Tests inject a fake to capture routing decisions; production path uses GatewayGalaxyDataWriter. - Runtime/GatewayGalaxyDataWriter.cs — production. Lazy-AddItem caches itemHandles, encodes value, routes Write vs WriteSecured, translates MxCommandReply (ProtocolStatus → BadCommunicationError; first MxStatusProxy in statuses[] via StatusCodeMap.FromMxStatus). Per-tag exception isolation: one bad write doesn't fail the batch. - GalaxyDriver: now implements IWritable. Discovery wraps the supplied IAddressSpaceBuilder in SecurityCapturingBuilder which records each attribute's SecurityClass into _securityByFullRef before delegating. WriteAsync resolves classification per tag (FreeAccess default for unknown tags — matches the legacy backend), routes through the injected writer. Throws NotSupportedException with PR 4.4 pointer when no writer is wired (production path requires GalaxyMxSession.Connect from PR 4.4). Tests (32 new, 94 Galaxy total): - MxValueEncoder: every scalar type, narrowing checks (sbyte/short/byte/ ushort fit Int32; uint within Int32 range; ulong within Int64), DateTime.Local → UTC conversion, array variants for bool/double/string/ DateTime, Dimensions populated, unsupported-type throws ArgumentException, encoder/decoder round-trip pin. - GalaxyDriverWriteTests: WriteAsync routes through fake writer with values intact; theory exercises every SecurityClassification value through the discovery-then-write path; unknown-tag defaults to FreeAccess; empty- request short-circuit; no-writer fail-loud; post-dispose throws. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../GalaxyDriver.cs | 79 +++++++- .../Runtime/GatewayGalaxyDataWriter.cs | 162 ++++++++++++++++ .../Runtime/IGalaxyDataWriter.cs | 33 ++++ .../Runtime/MxValueEncoder.cs | 85 +++++++++ .../Runtime/GalaxyDriverWriteTests.cs | 175 ++++++++++++++++++ .../Runtime/MxValueEncoderTests.cs | 126 +++++++++++++ 6 files changed, 654 insertions(+), 6 deletions(-) create mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GatewayGalaxyDataWriter.cs create mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/IGalaxyDataWriter.cs create mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/MxValueEncoder.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GalaxyDriverWriteTests.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/MxValueEncoderTests.cs diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs index 0b35375..31892e1 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs @@ -23,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, IReadable, IDisposable +public sealed class GalaxyDriver : IDriver, ITagDiscovery, IReadable, IWritable, IDisposable { private readonly string _driverInstanceId; private readonly GalaxyDriverOptions _options; @@ -43,6 +43,14 @@ public sealed class GalaxyDriver : IDriver, ITagDiscovery, IReadable, IDisposabl // capability-routing). private readonly IGalaxyDataReader? _dataReader; + // PR 4.3 — IGalaxyDataWriter is the test seam for IWritable. Production wraps + // GalaxyMxSession via GatewayGalaxyDataWriter (Write / WriteSecured routing). The + // per-tag SecurityClassification map is populated during ITagDiscovery and consumed + // here at write time. + private readonly IGalaxyDataWriter? _dataWriter; + private readonly System.Collections.Concurrent.ConcurrentDictionary + _securityByFullRef = new(StringComparer.OrdinalIgnoreCase); + private DriverHealth _health = new(DriverState.Unknown, null, null); private bool _disposed; @@ -50,20 +58,21 @@ public sealed class GalaxyDriver : IDriver, ITagDiscovery, IReadable, IDisposabl string driverInstanceId, GalaxyDriverOptions options, ILogger? logger = null) - : this(driverInstanceId, options, hierarchySource: null, dataReader: null, logger) + : this(driverInstanceId, options, hierarchySource: null, dataReader: null, dataWriter: null, logger) { } /// - /// Test-visible ctor — inject custom seams so + - /// can be exercised against canned data without building - /// real gRPC channels. + /// Test-visible ctor — inject custom seams so , + /// , and can be exercised against + /// canned data without building real gRPC channels. /// internal GalaxyDriver( string driverInstanceId, GalaxyDriverOptions options, IGalaxyHierarchySource? hierarchySource, IGalaxyDataReader? dataReader = null, + IGalaxyDataWriter? dataWriter = null, ILogger? logger = null) { _driverInstanceId = !string.IsNullOrWhiteSpace(driverInstanceId) @@ -73,6 +82,7 @@ public sealed class GalaxyDriver : IDriver, ITagDiscovery, IReadable, IDisposabl _logger = logger ?? NullLogger.Instance; _hierarchySource = hierarchySource; _dataReader = dataReader; + _dataWriter = dataWriter; } /// @@ -135,11 +145,18 @@ public sealed class GalaxyDriver : IDriver, ITagDiscovery, IReadable, IDisposabl ObjectDisposedException.ThrowIf(_disposed, this); ArgumentNullException.ThrowIfNull(builder); + // PR 4.3 — wrap the supplied builder in a capturing proxy that records each + // attribute's SecurityClassification into _securityByFullRef. The wrapper is + // transparent to GalaxyDiscoverer; it forwards every call to the real builder. + var capturingBuilder = new SecurityCapturingBuilder(builder, _securityByFullRef); var source = _hierarchySource ??= BuildDefaultHierarchySource(); var discoverer = new GalaxyDiscoverer(source); - await discoverer.DiscoverAsync(builder, cancellationToken).ConfigureAwait(false); + await discoverer.DiscoverAsync(capturingBuilder, cancellationToken).ConfigureAwait(false); } + private SecurityClassification ResolveSecurity(string fullReference) => + _securityByFullRef.TryGetValue(fullReference, out var sec) ? sec : SecurityClassification.FreeAccess; + // ===== IReadable (PR 4.2 — abstraction; PR 4.4 supplies production reader) ===== /// @@ -165,6 +182,30 @@ public sealed class GalaxyDriver : IDriver, ITagDiscovery, IReadable, IDisposabl return _dataReader.ReadAsync(fullReferences, cancellationToken); } + // ===== IWritable (PR 4.3) ===== + + /// + public Task> WriteAsync( + IReadOnlyList writes, CancellationToken cancellationToken) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentNullException.ThrowIfNull(writes); + if (writes.Count == 0) return Task.FromResult>([]); + + if (_dataWriter is null) + { + // Mirror the IReadable fallback: production write path runs on top of + // GalaxyMxSession (PR 4.2 skeleton; PR 4.4 wires the live session). Until + // that lands, deployments selecting Galaxy:Backend=mxgateway can't write. + throw new NotSupportedException( + "GalaxyDriver.WriteAsync requires GatewayGalaxyDataWriter wired against a connected " + + "GalaxyMxSession (PR 4.4). Until that lands, route writes through the legacy-host " + + "backend (Galaxy:Backend=legacy-host)."); + } + + return _dataWriter.WriteAsync(writes, ResolveSecurity, cancellationToken); + } + /// /// Lazily builds the default from /// _options.Gateway. Owned is disposed in @@ -200,4 +241,30 @@ public sealed class GalaxyDriver : IDriver, ITagDiscovery, IReadable, IDisposabl _ownedRepositoryClient = null; _hierarchySource = null; } + + /// + /// Address-space builder wrapper that records each variable's + /// into the supplied dictionary + /// before delegating to the inner builder. Used by + /// to capture per-tag classifications for the IWritable routing decision — + /// PR 4.3 needs the data, but the discoverer itself doesn't (and shouldn't) + /// know about the driver's internal state. + /// + private sealed class SecurityCapturingBuilder( + IAddressSpaceBuilder inner, + System.Collections.Concurrent.ConcurrentDictionary map) + : IAddressSpaceBuilder + { + public IAddressSpaceBuilder Folder(string browseName, string displayName) + => new SecurityCapturingBuilder(inner.Folder(browseName, displayName), map); + + public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo) + { + map[attributeInfo.FullName] = attributeInfo.SecurityClass; + return inner.Variable(browseName, displayName, attributeInfo); + } + + public void AddProperty(string browseName, DriverDataType dataType, object? value) + => inner.AddProperty(browseName, dataType, value); + } } diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GatewayGalaxyDataWriter.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GatewayGalaxyDataWriter.cs new file mode 100644 index 0000000..3b0d22b --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GatewayGalaxyDataWriter.cs @@ -0,0 +1,162 @@ +using System.Collections.Concurrent; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using MxGateway.Client; +using MxGateway.Contracts.Proto; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime; + +/// +/// Production over . +/// For each batch entry: lazy-AddItem to obtain the MXAccess item handle, encode +/// the value via , route through Write or WriteSecured +/// based on the per-tag , and translate the +/// reply's MxStatusProxy into an OPC UA . +/// +/// +/// Item handle cache survives across writes — repeated writes to the same tag avoid +/// re-AddItem. Per-tag failures are isolated: one bad write doesn't fail the batch. +/// PR 4.4 will share this cache with the subscription registry; for now it lives +/// here so the writer is independently testable. +/// +public sealed class GatewayGalaxyDataWriter : IGalaxyDataWriter +{ + private readonly GalaxyMxSession _session; + private readonly int _writeUserId; + private readonly ILogger _logger; + private readonly ConcurrentDictionary _itemHandles = + new(StringComparer.OrdinalIgnoreCase); + + public GatewayGalaxyDataWriter(GalaxyMxSession session, int writeUserId, ILogger? logger = null) + { + _session = session ?? throw new ArgumentNullException(nameof(session)); + _writeUserId = writeUserId; + _logger = logger ?? NullLogger.Instance; + } + + public async Task> WriteAsync( + IReadOnlyList writes, + Func securityResolver, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(writes); + ArgumentNullException.ThrowIfNull(securityResolver); + + var session = _session.Session + ?? throw new InvalidOperationException( + "GalaxyMxSession is not connected. Call ConnectAsync before issuing writes."); + var serverHandle = _session.ServerHandle; + + var results = new WriteResult[writes.Count]; + for (var i = 0; i < writes.Count; i++) + { + results[i] = await WriteOneAsync(session, serverHandle, writes[i], + securityResolver(writes[i].FullReference), cancellationToken) + .ConfigureAwait(false); + } + return results; + } + + private async Task WriteOneAsync( + MxGatewaySession session, int serverHandle, WriteRequest request, + SecurityClassification classification, CancellationToken ct) + { + try + { + var itemHandle = await EnsureItemHandleAsync(session, serverHandle, request.FullReference, ct) + .ConfigureAwait(false); + var mxValue = MxValueEncoder.Encode(request.Value); + + var reply = NeedsSecuredWrite(classification) + ? await InvokeWriteSecuredAsync(session, serverHandle, itemHandle, mxValue, ct).ConfigureAwait(false) + : await session.WriteRawAsync(serverHandle, itemHandle, mxValue, _writeUserId, ct).ConfigureAwait(false); + + return TranslateReply(reply, request.FullReference); + } + catch (ArgumentException ex) + { + // Bad value type — caller passed a CLR type the encoder can't render. + _logger.LogWarning(ex, + "GalaxyDriver write rejected — unsupported value type for {FullRef}", request.FullReference); + return new WriteResult(StatusCodeMap.BadInternalError); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } + catch (Exception ex) + { + _logger.LogWarning(ex, "GalaxyDriver write failed for {FullRef}", request.FullReference); + return new WriteResult(StatusCodeMap.BadCommunicationError); + } + } + + private static bool NeedsSecuredWrite(SecurityClassification classification) => + classification is SecurityClassification.SecuredWrite or SecurityClassification.VerifiedWrite; + + private async Task EnsureItemHandleAsync( + MxGatewaySession session, int serverHandle, string fullRef, CancellationToken ct) + { + if (_itemHandles.TryGetValue(fullRef, out var existing)) return existing; + var handle = await session.AddItemAsync(serverHandle, fullRef, ct).ConfigureAwait(false); + _itemHandles[fullRef] = handle; + return handle; + } + + /// + /// Issue a WriteSecured command. The high-level session client doesn't expose + /// WriteSecuredAsync as a typed method — we build the + /// directly and route through InvokeAsync. Verifier user is left at zero + /// for SecuredWrite; VerifiedWrite uses the same path because the gw's worker + /// interprets the underlying MXAccess command kind. + /// + private static Task InvokeWriteSecuredAsync( + MxGatewaySession session, int serverHandle, int itemHandle, MxValue value, CancellationToken ct) + { + var command = new MxCommand + { + Kind = MxCommandKind.WriteSecured, + WriteSecured = new WriteSecuredCommand + { + ServerHandle = serverHandle, + ItemHandle = itemHandle, + Value = value, + CurrentUserId = 0, + VerifierUserId = 0, + }, + }; + var request = new MxCommandRequest + { + SessionId = session.SessionId, + ClientCorrelationId = Guid.NewGuid().ToString("N"), + Command = command, + }; + return session.InvokeAsync(request, ct); + } + + /// + /// Translate a gateway into an OPC UA + /// . Honours the protocol-level Status field first + /// (transport / dispatch failures), then the first MXAccess status row. + /// + private WriteResult TranslateReply(MxCommandReply reply, string fullRef) + { + // Protocol status — wraps transport / worker-side failures that happen before + // MXAccess saw the command. + if (reply.ProtocolStatus is { } proto && proto.Code != ProtocolStatusCode.Ok) + { + _logger.LogWarning( + "GalaxyDriver write protocol failure {Code} for {FullRef}: {Message}", + proto.Code, fullRef, proto.Message); + return new WriteResult(StatusCodeMap.BadCommunicationError); + } + + // MX-side status — the worker's WriteCompleteEvent rolls into the reply's + // statuses array. Use the first row (single-write contract). + if (reply.Statuses.Count > 0) + { + var status = reply.Statuses[0]; + return new WriteResult(StatusCodeMap.FromMxStatus(status, _logger)); + } + + return new WriteResult(StatusCodeMap.Good); + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/IGalaxyDataWriter.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/IGalaxyDataWriter.cs new file mode 100644 index 0000000..f91e66e --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/IGalaxyDataWriter.cs @@ -0,0 +1,33 @@ +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime; + +/// +/// Driver-side seam for batched writes. Production implementation routes by +/// : SecuredWrite / VerifiedWrite go through +/// MxCommandKind.WriteSecured, everything else through +/// MxGatewaySession.WriteAsync. Tests substitute a fake to record routing +/// decisions without touching real gw infrastructure. +/// +public interface IGalaxyDataWriter +{ + /// + /// Write each entry; return one + /// per request entry, in input order. Implementations + /// MUST return the same length as the input — partial-tag failures are encoded + /// as Bad-status results, not omitted. + /// + /// Pairs of full reference + value to write. + /// + /// Maps a full reference to its discovered + /// so the writer can route SecuredWrite / VerifiedWrite tags through the + /// WriteSecured command instead of Write. Returns + /// when the tag isn't tracked + /// (the safest default — non-secured Write). + /// + /// Aborts the in-flight batch. + Task> WriteAsync( + IReadOnlyList writes, + Func securityResolver, + CancellationToken cancellationToken); +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/MxValueEncoder.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/MxValueEncoder.cs new file mode 100644 index 0000000..b5519f5 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/MxValueEncoder.cs @@ -0,0 +1,85 @@ +using Google.Protobuf.WellKnownTypes; +using MxGateway.Contracts.Proto; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime; + +/// +/// Translates boxed CLR values from WriteRequest.Value into gateway-side +/// instances. Inverse of . +/// Handles the seven Galaxy data types — Boolean, Int32, Int64, Float32, Float64, +/// String, DateTime — and their array variants. Null + unsupported types throw +/// so the IWritable caller can fail the write with a +/// clear status code rather than silently mis-typing the wire payload. +/// +internal static class MxValueEncoder +{ + public static MxValue Encode(object? value) + { + if (value is null) return new MxValue { IsNull = true }; + + switch (value) + { + case bool b: return new MxValue { BoolValue = b }; + case sbyte i8: return new MxValue { Int32Value = i8 }; + case short i16: return new MxValue { Int32Value = i16 }; + case int i32: return new MxValue { Int32Value = i32 }; + case byte u8: return new MxValue { Int32Value = u8 }; + case ushort u16: return new MxValue { Int32Value = u16 }; + case uint u32 when u32 <= int.MaxValue: return new MxValue { Int32Value = (int)u32 }; + case long i64: return new MxValue { Int64Value = i64 }; + case ulong u64 when u64 <= long.MaxValue: return new MxValue { Int64Value = (long)u64 }; + case float f32: return new MxValue { FloatValue = f32 }; + case double f64: return new MxValue { DoubleValue = f64 }; + case string s: return new MxValue { StringValue = s }; + case DateTime dt: return new MxValue { TimestampValue = Timestamp.FromDateTime(EnsureUtc(dt)) }; + case DateTimeOffset dto: return new MxValue { TimestampValue = Timestamp.FromDateTimeOffset(dto) }; + + case bool[] arr: return EncodeArray(arr, (mx, vs) => mx.BoolValues = ToBoolArray(vs)); + case int[] arr: return EncodeArray(arr, (mx, vs) => mx.Int32Values = ToInt32Array(vs)); + case long[] arr: return EncodeArray(arr, (mx, vs) => mx.Int64Values = ToInt64Array(vs)); + case float[] arr: return EncodeArray(arr, (mx, vs) => mx.FloatValues = ToFloatArray(vs)); + case double[] arr: return EncodeArray(arr, (mx, vs) => mx.DoubleValues = ToDoubleArray(vs)); + case string[] arr: return EncodeArray(arr, (mx, vs) => mx.StringValues = ToStringArray(vs)); + case DateTime[] arr: return EncodeArray(arr, (mx, vs) => mx.TimestampValues = ToTimestampArray(vs)); + + default: + throw new ArgumentException( + $"Cannot encode value of type {value.GetType()} as MxValue. Supported: " + + "bool, int / long (and their unsigned variants), float, double, string, DateTime, " + + "and their 1-D array variants.", + nameof(value)); + } + } + + private static MxValue EncodeArray(T[] values, Action populate) + { + var array = new MxArray(); + populate(array, values); + array.Dimensions.Add((uint)values.Length); + return new MxValue { ArrayValue = array }; + } + + private static BoolArray ToBoolArray(bool[] vs) { var a = new BoolArray(); a.Values.AddRange(vs); return a; } + private static Int32Array ToInt32Array(int[] vs) { var a = new Int32Array(); a.Values.AddRange(vs); return a; } + private static Int64Array ToInt64Array(long[] vs) { var a = new Int64Array(); a.Values.AddRange(vs); return a; } + private static FloatArray ToFloatArray(float[] vs) { var a = new FloatArray(); a.Values.AddRange(vs); return a; } + private static DoubleArray ToDoubleArray(double[] vs) { var a = new DoubleArray(); a.Values.AddRange(vs); return a; } + private static StringArray ToStringArray(string[] vs) { var a = new StringArray(); a.Values.AddRange(vs); return a; } + private static TimestampArray ToTimestampArray(DateTime[] vs) + { + var a = new TimestampArray(); + foreach (var dt in vs) a.Values.Add(Timestamp.FromDateTime(EnsureUtc(dt))); + return a; + } + + /// + /// requires UTC. Convert non-UTC inputs + /// explicitly so a caller passing local time gets predictable wire bytes. + /// + private static DateTime EnsureUtc(DateTime dt) => dt.Kind switch + { + DateTimeKind.Utc => dt, + DateTimeKind.Local => dt.ToUniversalTime(), + _ => DateTime.SpecifyKind(dt, DateTimeKind.Utc), + }; +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GalaxyDriverWriteTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GalaxyDriverWriteTests.cs new file mode 100644 index 0000000..8a00748 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GalaxyDriverWriteTests.cs @@ -0,0 +1,175 @@ +using MxGateway.Contracts.Proto.Galaxy; +using Shouldly; +using Xunit; +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.Tests.Runtime; + +/// +/// Tests for 's IWritable wiring. Verifies the +/// SecurityClassification per-tag map gets populated during Discovery and routes the +/// subsequent WriteAsync calls to the right gateway command (Write vs WriteSecured). +/// The actual Write / WriteSecured invocation is tested separately at the +/// level — this test class focuses on the +/// driver-side wiring. +/// +public sealed class GalaxyDriverWriteTests +{ + private static GalaxyDriverOptions Opts() => new( + new GalaxyGatewayOptions("https://mxgw.test:5001", "key"), + new GalaxyMxAccessOptions("OtOpcUa-A"), + new GalaxyRepositoryOptions(), + new GalaxyReconnectOptions()); + + private sealed class FakeHierarchySource(IReadOnlyList objects) : IGalaxyHierarchySource + { + public Task> GetHierarchyAsync(CancellationToken cancellationToken) + => Task.FromResult(objects); + } + + private sealed class FakeBuilder : IAddressSpaceBuilder + { + public List Variables { get; } = []; + + public IAddressSpaceBuilder Folder(string browseName, string displayName) => this; + public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo) + { + Variables.Add(attributeInfo); + return new FakeHandle(attributeInfo.FullName); + } + public void AddProperty(string browseName, DriverDataType dataType, object? value) { } + + private sealed class FakeHandle(string fullRef) : IVariableHandle + { + public string FullReference { get; } = fullRef; + public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NoopSink(); + private sealed class NoopSink : IAlarmConditionSink { public void OnTransition(AlarmEventArgs args) { } } + } + } + + private sealed class FakeWriter : IGalaxyDataWriter + { + public List<(string FullRef, object? Value, SecurityClassification Resolved)> Calls { get; } = []; + + public Task> WriteAsync( + IReadOnlyList writes, + Func securityResolver, + CancellationToken cancellationToken) + { + var results = new WriteResult[writes.Count]; + for (var i = 0; i < writes.Count; i++) + { + Calls.Add((writes[i].FullReference, writes[i].Value, securityResolver(writes[i].FullReference))); + results[i] = new WriteResult(StatusCodeMap.Good); + } + return Task.FromResult>(results); + } + } + + private static GalaxyAttribute Attr(string name, int sec) + => new() { AttributeName = name, MxDataType = 2 /*Float32*/, SecurityClassification = sec }; + + private static GalaxyObject Obj(string tag, params GalaxyAttribute[] attrs) + { + var o = new GalaxyObject { TagName = tag, ContainedName = tag }; + o.Attributes.AddRange(attrs); + return o; + } + + [Fact] + public async Task WriteAsync_RoutesThroughInjectedWriter_AndPropagatesValues() + { + var src = new FakeHierarchySource([ + Obj("Tank1_Level", Attr("PV", sec: 0 /*FreeAccess*/), Attr("SP", sec: 1 /*Operate*/)), + ]); + var writer = new FakeWriter(); + var driver = new GalaxyDriver( + "g", Opts(), hierarchySource: src, dataReader: null, dataWriter: writer); + + var builder = new FakeBuilder(); + await driver.DiscoverAsync(builder, CancellationToken.None); + + await driver.WriteAsync([ + new WriteRequest("Tank1_Level.PV", 42.0), + new WriteRequest("Tank1_Level.SP", 50.0), + ], CancellationToken.None); + + writer.Calls.Count.ShouldBe(2); + writer.Calls[0].Resolved.ShouldBe(SecurityClassification.FreeAccess); + writer.Calls[1].Resolved.ShouldBe(SecurityClassification.Operate); + } + + [Theory] + [InlineData(0, SecurityClassification.FreeAccess)] + [InlineData(1, SecurityClassification.Operate)] + [InlineData(2, SecurityClassification.SecuredWrite)] + [InlineData(3, SecurityClassification.VerifiedWrite)] + [InlineData(4, SecurityClassification.Tune)] + [InlineData(5, SecurityClassification.Configure)] + [InlineData(6, SecurityClassification.ViewOnly)] + public async Task WriteAsync_ResolvesEverySecurityClassification_FromDiscovery(int mxSec, SecurityClassification expected) + { + var src = new FakeHierarchySource([ + Obj("Tank", Attr("PV", sec: mxSec)), + ]); + var writer = new FakeWriter(); + var driver = new GalaxyDriver( + "g", Opts(), hierarchySource: src, dataReader: null, dataWriter: writer); + + await driver.DiscoverAsync(new FakeBuilder(), CancellationToken.None); + await driver.WriteAsync([new WriteRequest("Tank.PV", 1.0)], CancellationToken.None); + + writer.Calls[0].Resolved.ShouldBe(expected); + } + + [Fact] + public async Task WriteAsync_UnknownTag_ResolvesToFreeAccess_DefaultsToWrite() + { + var writer = new FakeWriter(); + var driver = new GalaxyDriver( + "g", Opts(), hierarchySource: null, dataReader: null, dataWriter: writer); + + // No DiscoverAsync call → classification map is empty → resolver returns FreeAccess + // for any tag the gateway might attempt. WriteAsync must not throw on unknown tags. + await driver.WriteAsync([new WriteRequest("Random.Tag", 1.0)], CancellationToken.None); + + writer.Calls[0].Resolved.ShouldBe(SecurityClassification.FreeAccess); + } + + [Fact] + public async Task WriteAsync_EmptyRequest_ReturnsEmpty_WithoutCallingWriter() + { + var writer = new FakeWriter(); + var driver = new GalaxyDriver( + "g", Opts(), hierarchySource: null, dataReader: null, dataWriter: writer); + + var result = await driver.WriteAsync([], CancellationToken.None); + + result.ShouldBeEmpty(); + writer.Calls.ShouldBeEmpty(); + } + + [Fact] + public async Task WriteAsync_NoWriter_Throws_PointingAtPR44() + { + var driver = new GalaxyDriver("g", Opts()); + + var ex = await Should.ThrowAsync(() => + driver.WriteAsync([new WriteRequest("x", 1)], CancellationToken.None)); + ex.Message.ShouldContain("PR 4.4"); + } + + [Fact] + public async Task WriteAsync_AfterDispose_Throws() + { + var writer = new FakeWriter(); + var driver = new GalaxyDriver( + "g", Opts(), hierarchySource: null, dataReader: null, dataWriter: writer); + driver.Dispose(); + await Should.ThrowAsync(() => + driver.WriteAsync([new WriteRequest("x", 1)], CancellationToken.None)); + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/MxValueEncoderTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/MxValueEncoderTests.cs new file mode 100644 index 0000000..1584659 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/MxValueEncoderTests.cs @@ -0,0 +1,126 @@ +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; + +/// +/// Tests for . Pinning each scalar + array case here +/// guards against accidental drift in the IWritable wire format. +/// +public sealed class MxValueEncoderTests +{ + [Fact] + public void Encode_Null_SetsIsNullFlag() + { + var v = MxValueEncoder.Encode(null); + v.IsNull.ShouldBeTrue(); + } + + [Fact] + public void Encode_Bool() => MxValueEncoder.Encode(true).BoolValue.ShouldBe(true); + + [Theory] + [InlineData((sbyte)-5, -5)] + [InlineData((short)-1000, -1000)] + [InlineData((byte)42, 42)] + [InlineData((ushort)42_000, 42_000)] + public void Encode_NarrowSignedAndUnsigned_FitsInInt32(object input, int expected) + { + var v = MxValueEncoder.Encode(input); + v.KindCase.ShouldBe(MxValue.KindOneofCase.Int32Value); + v.Int32Value.ShouldBe(expected); + } + + [Fact] + public void Encode_Int32_RoundTrip() => MxValueEncoder.Encode(int.MinValue).Int32Value.ShouldBe(int.MinValue); + + [Fact] + public void Encode_Int64_RoundTrip() + { + var v = MxValueEncoder.Encode(long.MaxValue); + v.KindCase.ShouldBe(MxValue.KindOneofCase.Int64Value); + v.Int64Value.ShouldBe(long.MaxValue); + } + + [Fact] + public void Encode_UInt32_FitsInInt32() => MxValueEncoder.Encode((uint)int.MaxValue).Int32Value.ShouldBe(int.MaxValue); + + [Fact] + public void Encode_Float() => MxValueEncoder.Encode(3.14f).FloatValue.ShouldBe(3.14f); + + [Fact] + public void Encode_Double() => MxValueEncoder.Encode(2.71828).DoubleValue.ShouldBe(2.71828); + + [Fact] + public void Encode_String() => MxValueEncoder.Encode("hello").StringValue.ShouldBe("hello"); + + [Fact] + public void Encode_DateTimeUtc() + { + var when = new DateTime(2026, 4, 29, 12, 0, 0, DateTimeKind.Utc); + var v = MxValueEncoder.Encode(when); + v.TimestampValue.ShouldNotBeNull(); + v.TimestampValue.ToDateTime().ShouldBe(when); + } + + [Fact] + public void Encode_DateTimeLocal_ConvertsToUtc() + { + var local = new DateTime(2026, 4, 29, 12, 0, 0, DateTimeKind.Local); + var v = MxValueEncoder.Encode(local); + v.TimestampValue.ToDateTime().ShouldBe(local.ToUniversalTime()); + } + + [Fact] + public void Encode_BoolArray() + { + var v = MxValueEncoder.Encode(new[] { true, false, true }); + v.ArrayValue.BoolValues.Values.ToArray().ShouldBe(new[] { true, false, true }); + v.ArrayValue.Dimensions[0].ShouldBe(3u); + } + + [Fact] + public void Encode_DoubleArray() + { + var v = MxValueEncoder.Encode(new[] { 1.0, 2.0, 3.5 }); + v.ArrayValue.DoubleValues.Values.ToArray().ShouldBe(new[] { 1.0, 2.0, 3.5 }); + } + + [Fact] + public void Encode_StringArray() + { + var v = MxValueEncoder.Encode(new[] { "a", "b" }); + v.ArrayValue.StringValues.Values.ToArray().ShouldBe(new[] { "a", "b" }); + } + + [Fact] + public void Encode_DateTimeArray_ConvertsAllToUtc() + { + var inputs = new[] { new DateTime(2026, 4, 29, 0, 0, 0, DateTimeKind.Utc) }; + var v = MxValueEncoder.Encode(inputs); + v.ArrayValue.TimestampValues.Values[0].ToDateTime().ShouldBe(inputs[0]); + } + + [Fact] + public void Encode_UnsupportedType_Throws() + { + Should.Throw(() => MxValueEncoder.Encode(new { Foo = 1 })); + } + + [Fact] + public void RoundTrip_AllScalarTypes_DecodeMatchesOriginal() + { + // The encoder + decoder must be inverses for every scalar a Galaxy driver might + // hand to a write. This pin-test catches accidental drift in either direction. + object[] inputs = [true, 42, 12345L, 3.14f, 2.71828, "x"]; + foreach (var input in inputs) + { + var encoded = MxValueEncoder.Encode(input); + var decoded = MxValueDecoder.Decode(encoded); + decoded.ShouldBe(input); + } + } +}