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>
306 lines
13 KiB
Markdown
306 lines
13 KiB
Markdown
# 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:
|
|
|
|
```sql
|
|
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
|
|
|
|
```bash
|
|
# 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:
|
|
|
|
```csharp
|
|
// 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:
|
|
|
|
```csharp
|
|
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
|
|
|
|
```csharp
|
|
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:
|
|
|
|
```csharp
|
|
if (refresh)
|
|
{
|
|
await subscription.ConditionRefreshAsync();
|
|
await console.Output.WriteLineAsync("Condition refresh requested.");
|
|
}
|
|
```
|
|
|
|
#### State Formatting
|
|
|
|
```csharp
|
|
static string FormatAlarmState(bool? active, bool? acked)
|
|
{
|
|
var activePart = active == true ? "Active" : "Inactive";
|
|
var ackedPart = acked == true ? "Acknowledged" : "Unacknowledged";
|
|
return $"{activePart}, {ackedPart}";
|
|
}
|
|
```
|