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>
This commit is contained in:
Joseph Doherty
2026-05-23 08:34:57 -04:00
parent 67ef6c4ebc
commit f2ee027145
11 changed files with 642 additions and 60 deletions

View File

@@ -10,6 +10,12 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Commands;
/// 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
{
@@ -25,18 +31,21 @@ public sealed class BrowseCommand : TwinCATCommandBase
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.
// 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 = !PollOnly,
UseNativeNotifications = true,
EnableControllerBrowse = true,
};
@@ -52,10 +61,8 @@ public sealed class BrowseCommand : TwinCATCommandBase
await driver.ShutdownAsync(CancellationToken.None);
}
var matched = builder.Variables
.Where(v => string.IsNullOrEmpty(Prefix) || v.BrowseName.StartsWith(Prefix, StringComparison.Ordinal))
.ToList();
var printLimit = Max <= 0 ? matched.Count : Math.Min(Max, matched.Count);
var matched = FilterByPrefix(builder.Variables, Prefix);
var printLimit = PrintLimit(matched.Count, Max);
await console.Output.WriteLineAsync($"AMS: {AmsNetId}:{AmsPort}");
await console.Output.WriteLineAsync(
@@ -64,8 +71,7 @@ public sealed class BrowseCommand : TwinCATCommandBase
foreach (var v in matched.Take(printLimit))
{
var access = v.Info.SecurityClass == SecurityClassification.ViewOnly ? "RO" : "RW";
await console.Output.WriteLineAsync($" [{access}] {v.Info.DriverDataType,-8} {v.BrowseName}");
await console.Output.WriteLineAsync($" [{AccessTag(v.Info)}] {v.Info.DriverDataType,-8} {v.BrowseName}");
}
if (matched.Count > printLimit)
@@ -73,7 +79,35 @@ public sealed class BrowseCommand : TwinCATCommandBase
$" … {matched.Count - printLimit} more — raise --max or tighten --prefix");
}
private sealed class CollectingAddressSpaceBuilder : IAddressSpaceBuilder
/// <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; } = [];

View File

@@ -11,7 +11,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Commands;
/// server near the endpoint.
/// </summary>
[Command("probe", Description = "Verify the TwinCAT runtime is reachable and a sample symbol reads.")]
public sealed class ProbeCommand : TwinCATCommandBase
public sealed class ProbeCommand : TwinCATTagCommandBase
{
[CommandOption("symbol", 's', Description =
"Symbol path to probe. System-global examples: " +
@@ -20,11 +20,14 @@ public sealed class ProbeCommand : TwinCATCommandBase
IsRequired = true)]
public string SymbolPath { get; init; } = default!;
[CommandOption("type", Description = "Data type (default DInt — TwinCAT DINT maps to int32).")]
[CommandOption("type", 't', Description =
"Bool / SInt / USInt / Int / UInt / DInt / UDInt / LInt / ULInt / Real / LReal / " +
"String / WString / Time / Date / DateTime / TimeOfDay (default DInt).")]
public TwinCATDataType DataType { get; init; } = TwinCATDataType.DInt;
public override async ValueTask ExecuteAsync(IConsole console)
{
Validate();
ConfigureLogging();
var ct = console.RegisterCancellationHandler();

View File

@@ -9,7 +9,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Commands;
/// member list into individual reads if you need them.
/// </summary>
[Command("read", Description = "Read a single TwinCAT symbol.")]
public sealed class ReadCommand : TwinCATCommandBase
public sealed class ReadCommand : TwinCATTagCommandBase
{
[CommandOption("symbol", 's', Description =
"Symbol path. Program scope: 'MAIN.bStart'. Global: 'GVL.Counter'. " +
@@ -24,6 +24,7 @@ public sealed class ReadCommand : TwinCATCommandBase
public override async ValueTask ExecuteAsync(IConsole console)
{
Validate();
ConfigureLogging();
var ct = console.RegisterCancellationHandler();

View File

@@ -1,4 +1,5 @@
using CliFx.Attributes;
using CliFx.Exceptions;
using CliFx.Infrastructure;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
@@ -10,7 +11,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Commands;
/// pushes on its own cycle); pass <c>--poll-only</c> to fall through to PollGroupEngine.
/// </summary>
[Command("subscribe", Description = "Watch a TwinCAT symbol via ADS notification or poll, until Ctrl+C.")]
public sealed class SubscribeCommand : TwinCATCommandBase
public sealed class SubscribeCommand : TwinCATTagCommandBase
{
[CommandOption("symbol", 's', Description = "Symbol path — same format as `read`.", IsRequired = true)]
public string SymbolPath { get; init; } = default!;
@@ -23,8 +24,17 @@ public sealed class SubscribeCommand : TwinCATCommandBase
[CommandOption("interval-ms", 'i', Description = "Publishing interval ms (default 1000).")]
public int IntervalMs { get; init; } = 1000;
protected override void Validate()
{
base.Validate();
if (IntervalMs <= 0)
throw new CommandException(
$"--interval-ms must be greater than 0 (got {IntervalMs}).");
}
public override async ValueTask ExecuteAsync(IConsole console)
{
Validate();
ConfigureLogging();
var ct = console.RegisterCancellationHandler();
@@ -43,19 +53,39 @@ public sealed class SubscribeCommand : TwinCATCommandBase
{
await driver.InitializeAsync("{}", ct);
// Native ADS notifications fire OnDataChange from the Beckhoff.TwinCAT.Ads
// notification callback thread — unlike the poll-mode path (which serialises on a
// single PollGroupEngine loop), the native callback can interleave with the banner
// write below and with subsequent change events if the PLC pushes faster than a
// single console write completes. A TextWriter is not guaranteed thread-safe, so
// we serialise every write through a lock to keep output clean for
// screen-recorded bug-report timelines (Driver.TwinCAT.Cli-002).
var writeLock = new object();
driver.OnDataChange += (_, e) =>
{
var line = $"[{DateTime.UtcNow:HH:mm:ss.fff}] " +
$"{e.FullReference} = {SnapshotFormatter.FormatValue(e.Snapshot.Value)} " +
$"({SnapshotFormatter.FormatStatus(e.Snapshot.StatusCode)})";
console.Output.WriteLine(line);
lock (writeLock)
{
console.Output.WriteLine(line);
}
};
handle = await driver.SubscribeAsync([tagName], TimeSpan.FromMilliseconds(IntervalMs), ct);
var mode = PollOnly ? "polling" : "ADS notification";
await console.Output.WriteLineAsync(
$"Subscribed to {SymbolPath} @ {IntervalMs}ms ({mode}). Ctrl+C to stop.");
// Driver.TwinCAT.Cli-003: derive the banner mechanism from the actual subscription
// handle the driver returned, not from --poll-only. The native ADS path tags its
// handle with a "twincat-native-sub-*" DiagnosticId; anything else means we landed
// on the shared PollGroupEngine. That way the line cannot disagree with what the
// driver actually did (e.g. a future fallback inside SubscribeAsync).
var mode = DescribeMechanism(handle);
lock (writeLock)
{
console.Output.WriteLine(
$"Subscribed to {SymbolPath} @ {IntervalMs}ms ({mode}). Ctrl+C to stop.");
}
try
{
await Task.Delay(System.Threading.Timeout.InfiniteTimeSpan, ct);
@@ -75,4 +105,16 @@ public sealed class SubscribeCommand : TwinCATCommandBase
await driver.ShutdownAsync(CancellationToken.None);
}
}
/// <summary>
/// Maps the returned subscription handle's <see cref="ISubscriptionHandle.DiagnosticId"/>
/// to the banner label. The TwinCAT driver tags native ADS subscriptions with
/// <c>twincat-native-sub-*</c> and the shared <c>PollGroupEngine</c> handle uses a
/// different format — anything else means we landed on the poll loop. Internal so the
/// test assembly can cover the mapping without spinning a real driver.
/// </summary>
internal static string DescribeMechanism(ISubscriptionHandle handle) =>
handle.DiagnosticId.StartsWith("twincat-native-sub-", StringComparison.Ordinal)
? "ADS notification"
: "polling";
}

View File

@@ -11,7 +11,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Commands;
/// JSON for those.
/// </summary>
[Command("write", Description = "Write a single TwinCAT symbol.")]
public sealed class WriteCommand : TwinCATCommandBase
public sealed class WriteCommand : TwinCATTagCommandBase
{
[CommandOption("symbol", 's', Description =
"Symbol path — same format as `read`.", IsRequired = true)]
@@ -29,6 +29,7 @@ public sealed class WriteCommand : TwinCATCommandBase
public override async ValueTask ExecuteAsync(IConsole console)
{
Validate();
ConfigureLogging();
var ct = console.RegisterCancellationHandler();

View File

@@ -1,13 +1,15 @@
using CliFx.Attributes;
using CliFx.Exceptions;
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli;
/// <summary>
/// Base for every TwinCAT CLI command. Carries the AMS target options
/// (<c>--ams-net-id</c> + <c>--ams-port</c>) + the notification-mode toggle that the
/// driver itself takes. Exposes <see cref="BuildOptions"/> so each command can build a
/// single-device / single-tag <see cref="TwinCATDriverOptions"/> from flag input.
/// (<c>--ams-net-id</c> + <c>--ams-port</c>) + the per-call timeout. Commands that build
/// a single-device / single-tag <see cref="TwinCATDriverOptions"/> from flag input inherit
/// from <see cref="TwinCATTagCommandBase"/> instead — that intermediate adds the
/// <c>--poll-only</c> flag and the <c>BuildOptions</c> helper.
/// </summary>
public abstract class TwinCATCommandBase : DriverCommandBase
{
@@ -23,16 +25,19 @@ public abstract class TwinCATCommandBase : DriverCommandBase
[CommandOption("timeout-ms", Description = "Per-operation timeout in ms (default 5000).")]
public int TimeoutMs { get; init; } = 5000;
[CommandOption("poll-only", Description =
"Disable native ADS notifications and fall through to the shared PollGroupEngine " +
"(same as setting UseNativeNotifications=false in a real driver config).")]
public bool PollOnly { get; init; }
/// <inheritdoc />
/// <summary>
/// The per-operation timeout, projected from <see cref="TimeoutMs"/>. The CliFx
/// <c>init</c> accessor required by the abstract base property is intentionally a
/// no-op: <see cref="TimeoutMs"/> is the only source of truth, so any value an
/// `init` initialiser supplies to <see cref="Timeout"/> directly is silently
/// dropped. Do NOT add a backing field "fixing" the empty body — it would diverge
/// from <see cref="TimeoutMs"/> and the two would drift on every refactor
/// (Driver.TwinCAT.Cli-007).
/// </summary>
public override TimeSpan Timeout
{
get => TimeSpan.FromMilliseconds(TimeoutMs);
init { /* driven by TimeoutMs */ }
init { /* see XML summary — driven by TimeoutMs */ }
}
/// <summary>
@@ -41,22 +46,29 @@ public abstract class TwinCATCommandBase : DriverCommandBase
/// </summary>
protected string Gateway => $"ads://{AmsNetId}:{AmsPort}";
/// <summary>
/// Build a <see cref="TwinCATDriverOptions"/> with the AMS target this base collected +
/// the tag list a subclass supplies. Probe disabled, controller-browse disabled,
/// native notifications toggled by <see cref="PollOnly"/>.
/// </summary>
protected TwinCATDriverOptions BuildOptions(IReadOnlyList<TwinCATTagDefinition> tags) => new()
{
Devices = [new TwinCATDeviceOptions(
HostAddress: Gateway,
DeviceName: $"cli-{AmsNetId}:{AmsPort}")],
Tags = tags,
Timeout = Timeout,
Probe = new TwinCATProbeOptions { Enabled = false },
UseNativeNotifications = !PollOnly,
EnableControllerBrowse = false,
};
protected string DriverInstanceId => $"twincat-cli-{AmsNetId}:{AmsPort}";
/// <summary>
/// Validates the numeric options every TwinCAT CLI command shares (timeout + AMS port).
/// Subclasses override and call <c>base.Validate()</c> first to add their own range
/// checks. Throwing here surfaces a clean CliFx one-line error before the driver gets
/// a chance to fail with an opaque transport error (Driver.TwinCAT.Cli-001).
/// </summary>
protected virtual void Validate()
{
if (TimeoutMs <= 0)
throw new CommandException(
$"--timeout-ms must be greater than 0 (got {TimeoutMs}).");
if (AmsPort is <= 0 or > 65535)
throw new CommandException(
$"--ams-port must be in the range 1..65535 (got {AmsPort}).");
}
// ---- Test hooks ----
// Protected members are exposed to the test assembly through these internal accessors so the
// test project can cover Gateway / DriverInstanceId composition + range validation without
// needing reflection on every assertion (Driver.TwinCAT.Cli-006).
internal string GatewayForTest => Gateway;
internal string DriverInstanceIdForTest => DriverInstanceId;
internal void ValidateForTest() => Validate();
}

View File

@@ -0,0 +1,40 @@
using CliFx.Attributes;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli;
/// <summary>
/// Intermediate base for the four TwinCAT CLI commands that build a single-device /
/// single-tag <see cref="TwinCATDriverOptions"/> — <c>probe</c>, <c>read</c>, <c>write</c>,
/// <c>subscribe</c>. Adds the <c>--poll-only</c> flag (relevant only when the driver is
/// about to register native ADS notifications, which is why it does NOT live on the
/// <c>browse</c> command — Driver.TwinCAT.Cli-004) and the <c>BuildOptions</c> helper that
/// assembles the driver-side options record.
/// </summary>
public abstract class TwinCATTagCommandBase : TwinCATCommandBase
{
[CommandOption("poll-only", Description =
"Disable native ADS notifications and fall through to the shared PollGroupEngine " +
"(same as setting UseNativeNotifications=false in a real driver config).")]
public bool PollOnly { get; init; }
/// <summary>
/// Build a <see cref="TwinCATDriverOptions"/> with the AMS target this base collected +
/// the tag list a subclass supplies. Probe disabled, controller-browse disabled,
/// native notifications toggled by <see cref="PollOnly"/>.
/// </summary>
protected TwinCATDriverOptions BuildOptions(IReadOnlyList<TwinCATTagDefinition> tags) => new()
{
Devices = [new TwinCATDeviceOptions(
HostAddress: Gateway,
DeviceName: $"cli-{AmsNetId}:{AmsPort}")],
Tags = tags,
Timeout = Timeout,
Probe = new TwinCATProbeOptions { Enabled = false },
UseNativeNotifications = !PollOnly,
EnableControllerBrowse = false,
};
// ---- Test hook ----
internal TwinCATDriverOptions BuildOptionsForTest(IReadOnlyList<TwinCATTagDefinition> tags)
=> BuildOptions(tags);
}