Files
lmxopcua/docs/AlarmTracking.md
Joseph Doherty 50b9603465 Propagate alarm events up the full notifier chain so subscribers at any ancestor see them
Previously alarms were only reported to the immediate parent node and the Server node.
Now ReportEventUpNotifierChain walks the full parent chain so clients subscribed at
TestArea see alarms from TestMachine_001, and EventNotifier is set on all ancestors
of alarm-containing nodes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 20:25:55 -04:00

8.4 KiB

Alarm Tracking

LmxNodeManager generates OPC UA alarm conditions from Galaxy attributes marked as alarms. The system detects alarm-capable attributes during address space construction, creates AlarmConditionState nodes, auto-subscribes to the runtime alarm tags via MXAccess, and reports state transitions as OPC UA events.

AlarmInfo Structure

Each tracked alarm is represented by an AlarmInfo instance stored in the _alarmInAlarmTags dictionary, keyed by the InAlarm tag reference:

private sealed class AlarmInfo
{
    public string SourceTagReference { get; set; }    // e.g., "Tag_001.Temperature"
    public NodeId SourceNodeId { get; set; }
    public string SourceName { get; set; }            // attribute name for event messages
    public bool LastInAlarm { get; set; }              // tracks previous state for edge detection
    public AlarmConditionState? ConditionNode { get; set; }
    public string PriorityTagReference { get; set; }   // e.g., "Tag_001.Temperature.Priority"
    public string DescAttrNameTagReference { get; set; } // e.g., "Tag_001.Temperature.DescAttrName"
    public ushort CachedSeverity { get; set; }
    public string CachedMessage { get; set; }
}

LastInAlarm enables edge detection so only actual transitions (inactive-to-active or active-to-inactive) generate events, not repeated identical values.

Alarm Detection via is_alarm Flag

During BuildAddressSpace (and BuildSubtree for incremental sync), the node manager scans each non-area Galaxy object for attributes where IsAlarm == true and PrimitiveName is empty (direct attributes only, not primitive children):

var alarmAttrs = objAttrs.Where(a => a.IsAlarm && string.IsNullOrEmpty(a.PrimitiveName)).ToList();

The IsAlarm flag originates from the AlarmExtension primitive in the Galaxy repository database. When a Galaxy attribute has an associated AlarmExtension primitive, the SQL query sets is_alarm = 1 on the corresponding GalaxyAttributeInfo.

For each alarm attribute, the code verifies that a corresponding InAlarm sub-attribute variable node exists in _tagToVariableNode (constructed from FullTagReference + ".InAlarm"). If the variable node is missing, the alarm is skipped -- this prevents creating orphaned alarm conditions for attributes whose extension primitives were not published.

AlarmConditionState Creation

Each detected alarm attribute produces an AlarmConditionState node:

var condition = new AlarmConditionState(sourceVariable);
condition.Create(SystemContext, conditionNodeId,
    new QualifiedName(alarmAttr.AttributeName + "Alarm", NamespaceIndex),
    new LocalizedText("en", alarmAttr.AttributeName + " Alarm"),
    true);

Key configuration on the condition node:

  • SourceNode -- Set to the OPC UA NodeId of the source variable, linking the condition to the attribute that triggered it.
  • SourceName / ConditionName -- Set to the Galaxy attribute name for identification in event notifications.
  • AutoReportStateChanges -- Set to true so the OPC UA framework automatically generates event notifications when condition properties change.
  • Initial state -- Enabled, inactive, acknowledged, severity Medium, retain false.
  • HasCondition references -- Bidirectional references are added between the source variable and the condition node.

The condition's OnReportEvent callback forwards events to Server.ReportEvent so they reach clients subscribed at the server level.

Auto-subscription to Alarm Tags

After alarm condition nodes are created, SubscribeAlarmTags opens MXAccess subscriptions for three tags per alarm:

  1. InAlarm (Tag_001.Temperature.InAlarm) -- The boolean trigger for alarm activation/deactivation.
  2. Priority (Tag_001.Temperature.Priority) -- Numeric priority that maps to OPC UA severity.
  3. DescAttrName (Tag_001.Temperature.DescAttrName) -- String description used as the alarm event message.

These subscriptions are opened unconditionally (not ref-counted) because they serve the server's own alarm tracking, not client-initiated monitoring. Tags that do not have corresponding variable nodes in _tagToVariableNode are skipped.

EventNotifier Propagation

When a Galaxy object contains at least one alarm attribute, EventNotifiers.SubscribeToEvents is set on the object node and all its ancestors up to the root. This allows OPC UA clients to subscribe to events at any level in the hierarchy and receive alarm notifications from all descendants:

if (hasAlarms && _nodeMap.TryGetValue(obj.GobjectId, out var objNode))
    EnableEventNotifierUpChain(objNode);

For example, an alarm on TestMachine_001.SubObject.Temperature will be visible to clients subscribed on SubObject, TestMachine_001, or the root ZB folder. The root ZB folder also has EventNotifiers.SubscribeToEvents enabled during initial construction.

InAlarm Transition Detection in DispatchLoop

Alarm state changes are detected in the dispatch loop's Phase 1 (outside Lock), which runs on the background dispatch thread rather than the STA thread. This placement is intentional because the detection logic reads Priority and DescAttrName values from MXAccess, which would block the STA thread if done inside the OnMxAccessDataChange callback.

For each pending data change, the loop checks whether the address matches a key in _alarmInAlarmTags:

if (_alarmInAlarmTags.TryGetValue(address, out var alarmInfo))
{
    var newInAlarm = vtq.Value is true || vtq.Value is 1
        || (vtq.Value is int intVal && intVal != 0);
    if (newInAlarm != alarmInfo.LastInAlarm)
    {
        alarmInfo.LastInAlarm = newInAlarm;
        // Read Priority and DescAttrName via MXAccess (outside Lock)
        ...
        pendingAlarmEvents.Add((alarmInfo, newInAlarm));
    }
}

The boolean coercion handles multiple value representations: true, integer 1, or any non-zero integer. When the value changes state, Priority and DescAttrName are read synchronously from MXAccess to populate CachedSeverity and CachedMessage. These reads happen outside Lock because they call into the STA thread.

Priority values are clamped to the OPC UA severity range (1-1000). Both int and short types are handled.

ReportAlarmEvent

ReportAlarmEvent runs inside Lock during Phase 2 of the dispatch loop. It updates the AlarmConditionState and generates an OPC UA event:

condition.SetActiveState(SystemContext, active);
condition.Message.Value = new LocalizedText("en", message);
condition.SetSeverity(SystemContext, (EventSeverity)severity);
condition.Retain.Value = active || (condition.AckedState?.Id?.Value == false);

Key behaviors:

  • Active state -- Set to true on activation, false on clearing.
  • Message -- Uses CachedMessage (from DescAttrName) when available on activation. Falls back to a generated "Alarm active: {SourceName}" string. Cleared alarms always use "Alarm cleared: {SourceName}".
  • Severity -- Set from CachedSeverity, which was read from the Priority tag.
  • Retain -- true while the alarm is active or unacknowledged. This keeps the condition visible in condition refresh responses.
  • Acknowledged state -- Reset to false when the alarm activates, requiring explicit client acknowledgment.

The event is reported by walking up the notifier chain from the source variable's parent through all ancestor nodes. Each ancestor with EventNotifier set receives the event via ReportEvent, so clients subscribed at any level in the Galaxy hierarchy see alarm transitions from descendant objects.

Condition Refresh Override

The ConditionRefresh override iterates all tracked alarms and queues retained conditions to the requesting monitored items:

public override ServiceResult ConditionRefresh(OperationContext context,
    IList<IEventMonitoredItem> monitoredItems)
{
    foreach (var kvp in _alarmInAlarmTags)
    {
        var info = kvp.Value;
        if (info.ConditionNode == null || info.ConditionNode.Retain?.Value != true)
            continue;
        foreach (var item in monitoredItems)
            item.QueueEvent(info.ConditionNode);
    }
    return ServiceResult.Good;
}

Only conditions where Retain.Value == true are included. This means only active or unacknowledged alarms appear in condition refresh responses, matching the OPC UA specification requirement that condition refresh returns the current state of all retained conditions.