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
@@ -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();
}