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:
@@ -0,0 +1,85 @@
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Exceptions;
|
||||
using CliFx.Infrastructure;
|
||||
using Opc.Ua;
|
||||
using ZB.MOM.WW.OtOpcUa.Client.CLI.Helpers;
|
||||
using ZB.MOM.WW.OtOpcUa.Client.Shared;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Client.CLI.Commands;
|
||||
|
||||
[Command("comment", Description = "Add an operator comment to an alarm condition (OPC UA Part 9 AddComment)")]
|
||||
public class CommentCommand : CommandBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates the comment command used to attach an operator comment to an OPC UA condition from the terminal.
|
||||
/// </summary>
|
||||
/// <param name="factory">The factory that creates the shared client service for the command run.</param>
|
||||
public CommentCommand(IOpcUaClientServiceFactory factory) : base(factory)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the condition node ID of the alarm event to comment on.
|
||||
/// </summary>
|
||||
[CommandOption("node", 'n', Description = "Condition node ID of the alarm to comment on", IsRequired = true)]
|
||||
public string NodeId { get; init; } = default!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the event ID (hex-encoded) returned by the server in the alarm notification.
|
||||
/// </summary>
|
||||
[CommandOption("event-id", 'e', Description = "EventId from the alarm notification (hex-encoded byte array)", IsRequired = true)]
|
||||
public string EventId { get; init; } = default!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the operator comment to attach to the condition.
|
||||
/// </summary>
|
||||
[CommandOption("comment", 'c', Description = "Operator comment to attach", IsRequired = true)]
|
||||
public string Comment { get; init; } = string.Empty;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
ConfigureLogging();
|
||||
|
||||
try
|
||||
{
|
||||
NodeIdParser.ParseRequired(NodeId);
|
||||
}
|
||||
catch (Exception ex) when (ex is FormatException or ArgumentException)
|
||||
{
|
||||
throw new CommandException($"Invalid --node value: {ex.Message}");
|
||||
}
|
||||
|
||||
byte[] eventId;
|
||||
try
|
||||
{
|
||||
eventId = Convert.FromHexString(EventId);
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
throw new CommandException($"Invalid --event-id value: {ex.Message} (expected hex-encoded bytes, e.g. 0A1B2C)");
|
||||
}
|
||||
|
||||
IOpcUaClientService? service = null;
|
||||
try
|
||||
{
|
||||
var ct = console.RegisterCancellationHandler();
|
||||
(service, _) = await CreateServiceAndConnectAsync(ct);
|
||||
|
||||
var statusCode = await service.AddCommentAsync(NodeId, eventId, Comment, ct);
|
||||
|
||||
if (StatusCode.IsGood(statusCode))
|
||||
await console.Output.WriteLineAsync($"Comment added: {NodeId}");
|
||||
else
|
||||
await console.Output.WriteLineAsync($"Comment failed: {statusCode}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (service != null)
|
||||
{
|
||||
await service.DisconnectAsync();
|
||||
service.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using Opc.Ua;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Adapters;
|
||||
|
||||
/// <summary>
|
||||
/// The one <see cref="ITelemetryContext" /> the shared client hands the SDK's non-obsolete (telemetry-taking)
|
||||
/// overloads. An empty builder keeps SDK internals off any logging provider; the client logs through Serilog.
|
||||
/// </summary>
|
||||
internal static class ClientTelemetry
|
||||
{
|
||||
private static readonly Lazy<ITelemetryContext> Instance = new(() => DefaultTelemetry.Create(_ => { }));
|
||||
|
||||
/// <summary>Gets the shared telemetry context.</summary>
|
||||
public static ITelemetryContext Context => Instance.Value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bounded sync-over-async for the <c>Dispose</c> paths, which cannot await. Runs on the thread pool so a
|
||||
/// caller on a synchronization context (the Avalonia UI thread) cannot deadlock, and waits at most
|
||||
/// <see cref="Timeout" /> so a dead server cannot hang teardown.
|
||||
/// </summary>
|
||||
internal static class DisposeBridge
|
||||
{
|
||||
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <summary>Runs <paramref name="work" /> to completion (or the timeout), swallowing every failure.</summary>
|
||||
public static void Run(Func<CancellationToken, Task> work)
|
||||
{
|
||||
using var cts = new CancellationTokenSource(Timeout);
|
||||
try
|
||||
{
|
||||
Task.Run(() => work(cts.Token), cts.Token).Wait(Timeout);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Teardown is best-effort, exactly as the synchronous Close/Delete it replaces.
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -58,14 +58,14 @@ internal sealed class DefaultApplicationConfigurationFactory : IApplicationConfi
|
||||
}
|
||||
};
|
||||
|
||||
await config.Validate(ApplicationType.Client);
|
||||
await config.ValidateAsync(ApplicationType.Client, ct);
|
||||
|
||||
if (settings.AutoAcceptCertificates)
|
||||
config.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
|
||||
|
||||
if (settings.SecurityMode != SecurityMode.None)
|
||||
{
|
||||
var app = new ApplicationInstance
|
||||
var app = new ApplicationInstance(ClientTelemetry.Context)
|
||||
{
|
||||
ApplicationName = "OtOpcUaClient",
|
||||
ApplicationType = ApplicationType.Client,
|
||||
|
||||
@@ -12,18 +12,14 @@ internal sealed class DefaultEndpointDiscovery : IEndpointDiscovery
|
||||
private static readonly ILogger Logger = Log.ForContext<DefaultEndpointDiscovery>();
|
||||
|
||||
/// <inheritdoc />
|
||||
public EndpointDescription SelectEndpoint(ApplicationConfiguration config, string endpointUrl,
|
||||
MessageSecurityMode requestedMode)
|
||||
public async Task<EndpointDescription> SelectEndpointAsync(ApplicationConfiguration config, string endpointUrl,
|
||||
MessageSecurityMode requestedMode, CancellationToken ct = default)
|
||||
{
|
||||
if (requestedMode == MessageSecurityMode.None)
|
||||
{
|
||||
#pragma warning disable CS0618 // Acceptable for endpoint selection
|
||||
return CoreClientUtils.SelectEndpoint(config, endpointUrl, false);
|
||||
#pragma warning restore CS0618
|
||||
}
|
||||
|
||||
using var client = DiscoveryClient.Create(new Uri(endpointUrl));
|
||||
var allEndpoints = client.GetEndpoints(null);
|
||||
// One path for every mode: discover, filter by mode, rewrite the host. (The None branch used to call the
|
||||
// obsolete synchronous CoreClientUtils.SelectEndpoint, which does the same filter + rewrite.)
|
||||
using var client = await DiscoveryClient.CreateAsync(
|
||||
config, new Uri(endpointUrl), DiagnosticsMasks.None, ct).ConfigureAwait(false);
|
||||
var allEndpoints = await client.GetEndpointsAsync(null, ct).ConfigureAwait(false);
|
||||
|
||||
return EndpointSelector.SelectBest(allEndpoints, endpointUrl, requestedMode);
|
||||
}
|
||||
|
||||
@@ -264,7 +264,7 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_session.Connected) _session.Close();
|
||||
if (_session.Connected) DisposeBridge.Run(ct => _session.CloseAsync(ct));
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
||||
@@ -23,14 +23,18 @@ internal sealed class DefaultSessionFactory : ISessionFactory
|
||||
var endpointConfig = EndpointConfiguration.Create(config);
|
||||
var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfig);
|
||||
|
||||
var session = await Session.Create(
|
||||
var created = await new Opc.Ua.Client.DefaultSessionFactory(ClientTelemetry.Context).CreateAsync(
|
||||
config,
|
||||
configuredEndpoint,
|
||||
false,
|
||||
sessionName,
|
||||
sessionTimeoutMs,
|
||||
identity,
|
||||
null);
|
||||
null,
|
||||
ct);
|
||||
var session = created as Session
|
||||
?? throw new InvalidOperationException(
|
||||
$"Session factory returned {created.GetType().Name}, expected {nameof(Session)}.");
|
||||
|
||||
Logger.Information("Session created: {SessionName} -> {EndpointUrl}", sessionName, endpoint.EndpointUrl);
|
||||
return new DefaultSessionAdapter(session);
|
||||
|
||||
@@ -123,7 +123,7 @@ internal sealed class DefaultSubscriptionAdapter : ISubscriptionAdapter
|
||||
{
|
||||
try
|
||||
{
|
||||
_subscription.Delete(true);
|
||||
DisposeBridge.Run(ct => _subscription.DeleteAsync(true, ct));
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
||||
@@ -14,7 +14,8 @@ internal interface IEndpointDiscovery
|
||||
/// <param name="config">The OPC UA application configuration.</param>
|
||||
/// <param name="endpointUrl">The endpoint URL to discover.</param>
|
||||
/// <param name="requestedMode">The requested message security mode.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The best-matching endpoint description, with its URL hostname rewritten to match <paramref name="endpointUrl"/> when they differ.</returns>
|
||||
EndpointDescription SelectEndpoint(ApplicationConfiguration config, string endpointUrl,
|
||||
MessageSecurityMode requestedMode);
|
||||
Task<EndpointDescription> SelectEndpointAsync(ApplicationConfiguration config, string endpointUrl,
|
||||
MessageSecurityMode requestedMode, CancellationToken ct = default);
|
||||
}
|
||||
@@ -127,6 +127,16 @@ public interface IOpcUaClientService : IDisposable
|
||||
/// </returns>
|
||||
Task<StatusCode> ConfirmAlarmAsync(string conditionNodeId, byte[] eventId, string comment, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Attaches an operator comment to a condition (Part 9 <c>AddComment</c>) using the event identifier of an alarm notification.
|
||||
/// </summary>
|
||||
/// <param name="conditionNodeId">The condition node the comment is attached to.</param>
|
||||
/// <param name="eventId">The event identifier returned by the OPC UA server for the alarm event.</param>
|
||||
/// <param name="comment">The operator comment.</param>
|
||||
/// <param name="ct">The cancellation token that aborts the request.</param>
|
||||
/// <returns><see cref="StatusCodes.Good"/> on success, or the server's bad <see cref="StatusCode"/> when the call is refused.</returns>
|
||||
Task<StatusCode> AddCommentAsync(string conditionNodeId, byte[] eventId, string comment, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Shelves or unshelves an active alarm condition (OPC UA Part 9 ShelvedStateMachine).
|
||||
/// </summary>
|
||||
|
||||
@@ -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