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}";
}