Files
lmxopcua/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli/Commands/BrowseCommand.cs
T
Joseph Doherty a53be2af65 v3(b1-twincat): apply Modbus exemplar pattern to TwinCAT (multi-device)
- Rename TwinCATEquipmentTagParser -> TwinCATTagDefinitionFactory; TryParse ->
  FromTagConfig(tagConfig, rawPath, out def). Drop leading-'{' heuristic + the
  deviceHostAddress key; def.Name = rawPath. Keep strict-enum + Inspect; add a
  Structure guard (sole tag path now) + inverse ToTagConfig.
- Options: Tags -> RawTags (RawTagEntry); keep Devices + protocol fields.
- Driver: _tagsByRawPath (Ordinal); byName-only resolver; build table from
  _options.RawTags via FromTagConfig, threading WriteIdempotent + DeviceName.
  Multi-device: ResolveDeviceHost matches entry.DeviceName against options.Devices
  (by name, then host) -> def.DeviceHostAddress (TODO(v3 WaveC): live host from the
  Device row's DeviceConfig). DiscoverAsync now emits from the table.
- Factory: retire pre-declared tags[] DTO path; bind rawTags; keep Devices.
- Cli: BuildOptions serialises typed defs -> RawTagEntry via ToTagConfig.
- Tests: TwinCATRawTags helper; migrate Tags= -> RawTags=; rename parser tests to
  FromTagConfig; equipment/resolve-host/array tests delivered via RawTags.

Gate: Contracts + Driver build green; Driver.TwinCAT.Tests 192 pass, Cli.Tests 70 pass.
2026-07-15 20:11:23 -04:00

159 lines
7.4 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.
/// </remarks>
[Command("browse", Description = "Enumerate controller symbols via the driver's DiscoverAsync walk.")]
public sealed class BrowseCommand : TwinCATCommandBase
{
/// <summary>Gets or sets the case-sensitive instance-path prefix to filter on.</summary>
[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; }
/// <summary>Gets or sets the maximum number of symbols to print.</summary>
[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;
/// <inheritdoc />
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}")],
RawTags = [],
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);
FlushLogging();
}
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>
/// <param name="source">The source collection to filter.</param>
/// <param name="prefix">The prefix to filter on, or null to keep everything.</param>
/// <returns>The subset of <paramref name="source"/> whose browse name starts with <paramref name="prefix"/>.</returns>
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>
/// <param name="matchedCount">The number of matched items.</param>
/// <param name="max">The maximum number to show, or 0 for unbounded.</param>
/// <returns>The number of items to print — <paramref name="matchedCount"/> if <paramref name="max"/>
/// is unbounded, otherwise the smaller of the two.</returns>
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>
/// <param name="info">The attribute info to label.</param>
/// <returns><c>"RO"</c> when the attribute is view-only; otherwise <c>"RW"</c>.</returns>
internal static string AccessTag(DriverAttributeInfo info)
=> info.SecurityClass == SecurityClassification.ViewOnly ? "RO" : "RW";
/// <summary>An address space builder that collects variables for enumeration.</summary>
internal sealed class CollectingAddressSpaceBuilder : IAddressSpaceBuilder
{
/// <summary>Gets the collected variables.</summary>
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = [];
/// <inheritdoc />
public IAddressSpaceBuilder Folder(string browseName, string displayName) => this;
/// <inheritdoc />
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
{
Variables.Add((browseName, info));
return new Handle(info.FullName);
}
/// <inheritdoc />
public void AddProperty(string name, DriverDataType type, object? value) { }
/// <summary>A variable handle that stores the full reference.</summary>
private sealed class Handle(string fullRef) : IVariableHandle
{
/// <inheritdoc />
public string FullReference => fullRef;
/// <inheritdoc />
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
}
/// <summary>A null sink that ignores alarm condition transitions.</summary>
private sealed class NullSink : IAlarmConditionSink
{
/// <inheritdoc />
public void OnTransition(AlarmEventArgs args) { }
}
}
}