Files
lmxopcua/hda_plan.md
Joseph Doherty 415e62c585 Add security classification, alarm detection, historical data access, and primitive grouping
Wire Galaxy security_classification to OPC UA AccessLevel (ReadOnly for SecuredWrite/VerifiedWrite/ViewOnly).
Use deployed package chain for attribute queries to exclude undeployed attributes.
Group primitive attributes under their parent variable node (merged Variable+Object).
Add is_historized and is_alarm detection via HistoryExtension/AlarmExtension primitives.
Implement OPC UA HistoryRead backed by Wonderware Historian Runtime database.
Implement AlarmConditionState nodes driven by InAlarm with condition refresh support.
Add historyread and alarms CLI commands for testing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:32:33 -04:00

13 KiB

Alarm & History Detection Plan

Context

Galaxy attributes can be alarms (with AlarmExtension primitives) or historized (with HistoryExtension primitives). This plan documents how to detect these in the Galaxy Repository database and maps Galaxy alarm properties to OPC UA Alarms & Conditions concepts.

1. Detection in the Galaxy Repository

Alarm Detection

An attribute is an alarm when a primitive_instance exists in the deployed package chain where:

  • primitive_instance.primitive_name matches the dynamic_attribute.attribute_name
  • primitive_definition.primitive_name = 'AlarmExtension'

Example: TestMachine_001.TestAlarm001 has a primitive_instance named TestAlarm001 with primitive_definition.primitive_name = 'AlarmExtension'.

History Detection

Already implemented in the attributes queries. Same pattern but checking for primitive_definition.primitive_name = 'HistoryExtension'.

Query Pattern

Both use the same EXISTS subquery against the deployed package chain:

CASE WHEN EXISTS (
    SELECT 1 FROM deployed_package_chain dpc2
    INNER JOIN primitive_instance pi
        ON pi.package_id = dpc2.package_id
        AND pi.primitive_name = da.attribute_name
    INNER JOIN primitive_definition pd
        ON pd.primitive_definition_id = pi.primitive_definition_id
        AND pd.primitive_name = 'AlarmExtension'  -- or 'HistoryExtension'
    WHERE dpc2.gobject_id = dpc.gobject_id
) THEN 1 ELSE 0 END AS is_alarm

2. Galaxy Alarm Properties (AlarmExtension)

The AlarmExtension primitive exposes 24 public attributes per alarm. These are already returned by the extended attributes query as primitive child attributes (e.g., TestMachine_001.TestAlarm001.Acked).

Key Properties and Runtime Values

Galaxy Attribute Data Type Runtime Example Description
InAlarm Boolean False Whether alarm condition is currently active
Acked Boolean False Whether alarm has been acknowledged
Condition Boolean False Raw condition value (input to alarm logic)
ActiveAlarmState Boolean True Active state of alarm processing
Priority Integer 500 Alarm priority (1-999, higher = more urgent)
Category Enum 1 (Discrete) Alarm category type
AlarmMode Enum 1 (Enable) Operational mode: 1=Enable, 2=Disable, 3=Silence
AckMsg String "" Acknowledgment message/comment
TimeAlarmOn DateTime When alarm condition triggered
TimeAlarmOff DateTime When alarm condition cleared
TimeAlarmAcked DateTime When alarm was acknowledged
AlarmInhibit Boolean Inhibit alarm processing
AlarmShelved Boolean False Whether alarm is shelved
AlarmShelveNode String Node that shelved the alarm
AlarmShelveReason String Reason for shelving
AlarmShelveUser String User who shelved
AlarmShelveStartTime DateTime When shelve started
AlarmShelveStopTime DateTime When shelve ends
AlarmShelveCmd String Shelving command
AlarmModeCmd Enum Command to change alarm mode
AlarmSourceAttr Reference Source attribute reference
DescAttrName String Descriptive attribute name
Alarm.TimeDeadband ElapsedTime Time deadband for alarm

Alarm Enum Values

AlarmMode: Enable (1), Disable (2), Silence (3)

Category: Discrete (1), Value LoLo, Value Lo, Value Hi, Value HiHi, ROC, Deviation (and more)

3. Mapping Galaxy Alarm Properties to OPC UA

OPC UA Alarm Type Hierarchy

ConditionType
  └─ AcknowledgeableConditionType
       └─ AlarmConditionType
            ├─ DiscreteAlarmType        ← for Galaxy boolean alarms (Category=Discrete)
            ├─ OffNormalAlarmType        ← alternative for boolean alarms
            └─ LimitAlarmType           ← for analog alarms (Hi/Lo/HiHi/LoLo)
                 ├─ ExclusiveLimitAlarmType
                 └─ NonExclusiveLimitAlarmType

Galaxy boolean alarms (like TestAlarm001/002/003) map to DiscreteAlarmType or OffNormalAlarmType.

Property Mapping

Galaxy Property OPC UA Alarm Property Notes
InAlarm ActiveState.Id Boolean: alarm is active
Acked AckedState.Id Boolean: alarm acknowledged
Priority Severity Galaxy 1-999 maps to OPC UA 1-1000
AckMsg Comment Acknowledgment message
Condition Source variable value The boolean condition input
AlarmMode (Enable/Disable) EnabledState.Id Enable=true, Disable/Silence=false
ActiveAlarmState Retain Whether condition should be retained
TimeAlarmOn ActiveState.TransitionTime When alarm became active
TimeAlarmOff ActiveState.TransitionTime When alarm became inactive
TimeAlarmAcked AckedState.TransitionTime When alarm was acknowledged
AlarmShelved ShelvedState (current state) Maps to Unshelved/OneShotShelved/TimedShelved
AlarmShelveStartTime ShelvingState.UnshelveTime Computed from start/stop times
Category ConditionClassId Identifies condition class
AlarmInhibit SuppressedState.Id Alarm suppression
DescAttrName Message Description/message for alarm

Properties Not Available in Galaxy

These OPC UA properties have no direct Galaxy equivalent and would use defaults:

  • ConfirmedState — Galaxy doesn't have a confirmed concept (default: true)
  • BranchId — Galaxy doesn't support branching (default: null)
  • Quality — Use the source variable's StatusCode

4. Implementation Approach

OPC UA SDK Classes

  • AlarmConditionState — main class for alarm nodes
  • TwoStateVariableType — for ActiveState, AckedState, EnabledState, ShelvedState
  • ShelvedStateMachineType — for shelving state management

Key Implementation Steps

  1. Detect alarms in the query — add is_alarm column to attributes queries (same pattern as is_historized)
  2. Create alarm condition nodes — for attributes where is_alarm = 1, create an AlarmConditionState instead of a plain BaseDataVariableState
  3. Map properties — subscribe to the Galaxy alarm sub-attributes (InAlarm, Acked, Priority, etc.) and update the OPC UA alarm state
  4. Event notifications — when alarm state changes arrive via MXAccess OnDataChange, raise OPC UA alarm events via ReportEvent()
  5. Condition refresh — implement ConditionRefresh() to send current alarm states to newly subscribing clients
  6. Acknowledge method — implement the OPC UA Acknowledge method to write back to Galaxy via MXAccess

Galaxy Alarm Types in the Database

51 alarm-related primitive definitions exist. The main ones relevant to OPC UA mapping:

Galaxy Primitive OPC UA Alarm Type
AlarmExtension (Boolean) DiscreteAlarmType / OffNormalAlarmType
AnalogExtension.LevelAlarms.Hi/HiHi/Lo/LoLo ExclusiveLimitAlarmType or NonExclusiveLimitAlarmType
AnalogExtension.ROCAlarms RateOfChangeAlarmType
AnalogExtension.DeviationAlarms DeviationAlarmType

Files to Modify

  • gr/queries/attributes_extended.sql — add is_alarm column
  • gr/queries/attributes.sql — add is_alarm column
  • src/.../Domain/GalaxyAttributeInfo.cs — add IsAlarm property
  • src/.../GalaxyRepository/GalaxyRepositoryService.cs — read is_alarm from query results
  • src/.../OpcUa/LmxNodeManager.cs — create AlarmConditionState nodes for alarm attributes
  • New: alarm state update handler mapping MXAccess data changes to OPC UA alarm events
  • tools/opcuacli-dotnet/Commands/AlarmsCommand.cs — NEW CLI command
  • tools/opcuacli-dotnet/README.md — add alarms command documentation

5. OPC UA CLI Tool — Alarms Command

Add an alarms command to tools/opcuacli-dotnet/ for subscribing to and displaying OPC UA alarm events.

Usage

# Subscribe to all alarm events under a node (e.g., TestMachine_001)
dotnet run -- alarms -u opc.tcp://localhost:4840/LmxOpcUa -n "ns=1;s=TestMachine_001"

# Subscribe to all events under the root ZB node
dotnet run -- alarms -u opc.tcp://localhost:4840/LmxOpcUa -n "ns=1;s=ZB"

# Subscribe to all server events (Server node)
dotnet run -- alarms -u opc.tcp://localhost:4840/LmxOpcUa

# Request a condition refresh to get current alarm states immediately
dotnet run -- alarms -u opc.tcp://localhost:4840/LmxOpcUa -n "ns=1;s=TestMachine_001" --refresh

Command Options

Flag Description
-u, --url OPC UA server endpoint URL (required)
-n, --node Node ID to monitor for events (default: Server node i=2253)
--refresh Request a ConditionRefresh after subscribing to get current states
-i, --interval Publishing interval in milliseconds (default: 1000)

Output Format

Subscribed to alarm events on ns=1;s=TestMachine_001 (interval: 1000ms). Press Ctrl+C to stop.

[2026-03-26T04:30:12.000Z] ALARM  TestMachine_001.TestAlarm001
  State:    Active, Unacknowledged
  Severity: 500
  Message:  Discrete alarm triggered
  Source:   ns=1;s=TestMachine_001.TestAlarm001
  Retain:   True

[2026-03-26T04:30:45.000Z] ALARM  TestMachine_001.TestAlarm001
  State:    Active, Acknowledged
  Severity: 500
  Message:  Discrete alarm triggered
  AckUser:  operator1

[2026-03-26T04:31:02.000Z] ALARM  TestMachine_001.TestAlarm001
  State:    Inactive, Acknowledged
  Severity: 500
  Retain:   False

Implementation

New file: tools/opcuacli-dotnet/Commands/AlarmsCommand.cs

OPC UA alarm events are received through event-type monitored items, not regular data-change subscriptions. The key differences from the subscribe command:

// Create an event monitored item (not a data-change item)
var item = new MonitoredItem(subscription.DefaultItem)
{
    StartNodeId = nodeId,
    DisplayName = "AlarmMonitor",
    SamplingInterval = interval,
    NodeClass = NodeClass.Object,
    // Subscribe to events, not data changes
    AttributeId = Attributes.EventNotifier,
    // Select which event fields to return
    Filter = CreateEventFilter()
};

Event Filter

Select the standard alarm fields to display:

private static EventFilter CreateEventFilter()
{
    var filter = new EventFilter();
    filter.AddSelectClause(ObjectTypeIds.BaseEventType, "EventId");
    filter.AddSelectClause(ObjectTypeIds.BaseEventType, "EventType");
    filter.AddSelectClause(ObjectTypeIds.BaseEventType, "SourceName");
    filter.AddSelectClause(ObjectTypeIds.BaseEventType, "Time");
    filter.AddSelectClause(ObjectTypeIds.BaseEventType, "Message");
    filter.AddSelectClause(ObjectTypeIds.BaseEventType, "Severity");
    filter.AddSelectClause(ObjectTypeIds.ConditionType, "ConditionName");
    filter.AddSelectClause(ObjectTypeIds.ConditionType, "Retain");
    filter.AddSelectClause(ObjectTypeIds.AcknowledgeableConditionType, "AckedState/Id");
    filter.AddSelectClause(ObjectTypeIds.AlarmConditionType, "ActiveState/Id");
    filter.AddSelectClause(ObjectTypeIds.AlarmConditionType, "EnabledState/Id");
    filter.AddSelectClause(ObjectTypeIds.AlarmConditionType, "SuppressedOrShelved");
    return filter;
}

Event Notification Handler

item.Notification += (monitoredItem, e) =>
{
    if (e.NotificationValue is EventFieldList eventFields)
    {
        var time = eventFields.EventFields[3].Value as DateTime?;
        var sourceName = eventFields.EventFields[2].Value as string;
        var message = (eventFields.EventFields[4].Value as LocalizedText)?.Text;
        var severity = eventFields.EventFields[5].Value as ushort?;
        var ackedState = eventFields.EventFields[8].Value as bool?;
        var activeState = eventFields.EventFields[9].Value as bool?;
        var retain = eventFields.EventFields[7].Value as bool?;

        var stateDesc = FormatAlarmState(activeState, ackedState);
        Console.WriteLine($"[{time:O}] ALARM  {sourceName}");
        Console.WriteLine($"  State:    {stateDesc}");
        Console.WriteLine($"  Severity: {severity}");
        if (!string.IsNullOrEmpty(message))
            Console.WriteLine($"  Message:  {message}");
        Console.WriteLine($"  Retain:   {retain}");
        Console.WriteLine();
    }
};

Condition Refresh

When --refresh is specified, call ConditionRefresh after creating the subscription to receive the current state of all active alarms:

if (refresh)
{
    await subscription.ConditionRefreshAsync();
    await console.Output.WriteLineAsync("Condition refresh requested.");
}

State Formatting

static string FormatAlarmState(bool? active, bool? acked)
{
    var activePart = active == true ? "Active" : "Inactive";
    var ackedPart = acked == true ? "Acknowledged" : "Unacknowledged";
    return $"{activePart}, {ackedPart}";
}