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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user