Files
lmxopcua/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GatewayGalaxyDataWriter.cs
T
Joseph Doherty a25593a9c6 chore: organize solution into module folders (Core/Server/Drivers/Client/Tooling)
Group all 69 projects into category subfolders under src/ and tests/ so the
Rider Solution Explorer mirrors the module structure. Folders: Core, Server,
Drivers (with a nested Driver CLIs subfolder), Client, Tooling.

- Move every project folder on disk with git mv (history preserved as renames).
- Recompute relative paths in 57 .csproj files: cross-category ProjectReferences,
  the lib/ HintPath+None refs in Driver.Historian.Wonderware, and the external
  mxaccessgw refs in Driver.Galaxy and its test project.
- Rebuild ZB.MOM.WW.OtOpcUa.slnx with nested solution folders.
- Re-prefix project paths in functional scripts (e2e, compliance, smoke SQL,
  integration, install).

Build green (0 errors); unit tests pass. Docs left for a separate pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 01:55:28 -04:00

163 lines
7.0 KiB
C#

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;
/// <summary>
/// Production <see cref="IGalaxyDataWriter"/> over <see cref="GalaxyMxSession"/>.
/// For each batch entry: lazy-AddItem to obtain the MXAccess item handle, encode
/// the value via <see cref="MxValueEncoder"/>, route through Write or WriteSecured
/// based on the per-tag <see cref="SecurityClassification"/>, and translate the
/// reply's <c>MxStatusProxy</c> into an OPC UA <see cref="WriteResult"/>.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public sealed class GatewayGalaxyDataWriter : IGalaxyDataWriter
{
private readonly GalaxyMxSession _session;
private readonly int _writeUserId;
private readonly ILogger _logger;
private readonly ConcurrentDictionary<string, int> _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<IReadOnlyList<WriteResult>> WriteAsync(
IReadOnlyList<WriteRequest> writes,
Func<string, SecurityClassification> 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<WriteResult> 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<int> 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;
}
/// <summary>
/// Issue a WriteSecured command. The high-level session client doesn't expose
/// <c>WriteSecuredAsync</c> as a typed method — we build the <see cref="MxCommand"/>
/// directly and route through <c>InvokeAsync</c>. Verifier user is left at zero
/// for SecuredWrite; VerifiedWrite uses the same path because the gw's worker
/// interprets the underlying MXAccess command kind.
/// </summary>
private static Task<MxCommandReply> 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);
}
/// <summary>
/// Translate a gateway <see cref="MxCommandReply"/> into an OPC UA
/// <see cref="WriteResult"/>. Honours the protocol-level Status field first
/// (transport / dispatch failures), then the first MXAccess status row.
/// </summary>
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);
}
}