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>
This commit is contained in:
Joseph Doherty
2026-03-26 11:32:33 -04:00
parent bb0a89b2a1
commit 415e62c585
30 changed files with 2734 additions and 217 deletions

View File

@@ -0,0 +1,168 @@
using CliFx;
using CliFx.Attributes;
using CliFx.Infrastructure;
using Opc.Ua;
using Opc.Ua.Client;
namespace OpcUaCli.Commands;
[Command("alarms", Description = "Subscribe to alarm events on a node")]
public class AlarmsCommand : ICommand
{
[CommandOption("url", 'u', Description = "OPC UA server endpoint URL", IsRequired = true)]
public string Url { get; init; } = default!;
[CommandOption("node", 'n', Description = "Node ID to monitor for events (default: Server node)")]
public string? NodeId { get; init; }
[CommandOption("interval", 'i', Description = "Publishing interval in milliseconds")]
public int Interval { get; init; } = 1000;
[CommandOption("refresh", Description = "Request a ConditionRefresh after subscribing")]
public bool Refresh { get; init; }
public async ValueTask ExecuteAsync(IConsole console)
{
using var session = await OpcUaHelper.ConnectAsync(Url);
var nodeId = string.IsNullOrEmpty(NodeId)
? ObjectIds.Server
: new NodeId(NodeId);
var subscription = new Subscription(session.DefaultSubscription)
{
PublishingInterval = Interval,
DisplayName = "CLI Alarm Subscription"
};
var item = new MonitoredItem(subscription.DefaultItem)
{
StartNodeId = nodeId,
DisplayName = "AlarmMonitor",
SamplingInterval = Interval,
NodeClass = NodeClass.Object,
AttributeId = Attributes.EventNotifier,
Filter = CreateEventFilter()
};
item.Notification += (_, e) =>
{
if (e.NotificationValue is EventFieldList eventFields)
{
PrintAlarmEvent(eventFields);
}
};
subscription.AddItem(item);
session.AddSubscription(subscription);
await subscription.CreateAsync();
Console.WriteLine($"Subscribed to alarm events on {nodeId} (interval: {Interval}ms). Press Ctrl+C to stop.");
Console.Out.Flush();
if (Refresh)
{
try
{
await subscription.ConditionRefreshAsync();
Console.WriteLine("Condition refresh requested.");
}
catch (Exception ex)
{
Console.WriteLine($"Condition refresh not supported: {ex.Message}");
}
Console.Out.Flush();
}
var ct = console.RegisterCancellationHandler();
while (!ct.IsCancellationRequested)
{
await Task.Delay(2000, ct).ContinueWith(_ => { });
}
await console.Output.WriteLineAsync("Unsubscribed.");
}
private static EventFilter CreateEventFilter()
{
var filter = new EventFilter();
// 0: EventId
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.EventId);
// 1: EventType
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.EventType);
// 2: SourceName
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.SourceName);
// 3: Time
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.Time);
// 4: Message
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.Message);
// 5: Severity
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.Severity);
// 6: ConditionName
filter.AddSelectClause(ObjectTypeIds.ConditionType, BrowseNames.ConditionName);
// 7: Retain
filter.AddSelectClause(ObjectTypeIds.ConditionType, BrowseNames.Retain);
// 8: AckedState/Id
filter.AddSelectClause(ObjectTypeIds.AcknowledgeableConditionType, "AckedState/Id");
// 9: ActiveState/Id
filter.AddSelectClause(ObjectTypeIds.AlarmConditionType, "ActiveState/Id");
// 10: EnabledState/Id
filter.AddSelectClause(ObjectTypeIds.AlarmConditionType, "EnabledState/Id");
// 11: SuppressedOrShelved
filter.AddSelectClause(ObjectTypeIds.AlarmConditionType, "SuppressedOrShelved");
return filter;
}
private static void PrintAlarmEvent(EventFieldList eventFields)
{
var fields = eventFields.EventFields;
if (fields == null || fields.Count < 6)
return;
var time = fields.Count > 3 ? fields[3].Value as DateTime? : null;
var sourceName = fields.Count > 2 ? fields[2].Value as string : null;
var message = fields.Count > 4 ? (fields[4].Value as LocalizedText)?.Text : null;
var severity = fields.Count > 5 ? fields[5].Value : null;
var conditionName = fields.Count > 6 ? fields[6].Value as string : null;
var retain = fields.Count > 7 ? fields[7].Value as bool? : null;
var ackedState = fields.Count > 8 ? fields[8].Value as bool? : null;
var activeState = fields.Count > 9 ? fields[9].Value as bool? : null;
var enabledState = fields.Count > 10 ? fields[10].Value as bool? : null;
var suppressed = fields.Count > 11 ? fields[11].Value as bool? : null;
Console.WriteLine($"[{time:O}] ALARM {sourceName}");
if (conditionName != null)
Console.WriteLine($" Condition: {conditionName}");
if (activeState.HasValue || ackedState.HasValue)
{
var state = FormatAlarmState(activeState, ackedState);
Console.WriteLine($" State: {state}");
}
if (enabledState.HasValue)
Console.WriteLine($" Enabled: {enabledState.Value}");
Console.WriteLine($" Severity: {severity}");
if (!string.IsNullOrEmpty(message))
Console.WriteLine($" Message: {message}");
if (retain.HasValue)
Console.WriteLine($" Retain: {retain.Value}");
if (suppressed == true)
Console.WriteLine($" Suppressed/Shelved: True");
Console.WriteLine();
}
private static string FormatAlarmState(bool? active, bool? acked)
{
var activePart = active == true ? "Active" : "Inactive";
var ackedPart = acked == true ? "Acknowledged" : "Unacknowledged";
return $"{activePart}, {ackedPart}";
}
}

View File

@@ -0,0 +1,204 @@
using CliFx;
using CliFx.Attributes;
using CliFx.Infrastructure;
using Opc.Ua;
using Opc.Ua.Client;
namespace OpcUaCli.Commands;
[Command("historyread", Description = "Read historical data from a node")]
public class HistoryReadCommand : ICommand
{
[CommandOption("url", 'u', Description = "OPC UA server endpoint URL", IsRequired = true)]
public string Url { get; init; } = default!;
[CommandOption("node", 'n', Description = "Node ID (e.g. ns=1;s=TestMachine_001.TestHistoryValue)", IsRequired = true)]
public string NodeId { get; init; } = default!;
[CommandOption("start", Description = "Start time (ISO 8601 or date string, default: 24 hours ago)")]
public string? StartTime { get; init; }
[CommandOption("end", Description = "End time (ISO 8601 or date string, default: now)")]
public string? EndTime { get; init; }
[CommandOption("max", Description = "Maximum number of values to return")]
public int MaxValues { get; init; } = 1000;
[CommandOption("aggregate", Description = "Aggregate function: Average, Minimum, Maximum, Count")]
public string? Aggregate { get; init; }
[CommandOption("interval", Description = "Processing interval in milliseconds for aggregates")]
public double IntervalMs { get; init; } = 3600000;
public async ValueTask ExecuteAsync(IConsole console)
{
using var session = await OpcUaHelper.ConnectAsync(Url);
var nodeId = new NodeId(NodeId);
var start = string.IsNullOrEmpty(StartTime) ? DateTime.UtcNow.AddHours(-24) : DateTime.Parse(StartTime).ToUniversalTime();
var end = string.IsNullOrEmpty(EndTime) ? DateTime.UtcNow : DateTime.Parse(EndTime).ToUniversalTime();
if (string.IsNullOrEmpty(Aggregate))
{
await ReadRawAsync(session, console, nodeId, start, end);
}
else
{
await ReadProcessedAsync(session, console, nodeId, start, end);
}
}
private async Task ReadRawAsync(Session session, IConsole console, NodeId nodeId, DateTime start, DateTime end)
{
var details = new ReadRawModifiedDetails
{
StartTime = start,
EndTime = end,
NumValuesPerNode = (uint)MaxValues,
IsReadModified = false,
ReturnBounds = false
};
var nodesToRead = new HistoryReadValueIdCollection
{
new HistoryReadValueId { NodeId = nodeId }
};
await console.Output.WriteLineAsync(
$"History for {NodeId} ({start:yyyy-MM-dd HH:mm} → {end:yyyy-MM-dd HH:mm})");
await console.Output.WriteLineAsync();
await console.Output.WriteLineAsync($"{"Timestamp",-35} {"Value",-15} {"Status"}");
int totalValues = 0;
byte[]? continuationPoint = null;
do
{
if (continuationPoint != null)
nodesToRead[0].ContinuationPoint = continuationPoint;
session.HistoryRead(
null,
new ExtensionObject(details),
TimestampsToReturn.Source,
continuationPoint != null,
nodesToRead,
out var results,
out _);
if (results == null || results.Count == 0)
break;
var result = results[0];
if (StatusCode.IsBad(result.StatusCode))
{
await console.Error.WriteLineAsync($"HistoryRead failed: {result.StatusCode}");
break;
}
if (result.HistoryData == null)
{
await console.Error.WriteLineAsync($"No history data returned (status: {result.StatusCode})");
break;
}
if (result.HistoryData is ExtensionObject ext && ext.Body is HistoryData historyData)
{
foreach (var dv in historyData.DataValues)
{
var status = StatusCode.IsGood(dv.StatusCode) ? "Good"
: StatusCode.IsBad(dv.StatusCode) ? "Bad"
: "Uncertain";
await console.Output.WriteLineAsync(
$"{dv.SourceTimestamp.ToString("O"),-35} {dv.Value,-15} {status}");
totalValues++;
}
}
continuationPoint = result.ContinuationPoint;
}
while (continuationPoint != null && continuationPoint.Length > 0 && totalValues < MaxValues);
await console.Output.WriteLineAsync();
await console.Output.WriteLineAsync($"{totalValues} values returned.");
}
private async Task ReadProcessedAsync(Session session, IConsole console, NodeId nodeId, DateTime start, DateTime end)
{
var aggregateId = MapAggregateName(Aggregate!);
if (aggregateId == null)
{
await console.Error.WriteLineAsync($"Unknown aggregate: {Aggregate}. Supported: Average, Minimum, Maximum, Count, Start, End");
return;
}
var details = new ReadProcessedDetails
{
StartTime = start,
EndTime = end,
ProcessingInterval = IntervalMs,
AggregateType = new NodeIdCollection { aggregateId }
};
var nodesToRead = new HistoryReadValueIdCollection
{
new HistoryReadValueId { NodeId = nodeId }
};
session.HistoryRead(
null,
new ExtensionObject(details),
TimestampsToReturn.Source,
false,
nodesToRead,
out var results,
out _);
await console.Output.WriteLineAsync(
$"History for {NodeId} ({Aggregate}, interval={IntervalMs}ms)");
await console.Output.WriteLineAsync();
await console.Output.WriteLineAsync($"{"Timestamp",-35} {"Value",-15} {"Status"}");
int totalValues = 0;
if (results != null && results.Count > 0)
{
var result = results[0];
if (StatusCode.IsBad(result.StatusCode))
{
await console.Error.WriteLineAsync($"HistoryRead failed: {result.StatusCode}");
return;
}
if (result.HistoryData is ExtensionObject ext && ext.Body is HistoryData historyData)
{
foreach (var dv in historyData.DataValues)
{
var status = StatusCode.IsGood(dv.StatusCode) ? "Good"
: StatusCode.IsBad(dv.StatusCode) ? "Bad"
: "Uncertain";
await console.Output.WriteLineAsync(
$"{dv.SourceTimestamp.ToString("O"),-35} {dv.Value,-15} {status}");
totalValues++;
}
}
}
await console.Output.WriteLineAsync();
await console.Output.WriteLineAsync($"{totalValues} values returned.");
}
private static NodeId? MapAggregateName(string name)
{
return name.ToLowerInvariant() switch
{
"average" => ObjectIds.AggregateFunction_Average,
"minimum" or "min" => ObjectIds.AggregateFunction_Minimum,
"maximum" or "max" => ObjectIds.AggregateFunction_Maximum,
"count" => ObjectIds.AggregateFunction_Count,
"start" or "first" => ObjectIds.AggregateFunction_Start,
"end" or "last" => ObjectIds.AggregateFunction_End,
_ => null
};
}
}

View File

@@ -48,11 +48,11 @@ public class SubscribeCommand : ICommand
SamplingInterval = Interval
};
item.Notification += (_, e) =>
item.Notification += (monitoredItem, e) =>
{
if (e.NotificationValue is MonitoredItemNotification notification)
{
console.Output.WriteLine(
Console.WriteLine(
$"[{notification.Value.SourceTimestamp:O}] {NodeId} = {notification.Value.Value} ({notification.Value.StatusCode})");
}
};
@@ -66,8 +66,18 @@ public class SubscribeCommand : ICommand
var ct = console.RegisterCancellationHandler();
try { await Task.Delay(Timeout.Infinite, ct); }
catch (OperationCanceledException) { }
int tick = 0;
while (!ct.IsCancellationRequested)
{
await Task.Delay(2000, ct).ContinueWith(_ => { });
tick++;
Console.WriteLine(
$" [tick {tick}] Session={session.Connected}, Sub.Id={subscription.Id}, " +
$"PublishingEnabled={subscription.PublishingEnabled}, " +
$"MonitoredItemCount={subscription.MonitoredItemCount}, " +
$"ItemStatus={item.Status?.Id}, " +
$"LastNotification={((item.LastValue as MonitoredItemNotification)?.Value?.Value)} ({((item.LastValue as MonitoredItemNotification)?.Value?.StatusCode)})");
}
await console.Output.WriteLineAsync("Unsubscribed.");
}

View File

@@ -90,6 +90,51 @@ dotnet run -- browse -u opc.tcp://localhost:4840 -r -d 3
| `-d` | Maximum browse depth (default: 1) |
| `-r` | Browse recursively using `--depth` as max depth |
### historyread
Read historical data from a node:
```
dotnet run -- historyread -u opc.tcp://localhost:4840/LmxOpcUa -n "ns=1;s=TestMachine_001.TestHistoryValue" --start "2026-03-25" --end "2026-03-30"
```
Read with aggregate (1-hour average):
```
dotnet run -- historyread -u opc.tcp://localhost:4840/LmxOpcUa -n "ns=1;s=TestMachine_001.TestHistoryValue" --start "2026-03-25" --end "2026-03-30" --aggregate Average --interval 3600000
```
| Flag | Description |
|------|-------------|
| `-u` | OPC UA server endpoint URL (required) |
| `-n` | Node ID to read history for (required) |
| `--start` | Start time (default: 24 hours ago) |
| `--end` | End time (default: now) |
| `--max` | Maximum number of values (default: 1000) |
| `--aggregate` | Aggregate function: Average, Minimum, Maximum, Count |
| `--interval` | Processing interval in ms for aggregates (default: 3600000) |
### alarms
Subscribe to alarm events on a node:
```
dotnet run -- alarms -u opc.tcp://localhost:4840/LmxOpcUa -n "ns=1;s=TestMachine_001"
```
With condition refresh to get current alarm states:
```
dotnet run -- alarms -u opc.tcp://localhost:4840/LmxOpcUa -n "ns=1;s=TestMachine_001" --refresh
```
| Flag | Description |
|------|-------------|
| `-u` | OPC UA server endpoint URL (required) |
| `-n` | Node ID to monitor for events (default: Server node) |
| `-i` | Publishing interval in milliseconds (default: 1000) |
| `--refresh` | Request a ConditionRefresh after subscribing |
## Example: Testing the LmxOpcUa Server
```bash