feat(mtconnect): CONDITION data items as native Part 9 alarms via IAlarmSource (#626)
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
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
using System.Text;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
||||
|
||||
/// <summary>
|
||||
/// The MTConnect CONDITION → OPC UA Part 9 mapping (#626), as one pure function: given what the server was
|
||||
/// last told about a condition and what the Agent says now, the single <see cref="AlarmEventArgs"/> to
|
||||
/// raise — or none.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <list type="table">
|
||||
/// <listheader><term>Agent level</term><description>Part 9</description></listheader>
|
||||
/// <item><term>Normal</term><description>inactive (return to normal), quality Good</description></item>
|
||||
/// <item><term>Warning</term><description>active, severity 500 (<see cref="AlarmSeverity.Medium"/>)</description></item>
|
||||
/// <item><term>Fault</term><description>active, severity 800 (<see cref="AlarmSeverity.High"/> bucket)</description></item>
|
||||
/// <item><term>Unavailable</term><description>inactive, quality <c>BadNoCommunication</c></description></item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// Inactive → active is a <see cref="AlarmTransitionKind.Raise"/>; active → a DIFFERENT active statement
|
||||
/// (Warning → Fault, a new <c>nativeCode</c>, a changed message) is a
|
||||
/// <see cref="AlarmTransitionKind.Retrigger"/> — active and unacknowledged again with the new severity
|
||||
/// and message; active → Normal/Unavailable is a <see cref="AlarmTransitionKind.Clear"/>. A change that
|
||||
/// moves only the quality (Normal ⇄ Unavailable) is a quality-only annotation
|
||||
/// (<see cref="AlarmEventArgs.IsQualityOnly"/>). An unchanged statement
|
||||
/// (<see cref="MTConnectConditionState.SameAlarmAs"/>) emits nothing — which is what keeps a reconnect's
|
||||
/// replayed <c>/current</c> from re-raising, and so un-acknowledging, an alarm the operator acked.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The one deliberate exception is a <c>resync</c>: an unchanged <c>Unavailable</c> is
|
||||
/// re-asserted on a (re)subscribe, because the host re-annotates every condition Good when the driver
|
||||
/// reconnects (#477) and nothing else would put the per-condition Bad back.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class MTConnectConditionAlarmMapper
|
||||
{
|
||||
/// <summary>OPC UA severity of an active <c>Warning</c>.</summary>
|
||||
internal const int WarningSeverity = 500;
|
||||
|
||||
/// <summary>OPC UA severity of an active <c>Fault</c>.</summary>
|
||||
internal const int FaultSeverity = 800;
|
||||
|
||||
/// <summary>Canonical <c>Opc.Ua.StatusCodes.Good</c>.</summary>
|
||||
private const uint Good = 0x00000000u;
|
||||
|
||||
/// <summary>Canonical <c>Opc.Ua.StatusCodes.BadNoCommunication</c> — the Agent has no state for the item.</summary>
|
||||
private const uint BadNoCommunication = 0x80310000u;
|
||||
|
||||
/// <summary>
|
||||
/// The event moving the server from <paramref name="delivered"/> to <paramref name="current"/>, or
|
||||
/// <c>null</c> when nothing needs to be said.
|
||||
/// </summary>
|
||||
/// <param name="delivered">What the server was last told for this condition; <c>null</c> when nothing yet.</param>
|
||||
/// <param name="current">The Agent's current statement.</param>
|
||||
/// <param name="conditionId">The condition's ConditionId — the alarm-bearing raw tag's RawPath.</param>
|
||||
/// <param name="dataItemId">The Agent DataItem id (carried as <see cref="AlarmEventArgs.SourceNodeId"/>).</param>
|
||||
/// <param name="handle">The alarm subscription the event belongs to.</param>
|
||||
/// <param name="resync">True on a (re)subscribe: re-assert an unchanged Unavailable quality.</param>
|
||||
/// <returns>The event to raise, or <c>null</c>.</returns>
|
||||
public static AlarmEventArgs? Transition(
|
||||
MTConnectConditionState? delivered,
|
||||
MTConnectConditionState current,
|
||||
string conditionId,
|
||||
string dataItemId,
|
||||
IAlarmSubscriptionHandle handle,
|
||||
bool resync)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(current);
|
||||
|
||||
var wasActive = delivered?.IsActive == true;
|
||||
|
||||
if (delivered is not null && delivered.SameAlarmAs(current))
|
||||
{
|
||||
return resync && current.Level == MTConnectConditionLevel.Unavailable
|
||||
? Build(current, delivered, conditionId, dataItemId, handle, AlarmTransitionKind.Unspecified)
|
||||
: null;
|
||||
}
|
||||
|
||||
if (current.IsActive)
|
||||
{
|
||||
return Build(current, delivered, conditionId, dataItemId, handle,
|
||||
wasActive ? AlarmTransitionKind.Retrigger : AlarmTransitionKind.Raise);
|
||||
}
|
||||
|
||||
if (wasActive)
|
||||
{
|
||||
return Build(current, delivered, conditionId, dataItemId, handle, AlarmTransitionKind.Clear);
|
||||
}
|
||||
|
||||
// Inactive → inactive with a different level: only the quality moved. Nothing to say for a first-seen
|
||||
// Normal — the host's connectivity annotation already confirms a materialised condition Good.
|
||||
if (delivered is null && current.Level == MTConnectConditionLevel.Normal)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return Build(current, delivered, conditionId, dataItemId, handle, AlarmTransitionKind.Unspecified);
|
||||
}
|
||||
|
||||
/// <summary>The Part 9 severity of an active level.</summary>
|
||||
/// <param name="level">An active level.</param>
|
||||
internal static int SeverityOf(MTConnectConditionLevel level) =>
|
||||
level == MTConnectConditionLevel.Fault ? FaultSeverity : WarningSeverity;
|
||||
|
||||
/// <summary>
|
||||
/// The condition's message: its text content, else <c>"{type} {level}"</c>; followed by whichever of
|
||||
/// <c>nativeCode</c> / <c>nativeSeverity</c> / <c>qualifier</c> the Agent supplied — the Part 9 event
|
||||
/// model has no slot for them, and an operator needs the native code to look the alarm up.
|
||||
/// </summary>
|
||||
/// <param name="state">The state to describe.</param>
|
||||
internal static string MessageOf(MTConnectConditionState state)
|
||||
{
|
||||
var detail = state.Detail;
|
||||
var level = state.Level == MTConnectConditionLevel.Unavailable ? "UNAVAILABLE" : state.Level.ToString();
|
||||
var builder = new StringBuilder(detail.Text ?? $"{detail.ConditionType ?? "CONDITION"} {level}");
|
||||
|
||||
var extras = new List<string>(3);
|
||||
if (detail.NativeCode is not null) extras.Add($"nativeCode={detail.NativeCode}");
|
||||
if (detail.NativeSeverity is not null) extras.Add($"nativeSeverity={detail.NativeSeverity}");
|
||||
if (detail.Qualifier is not null) extras.Add($"qualifier={detail.Qualifier}");
|
||||
|
||||
if (extras.Count > 0) builder.Append(" [").AppendJoin(", ", extras).Append(']');
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static AlarmEventArgs Build(
|
||||
MTConnectConditionState current,
|
||||
MTConnectConditionState? delivered,
|
||||
string conditionId,
|
||||
string dataItemId,
|
||||
IAlarmSubscriptionHandle handle,
|
||||
AlarmTransitionKind kind)
|
||||
{
|
||||
// An inactive statement keeps the severity of the alarm it clears (or the one last shown), so a clear
|
||||
// does not visibly re-grade the condition; a never-active condition reports Warning's 500.
|
||||
var severity = current.IsActive
|
||||
? SeverityOf(current.Level)
|
||||
: delivered?.IsActive == true ? SeverityOf(delivered.Level) : WarningSeverity;
|
||||
|
||||
return new AlarmEventArgs(
|
||||
SubscriptionHandle: handle,
|
||||
SourceNodeId: dataItemId,
|
||||
ConditionId: conditionId,
|
||||
AlarmType: current.Detail.ConditionType ?? "CONDITION",
|
||||
Message: MessageOf(current),
|
||||
Severity: severity >= FaultSeverity ? AlarmSeverity.High : AlarmSeverity.Medium,
|
||||
SourceTimestampUtc: current.TimestampUtc,
|
||||
Kind: kind,
|
||||
OpcUaSeverity: severity,
|
||||
QualityStatusCode: current.Level == MTConnectConditionLevel.Unavailable ? BadNoCommunication : Good);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
||||
|
||||
/// <summary>The four MTConnect CONDITION levels the alarm path distinguishes (#626).</summary>
|
||||
internal enum MTConnectConditionLevel
|
||||
{
|
||||
/// <summary><c><Normal/></c> — nothing is active.</summary>
|
||||
Normal = 0,
|
||||
|
||||
/// <summary><c><Warning/></c> — active, Part 9 severity 500.</summary>
|
||||
Warning = 1,
|
||||
|
||||
/// <summary><c><Fault/></c> — active, Part 9 severity 800.</summary>
|
||||
Fault = 2,
|
||||
|
||||
/// <summary><c><Unavailable/></c> — the Agent has no state for the item (inactive, Bad quality).</summary>
|
||||
Unavailable = 3,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The derived state of ONE CONDITION DataItem as the alarm path sees it (#626): the most severe active
|
||||
/// condition when any is active, else Normal or Unavailable.
|
||||
/// </summary>
|
||||
/// <param name="Level">The derived level.</param>
|
||||
/// <param name="Detail">
|
||||
/// The detail of the observation the level comes from — the winning active condition, or the latest
|
||||
/// Normal/Unavailable observation. Never null.
|
||||
/// </param>
|
||||
/// <param name="TimestampUtc">That observation's Agent timestamp.</param>
|
||||
/// <param name="ActiveCount">How many distinct conditions (by <c>nativeCode</c>) are active at once.</param>
|
||||
internal sealed record MTConnectConditionState(
|
||||
MTConnectConditionLevel Level,
|
||||
MTConnectConditionDetail Detail,
|
||||
DateTime TimestampUtc,
|
||||
int ActiveCount)
|
||||
{
|
||||
/// <summary>Whether the derived condition is active (Warning or Fault).</summary>
|
||||
public bool IsActive => Level is MTConnectConditionLevel.Warning or MTConnectConditionLevel.Fault;
|
||||
|
||||
/// <summary>
|
||||
/// Whether two states are the SAME alarm statement — the dedup key the alarm path diffs on. Two active
|
||||
/// states match only when level, <c>nativeCode</c>, <c>nativeSeverity</c>, <c>qualifier</c> and message
|
||||
/// text all agree (a new code, an escalation, or a changed message is a new statement); two inactive
|
||||
/// states match on the level alone, so a re-reported Normal never re-clears. The timestamp and the
|
||||
/// active count are deliberately NOT part of it: a replayed <c>/current</c> after a reconnect carries the
|
||||
/// same condition under a new sequence, and re-raising it would un-acknowledge an operator's ack.
|
||||
/// </summary>
|
||||
/// <param name="other">The state to compare against.</param>
|
||||
/// <returns>True when the alarm path must emit nothing to move from one to the other.</returns>
|
||||
public bool SameAlarmAs(MTConnectConditionState other)
|
||||
{
|
||||
if (Level != other.Level) return false;
|
||||
if (!IsActive) return true;
|
||||
|
||||
return string.Equals(Detail.NativeCode, other.Detail.NativeCode, StringComparison.Ordinal)
|
||||
&& string.Equals(Detail.NativeSeverity, other.Detail.NativeSeverity, StringComparison.Ordinal)
|
||||
&& string.Equals(Detail.Qualifier, other.Detail.Qualifier, StringComparison.Ordinal)
|
||||
&& string.Equals(Detail.Text, other.Detail.Text, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Folds MTConnect CONDITION observations into per-DataItem alarm state with the protocol's own
|
||||
/// multi-condition semantics (#626) — the alarm path's analogue of <see cref="MTConnectObservationIndex"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Why the index is not enough.</b> The index reconciles several states reported for one DataItem
|
||||
/// only <i>within one document</i> and otherwise treats each document as the new statement of the
|
||||
/// value. That is right for a <c>/current</c> snapshot but wrong for a <c>/sample</c> delta: an Agent
|
||||
/// streams each <c>nativeCode</c>'s activation and clearing separately, so a chunk carrying
|
||||
/// <c><Normal nativeCode="A"/></c> clears code A only — code B, raised in an earlier chunk,
|
||||
/// is still active. The alarm path must not report "return to normal" there.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Rules, per DataItem, applied in document order.</b> <c>Warning</c>/<c>Fault</c> activates (or
|
||||
/// replaces) the entry keyed by its <c>nativeCode</c> (blank = one unnamed entry). <c>Normal</c> with a
|
||||
/// <c>nativeCode</c> clears that code; <c>Normal</c> without one clears every code. <c>Unavailable</c>
|
||||
/// clears every code and marks the item unavailable until any other level arrives. A <b>baseline</b>
|
||||
/// (<c>/current</c>, which lists every currently-active condition) first resets each DataItem it
|
||||
/// mentions, so an entry cleared while the driver was not listening cannot survive a re-baseline. An
|
||||
/// unrecognised (vendor) state word is treated as an active <c>Warning</c> — surfacing an unknown
|
||||
/// condition beats hiding it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Thread safety.</b> One internal lock; every member is atomic. The pump is the only writer.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class MTConnectConditionTracker
|
||||
{
|
||||
/// <summary>The parser's normalised spelling of <c><Unavailable/></c>.</summary>
|
||||
private const string UnavailableSentinel = "UNAVAILABLE";
|
||||
|
||||
private readonly Lock _lock = new();
|
||||
private readonly Dictionary<string, ItemState> _items = new(StringComparer.Ordinal);
|
||||
private long _order;
|
||||
|
||||
/// <summary>
|
||||
/// Folds every CONDITION observation of <paramref name="document"/> into the tracked state.
|
||||
/// </summary>
|
||||
/// <param name="document">A <c>/current</c> snapshot or one <c>/sample</c> chunk.</param>
|
||||
/// <param name="baseline">
|
||||
/// True for a <c>/current</c> answer — each DataItem it mentions is reset before its observations are
|
||||
/// applied; false for a <c>/sample</c> delta.
|
||||
/// </param>
|
||||
/// <returns>The DataItem ids whose condition state the document touched (empty when none).</returns>
|
||||
public IReadOnlySet<string> Apply(MTConnectStreamsResult document, bool baseline)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
|
||||
var touched = new HashSet<string>(StringComparer.Ordinal);
|
||||
if (document.Observations is not { Count: > 0 }) return touched;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
foreach (var observation in document.Observations)
|
||||
{
|
||||
if (observation?.Condition is null || string.IsNullOrWhiteSpace(observation.DataItemId)) continue;
|
||||
|
||||
if (!_items.TryGetValue(observation.DataItemId, out var item))
|
||||
{
|
||||
_items[observation.DataItemId] = item = new ItemState();
|
||||
}
|
||||
|
||||
if (touched.Add(observation.DataItemId) && baseline)
|
||||
{
|
||||
item.Active.Clear();
|
||||
item.Unavailable = false;
|
||||
}
|
||||
|
||||
ApplyOne(item, observation);
|
||||
}
|
||||
}
|
||||
|
||||
return touched;
|
||||
}
|
||||
|
||||
/// <summary>The derived state of one DataItem, or <c>null</c> when it has never been observed as a condition.</summary>
|
||||
/// <param name="dataItemId">The DataItem id.</param>
|
||||
public MTConnectConditionState? Get(string dataItemId)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (string.IsNullOrEmpty(dataItemId) || !_items.TryGetValue(dataItemId, out var item)) return null;
|
||||
|
||||
if (item.Active.Count > 0)
|
||||
{
|
||||
// Most severe wins; among equals, the most recently activated — the newest statement the
|
||||
// operator has not yet seen.
|
||||
var winner = item.Active.Values
|
||||
.OrderByDescending(e => e.Level)
|
||||
.ThenByDescending(e => e.Order)
|
||||
.First();
|
||||
|
||||
return new MTConnectConditionState(winner.Level, winner.Detail, winner.TimestampUtc, item.Active.Count);
|
||||
}
|
||||
|
||||
var last = item.LastInactive;
|
||||
return last is null
|
||||
? null
|
||||
: new MTConnectConditionState(
|
||||
item.Unavailable ? MTConnectConditionLevel.Unavailable : MTConnectConditionLevel.Normal,
|
||||
last.Detail,
|
||||
last.TimestampUtc,
|
||||
0);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Drops every tracked state — the Agent-restart re-baseline, mirroring <see cref="MTConnectObservationIndex.Clear"/>.</summary>
|
||||
public void Clear()
|
||||
{
|
||||
lock (_lock) _items.Clear();
|
||||
}
|
||||
|
||||
/// <summary>Maps a parsed condition state word onto a level (unknown vendor words ⇒ Warning).</summary>
|
||||
/// <param name="value">The observation's state word.</param>
|
||||
internal static MTConnectConditionLevel LevelOf(string? value) => value switch
|
||||
{
|
||||
UnavailableSentinel => MTConnectConditionLevel.Unavailable,
|
||||
_ when string.Equals(value, "Normal", StringComparison.OrdinalIgnoreCase) => MTConnectConditionLevel.Normal,
|
||||
_ when string.Equals(value, "Fault", StringComparison.OrdinalIgnoreCase) => MTConnectConditionLevel.Fault,
|
||||
_ when string.Equals(value, "Unavailable", StringComparison.OrdinalIgnoreCase) => MTConnectConditionLevel.Unavailable,
|
||||
_ => MTConnectConditionLevel.Warning,
|
||||
};
|
||||
|
||||
private void ApplyOne(ItemState item, MTConnectObservation observation)
|
||||
{
|
||||
var detail = observation.Condition!;
|
||||
var entry = new Entry(LevelOf(observation.Value), detail, observation.TimestampUtc, ++_order);
|
||||
|
||||
switch (entry.Level)
|
||||
{
|
||||
case MTConnectConditionLevel.Normal:
|
||||
if (detail.NativeCode is null) item.Active.Clear();
|
||||
else item.Active.Remove(detail.NativeCode);
|
||||
item.Unavailable = false;
|
||||
item.LastInactive = entry;
|
||||
break;
|
||||
|
||||
case MTConnectConditionLevel.Unavailable:
|
||||
item.Active.Clear();
|
||||
item.Unavailable = true;
|
||||
item.LastInactive = entry;
|
||||
break;
|
||||
|
||||
default:
|
||||
item.Active[detail.NativeCode ?? string.Empty] = entry;
|
||||
item.Unavailable = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record Entry(MTConnectConditionLevel Level, MTConnectConditionDetail Detail, DateTime TimestampUtc, long Order);
|
||||
|
||||
private sealed class ItemState
|
||||
{
|
||||
/// <summary>Active conditions keyed by <c>nativeCode</c> ("" for an unnamed one).</summary>
|
||||
public Dictionary<string, Entry> Active { get; } = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Whether the latest inactive statement was Unavailable.</summary>
|
||||
public bool Unavailable { get; set; }
|
||||
|
||||
/// <summary>The latest Normal/Unavailable observation (its text and type feed the clear message).</summary>
|
||||
public Entry? LastInactive { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
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}";
|
||||
}
|
||||
@@ -47,7 +47,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
||||
/// <see cref="IWritable"/>, <see cref="ISubscribable"/>, <see cref="ITagDiscovery"/>,
|
||||
/// <see cref="IHostConnectivityProbe"/> and <see cref="IRediscoverable"/> — the last two
|
||||
/// built on state this class already caches (the Agent <c>instanceId</c> and the probe
|
||||
/// model).
|
||||
/// model) — plus <see cref="IAlarmSource"/> for CONDITION data items (#626, in
|
||||
/// <c>MTConnectDriver.Alarms.cs</c>).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b><see cref="IWritable"/> is <i>observation injection</i>, not machine command, and it is
|
||||
@@ -71,7 +72,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
||||
/// <see cref="AgentSession.ProbeModel"/> (see <see cref="RunProbeLoopAsync"/>).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class MTConnectDriver
|
||||
public sealed partial class MTConnectDriver
|
||||
: IDriver, IReadable, IWritable, ISubscribable, ITagDiscovery, IHostConnectivityProbe, IRediscoverable,
|
||||
ISkippedTagReporter
|
||||
{
|
||||
@@ -1981,6 +1982,10 @@ public sealed class MTConnectDriver
|
||||
if (republishOnStart)
|
||||
{
|
||||
FanOut(SubscribedReferences(), ObservationIndex);
|
||||
|
||||
// #626: and the conditions, diffed against what the server was last told — so a restart
|
||||
// delivers what changed while the driver was down and re-raises nothing that did not.
|
||||
EmitConditionTransitions(touchedDataItemIds: null, resync: false);
|
||||
}
|
||||
|
||||
while (!ct.IsCancellationRequested)
|
||||
@@ -2140,6 +2145,7 @@ public sealed class MTConnectDriver
|
||||
// then fails, holding stale values from a dead device model would be worse than
|
||||
// holding none.
|
||||
ObservationIndex.Clear();
|
||||
ConditionTracker.Clear();
|
||||
FanOut(SubscribedReferences(), ObservationIndex);
|
||||
|
||||
// The driver's own state is consistent by this point, so it is safe to hand the
|
||||
@@ -2245,7 +2251,7 @@ public sealed class MTConnectDriver
|
||||
.ConfigureAwait(false);
|
||||
|
||||
PublishAgentCursor(current.InstanceId, current.NextSequence);
|
||||
ApplyAndFanOut(current);
|
||||
ApplyAndFanOut(current, baseline: true);
|
||||
|
||||
_streamDegraded = false;
|
||||
PublishHealthy(DateTime.UtcNow);
|
||||
@@ -2328,11 +2334,16 @@ public sealed class MTConnectDriver
|
||||
/// Folds a <c>/current</c> snapshot or one <c>/sample</c> chunk into the shared index and
|
||||
/// publishes every reference it touched to the handles that subscribe it.
|
||||
/// </summary>
|
||||
private void ApplyAndFanOut(MTConnectStreamsResult document)
|
||||
private void ApplyAndFanOut(MTConnectStreamsResult document, bool baseline = false)
|
||||
{
|
||||
var index = ObservationIndex;
|
||||
index.Apply(document);
|
||||
|
||||
// #626: CONDITION observations also feed the alarm path, which keeps its own multi-condition state
|
||||
// (a /sample Normal for one nativeCode is not a return to normal while another code is active).
|
||||
var touchedConditions = ConditionTracker.Apply(document, baseline);
|
||||
EmitConditionTransitions(touchedConditions, resync: false);
|
||||
|
||||
if (document.Observations is not { Count: > 0 })
|
||||
{
|
||||
return;
|
||||
@@ -2509,6 +2520,13 @@ public sealed class MTConnectDriver
|
||||
/// </summary>
|
||||
private bool HasSubscribedReferencesCore()
|
||||
{
|
||||
// #626: a CONDITION alarm subscription needs the same stream — a driver whose only authored tags are
|
||||
// alarms would otherwise never hear a condition change.
|
||||
if (HasAlarmReferencesCore())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach (var state in _subscriptions.Values)
|
||||
{
|
||||
if (state.References.Count > 0)
|
||||
@@ -2786,6 +2804,12 @@ public sealed class MTConnectDriver
|
||||
index.Apply(current);
|
||||
Volatile.Write(ref _index, index);
|
||||
|
||||
// #626: the CONDITION tracker is per-session state like the index. Nothing is RAISED here — the
|
||||
// lifecycle semaphore is held; the restarted pump delivers the diff as its first act.
|
||||
var conditions = new MTConnectConditionTracker();
|
||||
conditions.Apply(current, baseline: true);
|
||||
Volatile.Write(ref _conditions, conditions);
|
||||
|
||||
// A fresh session has no failures behind it — including the /sample verdict, because a
|
||||
// re-initialize is precisely the event that means an operator changed something.
|
||||
_streamDegraded = false;
|
||||
@@ -2867,6 +2891,7 @@ public sealed class MTConnectDriver
|
||||
// against a torn-down driver answers BadWaitingForInitialData ("authored, no value") rather
|
||||
// than BadNodeIdUnknown ("no such tag").
|
||||
Volatile.Write(ref _index, new MTConnectObservationIndex(Volatile.Read(ref _binding).IndexTags));
|
||||
Volatile.Write(ref _conditions, new MTConnectConditionTracker());
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -167,13 +167,35 @@ public sealed record MTConnectStreamsResult(
|
||||
/// observation. Carried for the parser contract; not yet surfaced on the served node (see
|
||||
/// docs/drivers/MTConnect.md). <c>null</c> when absent.
|
||||
/// </param>
|
||||
/// <param name="Condition">
|
||||
/// The CONDITION-only detail (#626) — set exactly when the observation came from a
|
||||
/// <c><Condition></c> container, <c>null</c> for every Sample/Event. Its presence is what marks an
|
||||
/// observation as a condition for the alarm path; <see cref="Value"/> still carries the state word.
|
||||
/// </param>
|
||||
public sealed record MTConnectObservation(
|
||||
string DataItemId,
|
||||
string Value,
|
||||
DateTime TimestampUtc,
|
||||
bool IsStructured = false,
|
||||
int? SampleCount = null,
|
||||
double? SampleRate = null);
|
||||
double? SampleRate = null,
|
||||
MTConnectConditionDetail? Condition = null);
|
||||
|
||||
/// <summary>
|
||||
/// What an MTConnect CONDITION observation says beyond its state word (#626). Every field is the
|
||||
/// Agent's own text, trimmed, or <c>null</c> when absent or blank — none is interpreted at this layer.
|
||||
/// </summary>
|
||||
/// <param name="Text">The element's text content: the operator-facing condition message.</param>
|
||||
/// <param name="NativeCode">The <c>nativeCode</c> attribute — the controller's own alarm number/code.</param>
|
||||
/// <param name="NativeSeverity">The <c>nativeSeverity</c> attribute — the controller's own severity, verbatim.</param>
|
||||
/// <param name="Qualifier">The <c>qualifier</c> attribute (e.g. <c>HIGH</c>, <c>LOW</c>).</param>
|
||||
/// <param name="ConditionType">The element's <c>type</c> attribute (e.g. <c>SYSTEM</c>, <c>TEMPERATURE</c>).</param>
|
||||
public sealed record MTConnectConditionDetail(
|
||||
string? Text,
|
||||
string? NativeCode,
|
||||
string? NativeSeverity,
|
||||
string? Qualifier,
|
||||
string? ConditionType);
|
||||
|
||||
/// <summary>
|
||||
/// Recursive helpers over the <see cref="IMTConnectComponentContainer"/> device/component tree.
|
||||
|
||||
@@ -379,6 +379,7 @@ internal static class MTConnectStreamsParser
|
||||
string? conditionState = null;
|
||||
int? sampleCount = null;
|
||||
double? sampleRate = null;
|
||||
(string? NativeCode, string? NativeSeverity, string? Qualifier, string? Type) conditionAttributes = default;
|
||||
|
||||
if (isObservation)
|
||||
{
|
||||
@@ -387,6 +388,17 @@ internal static class MTConnectStreamsParser
|
||||
timestampUtc = ReadTimestampUtc(reader, dataItemId);
|
||||
conditionState = isCondition ? ConditionState(name) : null;
|
||||
|
||||
// #626: a condition's alarm identity rides the element's attributes — read here, before the
|
||||
// subtree walk moves the reader off them. Verbatim (trimmed), never interpreted.
|
||||
if (isCondition)
|
||||
{
|
||||
conditionAttributes = (
|
||||
MTConnectXml.OptionalAttribute(reader, "nativeCode")?.Trim(),
|
||||
MTConnectXml.OptionalAttribute(reader, "nativeSeverity")?.Trim(),
|
||||
MTConnectXml.OptionalAttribute(reader, "qualifier")?.Trim(),
|
||||
MTConnectXml.OptionalAttribute(reader, "type")?.Trim());
|
||||
}
|
||||
|
||||
// TIME_SERIES metadata rides the observation element itself. Read here, before the subtree
|
||||
// walk moves the reader off this element's attributes. Lenient: a vendor's unparseable
|
||||
// attribute is dropped rather than failing the whole document (the vector is checked
|
||||
@@ -413,9 +425,11 @@ internal static class MTConnectStreamsParser
|
||||
observations.Add(null!);
|
||||
}
|
||||
|
||||
// A CONDITION's value is its element NAME, so nothing below it is collected for it — but an
|
||||
// enclosing observation's text still is, hence the ancestor sink rather than none at all.
|
||||
var sink = isObservation && !isCondition ? new ValueSink() : textSink;
|
||||
// A CONDITION's VALUE is its element NAME, so its text never becomes the value — but since #626
|
||||
// the text is still collected, into a sink of its own, because it is the condition's
|
||||
// operator-facing MESSAGE (the alarm path's Message). It is deliberately not appended to an
|
||||
// enclosing observation's sink: a condition is never nested inside a text-valued observation.
|
||||
var sink = isObservation ? new ValueSink() : textSink;
|
||||
|
||||
var hasElements = !reader.IsEmptyElement && ReadChildren(reader, kind, observations, sink);
|
||||
|
||||
@@ -425,9 +439,17 @@ internal static class MTConnectStreamsParser
|
||||
}
|
||||
|
||||
string value;
|
||||
MTConnectConditionDetail? condition = null;
|
||||
if (isCondition)
|
||||
{
|
||||
value = conditionState!;
|
||||
var text = sink!.Value.Trim();
|
||||
condition = new MTConnectConditionDetail(
|
||||
text.Length == 0 ? null : text,
|
||||
conditionAttributes.NativeCode,
|
||||
conditionAttributes.NativeSeverity,
|
||||
conditionAttributes.Qualifier,
|
||||
conditionAttributes.Type);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -452,7 +474,8 @@ internal static class MTConnectStreamsParser
|
||||
// away a perfectly well-determined state.
|
||||
IsStructured: !isCondition && hasElements,
|
||||
SampleCount: sampleCount,
|
||||
SampleRate: sampleRate);
|
||||
SampleRate: sampleRate,
|
||||
Condition: condition);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
Reference in New Issue
Block a user