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