653d138637
The parser keeps a condition's text, nativeCode, nativeSeverity, qualifier and type. A new MTConnectConditionTracker applies MTConnect's multi-condition semantics (per-nativeCode activation/clearing across /sample deltas, /current baseline reset, Unavailable), and MTConnectConditionAlarmMapper turns tracker state into Part 9 transitions: Warning=500, Fault=800, Normal=clear, Unavailable=inactive + BadNoCommunication; escalation or a new code retriggers. The driver implements IAlarmSource keyed by the alarm tag's RawPath, diffs against what the server was actually told (only when a handler is attached), so a reconnect/re-baseline never re-raises an unchanged condition and a missed transition is delivered on the next subscribe. Ack is a no-op success. Existing CONDITION String tags are unchanged; a tag becomes an alarm only via the standard TagConfig 'alarm' object. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
231 lines
11 KiB
C#
231 lines
11 KiB
C#
using Microsoft.Extensions.Logging;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
|
|
|
/// <summary>
|
|
/// The <see cref="IAlarmSource"/> half of <see cref="MTConnectDriver"/> (#626): CONDITION data items as
|
|
/// native OPC UA Part 9 alarms.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Which tags are alarms.</b> The server decides, not this driver: a raw tag whose <c>TagConfig</c>
|
|
/// carries the cross-driver <c>alarm</c> object is materialised as a Part 9 condition at its RawPath, and
|
|
/// its RawPath arrives here through <see cref="SubscribeAlarmsAsync"/>. Every event this driver raises
|
|
/// uses that RawPath as <see cref="AlarmEventArgs.ConditionId"/> — the key the host routes on. A CONDITION
|
|
/// tag WITHOUT an <c>alarm</c> object is unchanged: it stays the plain String value it always was.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>One condition per DataItem.</b> MTConnect can hold several simultaneous conditions on one
|
|
/// DataItem (distinct <c>nativeCode</c>s); the Part 9 condition shows the most severe active one
|
|
/// (<see cref="MTConnectConditionTracker"/>), and an escalation or a new code re-raises it
|
|
/// (<see cref="MTConnectConditionAlarmMapper"/>).
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Delivery is diffed against what the server was actually TOLD.</b> <see cref="_deliveredConditions"/>
|
|
/// records a state only when an <see cref="OnAlarmEvent"/> handler was attached to receive it. The host's
|
|
/// <c>DriverInstanceActor</c> 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Raised under <see cref="_alarmLock"/>.</b> Two threads emit — the <c>/sample</c> 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 <c>DriverInstanceActor</c>'s, a
|
|
/// non-blocking <c>Tell</c>. <see cref="_alarmLock"/> is never taken while holding
|
|
/// <see cref="_subscriptionLock"/> or the lifecycle semaphore, and never takes either.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Comms loss emits nothing here</b>, 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
|
|
/// <c>BadNoCommunication</c> on the connectivity edge (#477), keeping each one's Active state. On recovery
|
|
/// the re-baselined <c>/current</c> is diffed like any other document.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed partial class MTConnectDriver : IAlarmSource
|
|
{
|
|
/// <summary>Serializes condition-alarm diffing and delivery. See the class remarks.</summary>
|
|
private readonly Lock _alarmLock = new();
|
|
|
|
/// <summary>
|
|
/// ConditionId (RawPath) → the state the server was last told. Guarded by <see cref="_alarmLock"/>.
|
|
/// Survives re-subscribes and lifecycle restarts on purpose — it describes the SERVER's condition node,
|
|
/// which outlives both.
|
|
/// </summary>
|
|
private readonly Dictionary<string, MTConnectConditionState> _deliveredConditions = new(StringComparer.Ordinal);
|
|
|
|
/// <summary>The CONDITION tracker for the current session; replaced alongside the observation index.</summary>
|
|
private MTConnectConditionTracker _conditions = new();
|
|
|
|
/// <summary>
|
|
/// The one live alarm subscription, or <c>null</c>. Written under <see cref="_subscriptionLock"/> (it
|
|
/// keeps the shared <c>/sample</c> pump alive); read with <see cref="Volatile.Read{T}"/> elsewhere.
|
|
/// </summary>
|
|
private AlarmSubscriptionState? _alarmSubscription;
|
|
|
|
private long _nextAlarmSubscriptionId;
|
|
|
|
/// <inheritdoc/>
|
|
public event EventHandler<AlarmEventArgs>? OnAlarmEvent;
|
|
|
|
/// <summary>The condition tracker the pump folds CONDITION observations into. Exposed for tests.</summary>
|
|
internal MTConnectConditionTracker ConditionTracker => Volatile.Read(ref _conditions);
|
|
|
|
/// <inheritdoc/>
|
|
/// <remarks>
|
|
/// Collapses to ONE live subscription (the latest), like the Galaxy and FOCAS sources: the owning
|
|
/// <c>DriverInstanceActor</c> is the only caller and re-subscribes on every Connected entry. Starts the
|
|
/// shared <c>/sample</c> 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.
|
|
/// </remarks>
|
|
public Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(
|
|
IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(sourceNodeIds);
|
|
|
|
var references = new HashSet<string>(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<IAlarmSubscriptionHandle>(handle);
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
/// <remarks>An unknown or superseded handle is a no-op, matching <see cref="UnsubscribeAsync"/>.</remarks>
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
public Task AcknowledgeAsync(
|
|
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(acknowledgements);
|
|
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <summary>Whether the alarm subscription names any condition. <b>Caller must hold <see cref="_subscriptionLock"/>.</b></summary>
|
|
private bool HasAlarmReferencesCore() => Volatile.Read(ref _alarmSubscription) is { References.Count: > 0 };
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
/// <param name="touchedDataItemIds">
|
|
/// Only conditions bound to these DataItems are evaluated; <c>null</c> evaluates every subscribed one.
|
|
/// </param>
|
|
/// <param name="resync">Whether this is a (re)subscribe — see <see cref="MTConnectConditionAlarmMapper"/>.</param>
|
|
private void EmitConditionTransitions(IReadOnlySet<string>? 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<AlarmEventArgs>)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);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>The live alarm subscription: its handle and the ConditionIds (RawPaths) it names.</summary>
|
|
private sealed record AlarmSubscriptionState(MTConnectAlarmSubscriptionHandle Handle, HashSet<string> References);
|
|
}
|
|
|
|
/// <summary>Handle returned by <see cref="MTConnectDriver.SubscribeAlarmsAsync"/>.</summary>
|
|
/// <param name="Id">The driver-local subscription number.</param>
|
|
public sealed record MTConnectAlarmSubscriptionHandle(long Id) : IAlarmSubscriptionHandle
|
|
{
|
|
/// <inheritdoc />
|
|
public string DiagnosticId => $"mtconnect-alarm-sub-{Id}";
|
|
}
|