Add UI features, alarm ack, historian UTC fix, and Client.UI documentation

Major changes across the client stack:
- Settings persistence (connection, subscriptions, alarm source)
- Deferred OPC UA SDK init for instant startup
- Array/status code formatting, write value popup, alarm acknowledgment
- Severity-colored alarm rows, condition dedup on server side
- DateTimeRangePicker control with preset buttons and UTC text input
- Historian queries use wwTimezone=UTC and OPCQuality column
- Recursive subscribe from tree, multi-select remove
- Connection panel with expander, folder chooser for cert path
- Dynamic tab headers showing subscription/alarm counts
- Client.UI.md documentation with headless-rendered screenshots

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-03-31 20:46:45 -04:00
parent 8fae2cb790
commit 188cbf7d24
53 changed files with 2652 additions and 189 deletions

View File

@@ -220,4 +220,27 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
_session.Dispose();
}
public async Task<IList<object>?> CallMethodAsync(NodeId objectId, NodeId methodId, object[] inputArguments,
CancellationToken ct = default)
{
var result = await _session.CallAsync(
null,
new CallMethodRequestCollection
{
new()
{
ObjectId = objectId,
MethodId = methodId,
InputArguments = new VariantCollection(inputArguments.Select(a => new Variant(a)))
}
},
ct);
var callResult = result.Results[0];
if (StatusCode.IsBad(callResult.StatusCode))
throw new ServiceResultException(callResult.StatusCode);
return callResult.OutputArguments?.Select(v => v.Value).ToList();
}
}

View File

@@ -59,5 +59,7 @@ internal interface ISessionAdapter : IDisposable
/// </summary>
Task<ISubscriptionAdapter> CreateSubscriptionAsync(int publishingIntervalMs, CancellationToken ct = default);
Task<IList<object>?> CallMethodAsync(NodeId objectId, NodeId methodId, object[] inputArguments, CancellationToken ct = default);
Task CloseAsync(CancellationToken ct = default);
}

View File

@@ -25,6 +25,7 @@ public interface IOpcUaClientService : IDisposable
Task SubscribeAlarmsAsync(NodeId? sourceNodeId = null, int intervalMs = 1000, CancellationToken ct = default);
Task UnsubscribeAlarmsAsync(CancellationToken ct = default);
Task RequestConditionRefreshAsync(CancellationToken ct = default);
Task<StatusCode> AcknowledgeAlarmAsync(string conditionNodeId, byte[] eventId, string comment, CancellationToken ct = default);
Task<IReadOnlyList<DataValue>> HistoryReadRawAsync(NodeId nodeId, DateTime startTime, DateTime endTime,
int maxValues = 1000, CancellationToken ct = default);

View File

@@ -13,7 +13,9 @@ public sealed class AlarmEventArgs : EventArgs
bool retain,
bool activeState,
bool ackedState,
DateTime time)
DateTime time,
byte[]? eventId = null,
string? conditionNodeId = null)
{
SourceName = sourceName;
ConditionName = conditionName;
@@ -23,6 +25,8 @@ public sealed class AlarmEventArgs : EventArgs
ActiveState = activeState;
AckedState = ackedState;
Time = time;
EventId = eventId;
ConditionNodeId = conditionNodeId;
}
/// <summary>The name of the source object that raised the alarm.</summary>
@@ -48,4 +52,10 @@ public sealed class AlarmEventArgs : EventArgs
/// <summary>The time the event occurred.</summary>
public DateTime Time { get; }
/// <summary>The EventId used for alarm acknowledgment.</summary>
public byte[]? EventId { get; }
/// <summary>The NodeId of the condition instance (SourceNode), used for acknowledgment.</summary>
public string? ConditionNodeId { get; }
}

View File

@@ -277,6 +277,28 @@ public sealed class OpcUaClientService : IOpcUaClientService
Logger.Debug("Condition refresh requested");
}
public async Task<StatusCode> AcknowledgeAlarmAsync(string conditionNodeId, byte[] eventId, string comment,
CancellationToken ct = default)
{
ThrowIfDisposed();
ThrowIfNotConnected();
// The Acknowledge method lives on the .Condition child node, not the source node itself
var conditionObjId = conditionNodeId.EndsWith(".Condition")
? NodeId.Parse(conditionNodeId)
: NodeId.Parse(conditionNodeId + ".Condition");
var acknowledgeMethodId = MethodIds.AcknowledgeableConditionType_Acknowledge;
await _session!.CallMethodAsync(
conditionObjId,
acknowledgeMethodId,
[eventId, new LocalizedText(comment)],
ct);
Logger.Debug("Acknowledged alarm on {ConditionId}", conditionNodeId);
return StatusCodes.Good;
}
public async Task<IReadOnlyList<DataValue>> HistoryReadRawAsync(
NodeId nodeId, DateTime startTime, DateTime endTime, int maxValues = 1000, CancellationToken ct = default)
{
@@ -502,17 +524,86 @@ public sealed class OpcUaClientService : IOpcUaClientService
if (fields == null || fields.Count < 6)
return;
var eventId = fields.Count > 0 ? fields[0].Value as byte[] : null;
var sourceName = fields.Count > 2 ? fields[2].Value as string ?? string.Empty : string.Empty;
var time = fields.Count > 3 ? fields[3].Value as DateTime? ?? DateTime.MinValue : DateTime.MinValue;
var time = DateTime.MinValue;
if (fields.Count > 3 && fields[3].Value != null)
{
if (fields[3].Value is DateTime dt)
time = dt;
else if (DateTime.TryParse(fields[3].Value.ToString(), out var parsed))
time = parsed;
}
var message = fields.Count > 4 ? (fields[4].Value as LocalizedText)?.Text ?? string.Empty : string.Empty;
var severity = fields.Count > 5 ? Convert.ToUInt16(fields[5].Value) : (ushort)0;
var conditionName = fields.Count > 6 ? fields[6].Value as string ?? string.Empty : string.Empty;
var retain = fields.Count > 7 ? fields[7].Value as bool? ?? false : false;
var ackedState = fields.Count > 8 ? fields[8].Value as bool? ?? false : false;
var activeState = fields.Count > 9 ? fields[9].Value as bool? ?? false : false;
var retain = fields.Count > 7 && ParseBool(fields[7].Value);
var conditionNodeId = fields.Count > 12 ? fields[12].Value?.ToString() : null;
// Try standard OPC UA ActiveState/AckedState fields first
bool? ackedField = fields.Count > 8 && fields[8].Value != null ? ParseBool(fields[8].Value) : null;
bool? activeField = fields.Count > 9 && fields[9].Value != null ? ParseBool(fields[9].Value) : null;
var ackedState = ackedField ?? false;
var activeState = activeField ?? false;
// Fallback: read InAlarm/Acked from condition node Galaxy attributes
// when the server doesn't populate standard event fields.
// Must run on a background thread to avoid deadlocking the notification thread.
if (ackedField == null && activeField == null && conditionNodeId != null && _session != null)
{
var session = _session;
var capturedConditionNodeId = conditionNodeId;
var capturedMessage = message;
_ = Task.Run(async () =>
{
try
{
var inAlarmValue = await session.ReadValueAsync(NodeId.Parse($"{capturedConditionNodeId}.InAlarm"));
if (inAlarmValue.Value is bool inAlarm) activeState = inAlarm;
var ackedValue = await session.ReadValueAsync(NodeId.Parse($"{capturedConditionNodeId}.Acked"));
if (ackedValue.Value is bool acked) ackedState = acked;
if (time == DateTime.MinValue && activeState)
{
var timeValue = await session.ReadValueAsync(NodeId.Parse($"{capturedConditionNodeId}.TimeAlarmOn"));
if (timeValue.Value is DateTime alarmTime && alarmTime != DateTime.MinValue)
time = alarmTime;
}
// Read alarm description to use as message
try
{
var descValue = await session.ReadValueAsync(NodeId.Parse($"{capturedConditionNodeId}.DescAttrName"));
if (descValue.Value is string desc && !string.IsNullOrEmpty(desc))
capturedMessage = desc;
}
catch { /* DescAttrName may not exist */ }
}
catch
{
// Supplemental read failed; use defaults
}
AlarmEvent?.Invoke(this, new AlarmEventArgs(
sourceName, conditionName, severity, capturedMessage, retain, activeState, ackedState, time,
eventId, capturedConditionNodeId));
});
return;
}
AlarmEvent?.Invoke(this, new AlarmEventArgs(
sourceName, conditionName, severity, message, retain, activeState, ackedState, time));
sourceName, conditionName, severity, message, retain, activeState, ackedState, time,
eventId, conditionNodeId));
}
private static bool ParseBool(object? value)
{
if (value == null) return false;
if (value is bool b) return b;
try { return Convert.ToBoolean(value); }
catch { return false; }
}
private static EventFilter CreateAlarmEventFilter()
@@ -542,6 +633,8 @@ public sealed class OpcUaClientService : IOpcUaClientService
filter.AddSelectClause(ObjectTypeIds.AlarmConditionType, "EnabledState/Id");
// 11: SuppressedOrShelved
filter.AddSelectClause(ObjectTypeIds.AlarmConditionType, "SuppressedOrShelved");
// 12: SourceNode (ConditionId for acknowledgment)
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.SourceNode);
return filter;
}