64e3fbe035
v2-ci / build (push) Failing after 1m43s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
Adds <summary>, <param>, <typeparam>, and <inheritdoc/> tags to public members surfaced by commentchecker — resolves 5,847 of 5,869 issues (99.6%) across three /fixdocs passes.
283 lines
12 KiB
C#
283 lines
12 KiB
C#
using System.Collections.Concurrent;
|
|
using System.Threading.Channels;
|
|
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;
|
|
using ZB.MOM.WW.OtOpcUa.Client.Shared.Models;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Client.CLI.Commands;
|
|
|
|
[Command("subscribe", Description = "Monitor a node for value changes")]
|
|
public class SubscribeCommand : CommandBase
|
|
{
|
|
/// <summary>
|
|
/// Creates the subscribe command used to monitor a node (or a subtree of nodes) for data-change
|
|
/// notifications.
|
|
/// </summary>
|
|
/// <param name="factory">The factory that creates the shared client service for the command run.</param>
|
|
public SubscribeCommand(IOpcUaClientServiceFactory factory) : base(factory)
|
|
{
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the node ID to monitor. When <see cref="Recursive" /> is set, this node is the browse root
|
|
/// and every <c>Variable</c> child it reaches is subscribed.
|
|
/// </summary>
|
|
[CommandOption("node", 'n', Description = "Node ID to monitor", IsRequired = true)]
|
|
public string NodeId { get; init; } = default!;
|
|
|
|
/// <summary>
|
|
/// Gets the sampling interval, in milliseconds, requested for every monitored item.
|
|
/// </summary>
|
|
[CommandOption("interval", 'i', Description = "Sampling interval in milliseconds")]
|
|
public int Interval { get; init; } = 1000;
|
|
|
|
/// <summary>
|
|
/// Gets a value indicating whether the command should browse from <see cref="NodeId" />
|
|
/// and subscribe to every <c>Variable</c> in the subtree.
|
|
/// </summary>
|
|
[CommandOption("recursive", 'r', Description = "Browse recursively from --node and subscribe to every Variable found")]
|
|
public bool Recursive { get; init; }
|
|
|
|
/// <summary>
|
|
/// Gets the maximum recursion depth applied while collecting variables when <see cref="Recursive" /> is set.
|
|
/// </summary>
|
|
[CommandOption("max-depth", Description = "Maximum recursion depth when --recursive is set")]
|
|
public int MaxDepth { get; init; } = 10;
|
|
|
|
/// <summary>
|
|
/// Gets a value indicating whether per-update lines should be suppressed in favour of the final summary only.
|
|
/// </summary>
|
|
[CommandOption("quiet", 'q', Description = "Suppress per-update output; only print a final summary on Ctrl+C")]
|
|
public bool Quiet { get; init; }
|
|
|
|
/// <summary>
|
|
/// Gets the duration, in seconds, before the command auto-exits and prints its summary.
|
|
/// A value of <c>0</c> means the command runs until Ctrl+C.
|
|
/// </summary>
|
|
[CommandOption("duration", Description = "Auto-exit after N seconds and print summary (0 = run until Ctrl+C)")]
|
|
public int DurationSeconds { get; init; } = 0;
|
|
|
|
/// <summary>
|
|
/// Gets the optional path that the command should write the final summary to on exit, in addition to stdout.
|
|
/// </summary>
|
|
[CommandOption("summary-file", Description = "Write summary to this file path on exit (in addition to stdout)")]
|
|
public string? SummaryFile { get; init; }
|
|
|
|
/// <inheritdoc />
|
|
public override async ValueTask ExecuteAsync(IConsole console)
|
|
{
|
|
ConfigureLogging();
|
|
|
|
if (Interval <= 0)
|
|
throw new CommandException($"--interval must be greater than 0 (was {Interval}).");
|
|
if (Recursive && MaxDepth <= 0)
|
|
throw new CommandException($"--max-depth must be greater than 0 (was {MaxDepth}).");
|
|
if (DurationSeconds < 0)
|
|
throw new CommandException($"--duration must be 0 or a positive number (was {DurationSeconds}).");
|
|
|
|
NodeId rootNodeId;
|
|
try
|
|
{
|
|
rootNodeId = NodeIdParser.ParseRequired(NodeId);
|
|
}
|
|
catch (Exception ex) when (ex is FormatException or ArgumentException)
|
|
{
|
|
throw new CommandException($"Invalid --node value: {ex.Message}");
|
|
}
|
|
|
|
IOpcUaClientService? service = null;
|
|
try
|
|
{
|
|
var ct = console.RegisterCancellationHandler();
|
|
(service, _) = await CreateServiceAndConnectAsync(ct);
|
|
|
|
var targets = new List<(NodeId nodeId, string displayPath)>();
|
|
if (Recursive)
|
|
{
|
|
await console.Output.WriteLineAsync($"Browsing subtree of {NodeId} (max depth {MaxDepth})...");
|
|
await CollectVariablesAsync(service, rootNodeId, NodeId, MaxDepth, 0, targets, ct);
|
|
await console.Output.WriteLineAsync($"Found {targets.Count} variable nodes.");
|
|
}
|
|
else
|
|
{
|
|
targets.Add((rootNodeId, NodeId));
|
|
}
|
|
|
|
var lastStatus = new ConcurrentDictionary<string, (StatusCode Status, DateTime LastUpdate, object? Value)>();
|
|
var updateCount = new ConcurrentDictionary<string, int>();
|
|
var everBad = new ConcurrentDictionary<string, byte>();
|
|
var displayNameByNodeId = targets.ToDictionary(t => t.nodeId.ToString(), t => t.displayPath);
|
|
|
|
// Channel serialises notification-thread writes to the main async loop so that
|
|
// concurrent SDK callbacks and main-thread summary output never interleave on
|
|
// the shared TextWriter.
|
|
var outputChannel = Channel.CreateUnbounded<string>(
|
|
new UnboundedChannelOptions { SingleReader = true });
|
|
|
|
void DataChangedHandler(object? sender, DataChangedEventArgs e)
|
|
{
|
|
try
|
|
{
|
|
var key = e.NodeId.ToString();
|
|
lastStatus[key] = (e.Value.StatusCode, DateTime.UtcNow, e.Value.Value);
|
|
updateCount.AddOrUpdate(key, 1, (_, v) => v + 1);
|
|
if (!StatusCode.IsGood(e.Value.StatusCode))
|
|
everBad.TryAdd(key, 0);
|
|
if (!Quiet)
|
|
{
|
|
var line =
|
|
$"[{e.Value.SourceTimestamp:O}] {displayNameByNodeId.GetValueOrDefault(key, key)} = {e.Value.Value} ({e.Value.StatusCode})";
|
|
outputChannel.Writer.TryWrite(line);
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// Never let handler exceptions escape into the SDK callback.
|
|
}
|
|
}
|
|
|
|
service.DataChanged += DataChangedHandler;
|
|
|
|
var subscribed = 0;
|
|
foreach (var (nodeId, _) in targets)
|
|
{
|
|
try
|
|
{
|
|
await service.SubscribeAsync(nodeId, Interval, ct);
|
|
subscribed++;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
await console.Output.WriteLineAsync($" FAILED to subscribe {nodeId}: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
await console.Output.WriteLineAsync(
|
|
$"Subscribed to {subscribed}/{targets.Count} nodes (interval: {Interval}ms). Press Ctrl+C to stop and print summary.");
|
|
|
|
// Drain the output channel on the main thread until cancellation fires.
|
|
using var drainCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
|
var drainTask = Task.Run(async () =>
|
|
{
|
|
await foreach (var line in outputChannel.Reader.ReadAllAsync(drainCts.Token))
|
|
await console.Output.WriteLineAsync(line);
|
|
}, CancellationToken.None);
|
|
|
|
try
|
|
{
|
|
if (DurationSeconds > 0)
|
|
await Task.Delay(TimeSpan.FromSeconds(DurationSeconds), ct);
|
|
else
|
|
await Task.Delay(Timeout.Infinite, ct);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
}
|
|
|
|
// Stop accepting new notifications before writing the summary.
|
|
service.DataChanged -= DataChangedHandler;
|
|
outputChannel.Writer.Complete();
|
|
await drainCts.CancelAsync();
|
|
try { await drainTask; } catch (OperationCanceledException) { }
|
|
|
|
// Summary
|
|
var summary = new List<string>();
|
|
summary.Add("");
|
|
summary.Add("==================== SUMMARY ====================");
|
|
var good = new List<string>();
|
|
var bad = new List<string>();
|
|
var never = new List<string>();
|
|
foreach (var (nodeId, display) in targets)
|
|
{
|
|
var key = nodeId.ToString();
|
|
if (!lastStatus.TryGetValue(key, out var entry))
|
|
{
|
|
never.Add(display);
|
|
continue;
|
|
}
|
|
if (StatusCode.IsGood(entry.Status))
|
|
good.Add($"{display} = {entry.Value} ({entry.Status})");
|
|
else
|
|
bad.Add($"{display} = {entry.Value} ({entry.Status})");
|
|
}
|
|
|
|
var neverWentBad = targets
|
|
.Where(t => lastStatus.ContainsKey(t.nodeId.ToString()) && !everBad.ContainsKey(t.nodeId.ToString()))
|
|
.Select(t => t.displayPath)
|
|
.ToList();
|
|
var didGoBad = targets.Count(t => everBad.ContainsKey(t.nodeId.ToString()));
|
|
|
|
summary.Add($"Total subscribed: {targets.Count}");
|
|
summary.Add($" Ever went BAD during window: {didGoBad}");
|
|
summary.Add($" NEVER went bad (suspect): {neverWentBad.Count}");
|
|
summary.Add($" Last status GOOD: {good.Count}");
|
|
summary.Add($" Last status NOT-GOOD: {bad.Count}");
|
|
summary.Add($" No update received at all: {never.Count}");
|
|
|
|
if (neverWentBad.Count > 0 && neverWentBad.Count < targets.Count)
|
|
{
|
|
summary.Add("");
|
|
summary.Add("--- Nodes that NEVER received a bad-quality update (suspect) ---");
|
|
foreach (var line in neverWentBad) summary.Add($" {line}");
|
|
}
|
|
if (never.Count > 0)
|
|
{
|
|
summary.Add("");
|
|
summary.Add("--- Nodes that never received an update at all ---");
|
|
foreach (var line in never) summary.Add($" {line}");
|
|
}
|
|
|
|
foreach (var line in summary) await console.Output.WriteLineAsync(line);
|
|
if (!string.IsNullOrEmpty(SummaryFile))
|
|
{
|
|
try { await File.WriteAllLinesAsync(SummaryFile, summary); }
|
|
catch (Exception ex) { await console.Output.WriteLineAsync($"Failed to write summary file: {ex.Message}"); }
|
|
}
|
|
|
|
foreach (var (nodeId, _) in targets)
|
|
{
|
|
try { await service.UnsubscribeAsync(nodeId); } catch { /* ignore */ }
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
if (service != null)
|
|
{
|
|
await service.DisconnectAsync();
|
|
service.Dispose();
|
|
}
|
|
}
|
|
}
|
|
|
|
private static async Task CollectVariablesAsync(
|
|
IOpcUaClientService service,
|
|
NodeId? parent,
|
|
string parentPath,
|
|
int maxDepth,
|
|
int currentDepth,
|
|
List<(NodeId nodeId, string displayPath)> into,
|
|
CancellationToken ct)
|
|
{
|
|
if (currentDepth >= maxDepth) return;
|
|
var children = await service.BrowseAsync(parent, ct);
|
|
foreach (var child in children)
|
|
{
|
|
var nodeId = NodeIdParser.Parse(child.NodeId);
|
|
if (nodeId is null) continue;
|
|
var childPath = $"{parentPath}/{child.DisplayName}";
|
|
if (child.NodeClass == "Variable")
|
|
{
|
|
into.Add((nodeId, childPath));
|
|
}
|
|
if (child.HasChildren)
|
|
{
|
|
await CollectVariablesAsync(service, nodeId, childPath, maxDepth, currentDepth + 1, into, ct);
|
|
}
|
|
}
|
|
}
|
|
}
|