Files
lmxopcua/tools/opcuacli-dotnet/Commands/BrowseCommand.cs
Joseph Doherty afd6c33d9d Add client-side failover to CLI tool for redundancy testing
All commands gain --failover-urls (-F) to specify alternate endpoints.
Short-lived commands try each URL in order on initial connect. The
subscribe command monitors KeepAlive and automatically reconnects to
the next available server, re-creating the subscription on failover.
Verified with live service start/stop: primary down triggers failover
to secondary, primary restart allows failback.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 14:41:06 -04:00

123 lines
4.5 KiB
C#

using CliFx;
using CliFx.Attributes;
using CliFx.Infrastructure;
using Opc.Ua;
using Opc.Ua.Client;
namespace OpcUaCli.Commands;
[Command("browse", Description = "Browse the OPC UA address space")]
public class BrowseCommand : ICommand
{
/// <summary>
/// Gets the OPC UA endpoint URL to connect to before browsing.
/// </summary>
[CommandOption("url", 'u', Description = "OPC UA server endpoint URL", IsRequired = true)]
public string Url { get; init; } = default!;
[CommandOption("username", 'U', Description = "Username for authentication")]
public string? Username { get; init; }
[CommandOption("password", 'P', Description = "Password for authentication")]
public string? Password { get; init; }
[CommandOption("security", 'S', Description = "Transport security: none, sign, encrypt (default: none)")]
public string Security { get; init; } = "none";
[CommandOption("failover-urls", 'F', Description = "Comma-separated failover endpoint URLs for redundancy")]
public string? FailoverUrls { get; init; }
/// <summary>
/// Gets the optional node identifier to browse from; defaults to the OPC UA Objects folder.
/// </summary>
[CommandOption("node", 'n', Description = "Node ID to browse (default: Objects folder)")]
public string? NodeId { get; init; }
/// <summary>
/// Gets the maximum browse depth when recursive traversal is enabled.
/// </summary>
[CommandOption("depth", 'd', Description = "Maximum browse depth")]
public int Depth { get; init; } = 1;
/// <summary>
/// Gets a value indicating whether browse recursion should continue into child objects.
/// </summary>
[CommandOption("recursive", 'r', Description = "Browse recursively (uses --depth as max depth)")]
public bool Recursive { get; init; }
/// <summary>
/// Connects to the OPC UA endpoint and writes the browse tree to the console.
/// </summary>
/// <param name="console">The console used to emit browse output.</param>
public async ValueTask ExecuteAsync(IConsole console)
{
var urls = FailoverUrlParser.Parse(Url, FailoverUrls);
using var failover = new OpcUaFailoverHelper(urls, Username, Password, Security);
using var session = await failover.ConnectAsync();
var startNode = string.IsNullOrEmpty(NodeId)
? ObjectIds.ObjectsFolder
: new NodeId(NodeId);
var maxDepth = Recursive ? Depth : 1;
await BrowseNodeAsync(session, console, startNode, maxDepth, 0);
}
private static async Task BrowseNodeAsync(
ISession session, IConsole console, NodeId nodeId, int maxDepth, int currentDepth)
{
var indent = new string(' ', currentDepth * 2);
var (_, continuationPoint, references) = await session.BrowseAsync(
null,
null,
nodeId,
0u,
BrowseDirection.Forward,
ReferenceTypeIds.HierarchicalReferences,
true,
(uint)NodeClass.Object | (uint)NodeClass.Variable | (uint)NodeClass.Method);
if (references == null) return;
// Handle continuation points for large result sets
while (references.Count > 0)
{
foreach (var reference in references)
{
var nodeClass = reference.NodeClass;
var marker = nodeClass switch
{
NodeClass.Object => "[Object]",
NodeClass.Variable => "[Variable]",
NodeClass.Method => "[Method]",
_ => $"[{nodeClass}]"
};
await console.Output.WriteLineAsync(
$"{indent}{marker} {reference.DisplayName} (NodeId: {reference.NodeId})");
if (currentDepth + 1 < maxDepth && nodeClass == NodeClass.Object)
{
var childNodeId = ExpandedNodeId.ToNodeId(
reference.NodeId, session.NamespaceUris);
await BrowseNodeAsync(session, console, childNodeId, maxDepth, currentDepth + 1);
}
}
// Follow continuation point if present
if (continuationPoint != null && continuationPoint.Length > 0)
{
var (_, nextCp, nextRefs) = await session.BrowseNextAsync(
null, false, continuationPoint);
continuationPoint = nextCp;
references = nextRefs;
}
else
{
break;
}
}
}
}