using Microsoft.Extensions.Logging; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect; /// /// The half of (#626): CONDITION data items as /// native OPC UA Part 9 alarms. /// /// /// /// Which tags are alarms. The server decides, not this driver: a raw tag whose TagConfig /// carries the cross-driver alarm object is materialised as a Part 9 condition at its RawPath, and /// its RawPath arrives here through . Every event this driver raises /// uses that RawPath as — the key the host routes on. A CONDITION /// tag WITHOUT an alarm object is unchanged: it stays the plain String value it always was. /// /// /// One condition per DataItem. MTConnect can hold several simultaneous conditions on one /// DataItem (distinct nativeCodes); the Part 9 condition shows the most severe active one /// (), and an escalation or a new code re-raises it /// (). /// /// /// Delivery is diffed against what the server was actually TOLD. /// records a state only when an handler was attached to receive it. The host's /// DriverInstanceActor detaches its handler while it reconnects and re-subscribes on every /// Connected entry, so a transition that happened while nobody listened stays pending and is delivered on /// the re-subscribe — and an unchanged condition is NOT re-raised there, which would un-acknowledge it. /// /// /// Raised under . Two threads emit — the /sample pump and a /// subscribing caller — and the diff + record + raise must be one step or a Raise and its Clear could /// reach the server reversed. The only production handler is DriverInstanceActor's, a /// non-blocking Tell. is never taken while holding /// or the lifecycle semaphore, and never takes either. /// /// /// Comms loss emits nothing here, matching every other native source: a driver that cannot reach /// its Agent has nothing new to say, and the host annotates every condition it owns /// BadNoCommunication on the connectivity edge (#477), keeping each one's Active state. On recovery /// the re-baselined /current is diffed like any other document. /// /// public sealed partial class MTConnectDriver : IAlarmSource { /// Serializes condition-alarm diffing and delivery. See the class remarks. private readonly Lock _alarmLock = new(); /// /// ConditionId (RawPath) → the state the server was last told. Guarded by . /// Survives re-subscribes and lifecycle restarts on purpose — it describes the SERVER's condition node, /// which outlives both. /// private readonly Dictionary _deliveredConditions = new(StringComparer.Ordinal); /// The CONDITION tracker for the current session; replaced alongside the observation index. private MTConnectConditionTracker _conditions = new(); /// /// The one live alarm subscription, or null. Written under (it /// keeps the shared /sample pump alive); read with elsewhere. /// private AlarmSubscriptionState? _alarmSubscription; private long _nextAlarmSubscriptionId; /// public event EventHandler? OnAlarmEvent; /// The condition tracker the pump folds CONDITION observations into. Exposed for tests. internal MTConnectConditionTracker ConditionTracker => Volatile.Read(ref _conditions); /// /// /// Collapses to ONE live subscription (the latest), like the Galaxy and FOCAS sources: the owning /// DriverInstanceActor is the only caller and re-subscribes on every Connected entry. Starts the /// shared /sample pump when nothing else had (a driver whose only authored tags are alarms), and /// immediately delivers the current state of every subscribed condition that differs from what the server /// was last told — the re-establish step after a restart or a reconnect. /// public Task SubscribeAlarmsAsync( IReadOnlyList sourceNodeIds, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(sourceNodeIds); var references = new HashSet(StringComparer.Ordinal); foreach (var reference in sourceNodeIds) { if (!string.IsNullOrWhiteSpace(reference)) references.Add(reference); } var handle = new MTConnectAlarmSubscriptionHandle(Interlocked.Increment(ref _nextAlarmSubscriptionId)); lock (_subscriptionLock) { Volatile.Write(ref _alarmSubscription, new AlarmSubscriptionState(handle, references)); StartSampleStreamCore(republishOnStart: false); } lock (_alarmLock) { // A condition that left the set has its node torn down by the server; if it comes back it is a NEW // node that has been told nothing, so forget what its predecessor was told. foreach (var stale in _deliveredConditions.Keys.Where(k => !references.Contains(k)).ToList()) { _deliveredConditions.Remove(stale); } } EmitConditionTransitions(touchedDataItemIds: null, resync: true); _logger.LogDebug( "MTConnect driver {DriverInstanceId} opened alarm subscription {SubscriptionId} over {ConditionCount} condition(s).", _driverInstanceId, handle.DiagnosticId, references.Count); return Task.FromResult(handle); } /// /// An unknown or superseded handle is a no-op, matching . public async Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(handle); bool stopStream; lock (_subscriptionLock) { if (Volatile.Read(ref _alarmSubscription) is not { } live || !ReferenceEquals(live.Handle, handle)) { return; } Volatile.Write(ref _alarmSubscription, null); stopStream = !HasSubscribedReferencesCore(); } if (stopStream) { // Outside the lock — see UnsubscribeAsync. await StopSampleStreamAsync(cancellationToken).ConfigureAwait(false); } } /// /// /// MTConnect has no acknowledgement — an Agent's conditions clear when the machine says so. The Part 9 /// Acknowledge (and AddComment) is applied server-side; this is the no-op success the host routes it to, /// so nothing is sent to the Agent and capability negotiation still succeeds. Same posture as FOCAS. /// public Task AcknowledgeAsync( IReadOnlyList acknowledgements, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(acknowledgements); return Task.CompletedTask; } /// Whether the alarm subscription names any condition. Caller must hold . private bool HasAlarmReferencesCore() => Volatile.Read(ref _alarmSubscription) is { References.Count: > 0 }; /// /// Diffs the subscribed conditions against what the server was last told and raises one event per /// condition that moved. Never throws: a faulting handler is logged and the next condition still goes. /// /// /// Only conditions bound to these DataItems are evaluated; null evaluates every subscribed one. /// /// Whether this is a (re)subscribe — see . private void EmitConditionTransitions(IReadOnlySet? touchedDataItemIds, bool resync) { if (touchedDataItemIds is { Count: 0 }) return; var subscription = Volatile.Read(ref _alarmSubscription); if (subscription is not { References.Count: > 0 }) return; lock (_alarmLock) { // Nobody to tell (the host detached while it reconnects): record nothing, so the re-subscribe that // follows still sees the difference and delivers it. var handlers = OnAlarmEvent; if (handlers is null) return; var binding = Volatile.Read(ref _binding); var tracker = Volatile.Read(ref _conditions); foreach (var conditionId in subscription.References) { var dataItemId = binding.ToDataItemId(conditionId); if (touchedDataItemIds is not null && !touchedDataItemIds.Contains(dataItemId)) continue; var current = tracker.Get(dataItemId); if (current is null) continue; _deliveredConditions.TryGetValue(conditionId, out var delivered); var evt = MTConnectConditionAlarmMapper.Transition( delivered, current, conditionId, dataItemId, subscription.Handle, resync); _deliveredConditions[conditionId] = current; if (evt is null) continue; foreach (var registration in handlers.GetInvocationList()) { try { ((EventHandler)registration).Invoke(this, evt); } catch (Exception ex) { _logger.LogWarning( ex, "MTConnect driver {DriverInstanceId} caught a fault from an alarm subscriber for {ConditionId}; delivery continues.", _driverInstanceId, conditionId); } } } } } /// The live alarm subscription: its handle and the ConditionIds (RawPaths) it names. private sealed record AlarmSubscriptionState(MTConnectAlarmSubscriptionHandle Handle, HashSet References); } /// Handle returned by . /// The driver-local subscription number. public sealed record MTConnectAlarmSubscriptionHandle(long Id) : IAlarmSubscriptionHandle { /// public string DiagnosticId => $"mtconnect-alarm-sub-{Id}"; }