From cb85a455c9f8f2c449ebcf9dc5a864f1399a37d2 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 8 Oct 2026 14:55:46 -0400 Subject: [PATCH] feat(alarms): exact OPC UA severity + per-condition quality on native alarm events (#626) AlarmEventArgs gains optional OpcUaSeverity (supersedes the four-bucket mapping, clamped 1..1000) and QualityStatusCode (projected onto the condition's Quality). An Unspecified-kind event carrying a quality is a quality-only annotation: the host updates the condition but publishes no alerts row. Both default null, so every existing native source is unchanged. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe --- .../IAlarmSource.cs | 31 +++++++++- .../Drivers/DriverHostActor.cs | 7 ++- .../Drivers/NativeAlarmProjector.cs | 9 ++- .../DriverHostActorNativeAlarmTests.cs | 56 +++++++++++++++++++ .../Drivers/NativeAlarmProjectorTests.cs | 43 ++++++++++++++ 5 files changed, 140 insertions(+), 6 deletions(-) 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,