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:
@@ -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"/> <= 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; } = [];
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user