using System.Collections.Concurrent;
using System.Globalization;
using Grpc.Core;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.MxGateway.Client;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
///
/// Production implementation over the
/// ZB.MOM.WW.MxGateway.Client NuGet package. This is the only type in the
/// Data Connection Layer that references the generated gRPC/protobuf contracts;
/// the adapter and its tests run entirely against the neutral seam.
///
public sealed class RealMxGatewayClient : IMxGatewayClient
{
private readonly ILogger _logger;
private readonly ILoggerFactory? _loggerFactory;
private MxGatewayClient? _client;
private GalaxyRepositoryClient? _galaxy;
private MxGatewaySession? _session;
private int _serverHandle;
private int _writeUserId;
private int _readTimeoutMs;
private int _supervisoryAdviseParallelism = 16;
private ulong _lastSeq;
// tag ↔ MXAccess item handle, maintained across subscribe/write.
private readonly ConcurrentDictionary _tagToHandle = new();
private readonly ConcurrentDictionary _handleToTag = new();
// Item handles already advised in SUPERVISORY mode (advise-once tracking).
// MXAccess only accepts a write on an item that holds a supervisory advise; a
// plain Advise is not sufficient and the COM Write blocks until the gateway
// command timeout. When the connection has no write-user context
// (WriteUserId == 0) we therefore advise supervisory by DEFAULT — for both the
// monitoring subscription and any write — so the one connection can read and
// write. A supervisory advise still delivers OnDataChange (monitoring is
// unaffected), and the worker's UnAdvise tears down either advice kind. The
// Lazy per handle guarantees the advise runs at most once even under a
// concurrent first-time subscribe+write on the same new handle: every racing
// caller awaits the same Task, so none reaches WriteBulk before the advise has
// completed. A faulted advise is evicted so the next write retries.
private readonly ConcurrentDictionary> _supervisoryAdvised = new();
// With no configured MXAccess write-user this connection behaves as a
// supervisory client: every advise defaults to AdviseSupervisory so writes are
// accepted. A configured write-user keeps the prior plain-advise behaviour.
private bool UseSupervisoryAdvise => _writeUserId == 0;
/// Initializes a new instance of .
/// Logger factory shared with the gateway client.
public RealMxGatewayClient(ILoggerFactory? loggerFactory)
{
_loggerFactory = loggerFactory;
_logger = (loggerFactory ?? Microsoft.Extensions.Logging.Abstractions.NullLoggerFactory.Instance)
.CreateLogger();
}
///
public async Task ConnectAsync(MxGatewayConnectionOptions options, CancellationToken ct = default)
{
_writeUserId = options.WriteUserId;
_readTimeoutMs = options.ReadTimeoutMs;
_supervisoryAdviseParallelism = Math.Max(1, options.SupervisoryAdviseParallelism);
var clientOptions = new MxGatewayClientOptions
{
Endpoint = new Uri(options.Endpoint),
ApiKey = options.ApiKey,
UseTls = options.UseTls,
CaCertificatePath = options.CaFile,
ServerNameOverride = options.ServerName,
LoggerFactory = _loggerFactory,
};
_client = MxGatewayClient.Create(clientOptions);
_galaxy = GalaxyRepositoryClient.Create(clientOptions);
_session = await _client.OpenSessionAsync(cancellationToken: ct).ConfigureAwait(false);
_serverHandle = await _session.RegisterAsync(options.ClientName, ct).ConfigureAwait(false);
}
///
public async Task DisconnectAsync(CancellationToken ct = default)
{
if (_session is not null)
await _session.CloseAsync(ct).ConfigureAwait(false);
}
///
public async Task SubscribeAsync(string tagPath, CancellationToken ct = default)
{
var handle = await GetOrAddItemHandleAsync(tagPath, ct).ConfigureAwait(false);
// With no write-user context, advise supervisory by default so the same
// subscription also makes the item writable; otherwise plain monitor-advise.
if (UseSupervisoryAdvise)
await EnsureSupervisoryAdvisedAsync(handle, ct).ConfigureAwait(false);
else
await _session!.AdviseAsync(_serverHandle, handle, ct).ConfigureAwait(false);
return handle.ToString(CultureInfo.InvariantCulture);
}
///
public async Task> SubscribeBulkAsync(
IReadOnlyList tagPaths, CancellationToken ct = default)
{
if (tagPaths.Count == 0)
return [];
// Plain-advise mode (a configured write user): the worker's SubscribeBulk does
// AddItem + Advise for every tag in ONE gateway command, collapsing the historical
// 2 RPCs per tag to 1 RPC per chunk.
if (!UseSupervisoryAdvise)
{
var replies = await _session!.SubscribeBulkAsync(_serverHandle, tagPaths, ct).ConfigureAwait(false);
return MapSubscribeResults(tagPaths, replies);
}
// Supervisory mode (WriteUserId == 0): the worker has no BULK supervisory advise
// (SubscribeBulk issues plain Advise), so bulk-add the items in one command and
// pipeline the per-item AdviseSupervisory commands with a bounded window instead
// of issuing them serially. Cross-repo follow-up: an additive `supervisory` flag on
// SubscribeBulkCommand in mxaccessgw would collapse this to one RPC as well.
var added = await _session!.AddItemBulkAsync(_serverHandle, tagPaths, ct).ConfigureAwait(false);
var outcomes = MapSubscribeResults(tagPaths, added);
var advisable = outcomes
.Select((o, i) => (Outcome: o, Index: i))
.Where(x => x.Outcome.Success)
.ToList();
var advised = await BulkPipeline.RunAsync(
advisable,
_supervisoryAdviseParallelism,
async (x, token) =>
{
var handle = int.Parse(x.Outcome.SubscriptionId!, NumberStyles.Integer, CultureInfo.InvariantCulture);
await EnsureSupervisoryAdvisedAsync(handle, token).ConfigureAwait(false);
return x.Outcome;
},
(x, ex) => x.Outcome with { Success = false, SubscriptionId = null, Error = ex.Message },
ct).ConfigureAwait(false);
var result = outcomes.ToArray();
for (var i = 0; i < advisable.Count; i++)
result[advisable[i].Index] = advised[i];
// A failed advise leaves an added-but-unadvised item; drop our handle mapping so a
// later retry re-adds cleanly rather than reusing a half-subscribed handle.
foreach (var failed in result.Where(r => !r.Success))
{
if (_tagToHandle.TryRemove(failed.TagPath, out var staleHandle))
_handleToTag.TryRemove(staleHandle, out _);
}
return result;
}
///
/// Maps the gateway's per-item bulk results back onto the requested tag paths. Results
/// are returned in request order; the tag address on the reply is preferred when the
/// gateway echoes it. Successful rows populate the tag ↔ item-handle maps that the
/// event loop and the write path key off.
///
private MxSubscribeOutcome[] MapSubscribeResults(
IReadOnlyList tagPaths, IReadOnlyList replies)
{
var outcomes = new MxSubscribeOutcome[tagPaths.Count];
for (var i = 0; i < tagPaths.Count; i++)
{
var tag = tagPaths[i];
if (i >= replies.Count)
{
outcomes[i] = new MxSubscribeOutcome(tag, false, null, "Gateway returned no result for this tag.");
continue;
}
var reply = replies[i];
if (!reply.WasSuccessful)
{
outcomes[i] = new MxSubscribeOutcome(tag, false, null,
string.IsNullOrEmpty(reply.ErrorMessage) ? "Bulk subscribe failed." : reply.ErrorMessage);
continue;
}
_tagToHandle[tag] = reply.ItemHandle;
_handleToTag[reply.ItemHandle] = tag;
outcomes[i] = new MxSubscribeOutcome(
tag, true, reply.ItemHandle.ToString(CultureInfo.InvariantCulture), null);
}
return outcomes;
}
///
public async Task UnsubscribeBulkAsync(IReadOnlyList subscriptionIds, CancellationToken ct = default)
{
var handles = new List(subscriptionIds.Count);
foreach (var id in subscriptionIds)
{
if (int.TryParse(id, NumberStyles.Integer, CultureInfo.InvariantCulture, out var handle))
handles.Add(handle);
}
if (handles.Count == 0)
return;
await _session!.UnsubscribeBulkAsync(_serverHandle, handles, ct).ConfigureAwait(false);
foreach (var handle in handles)
{
// UnsubscribeBulk unadvises AND removes the item, releasing every advice kind.
_supervisoryAdvised.TryRemove(handle, out _);
if (_handleToTag.TryRemove(handle, out var tag))
((ICollection>)_tagToHandle).Remove(new(tag, handle));
}
}
///
public async Task UnsubscribeAsync(string subscriptionId, CancellationToken ct = default)
{
if (!int.TryParse(subscriptionId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var handle))
return;
await _session!.UnAdviseAsync(_serverHandle, handle, ct).ConfigureAwait(false);
await _session.RemoveItemAsync(_serverHandle, handle, ct).ConfigureAwait(false);
// RemoveItem releases the item and all its advices (plain + supervisory),
// so forget any supervisory-advise state for this handle.
_supervisoryAdvised.TryRemove(handle, out _);
if (_handleToTag.TryRemove(handle, out var tag))
((ICollection>)_tagToHandle).Remove(new(tag, handle));
}
///
public async Task> ReadAsync(IReadOnlyList tagPaths, CancellationToken ct = default)
{
var results = await _session!
.ReadBulkAsync(_serverHandle, tagPaths, TimeSpan.FromMilliseconds(_readTimeoutMs), ct)
.ConfigureAwait(false);
return results.Select(r => new MxReadOutcome(
r.TagAddress,
r.WasSuccessful,
r.WasSuccessful ? r.Value?.ToClrValue() : null,
MapQuality(r.Quality, r.Statuses),
r.SourceTimestamp?.ToDateTimeOffset() ?? DateTimeOffset.UtcNow,
r.WasSuccessful ? null : r.ErrorMessage)).ToList();
}
///
public async Task> WriteAsync(IReadOnlyList<(string TagPath, object? Value)> writes, CancellationToken ct = default)
{
// Build entries in request order; remember the tag for each handle so the
// per-handle BulkWriteResult can be mapped back to its tag.
var entries = new List(writes.Count);
var orderedTags = new List(writes.Count);
foreach (var (tag, value) in writes)
{
// MXAccess addresses a WHOLE array attribute with a trailing "[]" on the
// item reference. A write to the bare reference is silently dropped — the
// COM Write returns success but the value never commits (reads work
// either way). This mirrors the AVEVA MES Camstar API, which registers
// array tags as "