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
This commit is contained in:
Joseph Doherty
2026-10-08 14:55:46 -04:00
parent e6028678b3
commit cb85a455c9
5 changed files with 140 additions and 6 deletions
@@ -3,7 +3,8 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// 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&amp;C events from upstream server).
/// (CNC alarms), OPC UA Client (A&amp;C events from upstream server), MTConnect
/// (CONDITION data items, Gitea #626).
/// </summary>
public interface IAlarmSource
{
@@ -85,6 +86,22 @@ public sealed record AlarmAcknowledgeRequest(
/// other fields. <see cref="AlarmTransitionKind.Unspecified"/> when the upstream
/// path doesn't surface a distinct kind.
/// </param>
/// <param name="OpcUaSeverity">
/// Optional exact OPC UA severity (1..1000) for a source whose own scale is finer than the four
/// <see cref="AlarmSeverity"/> buckets (Gitea #626: MTConnect Warning = 500, Fault = 800). When set it
/// supersedes the bucket on the projected Part 9 condition (clamped to 1..1000); <c>null</c> — every
/// pre-#626 source — keeps the bucket mapping. <see cref="Severity"/> must still carry the nearest
/// bucket for consumers that only read the bucket.
/// </param>
/// <param name="QualityStatusCode">
/// Optional status code of the condition's SOURCE data (Part 9 <c>ConditionType.Quality</c>), for a
/// source that can report "no value for this condition" per alarm rather than per connection (Gitea
/// #626: MTConnect <c>Unavailable</c>). <c>null</c> keeps the pre-#626 behaviour (projected Good;
/// driver-wide comms loss is annotated separately by the host). An event whose <see cref="Kind"/> is
/// <see cref="AlarmTransitionKind.Unspecified"/> and which carries a quality is a
/// <b>quality-only annotation</b> (<see cref="IsQualityOnly"/>): the host updates the condition's
/// Quality without touching its Active/Acked state and publishes no <c>alerts</c> row for it.
/// </param>
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)
{
/// <summary>
/// True when this event only re-annotates the condition's source-data quality — a
/// <see cref="QualityStatusCode"/> with an <see cref="AlarmTransitionKind.Unspecified"/> kind. No
/// alarm-state change happened, so the host must not publish it as an alarm transition.
/// </summary>
public bool IsQualityOnly => Kind == AlarmTransitionKind.Unspecified && QualityStatusCode is not null;
}
/// <summary>Mirrors the <c>NodePermissions</c> alarm-severity enum in <c>docs/v2/acl-design.md</c>.</summary>
public enum AlarmSeverity { Low, Medium, High, Critical }
@@ -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,
@@ -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);
}
/// <summary>Clears all tracked per-node state (call on address-space rebuild).</summary>