feat(client): AddComment + comment CLI, drop Galaxy-era alarm fallback, serialize CLI tests (#598); Client.Shared off obsolete sync SDK APIs (#621)

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 06:53:57 -04:00
parent 3baa332d6b
commit 48bd266e19
18 changed files with 363 additions and 211 deletions
@@ -428,6 +428,35 @@ public sealed class OpcUaClientService : IOpcUaClientService
return StatusCodes.Good;
}
/// <inheritdoc />
public async Task<StatusCode> AddCommentAsync(string conditionNodeId, byte[] eventId, string comment,
CancellationToken ct = default)
{
ThrowIfDisposed();
ThrowIfNotConnected();
// AddComment is a ConditionType method, invoked on the condition node itself.
var conditionObjId = NodeId.Parse(conditionNodeId);
try
{
await _session!.CallMethodAsync(
conditionObjId,
MethodIds.ConditionType_AddComment,
[eventId, new LocalizedText(comment)],
ct);
}
catch (ServiceResultException ex)
{
Logger.Warning(ex, "Failed to add comment on {ConditionId} (status {Status})",
conditionNodeId, ex.StatusCode);
return ex.StatusCode;
}
Logger.Debug("Added comment on {ConditionId}", conditionNodeId);
return StatusCodes.Good;
}
/// <inheritdoc />
public async Task<StatusCode> ShelveAlarmAsync(string conditionNodeId, ShelveKind kind,
double shelvingTimeSeconds = 0, CancellationToken ct = default)
@@ -633,7 +662,7 @@ public sealed class OpcUaClientService : IOpcUaClientService
var config = await _configFactory.CreateAsync(effectiveSettings, ct);
var requestedMode = SecurityModeMapper.ToMessageSecurityMode(settings.SecurityMode);
var endpoint = _endpointDiscovery.SelectEndpoint(config, endpointUrl, requestedMode);
var endpoint = await _endpointDiscovery.SelectEndpointAsync(config, endpointUrl, requestedMode, ct);
var identity = settings.Username != null
? new UserIdentity(settings.Username, Encoding.UTF8.GetBytes(settings.Password ?? ""))
@@ -824,74 +853,8 @@ public sealed class OpcUaClientService : IOpcUaClientService
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.
// Capture the session reference now; skip supplemental reads if the session has
// been replaced by a concurrent failover before the Task.Run body executes.
if (ackedField == null && activeField == null && conditionNodeId != null && _session != null)
{
var session = _session;
var capturedConditionNodeId = conditionNodeId;
var capturedMessage = message;
_ = Task.Run(async () =>
{
// If the session was replaced by a failover before we started reading,
// skip the supplemental reads to avoid hitting a disposed session.
if (!ReferenceEquals(session, _session))
return;
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 (ObjectDisposedException)
{
// Session was disposed during supplemental reads (concurrent failover);
// skip the event rather than delivering stale/default states.
Logger.Debug("Supplemental alarm read skipped — session disposed during failover for {ConditionNodeId}", capturedConditionNodeId);
return;
}
catch
{
// Other supplemental read failure; deliver event with defaults
}
// OperatorComment is intentionally left null (default) here.
// Two concrete reasons make it unrecoverable in this Galaxy sub-attribute fallback path:
// (a) Galaxy's AckMsg attribute is WRITE-ONLY — there is no readable attribute that
// exposes the last operator comment, so no supplemental read can recover it.
// (b) The OPC UA event SelectClause (CreateAlarmEventFilter) carries no comment field;
// the OPC UA event notification itself never delivers a comment string.
// The operator comment is only available via the gateway's native alarm feed
// (the Galaxy driver path, GalaxyAlarmTransition.OperatorComment), not this
// OpcUaClient sub-attribute fallback.
AlarmEvent?.Invoke(this, new AlarmEventArgs(
sourceName, conditionName, severity, capturedMessage, retain, activeState, ackedState, time,
eventId, capturedConditionNodeId));
});
return;
}
// No Galaxy-era supplemental reads (<condition>.InAlarm/.Acked/...): the server publishes the
// standard Part 9 fields. A foreign server that leaves both null reports Active=false/Acked=false.
AlarmEvent?.Invoke(this, new AlarmEventArgs(
sourceName, conditionName, severity, message, retain, activeState, ackedState, time,