Files
lmxopcua/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli/Commands/BrowseCommand.cs
Joseph Doherty f2ee027145 fix(driver-twincat-cli): resolve Low code-review findings (Driver.TwinCAT.Cli-001,002,003,004,005,006,007)
- Driver.TwinCAT.Cli-001: TwinCATCommandBase.Validate rejects
  non-positive TimeoutMs / IntervalMs and AmsPort outside 1..65535;
  ExecuteAsync calls it first.
- Driver.TwinCAT.Cli-002: SubscribeCommand serialises every WriteLine
  through a writeLock to remove the notification-callback vs banner
  interleave risk.
- Driver.TwinCAT.Cli-003: SubscribeCommand.DescribeMechanism derives
  the banner label from the returned ISubscriptionHandle.DiagnosticId
  so it can't disagree with what the driver actually did.
- Driver.TwinCAT.Cli-004: introduced TwinCATTagCommandBase carrying
  --poll-only + BuildOptions; BrowseCommand stays on the slimmer
  TwinCATCommandBase so --poll-only no longer surfaces in browse --help.
- Driver.TwinCAT.Cli-005: ProbeCommand --type now carries the 't' short
  alias to match the other commands.
- Driver.TwinCAT.Cli-006: 35 new tests covering Gateway / AmsAddress
  parse / BuildOptions / PollOnly / browse-helpers / probe-alias /
  mechanism derivation.
- Driver.TwinCAT.Cli-007: replaced the empty-init <inheritdoc/> with an
  explicit summary warning future maintainers about the no-op init.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 08:34:57 -04:00

136 lines
6.0 KiB
C#

using CliFx.Attributes;
using CliFx.Infrastructure;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Commands;
/// <summary>
/// Walk the target's symbol table (ADS <c>SymbolLoaderFactory</c>, flat mode) and print every
/// symbol the driver's atomic-type mapper recognizes. Same path <c>DiscoverAsync</c> takes
/// when <c>EnableControllerBrowse = true</c> — structured UDTs / function-block instances
/// won't appear because the driver filters to the supported primitive surface.
/// </summary>
/// <remarks>
/// Inherits from <see cref="TwinCATCommandBase"/> rather than
/// <see cref="TwinCATTagCommandBase"/> so the <c>--poll-only</c> flag does NOT surface in
/// <c>browse --help</c>: browse never subscribes, the flag would be a no-op, and the help
/// text would mislead users (Driver.TwinCAT.Cli-004).
/// </remarks>
[Command("browse", Description = "Enumerate controller symbols via the driver's DiscoverAsync walk.")]
public sealed class BrowseCommand : TwinCATCommandBase
{
[CommandOption("prefix", Description =
"Case-sensitive instance-path prefix to filter on (e.g. 'GVL_Fixture' or " +
"'MAIN.'). Empty (default) prints everything.")]
public string? Prefix { get; init; }
[CommandOption("max", Description =
"Maximum number of symbols to print. 0 = unbounded (default 500 for large " +
"controllers — flat-mode symbol counts easily top 10k).")]
public int Max { get; init; } = 500;
public override async ValueTask ExecuteAsync(IConsole console)
{
Validate();
ConfigureLogging();
var ct = console.RegisterCancellationHandler();
// Browse-only — no declared tags. EnableControllerBrowse=true flips DiscoverAsync's
// symbol-walk on so every recognized primitive surfaces through the builder. Native
// ADS notifications are irrelevant here (DiscoverAsync never subscribes); leave the
// default on so the options record matches the production wiring.
var options = new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(Gateway, $"cli-{AmsNetId}:{AmsPort}")],
Tags = [],
Timeout = Timeout,
Probe = new TwinCATProbeOptions { Enabled = false },
UseNativeNotifications = true,
EnableControllerBrowse = true,
};
await using var driver = new TwinCATDriver(options, DriverInstanceId);
var builder = new CollectingAddressSpaceBuilder();
try
{
await driver.InitializeAsync("{}", ct);
await driver.DiscoverAsync(builder, ct);
}
finally
{
await driver.ShutdownAsync(CancellationToken.None);
}
var matched = FilterByPrefix(builder.Variables, Prefix);
var printLimit = PrintLimit(matched.Count, Max);
await console.Output.WriteLineAsync($"AMS: {AmsNetId}:{AmsPort}");
await console.Output.WriteLineAsync(
$"Symbols: {matched.Count} matched ({builder.Variables.Count} total), showing {printLimit}");
await console.Output.WriteLineAsync();
foreach (var v in matched.Take(printLimit))
{
await console.Output.WriteLineAsync($" [{AccessTag(v.Info)}] {v.Info.DriverDataType,-8} {v.BrowseName}");
}
if (matched.Count > printLimit)
await console.Output.WriteLineAsync(
$" … {matched.Count - printLimit} more — raise --max or tighten --prefix");
}
/// <summary>
/// Case-sensitive prefix filter. A null/empty prefix keeps everything; otherwise we
/// keep symbols whose browse name starts with <paramref name="prefix"/> under
/// <see cref="StringComparison.Ordinal"/> — TwinCAT identifiers are case-sensitive on
/// the wire, so a relaxed match would be misleading.
/// </summary>
internal static List<(string BrowseName, DriverAttributeInfo Info)> FilterByPrefix(
IReadOnlyList<(string BrowseName, DriverAttributeInfo Info)> source, string? prefix)
=> source
.Where(v => string.IsNullOrEmpty(prefix) || v.BrowseName.StartsWith(prefix, StringComparison.Ordinal))
.ToList();
/// <summary>
/// Cap-to-max projection. <paramref name="max"/> &lt;= 0 means unbounded, otherwise the
/// min of <paramref name="matchedCount"/> and <paramref name="max"/>.
/// </summary>
internal static int PrintLimit(int matchedCount, int max)
=> max <= 0 ? matchedCount : Math.Min(max, matchedCount);
/// <summary>
/// Coarse RO/RW label used in the browse output. <see cref="SecurityClassification.ViewOnly"/>
/// is the only classification that is unconditionally read-only; everything else can be
/// written from at least one ACL tier, so the CLI labels it RW. The real per-tier
/// authorization is enforced server-side.
/// </summary>
internal static string AccessTag(DriverAttributeInfo info)
=> info.SecurityClass == SecurityClassification.ViewOnly ? "RO" : "RW";
internal sealed class CollectingAddressSpaceBuilder : IAddressSpaceBuilder
{
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = [];
public IAddressSpaceBuilder Folder(string browseName, string displayName) => this;
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
{
Variables.Add((browseName, info));
return new Handle(info.FullName);
}
public void AddProperty(string name, DriverDataType type, object? value) { }
private sealed class Handle(string fullRef) : IVariableHandle
{
public string FullReference => fullRef;
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
}
private sealed class NullSink : IAlarmConditionSink
{
public void OnTransition(AlarmEventArgs args) { }
}
}
}