diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectConditionAlarmMapper.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectConditionAlarmMapper.cs
new file mode 100644
index 00000000..3d2d3a55
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectConditionAlarmMapper.cs
@@ -0,0 +1,153 @@
+using System.Text;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
+
+///
+/// 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 to
+/// raise — or none.
+///
+///
+///
+/// Agent levelPart 9
+/// - Normalinactive (return to normal), quality Good
+/// - Warningactive, severity 500 ()
+/// - Faultactive, severity 800 ( bucket)
+/// - Unavailableinactive, quality BadNoCommunication
+///
+///
+/// Inactive → active is a ; active → a DIFFERENT active statement
+/// (Warning → Fault, a new nativeCode, a changed message) is a
+/// — active and unacknowledged again with the new severity
+/// and message; active → Normal/Unavailable is a . A change that
+/// moves only the quality (Normal ⇄ Unavailable) is a quality-only annotation
+/// (). An unchanged statement
+/// () emits nothing — which is what keeps a reconnect's
+/// replayed /current from re-raising, and so un-acknowledging, an alarm the operator acked.
+///
+///
+/// The one deliberate exception is a resync: an unchanged Unavailable 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.
+///
+///
+internal static class MTConnectConditionAlarmMapper
+{
+ /// OPC UA severity of an active Warning.
+ internal const int WarningSeverity = 500;
+
+ /// OPC UA severity of an active Fault.
+ internal const int FaultSeverity = 800;
+
+ /// Canonical Opc.Ua.StatusCodes.Good.
+ private const uint Good = 0x00000000u;
+
+ /// Canonical Opc.Ua.StatusCodes.BadNoCommunication — the Agent has no state for the item.
+ private const uint BadNoCommunication = 0x80310000u;
+
+ ///
+ /// The event moving the server from to , or
+ /// null when nothing needs to be said.
+ ///
+ /// What the server was last told for this condition; null when nothing yet.
+ /// The Agent's current statement.
+ /// The condition's ConditionId — the alarm-bearing raw tag's RawPath.
+ /// The Agent DataItem id (carried as ).
+ /// The alarm subscription the event belongs to.
+ /// True on a (re)subscribe: re-assert an unchanged Unavailable quality.
+ /// The event to raise, or null.
+ 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);
+ }
+
+ /// The Part 9 severity of an active level.
+ /// An active level.
+ internal static int SeverityOf(MTConnectConditionLevel level) =>
+ level == MTConnectConditionLevel.Fault ? FaultSeverity : WarningSeverity;
+
+ ///
+ /// The condition's message: its text content, else "{type} {level}"; followed by whichever of
+ /// nativeCode / nativeSeverity / qualifier 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.
+ ///
+ /// The state to describe.
+ 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(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);
+ }
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectConditionTracker.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectConditionTracker.cs
new file mode 100644
index 00000000..52bc2d04
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectConditionTracker.cs
@@ -0,0 +1,225 @@
+namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
+
+/// The four MTConnect CONDITION levels the alarm path distinguishes (#626).
+internal enum MTConnectConditionLevel
+{
+ /// <Normal/> — nothing is active.
+ Normal = 0,
+
+ /// <Warning/> — active, Part 9 severity 500.
+ Warning = 1,
+
+ /// <Fault/> — active, Part 9 severity 800.
+ Fault = 2,
+
+ /// <Unavailable/> — the Agent has no state for the item (inactive, Bad quality).
+ Unavailable = 3,
+}
+
+///
+/// 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.
+///
+/// The derived level.
+///
+/// The detail of the observation the level comes from — the winning active condition, or the latest
+/// Normal/Unavailable observation. Never null.
+///
+/// That observation's Agent timestamp.
+/// How many distinct conditions (by nativeCode) are active at once.
+internal sealed record MTConnectConditionState(
+ MTConnectConditionLevel Level,
+ MTConnectConditionDetail Detail,
+ DateTime TimestampUtc,
+ int ActiveCount)
+{
+ /// Whether the derived condition is active (Warning or Fault).
+ public bool IsActive => Level is MTConnectConditionLevel.Warning or MTConnectConditionLevel.Fault;
+
+ ///
+ /// Whether two states are the SAME alarm statement — the dedup key the alarm path diffs on. Two active
+ /// states match only when level, nativeCode, nativeSeverity, qualifier 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 /current after a reconnect carries the
+ /// same condition under a new sequence, and re-raising it would un-acknowledge an operator's ack.
+ ///
+ /// The state to compare against.
+ /// True when the alarm path must emit nothing to move from one to the other.
+ 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);
+ }
+}
+
+///
+/// Folds MTConnect CONDITION observations into per-DataItem alarm state with the protocol's own
+/// multi-condition semantics (#626) — the alarm path's analogue of .
+///
+///
+///
+/// Why the index is not enough. The index reconciles several states reported for one DataItem
+/// only within one document and otherwise treats each document as the new statement of the
+/// value. That is right for a /current snapshot but wrong for a /sample delta: an Agent
+/// streams each nativeCode's activation and clearing separately, so a chunk carrying
+/// <Normal nativeCode="A"/> clears code A only — code B, raised in an earlier chunk,
+/// is still active. The alarm path must not report "return to normal" there.
+///
+///
+/// Rules, per DataItem, applied in document order. Warning/Fault activates (or
+/// replaces) the entry keyed by its nativeCode (blank = one unnamed entry). Normal with a
+/// nativeCode clears that code; Normal without one clears every code. Unavailable
+/// clears every code and marks the item unavailable until any other level arrives. A baseline
+/// (/current, 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 Warning — surfacing an unknown
+/// condition beats hiding it.
+///
+///
+/// Thread safety. One internal lock; every member is atomic. The pump is the only writer.
+///
+///
+internal sealed class MTConnectConditionTracker
+{
+ /// The parser's normalised spelling of <Unavailable/>.
+ private const string UnavailableSentinel = "UNAVAILABLE";
+
+ private readonly Lock _lock = new();
+ private readonly Dictionary _items = new(StringComparer.Ordinal);
+ private long _order;
+
+ ///
+ /// Folds every CONDITION observation of into the tracked state.
+ ///
+ /// A /current snapshot or one /sample chunk.
+ ///
+ /// True for a /current answer — each DataItem it mentions is reset before its observations are
+ /// applied; false for a /sample delta.
+ ///
+ /// The DataItem ids whose condition state the document touched (empty when none).
+ public IReadOnlySet Apply(MTConnectStreamsResult document, bool baseline)
+ {
+ ArgumentNullException.ThrowIfNull(document);
+
+ var touched = new HashSet(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;
+ }
+
+ /// The derived state of one DataItem, or null when it has never been observed as a condition.
+ /// The DataItem id.
+ 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);
+ }
+ }
+
+ /// Drops every tracked state — the Agent-restart re-baseline, mirroring .
+ public void Clear()
+ {
+ lock (_lock) _items.Clear();
+ }
+
+ /// Maps a parsed condition state word onto a level (unknown vendor words ⇒ Warning).
+ /// The observation's state word.
+ 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
+ {
+ /// Active conditions keyed by nativeCode ("" for an unnamed one).
+ public Dictionary Active { get; } = new(StringComparer.Ordinal);
+
+ /// Whether the latest inactive statement was Unavailable.
+ public bool Unavailable { get; set; }
+
+ /// The latest Normal/Unavailable observation (its text and type feed the clear message).
+ public Entry? LastInactive { get; set; }
+ }
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.Alarms.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.Alarms.cs
new file mode 100644
index 00000000..c21970af
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.Alarms.cs
@@ -0,0 +1,230 @@
+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}";
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs
index a84f454a..e59aeb5e 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs
@@ -47,7 +47,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// , , ,
/// and — the last two
/// built on state this class already caches (the Agent instanceId and the probe
-/// model).
+/// model) — plus for CONDITION data items (#626, in
+/// MTConnectDriver.Alarms.cs).
///
///
/// is observation injection, not machine command, and it is
@@ -71,7 +72,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// (see ).
///
///
-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 /current snapshot or one /sample chunk into the shared index and
/// publishes every reference it touched to the handles that subscribe it.
///
- 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
///
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;
}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDtos.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDtos.cs
index 0158722b..14ba87ec 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDtos.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDtos.cs
@@ -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). null when absent.
///
+///
+/// The CONDITION-only detail (#626) — set exactly when the observation came from a
+/// <Condition> container, null for every Sample/Event. Its presence is what marks an
+/// observation as a condition for the alarm path; still carries the state word.
+///
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);
+
+///
+/// What an MTConnect CONDITION observation says beyond its state word (#626). Every field is the
+/// Agent's own text, trimmed, or null when absent or blank — none is interpreted at this layer.
+///
+/// The element's text content: the operator-facing condition message.
+/// The nativeCode attribute — the controller's own alarm number/code.
+/// The nativeSeverity attribute — the controller's own severity, verbatim.
+/// The qualifier attribute (e.g. HIGH, LOW).
+/// The element's type attribute (e.g. SYSTEM, TEMPERATURE).
+public sealed record MTConnectConditionDetail(
+ string? Text,
+ string? NativeCode,
+ string? NativeSeverity,
+ string? Qualifier,
+ string? ConditionType);
///
/// Recursive helpers over the device/component tree.
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectStreamsParser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectStreamsParser.cs
index 06a9a5cf..ba631790 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectStreamsParser.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectStreamsParser.cs
@@ -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);
}
///
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectAlarmSourceTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectAlarmSourceTests.cs
new file mode 100644
index 00000000..23dacc66
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectAlarmSourceTests.cs
@@ -0,0 +1,294 @@
+using System.Collections.Concurrent;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
+
+///
+/// Gitea #626 — 's half against
+/// : CONDITION transitions off the /sample stream become Part 9 alarm
+/// events keyed by the alarm tag's RawPath, an unchanged state is never re-raised on a re-subscribe or a
+/// re-baseline, and a transition nobody was listening for is delivered on the next subscribe.
+///
+///
+/// Every "nothing was raised" assertion is followed by a transition that MUST be raised, and asserts that
+/// one is the first event — an absence proven by order, never by a delay.
+///
+public sealed class MTConnectAlarmSourceTests
+{
+ private const string ConditionRawPath = "Plant/MT/dev1/SystemCond";
+ private const string ConditionValueRawPath = "Plant/MT/dev1/SystemCondText";
+ private const string ConditionId = "dev1_system_cond";
+ private const long FixtureInstanceId = 1655000000L;
+ private const long RestartedInstanceId = 1655999999L;
+
+ private static readonly DateTime ObservedAt = new(2026, 10, 8, 12, 30, 0, DateTimeKind.Utc);
+ private static readonly TimeSpan Watchdog = TimeSpan.FromSeconds(10);
+
+ private static CancellationToken Ct => TestContext.Current.CancellationToken;
+
+ private static MTConnectDriverOptions Opts() =>
+ new()
+ {
+ AgentUri = "http://fixture-agent:5000",
+ RequestTimeoutMs = 5000,
+ RawTags =
+ [
+ // The alarm-bearing raw tag and an ordinary String tag on the SAME DataItem: the alarm path
+ // must not disturb the existing value surface.
+ new RawTagEntry(ConditionRawPath, $$"""{"address":"{{ConditionId}}"}""", false, "dev1"),
+ new RawTagEntry(ConditionValueRawPath, $$"""{"address":"{{ConditionId}}"}""", false, "dev1"),
+ ],
+ };
+
+ private static async Task<(MTConnectDriver Driver, CannedAgentClient Agent, ConcurrentQueue Events)>
+ AlarmDriverAsync(CannedAgentClient? agent = null)
+ {
+ var client = agent ?? CannedAgentClient.FromFixtures();
+ var driver = new MTConnectDriver(Opts(), "mt1", _ => client);
+ await driver.InitializeAsync("{}", Ct);
+
+ var events = new ConcurrentQueue();
+ driver.OnAlarmEvent += (_, e) => events.Enqueue(e);
+
+ return (driver, client, events);
+ }
+
+ private static MTConnectStreamsResult Chunk(long first, long next, params MTConnectObservation[] observations) =>
+ new(FixtureInstanceId, next, first, observations);
+
+ private static MTConnectObservation Cond(string state, string? nativeCode = null, string? text = null) =>
+ new(ConditionId, state, ObservedAt,
+ Condition: new MTConnectConditionDetail(text, nativeCode, null, null, "SYSTEM"));
+
+ [Fact]
+ public async Task Normal_then_fault_then_normal_raises_then_clears_keyed_by_the_RawPath()
+ {
+ var (driver, agent, events) = await AlarmDriverAsync();
+
+ await driver.SubscribeAlarmsAsync([ConditionRawPath], Ct);
+ events.ShouldBeEmpty("the primed /current says Normal, and a first-seen Normal has nothing to say");
+
+ await agent.PumpAsync(Chunk(108, 109, Cond("Fault", "E17", "Spindle overtemperature"))).WaitAsync(Watchdog, Ct);
+ await agent.PumpAsync(Chunk(109, 110, Cond("Normal"))).WaitAsync(Watchdog, Ct);
+
+ var seen = events.ToArray();
+ seen.Length.ShouldBe(2);
+ seen[0].Kind.ShouldBe(AlarmTransitionKind.Raise);
+ seen[0].ConditionId.ShouldBe(ConditionRawPath);
+ seen[0].OpcUaSeverity.ShouldBe(800);
+ seen[0].Message.ShouldBe("Spindle overtemperature [nativeCode=E17]");
+ seen[1].Kind.ShouldBe(AlarmTransitionKind.Clear);
+ seen[1].ConditionId.ShouldBe(ConditionRawPath);
+
+ await driver.ShutdownAsync(Ct);
+ }
+
+ [Fact]
+ public async Task Several_native_codes_show_the_most_severe_and_clear_only_when_the_last_goes()
+ {
+ var (driver, agent, events) = await AlarmDriverAsync();
+ await driver.SubscribeAlarmsAsync([ConditionRawPath], Ct);
+
+ await agent.PumpAsync(Chunk(108, 109, Cond("Warning", "W1", "coolant low"))).WaitAsync(Watchdog, Ct);
+ await agent.PumpAsync(Chunk(109, 110, Cond("Fault", "F1", "spindle"))).WaitAsync(Watchdog, Ct);
+ await agent.PumpAsync(Chunk(110, 111, Cond("Normal", "F1"))).WaitAsync(Watchdog, Ct);
+ await agent.PumpAsync(Chunk(111, 112, Cond("Normal", "W1"))).WaitAsync(Watchdog, Ct);
+
+ var seen = events.ToArray();
+ seen.Select(e => e.Kind).ShouldBe(
+ [
+ AlarmTransitionKind.Raise, // Warning W1
+ AlarmTransitionKind.Retrigger, // escalation to Fault F1
+ AlarmTransitionKind.Retrigger, // F1 cleared — W1 is still active, NOT a return to normal
+ AlarmTransitionKind.Clear, // the last code cleared
+ ]);
+ seen.Select(e => e.OpcUaSeverity).ShouldBe([500, 800, 500, 500]);
+ seen[2].Message.ShouldStartWith("coolant low");
+
+ await driver.ShutdownAsync(Ct);
+ }
+
+ [Fact]
+ public async Task An_unavailable_condition_reaches_the_server_as_a_bad_quality_annotation()
+ {
+ var (driver, agent, events) = await AlarmDriverAsync();
+ await driver.SubscribeAlarmsAsync([ConditionRawPath], Ct);
+
+ await agent.PumpAsync(Chunk(108, 109, Cond("UNAVAILABLE"))).WaitAsync(Watchdog, Ct);
+
+ var evt = events.ShouldHaveSingleItem();
+ evt.IsQualityOnly.ShouldBeTrue();
+ evt.QualityStatusCode.ShouldBe(0x80310000u);
+
+ await driver.ShutdownAsync(Ct);
+ }
+
+ [Fact]
+ public async Task A_resubscribe_with_an_unchanged_active_state_raises_nothing()
+ {
+ // The reconnect path: DriverInstanceActor re-subscribes on every Connected entry. Re-raising the
+ // still-active Fault would un-acknowledge an operator's ack.
+ var (driver, agent, events) = await AlarmDriverAsync();
+ await driver.SubscribeAlarmsAsync([ConditionRawPath], Ct);
+ await agent.PumpAsync(Chunk(108, 109, Cond("Fault", "E17"))).WaitAsync(Watchdog, Ct);
+ events.Count.ShouldBe(1);
+
+ await driver.SubscribeAlarmsAsync([ConditionRawPath], Ct);
+
+ // Positive control: the next real transition is the very next event.
+ await agent.PumpAsync(Chunk(109, 110, Cond("Normal"))).WaitAsync(Watchdog, Ct);
+ events.Select(e => e.Kind).ShouldBe([AlarmTransitionKind.Raise, AlarmTransitionKind.Clear]);
+
+ await driver.ShutdownAsync(Ct);
+ }
+
+ [Fact]
+ public async Task A_reinitialize_replaying_the_same_active_state_raises_nothing()
+ {
+ var agent = CannedAgentClient.FromFixtures();
+ agent.Current = Chunk(1, 108, Cond("Fault", "E17", "overtemp"));
+ var (driver, _, events) = await AlarmDriverAsync(agent);
+ await driver.SubscribeAlarmsAsync([ConditionRawPath], Ct);
+ events.ShouldHaveSingleItem().Kind.ShouldBe(AlarmTransitionKind.Raise, "the subscribe re-establishes an active condition");
+
+ // A comms-loss recovery re-initializes against the same Agent, which replays the same Fault.
+ await driver.ReinitializeAsync("{}", Ct);
+
+ // The restarted pump republishes (values AND conditions) before it opens /sample, so its second
+ // /sample call is the barrier that the replay has been diffed.
+ await agent.WaitForSampleCallsAsync(2).WaitAsync(Watchdog, Ct);
+
+ await agent.PumpAsync(Chunk(108, 109, Cond("Normal"))).WaitAsync(Watchdog, Ct);
+ events.Select(e => e.Kind).ShouldBe([AlarmTransitionKind.Raise, AlarmTransitionKind.Clear]);
+
+ await driver.ShutdownAsync(Ct);
+ }
+
+ [Fact]
+ public async Task A_transition_nobody_was_listening_for_is_delivered_on_the_next_subscribe()
+ {
+ // While the host reconnects it detaches its handler; the fault that clears in that window must not be
+ // recorded as delivered, or the condition stays active on the server forever.
+ var agent = CannedAgentClient.FromFixtures();
+ var driver = new MTConnectDriver(Opts(), "mt1", _ => agent);
+ await driver.InitializeAsync("{}", Ct);
+ var events = new ConcurrentQueue();
+ EventHandler handler = (_, e) => events.Enqueue(e);
+ driver.OnAlarmEvent += handler;
+
+ await driver.SubscribeAlarmsAsync([ConditionRawPath], Ct);
+ await agent.PumpAsync(Chunk(108, 109, Cond("Fault", "E17"))).WaitAsync(Watchdog, Ct);
+
+ driver.OnAlarmEvent -= handler;
+ await agent.PumpAsync(Chunk(109, 110, Cond("Normal"))).WaitAsync(Watchdog, Ct);
+ events.Count.ShouldBe(1);
+
+ driver.OnAlarmEvent += handler;
+ await driver.SubscribeAlarmsAsync([ConditionRawPath], Ct);
+
+ events.Select(e => e.Kind).ShouldBe([AlarmTransitionKind.Raise, AlarmTransitionKind.Clear]);
+
+ await driver.ShutdownAsync(Ct);
+ }
+
+ [Fact]
+ public async Task An_agent_restart_whose_baseline_no_longer_lists_the_fault_clears_it()
+ {
+ var agent = CannedAgentClient.FromFixtures();
+ var (driver, _, events) = await AlarmDriverAsync(agent);
+ await driver.SubscribeAlarmsAsync([ConditionRawPath], Ct);
+ await agent.PumpAsync(Chunk(108, 109, Cond("Fault", "E17"))).WaitAsync(Watchdog, Ct);
+
+ // The restarted Agent's /current reports the condition Normal.
+ agent.CurrentAnswers.Enqueue(new MTConnectStreamsResult(RestartedInstanceId, 5, 1, [Cond("Normal")]));
+ await agent.PumpAsync(new MTConnectStreamsResult(RestartedInstanceId, 4, 1, [])).WaitAsync(Watchdog, Ct);
+ await agent.WaitForSampleCallsAsync(2).WaitAsync(Watchdog, Ct);
+
+ events.Select(e => e.Kind).ShouldBe([AlarmTransitionKind.Raise, AlarmTransitionKind.Clear]);
+
+ await driver.ShutdownAsync(Ct);
+ }
+
+ [Fact]
+ public async Task A_dropped_stream_raises_no_alarm_event()
+ {
+ // Comms loss is the host's to annotate (#477) — the driver says nothing on its own.
+ var (driver, agent, events) = await AlarmDriverAsync();
+ await driver.SubscribeAlarmsAsync([ConditionRawPath], Ct);
+ await agent.PumpAsync(Chunk(108, 109, Cond("Fault", "E17"))).WaitAsync(Watchdog, Ct);
+
+ agent.EndStream();
+ await agent.WaitForSampleCallsAsync(2).WaitAsync(Watchdog, Ct);
+
+ // Positive control: after reconnecting, the next transition is the next event.
+ await agent.PumpAsync(Chunk(109, 110, Cond("Normal"))).WaitAsync(Watchdog, Ct);
+ events.Select(e => e.Kind).ShouldBe([AlarmTransitionKind.Raise, AlarmTransitionKind.Clear]);
+
+ await driver.ShutdownAsync(Ct);
+ }
+
+ [Fact]
+ public async Task An_alarm_only_subscription_starts_the_sample_stream()
+ {
+ var (driver, agent, events) = await AlarmDriverAsync();
+ driver.SampleStreamTask.ShouldBeNull("no value subscription exists");
+
+ await driver.SubscribeAlarmsAsync([ConditionRawPath], Ct);
+ await agent.WaitForSampleCallsAsync(1).WaitAsync(Watchdog, Ct);
+
+ await agent.PumpAsync(Chunk(108, 109, Cond("Warning", "W1"))).WaitAsync(Watchdog, Ct);
+ events.ShouldHaveSingleItem().Kind.ShouldBe(AlarmTransitionKind.Raise);
+
+ await driver.ShutdownAsync(Ct);
+ }
+
+ [Fact]
+ public async Task The_condition_string_value_surface_is_unchanged_beside_the_alarm()
+ {
+ var (driver, agent, events) = await AlarmDriverAsync();
+ var values = new ConcurrentQueue();
+ driver.OnDataChange += (_, e) => values.Enqueue(e);
+
+ await driver.SubscribeAsync([ConditionValueRawPath], TimeSpan.FromMilliseconds(50), Ct);
+ await driver.SubscribeAlarmsAsync([ConditionRawPath], Ct);
+ await agent.PumpAsync(Chunk(108, 109, Cond("Fault", "E17"))).WaitAsync(Watchdog, Ct);
+
+ values.Where(v => v.FullReference == ConditionValueRawPath).Select(v => v.Snapshot.Value)
+ .ShouldBe(["Normal", "Fault"]);
+ values.ShouldNotContain(v => v.FullReference == ConditionRawPath,
+ "the alarm RawPath is not a value subscription and gets no data-change");
+ events.ShouldHaveSingleItem().ConditionId.ShouldBe(ConditionRawPath);
+
+ await driver.ShutdownAsync(Ct);
+ }
+
+ [Fact]
+ public async Task Acknowledge_is_a_no_op_success_that_sends_nothing_to_the_agent()
+ {
+ var (driver, agent, _) = await AlarmDriverAsync();
+
+ await driver.AcknowledgeAsync([new AlarmAcknowledgeRequest(ConditionRawPath, ConditionRawPath, "seen", "op")], Ct);
+
+ agent.Writes.ShouldBeEmpty();
+
+ await driver.ShutdownAsync(Ct);
+ }
+
+ [Fact]
+ public async Task Unsubscribing_the_last_alarm_subscription_stops_an_alarm_only_stream()
+ {
+ var (driver, agent, _) = await AlarmDriverAsync();
+ var handle = await driver.SubscribeAlarmsAsync([ConditionRawPath], Ct);
+ await agent.WaitForSampleCallsAsync(1).WaitAsync(Watchdog, Ct);
+ var pump = driver.SampleStreamTask.ShouldNotBeNull();
+
+ await driver.UnsubscribeAlarmsAsync(handle, Ct);
+
+ await pump.WaitAsync(Watchdog, Ct);
+ driver.SampleStreamTask.ShouldBeNull();
+
+ await driver.ShutdownAsync(Ct);
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectConditionAlarmMappingTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectConditionAlarmMappingTests.cs
new file mode 100644
index 00000000..68f698ba
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectConditionAlarmMappingTests.cs
@@ -0,0 +1,279 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
+
+///
+/// Gitea #626 — the pure halves of the CONDITION → Part 9 path:
+/// (MTConnect's multi-condition semantics) and (state mapping and
+/// transition sequencing). The driver wiring is covered by MTConnectAlarmSourceTests.
+///
+public sealed class MTConnectConditionAlarmMappingTests
+{
+ private const string Id = "dev1_system_cond";
+ private const string RawPath = "Plant/MT/dev1/SystemCond";
+ private const uint Good = 0x00000000u;
+ private const uint BadNoCommunication = 0x80310000u;
+
+ private static readonly DateTime T0 = new(2026, 10, 8, 12, 0, 0, DateTimeKind.Utc);
+
+ private static readonly IAlarmSubscriptionHandle Handle = new MTConnectAlarmSubscriptionHandle(1);
+
+ // ---- tracker ----
+
+ [Fact]
+ public void A_never_observed_item_has_no_state()
+ {
+ new MTConnectConditionTracker().Get(Id).ShouldBeNull();
+ }
+
+ [Fact]
+ public void Samples_and_events_never_reach_the_tracker()
+ {
+ var tracker = new MTConnectConditionTracker();
+
+ var touched = tracker.Apply(Doc(new MTConnectObservation(Id, "Fault", T0)), baseline: false);
+
+ touched.ShouldBeEmpty("an observation without Condition detail did not come from a ");
+ tracker.Get(Id).ShouldBeNull();
+ }
+
+ [Fact]
+ public void The_most_severe_of_several_simultaneous_conditions_wins()
+ {
+ var tracker = new MTConnectConditionTracker();
+
+ tracker.Apply(Doc(Cond("Warning", "W1", at: 0), Cond("Fault", "F1", at: 1), Cond("Warning", "W2", at: 2)), baseline: false);
+
+ var state = tracker.Get(Id).ShouldNotBeNull();
+ state.Level.ShouldBe(MTConnectConditionLevel.Fault);
+ state.Detail.NativeCode.ShouldBe("F1");
+ state.ActiveCount.ShouldBe(3);
+ }
+
+ [Fact]
+ public void A_normal_for_one_native_code_in_a_later_sample_clears_only_that_code()
+ {
+ // The index's per-document reconcile would read this chunk as "Normal"; the alarm path must not.
+ var tracker = new MTConnectConditionTracker();
+ tracker.Apply(Doc(Cond("Fault", "A", at: 0)), baseline: false);
+ tracker.Apply(Doc(Cond("Warning", "B", at: 1)), baseline: false);
+
+ tracker.Apply(Doc(Cond("Normal", "A", at: 2)), baseline: false);
+
+ var state = tracker.Get(Id).ShouldNotBeNull();
+ state.Level.ShouldBe(MTConnectConditionLevel.Warning);
+ state.Detail.NativeCode.ShouldBe("B");
+ }
+
+ [Fact]
+ public void A_normal_without_a_native_code_clears_every_code()
+ {
+ var tracker = new MTConnectConditionTracker();
+ tracker.Apply(Doc(Cond("Fault", "A", at: 0), Cond("Warning", "B", at: 1)), baseline: false);
+
+ tracker.Apply(Doc(Cond("Normal", null, at: 2)), baseline: false);
+
+ tracker.Get(Id).ShouldNotBeNull().Level.ShouldBe(MTConnectConditionLevel.Normal);
+ }
+
+ [Fact]
+ public void Unavailable_clears_every_code_until_any_other_level_arrives()
+ {
+ var tracker = new MTConnectConditionTracker();
+ tracker.Apply(Doc(Cond("Fault", "A", at: 0)), baseline: false);
+
+ tracker.Apply(Doc(Cond("UNAVAILABLE", null, at: 1)), baseline: false);
+ tracker.Get(Id).ShouldNotBeNull().Level.ShouldBe(MTConnectConditionLevel.Unavailable);
+
+ tracker.Apply(Doc(Cond("Normal", null, at: 2)), baseline: false);
+ tracker.Get(Id).ShouldNotBeNull().Level.ShouldBe(MTConnectConditionLevel.Normal);
+ }
+
+ [Fact]
+ public void A_baseline_drops_a_code_the_current_document_no_longer_lists()
+ {
+ // A code cleared while the driver was not listening must not survive a /current re-baseline.
+ var tracker = new MTConnectConditionTracker();
+ tracker.Apply(Doc(Cond("Fault", "A", at: 0), Cond("Warning", "B", at: 1)), baseline: false);
+
+ tracker.Apply(Doc(Cond("Warning", "B", at: 2)), baseline: true);
+
+ var state = tracker.Get(Id).ShouldNotBeNull();
+ state.Level.ShouldBe(MTConnectConditionLevel.Warning);
+ state.ActiveCount.ShouldBe(1);
+ }
+
+ [Fact]
+ public void An_unrecognised_vendor_state_is_surfaced_as_a_warning()
+ {
+ MTConnectConditionTracker.LevelOf("Alarm").ShouldBe(MTConnectConditionLevel.Warning);
+ MTConnectConditionTracker.LevelOf("UNAVAILABLE").ShouldBe(MTConnectConditionLevel.Unavailable);
+ MTConnectConditionTracker.LevelOf("fault").ShouldBe(MTConnectConditionLevel.Fault);
+ }
+
+ // ---- mapper: state mapping ----
+
+ [Fact]
+ public void Fault_raises_active_at_severity_800_with_the_text_as_message()
+ {
+ var evt = Map(null, State(MTConnectConditionLevel.Fault, "Spindle overtemperature", "E17", "2", "HIGH"))
+ .ShouldNotBeNull();
+
+ evt.Kind.ShouldBe(AlarmTransitionKind.Raise);
+ evt.ConditionId.ShouldBe(RawPath, "the host routes on ConditionId == RawPath");
+ evt.SourceNodeId.ShouldBe(Id);
+ evt.OpcUaSeverity.ShouldBe(800);
+ evt.Severity.ShouldBe(AlarmSeverity.High);
+ evt.QualityStatusCode.ShouldBe(Good);
+ evt.Message.ShouldBe("Spindle overtemperature [nativeCode=E17, nativeSeverity=2, qualifier=HIGH]");
+ evt.AlarmType.ShouldBe("SYSTEM");
+ evt.SourceTimestampUtc.ShouldBe(T0);
+ }
+
+ [Fact]
+ public void Warning_raises_active_at_severity_500()
+ {
+ var evt = Map(null, State(MTConnectConditionLevel.Warning)).ShouldNotBeNull();
+
+ evt.Kind.ShouldBe(AlarmTransitionKind.Raise);
+ evt.OpcUaSeverity.ShouldBe(500);
+ evt.Severity.ShouldBe(AlarmSeverity.Medium);
+ }
+
+ [Fact]
+ public void A_condition_without_text_falls_back_to_its_type_level_and_native_code()
+ {
+ MTConnectConditionAlarmMapper.MessageOf(State(MTConnectConditionLevel.Fault, text: null, nativeCode: "1001"))
+ .ShouldBe("SYSTEM Fault [nativeCode=1001]");
+ }
+
+ // ---- mapper: transition sequencing ----
+
+ [Fact]
+ public void Normal_then_fault_then_normal_is_raise_then_clear()
+ {
+ var normal = State(MTConnectConditionLevel.Normal);
+ var fault = State(MTConnectConditionLevel.Fault, "overtemp", "E17");
+
+ Map(null, normal).ShouldBeNull("a first-seen Normal has nothing to say — the node starts inactive");
+
+ var raise = Map(normal, fault).ShouldNotBeNull();
+ raise.Kind.ShouldBe(AlarmTransitionKind.Raise);
+
+ var clear = Map(fault, normal).ShouldNotBeNull();
+ clear.Kind.ShouldBe(AlarmTransitionKind.Clear);
+ clear.QualityStatusCode.ShouldBe(Good);
+ clear.OpcUaSeverity.ShouldBe(800, "a clear keeps the severity of the alarm it clears");
+ }
+
+ [Fact]
+ public void Warning_escalating_to_fault_is_a_retrigger_at_the_new_severity()
+ {
+ var warning = State(MTConnectConditionLevel.Warning, "coolant low", "W1");
+ var fault = State(MTConnectConditionLevel.Fault, "coolant empty", "F1");
+
+ var evt = Map(warning, fault).ShouldNotBeNull();
+
+ evt.Kind.ShouldBe(AlarmTransitionKind.Retrigger);
+ evt.OpcUaSeverity.ShouldBe(800);
+ evt.Message.ShouldStartWith("coolant empty");
+ }
+
+ [Fact]
+ public void A_different_active_native_code_at_the_same_level_is_a_retrigger()
+ {
+ var evt = Map(State(MTConnectConditionLevel.Fault, "a", "A"), State(MTConnectConditionLevel.Fault, "b", "B"))
+ .ShouldNotBeNull();
+
+ evt.Kind.ShouldBe(AlarmTransitionKind.Retrigger);
+ }
+
+ [Fact]
+ public void An_unchanged_active_statement_emits_nothing_even_with_a_new_timestamp_or_count()
+ {
+ // The reconnect replay: /current re-reports the same Fault under a new sequence. Re-raising it would
+ // un-acknowledge an operator's ack.
+ var before = State(MTConnectConditionLevel.Fault, "overtemp", "E17");
+ var replayed = before with { TimestampUtc = T0.AddMinutes(5), ActiveCount = 2 };
+
+ Map(before, replayed).ShouldBeNull();
+ Map(before, replayed, resync: true).ShouldBeNull();
+ }
+
+ [Fact]
+ public void A_repeated_normal_with_different_text_does_not_re_clear()
+ {
+ Map(State(MTConnectConditionLevel.Normal, "ok"), State(MTConnectConditionLevel.Normal, "still ok")).ShouldBeNull();
+ }
+
+ [Fact]
+ public void An_active_condition_going_unavailable_clears_with_bad_quality()
+ {
+ var evt = Map(State(MTConnectConditionLevel.Fault, "x", "E1"), State(MTConnectConditionLevel.Unavailable))
+ .ShouldNotBeNull();
+
+ evt.Kind.ShouldBe(AlarmTransitionKind.Clear);
+ evt.QualityStatusCode.ShouldBe(BadNoCommunication);
+ evt.Message.ShouldBe("SYSTEM UNAVAILABLE");
+ }
+
+ [Fact]
+ public void An_inactive_condition_going_unavailable_and_back_is_quality_only()
+ {
+ var normal = State(MTConnectConditionLevel.Normal);
+ var unavailable = State(MTConnectConditionLevel.Unavailable);
+
+ var lost = Map(normal, unavailable).ShouldNotBeNull();
+ lost.IsQualityOnly.ShouldBeTrue();
+ lost.QualityStatusCode.ShouldBe(BadNoCommunication);
+
+ var back = Map(unavailable, normal).ShouldNotBeNull();
+ back.IsQualityOnly.ShouldBeTrue();
+ back.QualityStatusCode.ShouldBe(Good);
+
+ Map(null, unavailable).ShouldNotBeNull().IsQualityOnly.ShouldBeTrue("first-seen Unavailable must reach the node");
+ }
+
+ [Fact]
+ public void An_unchanged_unavailable_is_reasserted_only_on_a_resync()
+ {
+ // The host re-annotates every condition Good on reconnect (#477); only the resync puts the Bad back.
+ var unavailable = State(MTConnectConditionLevel.Unavailable);
+
+ Map(unavailable, unavailable).ShouldBeNull();
+ Map(unavailable, unavailable, resync: true).ShouldNotBeNull().IsQualityOnly.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void Unavailable_recovering_straight_to_fault_raises_with_good_quality()
+ {
+ var evt = Map(State(MTConnectConditionLevel.Unavailable), State(MTConnectConditionLevel.Fault, "x", "E1"))
+ .ShouldNotBeNull();
+
+ evt.Kind.ShouldBe(AlarmTransitionKind.Raise);
+ evt.QualityStatusCode.ShouldBe(Good);
+ }
+
+ // ---- helpers ----
+
+ private static AlarmEventArgs? Map(MTConnectConditionState? delivered, MTConnectConditionState current, bool resync = false) =>
+ MTConnectConditionAlarmMapper.Transition(delivered, current, RawPath, Id, Handle, resync);
+
+ private static MTConnectConditionState State(
+ MTConnectConditionLevel level,
+ string? text = null,
+ string? nativeCode = null,
+ string? nativeSeverity = null,
+ string? qualifier = null) =>
+ new(level, new MTConnectConditionDetail(text, nativeCode, nativeSeverity, qualifier, "SYSTEM"), T0,
+ level is MTConnectConditionLevel.Warning or MTConnectConditionLevel.Fault ? 1 : 0);
+
+ private static MTConnectObservation Cond(string state, string? nativeCode, int at) =>
+ new(Id, state, T0.AddSeconds(at),
+ Condition: new MTConnectConditionDetail($"{state} {nativeCode}", nativeCode, null, null, "SYSTEM"));
+
+ private static MTConnectStreamsResult Doc(params MTConnectObservation[] observations) =>
+ new(1, 2, 1, observations);
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectStreamsParseTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectStreamsParseTests.cs
index 6c039508..b13e3dee 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectStreamsParseTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectStreamsParseTests.cs
@@ -131,6 +131,36 @@ public sealed class MTConnectStreamsParseTests
result.Observations.ShouldHaveSingleItem().Value.ShouldBe("Fault");
}
+ [Fact]
+ public void A_condition_carries_its_text_and_native_attributes_as_condition_detail()
+ {
+ // #626: the alarm path needs the message (the text) and the controller's own identity for it.
+ var result = MTConnectStreamsParser.Parse(StreamsDocument(
+ """ Spindle overtemperature """));
+
+ var detail = result.Observations.ShouldHaveSingleItem().Condition.ShouldNotBeNull();
+ detail.Text.ShouldBe("Spindle overtemperature");
+ detail.NativeCode.ShouldBe("E17");
+ detail.NativeSeverity.ShouldBe("2");
+ detail.Qualifier.ShouldBe("HIGH");
+ detail.ConditionType.ShouldBe("TEMPERATURE");
+ }
+
+ [Fact]
+ public void An_empty_condition_has_detail_with_null_text_and_samples_have_none()
+ {
+ var current = ParseCurrent();
+
+ var condition = current["dev1_system_cond"].Condition.ShouldNotBeNull();
+ condition.Text.ShouldBeNull();
+ condition.NativeCode.ShouldBeNull();
+ condition.ConditionType.ShouldBe("SYSTEM");
+
+ // The detail is the alarm path's "this came from a " marker — never on a Sample/Event.
+ current["dev1_pos"].Condition.ShouldBeNull();
+ current["dev1_execution"].Condition.ShouldBeNull();
+ }
+
[Fact]
public void An_unavailable_condition_element_is_the_unavailable_sentinel()
{