feat(dcl): RealMxGatewayClient over ZB.MOM.WW.MxGateway.Client
Seam implementation wrapping the gateway client + GalaxyRepositoryClient: OpenSession/Register, AddItem+Advise subscribe, ReadBulk/WriteBulk with handle tracking, StreamEvents loop with worker_sequence resume + OPC-style quality mapping, and Galaxy BrowseChildren mapping (objects keyed by gobject id, attributes by full tag reference). Only type touching the generated contracts.
This commit is contained in:
@@ -0,0 +1,267 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Production <see cref="IMxGatewayClient"/> implementation over the
|
||||||
|
/// <c>ZB.MOM.WW.MxGateway.Client</c> 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.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class RealMxGatewayClient : IMxGatewayClient
|
||||||
|
{
|
||||||
|
private readonly ILogger<RealMxGatewayClient> _logger;
|
||||||
|
private readonly ILoggerFactory? _loggerFactory;
|
||||||
|
|
||||||
|
private MxGatewayClient? _client;
|
||||||
|
private GalaxyRepositoryClient? _galaxy;
|
||||||
|
private MxGatewaySession? _session;
|
||||||
|
private int _serverHandle;
|
||||||
|
private int _writeUserId;
|
||||||
|
private int _readTimeoutMs;
|
||||||
|
private ulong _lastSeq;
|
||||||
|
|
||||||
|
// tag ↔ MXAccess item handle, maintained across subscribe/write.
|
||||||
|
private readonly ConcurrentDictionary<string, int> _tagToHandle = new();
|
||||||
|
private readonly ConcurrentDictionary<int, string> _handleToTag = new();
|
||||||
|
|
||||||
|
/// <summary>Initializes a new instance of <see cref="RealMxGatewayClient"/>.</summary>
|
||||||
|
/// <param name="loggerFactory">Logger factory shared with the gateway client.</param>
|
||||||
|
public RealMxGatewayClient(ILoggerFactory? loggerFactory)
|
||||||
|
{
|
||||||
|
_loggerFactory = loggerFactory;
|
||||||
|
_logger = (loggerFactory ?? Microsoft.Extensions.Logging.Abstractions.NullLoggerFactory.Instance)
|
||||||
|
.CreateLogger<RealMxGatewayClient>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task ConnectAsync(MxGatewayConnectionOptions options, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
_writeUserId = options.WriteUserId;
|
||||||
|
_readTimeoutMs = options.ReadTimeoutMs;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task DisconnectAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (_session is not null)
|
||||||
|
await _session.CloseAsync(ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<string> SubscribeAsync(string tagPath, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var handle = await GetOrAddItemHandleAsync(tagPath, ct).ConfigureAwait(false);
|
||||||
|
await _session!.AdviseAsync(_serverHandle, handle, ct).ConfigureAwait(false);
|
||||||
|
return handle.ToString(CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
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);
|
||||||
|
|
||||||
|
if (_handleToTag.TryRemove(handle, out var tag))
|
||||||
|
_tagToHandle.TryRemove(tag, out _);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<IReadOnlyList<MxReadOutcome>> ReadAsync(IReadOnlyList<string> 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<IReadOnlyList<MxWriteOutcome>> 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<WriteBulkEntry>(writes.Count);
|
||||||
|
var orderedTags = new List<string>(writes.Count);
|
||||||
|
foreach (var (tag, value) in writes)
|
||||||
|
{
|
||||||
|
var handle = await GetOrAddItemHandleAsync(tag, ct).ConfigureAwait(false);
|
||||||
|
entries.Add(new WriteBulkEntry
|
||||||
|
{
|
||||||
|
ItemHandle = handle,
|
||||||
|
Value = ToMxValue(value),
|
||||||
|
UserId = _writeUserId,
|
||||||
|
});
|
||||||
|
orderedTags.Add(tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
var results = await _session!.WriteBulkAsync(_serverHandle, entries, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
// Results are returned in request order; pair by index back to the tags.
|
||||||
|
return results.Select((r, i) => new MxWriteOutcome(
|
||||||
|
i < orderedTags.Count ? orderedTags[i] : (_handleToTag.TryGetValue(r.ItemHandle, out var t) ? t : ""),
|
||||||
|
r.WasSuccessful,
|
||||||
|
r.WasSuccessful ? null : r.ErrorMessage)).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<(IReadOnlyList<MxBrowseChild> Children, bool Truncated)> BrowseChildrenAsync(string? parentNodeId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var request = new BrowseChildrenRequest { IncludeAttributes = true };
|
||||||
|
// Object NodeIds are the Galaxy gobject id (encoded as a string); attribute
|
||||||
|
// NodeIds are FullTagReference leaves and never arrive here as a parent.
|
||||||
|
if (!string.IsNullOrEmpty(parentNodeId)
|
||||||
|
&& int.TryParse(parentNodeId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var gobjectId))
|
||||||
|
{
|
||||||
|
request.ParentGobjectId = gobjectId;
|
||||||
|
}
|
||||||
|
|
||||||
|
BrowseChildrenReply reply;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
reply = await _galaxy!.BrowseChildrenRawAsync(request, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (RpcException ex) when (ex.StatusCode == StatusCode.Unavailable)
|
||||||
|
{
|
||||||
|
throw new ConnectionNotConnectedException($"MxGateway repository unavailable: {ex.Status.Detail}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var children = new List<MxBrowseChild>();
|
||||||
|
for (var i = 0; i < reply.Children.Count; i++)
|
||||||
|
{
|
||||||
|
var obj = reply.Children[i];
|
||||||
|
var hasChildren = i < reply.ChildHasChildren.Count && reply.ChildHasChildren[i];
|
||||||
|
// Navigable container node, keyed by gobject id.
|
||||||
|
children.Add(new MxBrowseChild(
|
||||||
|
obj.GobjectId.ToString(CultureInfo.InvariantCulture),
|
||||||
|
string.IsNullOrEmpty(obj.TagName) ? obj.ContainedName : obj.TagName,
|
||||||
|
BrowseNodeClass.Object,
|
||||||
|
hasChildren || obj.Attributes.Count > 0));
|
||||||
|
|
||||||
|
// Selectable attribute leaves, keyed by their full tag reference.
|
||||||
|
foreach (var attr in obj.Attributes)
|
||||||
|
{
|
||||||
|
children.Add(new MxBrowseChild(
|
||||||
|
attr.FullTagReference,
|
||||||
|
attr.AttributeName,
|
||||||
|
BrowseNodeClass.Variable,
|
||||||
|
false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (children, !string.IsNullOrEmpty(reply.NextPageToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task RunEventLoopAsync(Action<MxValueUpdate> onUpdate, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
await foreach (var ev in _session!.StreamEventsAsync(_lastSeq, ct).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
_lastSeq = ev.WorkerSequence;
|
||||||
|
if (ev.Family != MxEventFamily.OnDataChange)
|
||||||
|
continue;
|
||||||
|
if (!_handleToTag.TryGetValue(ev.ItemHandle, out var tag))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
onUpdate(new MxValueUpdate(
|
||||||
|
tag,
|
||||||
|
ev.Value?.ToClrValue(),
|
||||||
|
MapQuality(ev.Quality, ev.Statuses),
|
||||||
|
ev.SourceTimestamp?.ToDateTimeOffset() ?? DateTimeOffset.UtcNow));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
if (_session is not null) await _session.DisposeAsync().ConfigureAwait(false);
|
||||||
|
if (_client is not null) await _client.DisposeAsync().ConfigureAwait(false);
|
||||||
|
if (_galaxy is not null) await _galaxy.DisposeAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<int> GetOrAddItemHandleAsync(string tagPath, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (_tagToHandle.TryGetValue(tagPath, out var existing))
|
||||||
|
return existing;
|
||||||
|
|
||||||
|
var handle = await _session!.AddItemAsync(_serverHandle, tagPath, ct).ConfigureAwait(false);
|
||||||
|
_tagToHandle[tagPath] = handle;
|
||||||
|
_handleToTag[handle] = tagPath;
|
||||||
|
return handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maps MXAccess quality. A failing status proxy is authoritative bad; otherwise
|
||||||
|
/// the OPC-style quality byte: ≥192 Good, ≥64 Uncertain, else Bad.
|
||||||
|
/// </summary>
|
||||||
|
private static QualityCode MapQuality(int quality, IEnumerable<MxStatusProxy> statuses)
|
||||||
|
{
|
||||||
|
if (statuses.Any(s => !s.IsSuccess()))
|
||||||
|
return QualityCode.Bad;
|
||||||
|
return quality switch
|
||||||
|
{
|
||||||
|
>= 192 => QualityCode.Good,
|
||||||
|
>= 64 => QualityCode.Uncertain,
|
||||||
|
_ => QualityCode.Bad,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MxValue ToMxValue(object? value) => value switch
|
||||||
|
{
|
||||||
|
null => new MxValue { IsNull = true },
|
||||||
|
bool b => b.ToMxValue(),
|
||||||
|
int i => i.ToMxValue(),
|
||||||
|
long l => l.ToMxValue(),
|
||||||
|
float f => f.ToMxValue(),
|
||||||
|
double d => d.ToMxValue(),
|
||||||
|
string s => s.ToMxValue(),
|
||||||
|
DateTimeOffset dto => dto.ToMxValue(),
|
||||||
|
DateTime dt => dt.ToMxValue(),
|
||||||
|
// Fall back to invariant string for any other CLR type.
|
||||||
|
_ => Convert.ToString(value, CultureInfo.InvariantCulture)!.ToMxValue(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Builds <see cref="RealMxGatewayClient"/> instances.</summary>
|
||||||
|
public sealed class RealMxGatewayClientFactory : IMxGatewayClientFactory
|
||||||
|
{
|
||||||
|
private readonly ILoggerFactory? _loggerFactory;
|
||||||
|
|
||||||
|
/// <summary>Initializes a new factory.</summary>
|
||||||
|
/// <param name="loggerFactory">Logger factory passed to each created client.</param>
|
||||||
|
public RealMxGatewayClientFactory(ILoggerFactory? loggerFactory) => _loggerFactory = loggerFactory;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IMxGatewayClient Create() => new RealMxGatewayClient(_loggerFactory);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user