diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs
index 1d763c7c..51671cde 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs
@@ -3,7 +3,8 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
///
/// Driver capability for alarm events. Optional — only drivers whose backends expose
/// alarm conditions implement this. Currently: Galaxy (MxAccess alarms), FOCAS
-/// (CNC alarms), OPC UA Client (A&C events from upstream server).
+/// (CNC alarms), OPC UA Client (A&C events from upstream server), MTConnect
+/// (CONDITION data items, Gitea #626).
///
public interface IAlarmSource
{
@@ -85,6 +86,22 @@ public sealed record AlarmAcknowledgeRequest(
/// other fields. when the upstream
/// path doesn't surface a distinct kind.
///
+///
+/// Optional exact OPC UA severity (1..1000) for a source whose own scale is finer than the four
+/// buckets (Gitea #626: MTConnect Warning = 500, Fault = 800). When set it
+/// supersedes the bucket on the projected Part 9 condition (clamped to 1..1000); null — every
+/// pre-#626 source — keeps the bucket mapping. must still carry the nearest
+/// bucket for consumers that only read the bucket.
+///
+///
+/// Optional status code of the condition's SOURCE data (Part 9 ConditionType.Quality), for a
+/// source that can report "no value for this condition" per alarm rather than per connection (Gitea
+/// #626: MTConnect Unavailable). null keeps the pre-#626 behaviour (projected Good;
+/// driver-wide comms loss is annotated separately by the host). An event whose is
+/// and which carries a quality is a
+/// quality-only annotation (): the host updates the condition's
+/// Quality without touching its Active/Acked state and publishes no alerts row for it.
+///
public sealed record AlarmEventArgs(
IAlarmSubscriptionHandle SubscriptionHandle,
string SourceNodeId,
@@ -96,7 +113,17 @@ public sealed record AlarmEventArgs(
string? OperatorComment = null,
DateTime? OriginalRaiseTimestampUtc = null,
string? AlarmCategory = null,
- AlarmTransitionKind Kind = AlarmTransitionKind.Unspecified);
+ AlarmTransitionKind Kind = AlarmTransitionKind.Unspecified,
+ int? OpcUaSeverity = null,
+ uint? QualityStatusCode = null)
+{
+ ///
+ /// True when this event only re-annotates the condition's source-data quality — a
+ /// with an kind. No
+ /// alarm-state change happened, so the host must not publish it as an alarm transition.
+ ///
+ public bool IsQualityOnly => Kind == AlarmTransitionKind.Unspecified && QualityStatusCode is not null;
+}
/// Mirrors the NodePermissions alarm-severity enum in docs/v2/acl-design.md.
public enum AlarmSeverity { Low, Medium, High, Critical }
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs
index d3e974e0..ac0cfbdd 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs
@@ -1811,8 +1811,11 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
_opcUaPublishActor.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.AlarmStateUpdate(
nodeId, snapshot, msg.Args.SourceTimestampUtc, n.Realm));
- // Only the Primary publishes the cluster-wide alerts transition (see the gate above).
- if (!serviceAlertsAsPrimary) continue;
+ // Only the Primary publishes the cluster-wide alerts transition (see the gate above). A quality-only
+ // annotation (#626: e.g. an MTConnect condition gone Unavailable while already Normal) is not an
+ // alarm transition at all — the condition write above carries it; an alerts row would read as
+ // "Activated" for something that never activated.
+ if (!serviceAlertsAsPrimary || msg.Args.IsQualityOnly) continue;
var meta = _alarmMetaByNodeId.TryGetValue(nodeId, out var m)
? m : (EquipmentId: nodeId, Name: nodeId, AlarmType: "AlarmCondition", HistorizeToAveva: (bool?)null,
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/NativeAlarmProjector.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/NativeAlarmProjector.cs
index a7e2b75e..4e52cf90 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/NativeAlarmProjector.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/NativeAlarmProjector.cs
@@ -21,7 +21,8 @@ public sealed class NativeAlarmProjector
public AlarmConditionSnapshot Project(string nodeId, AlarmEventArgs e)
{
var prev = _prior.TryGetValue(nodeId, out var p) ? p : (Active: false, Acked: true);
- var sev = MapSeverity(e.Severity);
+ // #626: a source with a finer scale than the four buckets supplies the exact Part 9 severity.
+ var sev = e.OpcUaSeverity is { } exact ? (ushort)Math.Clamp(exact, 1, 1000) : MapSeverity(e.Severity);
var (active, acked) = e.Kind switch
{
AlarmTransitionKind.Raise or AlarmTransitionKind.Retrigger => (true, false),
@@ -32,7 +33,11 @@ public sealed class NativeAlarmProjector
_prior[nodeId] = (active, acked);
return new AlarmConditionSnapshot(
Active: active, Acknowledged: acked, Confirmed: true, Enabled: true,
- Shelving: AlarmShelvingKind.Unshelved, Severity: sev, Message: e.Message);
+ Shelving: AlarmShelvingKind.Unshelved, Severity: sev, Message: e.Message,
+ // #626: a per-condition source quality (MTConnect Unavailable) when the driver reports one. Absent
+ // (every pre-#626 source) ⇒ Good, as before — driver-wide comms loss stays the host's
+ // out-of-band WriteAlarmQuality annotation (#477), not something the projector infers.
+ StatusCode: e.QualityStatusCode ?? OpcUaStatus.Good);
}
/// Clears all tracked per-node state (call on address-space rebuild).
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmTests.cs
index e14b57aa..0a99914d 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmTests.cs
@@ -271,6 +271,62 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
evt.User.ShouldBe("device");
}
+ /// Gitea #626 — a QUALITY-ONLY native event (an kind
+ /// carrying , e.g. an MTConnect condition gone Unavailable while
+ /// already Normal) re-annotates the condition's Quality through the ordinary state update, and publishes NO
+ /// alerts row — it is not an alarm transition. The positive control is the Raise that follows: it
+ /// MUST publish, and it must be the FIRST alerts message, so the absence is proven rather than timed.
+ [Fact]
+ public void Quality_only_native_event_updates_the_condition_but_publishes_no_alerts_row()
+ {
+ var db = NewInMemoryDbFactory();
+ var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi");
+
+ var alerts = CreateTestProbe();
+ SubscribeToAlerts(alerts);
+
+ var (actor, publish) = SpawnHostAndApply(db, deploymentId);
+
+ actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
+ new StubAlarmHandle(),
+ SourceNodeId: "Temp",
+ ConditionId: AlarmRawPath,
+ AlarmType: "SYSTEM",
+ Message: "SYSTEM UNAVAILABLE",
+ Severity: AlarmSeverity.Low,
+ SourceTimestampUtc: Ts,
+ Kind: AlarmTransitionKind.Unspecified,
+ QualityStatusCode: OpcUaStatus.BadNoCommunication)));
+
+ var annotated = publish.ExpectMsg(TimeSpan.FromSeconds(5));
+ annotated.AlarmNodeId.ShouldBe(AlarmRawPath);
+ annotated.State.Active.ShouldBeFalse();
+ annotated.State.StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication);
+
+ // Positive control: a real transition after it DOES publish, and is the first thing on the topic.
+ actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
+ new StubAlarmHandle(),
+ SourceNodeId: "Temp",
+ ConditionId: AlarmRawPath,
+ AlarmType: "SYSTEM",
+ Message: "spindle fault",
+ Severity: AlarmSeverity.High,
+ SourceTimestampUtc: Ts,
+ Kind: AlarmTransitionKind.Raise,
+ OpcUaSeverity: 800,
+ QualityStatusCode: OpcUaStatus.Good)));
+
+ var raised = publish.ExpectMsg(TimeSpan.FromSeconds(5));
+ raised.State.Active.ShouldBeTrue();
+ raised.State.Severity.ShouldBe((ushort)800);
+ raised.State.StatusCode.ShouldBe(OpcUaStatus.Good);
+
+ var evt = alerts.ExpectMsg(TimeSpan.FromSeconds(5));
+ evt.Message.ShouldBe("spindle fault", "the quality-only annotation must not have published an alerts row");
+ evt.TransitionKind.ShouldBe("Activated");
+ evt.Severity.ShouldBe(800);
+ }
+
/// #477 Layer 2 — a driver annotates the
/// Quality of the driver's native conditions via : comms
/// lost → Bad, restored → Good, at the raw condition NodeId + realm. No alarm transition is involved — this
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/NativeAlarmProjectorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/NativeAlarmProjectorTests.cs
index b7df4648..eaee1d77 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/NativeAlarmProjectorTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/NativeAlarmProjectorTests.cs
@@ -166,6 +166,49 @@ public class NativeAlarmProjectorTests
snap.StatusCode.ShouldBe(OpcUaStatus.Good);
}
+ // Gitea #626 — a source with a finer severity scale than the four buckets (MTConnect Fault = 800) sets
+ // OpcUaSeverity, which supersedes the bucket on the projected condition. Clamped to the Part 9 1..1000.
+ [Theory]
+ [InlineData(800, 800)]
+ [InlineData(500, 500)]
+ [InlineData(0, 1)]
+ [InlineData(5000, 1000)]
+ public void Explicit_opcua_severity_supersedes_the_bucket(int requested, int projected)
+ {
+ var sut = new NativeAlarmProjector();
+
+ var snap = sut.Project("n1", Evt(AlarmTransitionKind.Raise, AlarmSeverity.High) with { OpcUaSeverity = requested });
+
+ snap.Severity.ShouldBe((ushort)projected);
+ }
+
+ // Gitea #626 — a per-condition source quality (MTConnect Unavailable) rides the snapshot's StatusCode, the
+ // same Quality child #477's connectivity annotation writes.
+ [Fact]
+ public void Supplied_quality_is_projected_onto_the_snapshot()
+ {
+ var sut = new NativeAlarmProjector();
+
+ var snap = sut.Project("n1", Evt(AlarmTransitionKind.Clear) with { QualityStatusCode = OpcUaStatus.BadNoCommunication });
+
+ snap.StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication);
+ }
+
+ // Gitea #626 — a quality-only annotation (Unspecified kind + quality) must not move Active/Acked: an
+ // active, unacknowledged condition stays exactly that while only its Quality changes.
+ [Fact]
+ public void Quality_only_annotation_keeps_the_prior_alarm_state()
+ {
+ var sut = new NativeAlarmProjector();
+ sut.Project("n1", Evt(AlarmTransitionKind.Raise));
+
+ var snap = sut.Project("n1", Evt(AlarmTransitionKind.Unspecified) with { QualityStatusCode = OpcUaStatus.BadNoCommunication });
+
+ snap.Active.ShouldBeTrue();
+ snap.Acknowledged.ShouldBeFalse();
+ snap.StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication);
+ }
+
private static AlarmEventArgs Evt(
AlarmTransitionKind kind,
AlarmSeverity sev = AlarmSeverity.High,