using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.MxGateway.Client; using ZB.MOM.WW.MxGateway.Contracts.Proto; // Use the generated nested status enum for the SetBufferedUpdateInterval reply check. namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime; /// /// Production over a connected /// . Forwards SubscribeBulk / UnsubscribeBulk to the /// gateway and streams MxEvents via the gw's bidirectional events RPC. /// /// /// PR 6.3 wired the per-call buffered_update_interval_ms through /// . The gw's contract is session-level /// (SetBufferedUpdateInterval applies to all buffered subscriptions on the /// server handle), so we cache the last-applied value and skip redundant calls. /// public sealed class GatewayGalaxySubscriber : IGalaxySubscriber { private readonly GalaxyMxSession _session; private readonly ILogger _logger; private readonly Lock _intervalLock = new(); private int _lastAppliedIntervalMs = -1; // -1 = never applied; 0 = explicit "use gw default" /// Initializes a new instance of GatewayGalaxySubscriber. /// The Galaxy MX session to use for subscription operations. /// /// Optional logger; surfaces a warning when SetBufferedUpdateInterval /// soft-fails so the cadence-not-applied condition isn't silent. Null is allowed /// for unit-test construction and falls back to . /// public GatewayGalaxySubscriber(GalaxyMxSession session, ILogger? logger = null) { _session = session ?? throw new ArgumentNullException(nameof(session)); _logger = logger ?? NullLogger.Instance; } /// public async Task> SubscribeBulkAsync( IReadOnlyList fullReferences, int bufferedUpdateIntervalMs, CancellationToken cancellationToken) { var session = _session.Session ?? throw new InvalidOperationException( "GalaxyMxSession is not connected. Call ConnectAsync before subscribing."); var serverHandle = _session.ServerHandle; // The gw's SubscribeBulk RPC doesn't carry a per-call interval — buffered cadence // is session-level, set via SetBufferedUpdateInterval. Apply it before the // SubscribeBulk so the very first events on the new handles publish at the // requested cadence. Skip when the last-applied value already matches. if (bufferedUpdateIntervalMs > 0) { await EnsureSessionIntervalAsync(session, serverHandle, bufferedUpdateIntervalMs, cancellationToken) .ConfigureAwait(false); } return await session.SubscribeBulkAsync(serverHandle, fullReferences, cancellationToken) .ConfigureAwait(false); } /// /// Apply the gateway's session-level SetBufferedUpdateInterval command. The /// gw's contract is "for this server handle, every buffered subscription publishes /// at this cadence" — there's no per-handle granularity, so we cache the last /// applied value and skip redundant calls. /// private async Task EnsureSessionIntervalAsync( ZB.MOM.WW.MxGateway.Client.MxGatewaySession session, int serverHandle, int intervalMs, CancellationToken cancellationToken) { lock (_intervalLock) { if (_lastAppliedIntervalMs == intervalMs) return; } var reply = await session.InvokeAsync( new MxCommandRequest { SessionId = session.SessionId, ClientCorrelationId = Guid.NewGuid().ToString("N"), Command = new MxCommand { Kind = MxCommandKind.SetBufferedUpdateInterval, SetBufferedUpdateInterval = new SetBufferedUpdateIntervalCommand { ServerHandle = serverHandle, UpdateIntervalMilliseconds = intervalMs, }, }, }, cancellationToken).ConfigureAwait(false); var code = reply.ProtocolStatus?.Code; // MxaccessFailure means the COM-side SetBufferedUpdateInterval did NOT apply, so // we must NOT cache the requested value — caching it would suppress the retry on // the next subscribe at this interval. Only Ok records the value as applied. if (!ClassifyIntervalReply(code)) { // Don't throw on a soft failure — the SubscribeBulk will still succeed at the // gw's default cadence, which is functional just not the requested cadence. // The trace span (PR 6.1) plus this warning gives ops the signal, and leaving // _lastAppliedIntervalMs unchanged lets the next subscribe re-attempt the set. if (code == ProtocolStatusCode.MxaccessFailure) { _logger.LogWarning( "Galaxy SetBufferedUpdateInterval({IntervalMs}ms) soft-failed (MxaccessFailure); " + "buffered subscriptions on server handle {ServerHandle} will publish at the gateway's " + "default cadence. The requested interval was not cached, so a later subscribe will retry it.", intervalMs, serverHandle); } else { _logger.LogWarning( "Galaxy SetBufferedUpdateInterval({IntervalMs}ms) returned an unexpected protocol status " + "{Code} on server handle {ServerHandle}; treating it as not-applied and leaving the " + "requested interval uncached so a later subscribe retries it.", intervalMs, code, serverHandle); } return; } lock (_intervalLock) { _lastAppliedIntervalMs = intervalMs; } } /// /// Classifies a SetBufferedUpdateInterval reply: returns true only when /// the requested interval was actually applied and may be cached as last-applied. /// This is alone — /// means the COM-side set did NOT take effect (so caching it would prevent a retry), /// and a null or any other unexpected code is treated as not-applied. /// /// The protocol status code from the gateway reply, or null when absent. /// true if the interval should be recorded as applied; otherwise false. internal static bool ClassifyIntervalReply(ProtocolStatusCode? code) => code == ProtocolStatusCode.Ok; /// public async Task UnsubscribeBulkAsync(IReadOnlyList itemHandles, CancellationToken cancellationToken) { if (itemHandles.Count == 0) return; var session = _session.Session ?? throw new InvalidOperationException( "GalaxyMxSession is not connected. UnsubscribeBulk called after disconnect."); var serverHandle = _session.ServerHandle; await session.UnsubscribeBulkAsync(serverHandle, itemHandles, cancellationToken) .ConfigureAwait(false); } /// public IAsyncEnumerable StreamEventsAsync(CancellationToken cancellationToken) { var session = _session.Session ?? throw new InvalidOperationException( "GalaxyMxSession is not connected. StreamEventsAsync called before ConnectAsync."); return session.StreamEventsAsync(afterWorkerSequence: 0, cancellationToken); } }