docs: backfill XML documentation across 756 files
v2-ci / build (push) Failing after 1m43s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped

Adds <summary>, <param>, <typeparam>, and <inheritdoc/> tags to public
members surfaced by commentchecker — resolves 5,847 of 5,869 issues
(99.6%) across three /fixdocs passes.
This commit is contained in:
Joseph Doherty
2026-05-28 08:10:17 -04:00
parent f9fc7dd2e1
commit 64e3fbe035
756 changed files with 9876 additions and 96 deletions
@@ -11,6 +11,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli;
/// </summary>
public abstract class AbCipCommandBase : DriverCommandBase
{
/// <summary>Gets the canonical AB CIP gateway address.</summary>
[CommandOption("gateway", 'g', Description =
"Canonical AB CIP gateway: ab://host[:port]/cip-path. Port defaults to 44818 " +
"(EtherNet/IP). cip-path is family-specific: ControlLogix / CompactLogix need " +
@@ -19,10 +20,12 @@ public abstract class AbCipCommandBase : DriverCommandBase
IsRequired = true)]
public string Gateway { get; init; } = default!;
/// <summary>Gets the PLC family type.</summary>
[CommandOption("family", 'f', Description =
"ControlLogix / CompactLogix / Micro800 / GuardLogix (default ControlLogix).")]
public AbCipPlcFamily Family { get; init; } = AbCipPlcFamily.ControlLogix;
/// <summary>Gets the per-operation timeout in milliseconds.</summary>
[CommandOption("timeout-ms", Description = "Per-operation timeout in ms (default 5000).")]
public int TimeoutMs { get; init; } = 5000;
@@ -56,6 +59,7 @@ public abstract class AbCipCommandBase : DriverCommandBase
/// supplies. Probe + alarm projection are disabled — CLI runs are one-shot; the
/// probe loop would race the operator's own reads.
/// </summary>
/// <param name="tags">The list of tag definitions to include in the options.</param>
protected AbCipDriverOptions BuildOptions(IReadOnlyList<AbCipTagDefinition> tags) => new()
{
Devices = [new AbCipDeviceOptions(
@@ -82,6 +86,7 @@ public abstract class AbCipCommandBase : DriverCommandBase
/// Throws a <see cref="CliFx.Exceptions.CommandException"/> if <paramref name="type"/>
/// is <see cref="AbCipDataType.Structure"/>.
/// </summary>
/// <param name="type">The data type to validate.</param>
protected static void RejectStructure(AbCipDataType type)
{
if (type == AbCipDataType.Structure)
@@ -12,16 +12,19 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Commands;
[Command("probe", Description = "Verify the AB CIP gateway is reachable and a sample tag reads.")]
public sealed class ProbeCommand : AbCipCommandBase
{
/// <summary>Gets the tag path to probe.</summary>
[CommandOption("tag", 't', Description =
"Tag path to probe. ControlLogix default is '@raw_cpu_type' (the canonical libplctag " +
"system tag); Micro800 takes a user-supplied global (e.g. '_SYSVA_CLOCK_HOUR').",
IsRequired = true)]
public string TagPath { get; init; } = default!;
/// <summary>Gets the data type of the probe tag.</summary>
[CommandOption("type", Description =
"Logix atomic type of the probe tag (default DInt).")]
public AbCipDataType DataType { get; init; } = AbCipDataType.DInt;
/// <inheritdoc />
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();
@@ -13,17 +13,22 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Commands;
[Command("read", Description = "Read a single Logix tag by symbolic path.")]
public sealed class ReadCommand : AbCipCommandBase
{
/// <summary>Gets the tag path to read.</summary>
[CommandOption("tag", 't', Description =
"Logix symbolic path. Controller scope: 'Motor01_Speed'. Program scope: " +
"'Program:Main.Motor01_Speed'. Array element: 'Recipe[3]'. UDT member: " +
"'Motor01.Speed'.", IsRequired = true)]
public string TagPath { get; init; } = default!;
/// <summary>Gets the data type to read as.</summary>
[CommandOption("type", Description =
"Bool / SInt / Int / DInt / LInt / USInt / UInt / UDInt / ULInt / Real / LReal / " +
"String / Dt / Structure (default DInt).")]
public AbCipDataType DataType { get; init; } = AbCipDataType.DInt;
/// <summary>Executes the read operation.</summary>
/// <param name="console">The console for output and cancellation handling.</param>
/// <inheritdoc />
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();
@@ -59,6 +64,8 @@ public sealed class ReadCommand : AbCipCommandBase
/// Tag-name key the driver uses internally. The path + type pair is already unique
/// so we use them verbatim — keeps tag-level diagnostics readable without mangling.
/// </summary>
/// <param name="tagPath">The symbolic tag path.</param>
/// <param name="type">The data type.</param>
internal static string SynthesiseTagName(string tagPath, AbCipDataType type)
=> $"{tagPath}:{type}";
}
@@ -13,20 +13,24 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Commands;
[Command("subscribe", Description = "Watch a Logix tag via polled subscription until Ctrl+C.")]
public sealed class SubscribeCommand : AbCipCommandBase
{
/// <summary>Gets or sets the Logix tag path to subscribe to.</summary>
[CommandOption("tag", 't', Description =
"Logix symbolic path — same format as `read`.", IsRequired = true)]
public string TagPath { get; init; } = default!;
/// <summary>Gets or sets the data type of the tag.</summary>
[CommandOption("type", Description =
"Bool / SInt / Int / DInt / LInt / USInt / UInt / UDInt / ULInt / Real / LReal / " +
"String / Dt (default DInt).")]
public AbCipDataType DataType { get; init; } = AbCipDataType.DInt;
/// <summary>Gets or sets the subscription interval in milliseconds.</summary>
[CommandOption("interval-ms", 'i', Description =
"Publishing interval in milliseconds (default 1000). PollGroupEngine floors " +
"sub-250ms values.")]
public int IntervalMs { get; init; } = 1000;
/// <inheritdoc />
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();
@@ -98,6 +102,7 @@ public sealed class SubscribeCommand : AbCipCommandBase
/// <c>SubscribeAsync</c>; the CLI should fail fast with an actionable error rather
/// than relying on the downstream <c>PollGroupEngine</c> to clamp the value.
/// </summary>
/// <param name="intervalMs">The interval in milliseconds.</param>
internal static void ValidateInterval(int intervalMs)
{
if (intervalMs <= 0)
@@ -15,20 +15,24 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Commands;
[Command("write", Description = "Write a single Logix tag by symbolic path.")]
public sealed class WriteCommand : AbCipCommandBase
{
/// <summary>Gets the Logix symbolic path of the tag to write.</summary>
[CommandOption("tag", 't', Description =
"Logix symbolic path — same format as `read`.", IsRequired = true)]
public string TagPath { get; init; } = default!;
/// <summary>Gets the data type to write.</summary>
[CommandOption("type", Description =
"Bool / SInt / Int / DInt / LInt / USInt / UInt / UDInt / ULInt / Real / LReal / " +
"String / Dt (default DInt).")]
public AbCipDataType DataType { get; init; } = AbCipDataType.DInt;
/// <summary>Gets the value to write, as a string.</summary>
[CommandOption("value", 'v', Description =
"Value to write. Parsed per --type (booleans accept true/false/1/0).",
IsRequired = true)]
public string Value { get; init; } = default!;
/// <inheritdoc />
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();
@@ -73,6 +77,9 @@ public sealed class WriteCommand : AbCipCommandBase
/// <see cref="CliFx.Exceptions.CommandException"/> so CliFx renders a clean one-line
/// error rather than a full .NET stack trace.
/// </summary>
/// <param name="raw">The raw string value to parse.</param>
/// <param name="type">The AbCip data type to parse the value into.</param>
/// <returns>The parsed value as an object.</returns>
internal static object ParseValue(string raw, AbCipDataType type)
{
try
@@ -11,6 +11,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli;
/// </summary>
public abstract class AbLegacyCommandBase : DriverCommandBase
{
/// <summary>Gets the gateway endpoint for AB Legacy connection.</summary>
[CommandOption("gateway", 'g', Description =
"Canonical AB Legacy gateway: ab://host[:port]/cip-path. Port defaults to 44818. " +
"cip-path depends on the family: SLC 5/05 + PLC-5 typically '1,0'; MicroLogix " +
@@ -18,10 +19,12 @@ public abstract class AbLegacyCommandBase : DriverCommandBase
IsRequired = true)]
public string Gateway { get; init; } = default!;
/// <summary>Gets the PLC family type.</summary>
[CommandOption("plc-type", 'P', Description =
"Slc500 / MicroLogix / Plc5 / LogixPccc (default Slc500).")]
public AbLegacyPlcFamily PlcType { get; init; } = AbLegacyPlcFamily.Slc500;
/// <summary>Gets the per-operation timeout in milliseconds.</summary>
[CommandOption("timeout-ms", Description = "Per-operation timeout in ms (default 5000).")]
public int TimeoutMs { get; init; } = 5000;
@@ -36,6 +39,8 @@ public abstract class AbLegacyCommandBase : DriverCommandBase
/// Build an <see cref="AbLegacyDriverOptions"/> with the device + tag list a subclass
/// supplies. Probe disabled for CLI one-shot runs.
/// </summary>
/// <param name="tags">The tag definitions to include in the options.</param>
/// <returns>Configured AB Legacy driver options.</returns>
protected AbLegacyDriverOptions BuildOptions(IReadOnlyList<AbLegacyTagDefinition> tags) => new()
{
Devices = [new AbLegacyDeviceOptions(
@@ -47,5 +52,6 @@ public abstract class AbLegacyCommandBase : DriverCommandBase
Probe = new AbLegacyProbeOptions { Enabled = false },
};
/// <summary>Gets the driver instance identifier for CLI operations.</summary>
protected string DriverInstanceId => $"ablegacy-cli-{Gateway}";
}
@@ -12,15 +12,18 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Commands;
[Command("probe", Description = "Verify the AB Legacy endpoint is reachable and a sample PCCC read succeeds.")]
public sealed class ProbeCommand : AbLegacyCommandBase
{
/// <summary>Gets or sets the PCCC address to probe.</summary>
[CommandOption("address", 'a', Description =
"PCCC address to probe (default N7:0). Use S:0 for the status file when you want " +
"the pre-populated register every SLC / MicroLogix / PLC-5 ships with.")]
public string Address { get; init; } = "N7:0";
/// <summary>Gets or sets the PCCC data type of the probe address.</summary>
[CommandOption("type", 't', Description =
"PCCC data type of the probe address (default Int — matches N files).")]
public AbLegacyDataType DataType { get; init; } = AbLegacyDataType.Int;
/// <inheritdoc />
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();
@@ -10,6 +10,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Commands;
[Command("read", Description = "Read a single PCCC file address.")]
public sealed class ReadCommand : AbLegacyCommandBase
{
/// <summary>Gets the PCCC file address to read.</summary>
[CommandOption("address", 'a', Description =
"PCCC file address. File letter implies storage; bit-within-word via slash " +
"(B3:0/3 or N7:0/5). Sub-element access for timers/counters/controls uses " +
@@ -17,11 +18,13 @@ public sealed class ReadCommand : AbLegacyCommandBase
IsRequired = true)]
public string Address { get; init; } = default!;
/// <summary>Gets the data type of the address.</summary>
[CommandOption("type", 't', Description =
"Bit / Int / Long / Float / AnalogInt / String / TimerElement / CounterElement / " +
"ControlElement (default Int).")]
public AbLegacyDataType DataType { get; init; } = AbLegacyDataType.Int;
/// <inheritdoc />
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();
@@ -53,6 +56,8 @@ public sealed class ReadCommand : AbLegacyCommandBase
}
/// <summary>Tag-name key the driver uses internally. Address+type is already unique.</summary>
/// <param name="address">The PCCC file address.</param>
/// <param name="type">The data type of the address.</param>
internal static string SynthesiseTagName(string address, AbLegacyDataType type)
=> $"{address}:{type}";
}
@@ -13,18 +13,22 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Commands;
public sealed class SubscribeCommand : AbLegacyCommandBase
{
[CommandOption("address", 'a', Description = "PCCC file address — same format as `read`.", IsRequired = true)]
/// <summary>Gets or sets the PCCC file address to subscribe to.</summary>
public string Address { get; init; } = default!;
[CommandOption("type", 't', Description =
"Bit / Int / Long / Float / AnalogInt / String / TimerElement / CounterElement / " +
"ControlElement (default Int).")]
/// <summary>Gets or sets the data type of the address.</summary>
public AbLegacyDataType DataType { get; init; } = AbLegacyDataType.Int;
[CommandOption("interval-ms", 'i', Description =
"Publishing interval in milliseconds (default 1000). PollGroupEngine floors " +
"sub-250ms values.")]
/// <summary>Gets or sets the polling interval in milliseconds.</summary>
public int IntervalMs { get; init; } = 1000;
/// <inheritdoc />
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();
@@ -15,20 +15,24 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Commands;
[Command("write", Description = "Write a single PCCC file address.")]
public sealed class WriteCommand : AbLegacyCommandBase
{
/// <summary>Gets or sets the PCCC file address to write to.</summary>
[CommandOption("address", 'a', Description =
"PCCC file address — same format as `read`.", IsRequired = true)]
public string Address { get; init; } = default!;
/// <summary>Gets or sets the data type for the write operation.</summary>
[CommandOption("type", 't', Description =
"Bit / Int / Long / Float / AnalogInt / String / TimerElement / CounterElement / " +
"ControlElement (default Int).")]
public AbLegacyDataType DataType { get; init; } = AbLegacyDataType.Int;
/// <summary>Gets or sets the value to write.</summary>
[CommandOption("value", 'v', Description =
"Value to write. Parsed per --type (booleans accept true/false, 1/0, on/off, yes/no).",
IsRequired = true)]
public string Value { get; init; } = default!;
/// <inheritdoc />
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();
@@ -62,6 +66,9 @@ public sealed class WriteCommand : AbLegacyCommandBase
}
/// <summary>Parse <c>--value</c> per <see cref="AbLegacyDataType"/>, invariant culture.</summary>
/// <param name="raw">The raw string value to parse.</param>
/// <param name="type">The target data type for parsing.</param>
/// <returns>The parsed value.</returns>
/// <exception cref="CliFx.Exceptions.CommandException">
/// Thrown when <paramref name="raw"/> cannot be parsed as the requested type (malformed
/// input or out-of-range value) so CliFx renders a clean one-line error instead of a raw
@@ -41,6 +41,9 @@ public abstract class DriverCommandBase : ICommand
/// </summary>
public abstract TimeSpan Timeout { get; init; }
/// <summary>Executes the command with the given console output.</summary>
/// <param name="console">The console for output.</param>
/// <returns>A task that completes when the command is finished.</returns>
public abstract ValueTask ExecuteAsync(IConsole console);
/// <summary>
@@ -21,6 +21,8 @@ public static class SnapshotFormatter
/// Server Time: 2026-04-21T12:34:56.790Z
/// </code>
/// </summary>
/// <param name="tagName">The tag name to include in the output.</param>
/// <param name="snapshot">The data value snapshot to format.</param>
public static string Format(string tagName, DataValueSnapshot snapshot)
{
ArgumentNullException.ThrowIfNull(snapshot);
@@ -38,6 +40,8 @@ public static class SnapshotFormatter
/// <summary>
/// Write-result render, one line: <c>Write &lt;tag&gt;: 0x... (Good|...)</c>.
/// </summary>
/// <param name="tagName">The tag name to include in the output.</param>
/// <param name="result">The write result to format.</param>
public static string FormatWrite(string tagName, WriteResult result)
{
ArgumentNullException.ThrowIfNull(result);
@@ -48,6 +52,8 @@ public static class SnapshotFormatter
/// Table-style render for batch reads. Emits an aligned 4-column layout:
/// tag / value / status / source-time.
/// </summary>
/// <param name="tagNames">The list of tag names to include as rows.</param>
/// <param name="snapshots">The list of data value snapshots to format.</param>
public static string FormatTable(
IReadOnlyList<string> tagNames, IReadOnlyList<DataValueSnapshot> snapshots)
{
@@ -92,6 +98,9 @@ public static class SnapshotFormatter
return sb.ToString().TrimEnd();
}
/// <summary>Formats a value for console output, handling null, bool, string, and formattable types.</summary>
/// <param name="value">The value to format.</param>
/// <returns>A formatted string representation of the value.</returns>
public static string FormatValue(object? value) => value switch
{
null => "<null>",
@@ -101,6 +110,9 @@ public static class SnapshotFormatter
_ => value.ToString() ?? "<null>",
};
/// <summary>Formats an OPC UA status code as a hexadecimal string with named severity classification.</summary>
/// <param name="statusCode">The OPC UA status code to format.</param>
/// <returns>A formatted status string like "0x00000000 (Good)" or "0x80050000 (BadCommunicationError)".</returns>
public static string FormatStatus(uint statusCode)
{
// OPC UA status codes carry sub-code and flag bits in the low 16 bits (info type,
@@ -149,6 +161,9 @@ public static class SnapshotFormatter
: $"0x{statusCode:X8} ({name})";
}
/// <summary>Formats a UTC timestamp as an ISO 8601 string, or "-" if null.</summary>
/// <param name="ts">The timestamp to format, or null.</param>
/// <returns>An ISO 8601 formatted string or "-".</returns>
public static string FormatTimestamp(DateTime? ts)
{
if (ts is null) return "-";
@@ -14,13 +14,16 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Commands;
[Command("probe", Description = "Verify the CNC is reachable + a sample FOCAS read succeeds.")]
public sealed class ProbeCommand : FocasCommandBase
{
/// <summary>Gets the FOCAS address to probe.</summary>
[CommandOption("address", 'a', Description =
"FOCAS address to probe (default R100 — PMC R-file register 100).")]
public string Address { get; init; } = "R100";
/// <summary>Gets the data type to use for the probe read.</summary>
[CommandOption("type", Description = "Data type (default Int16).")]
public FocasDataType DataType { get; init; } = FocasDataType.Int16;
/// <inheritdoc />
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();
@@ -10,16 +10,20 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Commands;
[Command("read", Description = "Read a single FOCAS address.")]
public sealed class ReadCommand : FocasCommandBase
{
/// <summary>Gets the FOCAS address to read.</summary>
[CommandOption("address", 'a', Description =
"FOCAS address. Examples: R100 (PMC R-file word); X0.0 (PMC X-bit); " +
"PARAM:1815/0 (parameter 1815, axis 0); MACRO:500 (macro variable 500).",
IsRequired = true)]
public string Address { get; init; } = default!;
/// <summary>Gets the data type to interpret the address as.</summary>
[CommandOption("type", 't', Description =
"Bit / Byte / Int16 / Int32 / Float32 / Float64 / String (default Int16).")]
public FocasDataType DataType { get; init; } = FocasDataType.Int16;
/// <summary>Executes the read command against the FOCAS device.</summary>
/// <inheritdoc />
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();
@@ -45,6 +49,9 @@ public sealed class ReadCommand : FocasCommandBase
await console.Output.WriteLineAsync(SnapshotFormatter.Format(Address, snapshot[0]));
}
/// <summary>Constructs a tag name from address and data type.</summary>
/// <param name="address">The FOCAS address.</param>
/// <param name="type">The data type.</param>
internal static string SynthesiseTagName(string address, FocasDataType type)
=> $"{address}:{type}";
}
@@ -12,16 +12,20 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Commands;
[Command("subscribe", Description = "Watch a FOCAS address via polled subscription until Ctrl+C.")]
public sealed class SubscribeCommand : FocasCommandBase
{
/// <summary>Gets the FOCAS address to subscribe to.</summary>
[CommandOption("address", 'a', Description = "FOCAS address — same format as `read`.", IsRequired = true)]
public string Address { get; init; } = default!;
/// <summary>Gets the data type of the address.</summary>
[CommandOption("type", 't', Description =
"Bit / Byte / Int16 / Int32 / Float32 / Float64 / String (default Int16).")]
public FocasDataType DataType { get; init; } = FocasDataType.Int16;
/// <summary>Gets the polling interval in milliseconds.</summary>
[CommandOption("interval-ms", 'i', Description = "Publishing interval ms (default 1000).")]
public int IntervalMs { get; init; } = 1000;
/// <inheritdoc />
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();
@@ -14,18 +14,22 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Commands;
[Command("write", Description = "Write a single FOCAS address.")]
public sealed class WriteCommand : FocasCommandBase
{
/// <summary>Gets the FOCAS address to write to.</summary>
[CommandOption("address", 'a', Description = "FOCAS address — same format as `read`.", IsRequired = true)]
public string Address { get; init; } = default!;
/// <summary>Gets the data type of the value to write.</summary>
[CommandOption("type", 't', Description =
"Bit / Byte / Int16 / Int32 / Float32 / Float64 / String (default Int16).")]
public FocasDataType DataType { get; init; } = FocasDataType.Int16;
/// <summary>Gets the value to write.</summary>
[CommandOption("value", 'v', Description =
"Value to write. Parsed per --type (booleans accept true/false/1/0).",
IsRequired = true)]
public string Value { get; init; } = default!;
/// <inheritdoc />
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();
@@ -62,6 +66,9 @@ public sealed class WriteCommand : FocasCommandBase
/// as a clean <see cref="CliFx.Exceptions.CommandException"/> rather than a raw
/// .NET stack trace — matching the friendly message the Bit path already produces.
/// </remarks>
/// <param name="raw">The raw string value to parse.</param>
/// <param name="type">The data type to parse the value as.</param>
/// <returns>The parsed value as an object.</returns>
internal static object ParseValue(string raw, FocasDataType type)
{
if (type == FocasDataType.Bit) return ParseBool(raw);
@@ -10,19 +10,23 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli;
/// </summary>
public abstract class FocasCommandBase : DriverCommandBase
{
/// <summary>Gets the CNC IP address or hostname.</summary>
[CommandOption("cnc-host", 'h', Description =
"CNC IP address or hostname. FOCAS-over-EIP listens on port 8193 by default.",
IsRequired = true)]
public string CncHost { get; init; } = default!;
/// <summary>Gets the FOCAS TCP port.</summary>
[CommandOption("cnc-port", 'p', Description = "FOCAS TCP port (default 8193).")]
public int CncPort { get; init; } = 8193;
/// <summary>Gets the CNC series.</summary>
[CommandOption("series", 's', Description =
"CNC series: Unknown / Zero_i_D / Zero_i_F / Zero_i_MF / Zero_i_TF / Sixteen_i / " +
"Thirty_i / ThirtyOne_i / ThirtyTwo_i / PowerMotion_i (default Unknown).")]
public FocasCncSeries Series { get; init; } = FocasCncSeries.Unknown;
/// <summary>Gets the per-operation timeout in milliseconds.</summary>
[CommandOption("timeout-ms", Description = "Per-operation timeout in ms (default 2000).")]
public int TimeoutMs { get; init; } = 2000;
@@ -42,6 +46,7 @@ public abstract class FocasCommandBase : DriverCommandBase
/// wire client opens a TCP:8193 session to the CNC and surfaces unreachable endpoints
/// as <c>BadCommunicationError</c>.
/// </summary>
/// <param name="tags">The tag definitions to include in the driver options.</param>
protected FocasDriverOptions BuildOptions(IReadOnlyList<FocasTagDefinition> tags) => new()
{
Devices = [new FocasDeviceOptions(
@@ -53,6 +58,7 @@ public abstract class FocasCommandBase : DriverCommandBase
Probe = new FocasProbeOptions { Enabled = false },
};
/// <summary>Gets the driver instance ID.</summary>
protected string DriverInstanceId => $"focas-cli-{CncHost}:{CncPort}";
/// <summary>
@@ -64,6 +70,7 @@ public abstract class FocasCommandBase : DriverCommandBase
/// option is subscribe-only — pass <c>null</c> for probe/read/write so this
/// helper can be a single shared validator.
/// </summary>
/// <param name="intervalMs">The interval in milliseconds, or null if not applicable.</param>
protected void ValidateOptions(int? intervalMs = null)
{
if (CncPort < 1 || CncPort > 65535)
@@ -14,11 +14,13 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Commands;
[Command("probe", Description = "Verify the Modbus-TCP endpoint is reachable and speaks Modbus.")]
public sealed class ProbeCommand : ModbusCommandBase
{
/// <summary>Gets the holding-register address to use for the probe read.</summary>
[CommandOption("probe-address", Description =
"Holding-register address used as the cheap-read probe (default 0). Some PLCs lock " +
"register 0 — set this to a known-good address on your device.")]
public ushort ProbeAddress { get; init; }
/// <inheritdoc />
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();
@@ -80,6 +82,9 @@ public sealed class ProbeCommand : ModbusCommandBase
/// <item><c>OK</c> — driver Healthy and snapshot Good.</item>
/// </list>
/// </summary>
/// <param name="state">The driver's health state.</param>
/// <param name="statusCode">The OPC UA status code from the probe read.</param>
/// <returns>A string describing the verdict.</returns>
public static string ComputeVerdict(DriverState state, uint statusCode)
{
// OPC UA StatusCode top 2 bits encode the quality class:
@@ -14,35 +14,43 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Commands;
[Command("read", Description = "Read a single Modbus register or coil.")]
public sealed class ReadCommand : ModbusCommandBase
{
/// <summary>Gets the Modbus region to read from.</summary>
[CommandOption("region", 'r', Description =
"Coils / DiscreteInputs / InputRegisters / HoldingRegisters", IsRequired = true)]
public ModbusRegion Region { get; init; }
/// <summary>Gets the zero-based address within the region.</summary>
[CommandOption("address", 'a', Description =
"Zero-based address within the region.", IsRequired = true)]
public ushort Address { get; init; }
/// <summary>Gets the data type to read.</summary>
[CommandOption("type", 't', Description =
"Bool / Int16 / UInt16 / Int32 / UInt32 / Int64 / UInt64 / Float32 / Float64 / " +
"BitInRegister / String / Bcd16 / Bcd32", IsRequired = true)]
public ModbusDataType DataType { get; init; }
/// <summary>Gets the byte order for multi-register types.</summary>
[CommandOption("byte-order", Description =
"BigEndian (default, spec ABCD) or WordSwap (CDAB). Ignored for single-register types.")]
public ModbusByteOrder ByteOrder { get; init; } = ModbusByteOrder.BigEndian;
/// <summary>Gets the bit index for BitInRegister type.</summary>
[CommandOption("bit-index", Description =
"For type=BitInRegister: bit 0-15 LSB-first.")]
public byte BitIndex { get; init; }
/// <summary>Gets the string length for String type.</summary>
[CommandOption("string-length", Description =
"For type=String: character count (2 per register, rounded up).")]
public ushort StringLength { get; init; }
/// <summary>Gets the byte order for string values.</summary>
[CommandOption("string-byte-order", Description =
"For type=String: HighByteFirst (standard) or LowByteFirst (DirectLOGIC et al).")]
public ModbusStringByteOrder StringByteOrder { get; init; } = ModbusStringByteOrder.HighByteFirst;
/// <inheritdoc />
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();
@@ -86,6 +94,10 @@ public sealed class ReadCommand : ModbusCommandBase
/// (<c>HR[100]</c>, <c>Coil[5]</c>, <c>IR[42]</c>) — the driver treats the name
/// purely as a lookup key, so any stable string works.
/// </summary>
/// <param name="region">The Modbus region (Coils, DiscreteInputs, InputRegisters, or HoldingRegisters).</param>
/// <param name="address">The zero-based address within the region.</param>
/// <param name="type">The Modbus data type being read.</param>
/// <returns>A human-readable tag name.</returns>
internal static string SynthesiseTagName(
ModbusRegion region, ushort address, ModbusDataType type)
{
@@ -14,23 +14,28 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Commands;
[Command("subscribe", Description = "Watch a Modbus register via polled subscription until Ctrl+C.")]
public sealed class SubscribeCommand : ModbusCommandBase
{
/// <summary>Gets or sets the Modbus register region (Coils, DiscreteInputs, InputRegisters, or HoldingRegisters).</summary>
[CommandOption("region", 'r', Description =
"Coils / DiscreteInputs / InputRegisters / HoldingRegisters", IsRequired = true)]
public ModbusRegion Region { get; init; }
/// <summary>Gets or sets the zero-based address within the region.</summary>
[CommandOption("address", 'a', Description = "Zero-based address within the region.", IsRequired = true)]
public ushort Address { get; init; }
/// <summary>Gets or sets the data type (Bool, Int16, UInt16, Int32, UInt32, Int64, UInt64, Float32, Float64, BitInRegister, String, Bcd16, Bcd32).</summary>
[CommandOption("type", 't', Description =
"Bool / Int16 / UInt16 / Int32 / UInt32 / Int64 / UInt64 / Float32 / Float64 / " +
"BitInRegister / String / Bcd16 / Bcd32", IsRequired = true)]
public ModbusDataType DataType { get; init; }
/// <summary>Gets or sets the publishing interval in milliseconds.</summary>
[CommandOption("interval-ms", 'i', Description =
"Publishing interval in milliseconds (default 1000). The PollGroupEngine enforces " +
"a floor of ~250ms; values below it get rounded up.")]
public int IntervalMs { get; init; } = 1000;
/// <summary>Gets or sets the byte order (BigEndian or WordSwap).</summary>
[CommandOption("byte-order", Description =
"BigEndian (default) or WordSwap.")]
public ModbusByteOrder ByteOrder { get; init; } = ModbusByteOrder.BigEndian;
@@ -38,18 +43,22 @@ public sealed class SubscribeCommand : ModbusCommandBase
// Driver.Modbus.Cli-001: subscribe previously lacked these three options that read and
// write both expose. Without them, BitInRegister always watches bit 0 and String runs with
// StringLength=0, silently producing wrong results for any subscriber using those types.
/// <summary>Gets or sets the bit index for type=BitInRegister (0-15, LSB-first).</summary>
[CommandOption("bit-index", Description =
"For type=BitInRegister: which bit of the holding register (0-15, LSB-first).")]
public byte BitIndex { get; init; }
/// <summary>Gets or sets the string length for type=String (character count, 2 per register).</summary>
[CommandOption("string-length", Description =
"For type=String: character count (2 per register, rounded up).")]
public ushort StringLength { get; init; }
/// <summary>Gets or sets the string byte order for type=String (HighByteFirst or LowByteFirst).</summary>
[CommandOption("string-byte-order", Description =
"For type=String: HighByteFirst (standard) or LowByteFirst (DirectLOGIC).")]
public ModbusStringByteOrder StringByteOrder { get; init; } = ModbusStringByteOrder.HighByteFirst;
/// <inheritdoc />
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();
@@ -16,41 +16,51 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Commands;
[Command("write", Description = "Write a single Modbus coil or holding register.")]
public sealed class WriteCommand : ModbusCommandBase
{
/// <summary>Gets the Modbus region to write to.</summary>
[CommandOption("region", 'r', Description =
"Coils or HoldingRegisters (the only writable regions per the protocol spec).",
IsRequired = true)]
public ModbusRegion Region { get; init; }
/// <summary>Gets the zero-based address within the region.</summary>
[CommandOption("address", 'a', Description =
"Zero-based address within the region.", IsRequired = true)]
public ushort Address { get; init; }
/// <summary>Gets the data type of the value to write.</summary>
[CommandOption("type", 't', Description =
"Bool / Int16 / UInt16 / Int32 / UInt32 / Int64 / UInt64 / Float32 / Float64 / " +
"BitInRegister / String / Bcd16 / Bcd32", IsRequired = true)]
public ModbusDataType DataType { get; init; }
/// <summary>Gets the value string to write and parse.</summary>
[CommandOption("value", 'v', Description =
"Value to write. Parsed per --type (booleans accept true/false/0/1).",
IsRequired = true)]
public string Value { get; init; } = default!;
/// <summary>Gets the byte order for multi-register values.</summary>
[CommandOption("byte-order", Description =
"BigEndian (default, ABCD) or WordSwap (CDAB). Ignored for single-register types.")]
public ModbusByteOrder ByteOrder { get; init; } = ModbusByteOrder.BigEndian;
/// <summary>Gets the bit index for BitInRegister type.</summary>
[CommandOption("bit-index", Description =
"For type=BitInRegister: which bit of the holding register (0-15, LSB-first).")]
public byte BitIndex { get; init; }
/// <summary>Gets the string length for String type.</summary>
[CommandOption("string-length", Description =
"For type=String: character count (2 per register, rounded up).")]
public ushort StringLength { get; init; }
/// <summary>Gets the byte order for string characters.</summary>
[CommandOption("string-byte-order", Description =
"For type=String: HighByteFirst (standard) or LowByteFirst (DirectLOGIC).")]
public ModbusStringByteOrder StringByteOrder { get; init; } = ModbusStringByteOrder.HighByteFirst;
/// <summary>Executes the write command.</summary>
/// <inheritdoc />
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();
@@ -106,10 +116,13 @@ public sealed class WriteCommand : ModbusCommandBase
}
/// <summary>
/// Parse the operator's <c>--value</c> string into the CLR type the driver expects
/// for the declared <see cref="ModbusDataType"/>. Uses invariant culture everywhere
/// Parses the operator's <c>--value</c> string into the CLR type the driver expects
/// for the declared data type. Uses invariant culture everywhere
/// so <c>3.14</c> and <c>3,14</c> don't swap meaning between runs.
/// </summary>
/// <param name="raw">The raw value string from the command line.</param>
/// <param name="type">The data type to parse into.</param>
/// <returns>The parsed value in the appropriate CLR type.</returns>
internal static object ParseValue(string raw, ModbusDataType type) => type switch
{
ModbusDataType.Bool or ModbusDataType.BitInRegister => ParseBool(raw),
@@ -12,18 +12,23 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli;
/// </summary>
public abstract class ModbusCommandBase : DriverCommandBase
{
/// <summary>Gets the Modbus-TCP server hostname or IP address.</summary>
[CommandOption("host", 'h', Description = "Modbus-TCP server hostname or IP", IsRequired = true)]
public string Host { get; init; } = default!;
/// <summary>Gets the Modbus-TCP port number.</summary>
[CommandOption("port", 'p', Description = "Modbus-TCP port (default 502)")]
public int Port { get; init; } = 502;
/// <summary>Gets the Modbus unit ID (slave ID).</summary>
[CommandOption("unit-id", 'U', Description = "Modbus unit / slave ID (1-247, default 1)")]
public byte UnitId { get; init; } = 1;
/// <summary>Gets the per-PDU timeout in milliseconds.</summary>
[CommandOption("timeout-ms", Description = "Per-PDU timeout in milliseconds (default 2000)")]
public int TimeoutMs { get; init; } = 2000;
/// <summary>Gets a value indicating whether to disable automatic reconnection.</summary>
[CommandOption("disable-reconnect", Description =
"Disable the built-in mid-transaction reconnect-and-retry. Matches the driver's " +
"AutoReconnect=false setting — use when diagnosing socket teardown behaviour.")]
@@ -42,6 +47,7 @@ public abstract class ModbusCommandBase : DriverCommandBase
/// disabled — CLI runs are one-shot, the probe loop would race the operator's
/// command against its own keep-alive reads.
/// </summary>
/// <param name="tags">The tag definitions to include in the options.</param>
protected ModbusDriverOptions BuildOptions(IReadOnlyList<ModbusTagDefinition> tags) => new()
{
Host = Host,
@@ -16,11 +16,14 @@ public sealed class ProbeCommand : S7CommandBase
[CommandOption("address", 'a', Description =
"Probe address (default MW0 — merker word 0). DB1.DBW0 if your PLC project " +
"reserves a fingerprint DB.")]
/// <summary>Gets or sets the S7 address to probe.</summary>
public string Address { get; init; } = "MW0";
[CommandOption("type", Description = "Probe data type (default Int16).")]
/// <summary>Gets or sets the data type of the probe address.</summary>
public S7DataType DataType { get; init; } = S7DataType.Int16;
/// <inheritdoc />
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();
@@ -12,6 +12,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Commands;
[Command("read", Description = "Read a single S7 address.")]
public sealed class ReadCommand : S7CommandBase
{
/// <summary>Gets the S7 address to read.</summary>
[CommandOption("address", 'a', Description =
"S7 address. Examples: DB1.DBW0 (DB1, word 0); M0.0 (merker bit); IW4 (input word 4); " +
"QD8 (output dword 8); DB2.DBD20 (DB2, dword 20); DB5.DBX4.3 (DB5, byte 4, bit 3); " +
@@ -22,15 +23,18 @@ public sealed class ReadCommand : S7CommandBase
// Driver.S7.Cli-002: help text trimmed to the types the driver actually implements.
// Int64 / UInt64 / Float64 / String / DateTime are defined in S7DataType but the driver
// raises NotSupportedException (→ BadNotSupported) on reads of those types.
/// <summary>Gets the data type to interpret the address as.</summary>
[CommandOption("type", 't', Description =
"Bool / Byte / Int16 / UInt16 / Int32 / UInt32 / Float32 (default Int16). " +
"Int64, UInt64, Float64, String, and DateTime are not yet implemented and will return BadNotSupported.")]
public S7DataType DataType { get; init; } = S7DataType.Int16;
/// <summary>Gets the maximum string length for string-type reads.</summary>
[CommandOption("string-length", Description =
"For type=String: S7-string max length (default 254, S7 max).")]
public int StringLength { get; init; } = 254;
/// <inheritdoc />
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();
@@ -55,6 +59,8 @@ public sealed class ReadCommand : S7CommandBase
}
/// <summary>Tag-name key used internally. Address + type is already unique.</summary>
/// <param name="address">The S7 address to encode in the tag name.</param>
/// <param name="type">The data type to encode in the tag name.</param>
internal static string SynthesiseTagName(string address, S7DataType type)
=> $"{address}:{type}";
}
@@ -12,18 +12,22 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Commands;
[Command("subscribe", Description = "Watch an S7 address via polled subscription until Ctrl+C.")]
public sealed class SubscribeCommand : S7CommandBase
{
/// <summary>Gets the S7 address to subscribe to.</summary>
[CommandOption("address", 'a', Description = "S7 address — same format as `read`.", IsRequired = true)]
public string Address { get; init; } = default!;
/// <summary>Gets the data type of the address.</summary>
// Driver.S7.Cli-002: help text trimmed to the types the driver actually implements.
[CommandOption("type", 't', Description =
"Bool / Byte / Int16 / UInt16 / Int32 / UInt32 / Float32 (default Int16). " +
"Int64, UInt64, Float64, String, and DateTime are not yet implemented and will return BadNotSupported.")]
public S7DataType DataType { get; init; } = S7DataType.Int16;
/// <summary>Gets the polling interval in milliseconds.</summary>
[CommandOption("interval-ms", 'i', Description = "Publishing interval ms (default 1000).")]
public int IntervalMs { get; init; } = 1000;
/// <inheritdoc />
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();
@@ -14,10 +14,12 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Commands;
[Command("write", Description = "Write a single S7 address.")]
public sealed class WriteCommand : S7CommandBase
{
/// <summary>Gets or sets the S7 address to write to.</summary>
[CommandOption("address", 'a', Description =
"S7 address — same format as `read`.", IsRequired = true)]
public string Address { get; init; } = default!;
/// <summary>Gets or sets the data type of the value to write.</summary>
// Driver.S7.Cli-002: help text trimmed to the types the driver actually implements.
// Int64 / UInt64 / Float64 / String / DateTime are defined in S7DataType but the driver
// raises NotSupportedException (→ BadNotSupported) on any read/write of those types;
@@ -27,15 +29,18 @@ public sealed class WriteCommand : S7CommandBase
"Int64, UInt64, Float64, String, and DateTime are not yet implemented and will return BadNotSupported.")]
public S7DataType DataType { get; init; } = S7DataType.Int16;
/// <summary>Gets or sets the value to write.</summary>
[CommandOption("value", 'v', Description =
"Value to write. Parsed per --type (booleans accept true/false/1/0).",
IsRequired = true)]
public string Value { get; init; } = default!;
/// <summary>Gets or sets the maximum length for string values.</summary>
[CommandOption("string-length", Description =
"For type=String: S7-string max length (default 254).")]
public int StringLength { get; init; } = 254;
/// <inheritdoc />
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();
@@ -68,6 +73,9 @@ public sealed class WriteCommand : S7CommandBase
/// surfaces as a clean <see cref="CliFx.Exceptions.CommandException"/> rather than a
/// raw .NET stack trace — matching the friendly message the Bool path already produces.
/// </remarks>
/// <param name="raw">The raw string value to parse.</param>
/// <param name="type">The target data type for parsing.</param>
/// <returns>The parsed value as an object.</returns>
internal static object ParseValue(string raw, S7DataType type)
{
if (type == S7DataType.Bool) return ParseBool(raw);
@@ -12,24 +12,30 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Cli;
/// </summary>
public abstract class S7CommandBase : DriverCommandBase
{
/// <summary>Gets the PLC IP address or hostname.</summary>
[CommandOption("host", 'h', Description = "PLC IP address or hostname.", IsRequired = true)]
public string Host { get; init; } = default!;
/// <summary>Gets the ISO-on-TCP port.</summary>
[CommandOption("port", 'p', Description = "ISO-on-TCP port (default 102).")]
public int Port { get; init; } = 102;
/// <summary>Gets the S7 CPU family type.</summary>
[CommandOption("cpu", 'c', Description =
"S7 CPU family: S7200 / S7200Smart / S7300 / S7400 / S71200 / S71500 " +
"(default S71500). Determines the ISO-TSAP slot byte.")]
public S7NetCpuType CpuType { get; init; } = S7NetCpuType.S71500;
/// <summary>Gets the rack number.</summary>
[CommandOption("rack", Description = "Rack number (default 0 — single-rack).")]
public short Rack { get; init; } = 0;
/// <summary>Gets the CPU slot number.</summary>
[CommandOption("slot", Description =
"CPU slot. S7-300 = 2, S7-400 = 2 or 3, S7-1200 / S7-1500 = 0 (default 0).")]
public short Slot { get; init; } = 0;
/// <summary>Gets the per-operation timeout in milliseconds.</summary>
[CommandOption("timeout-ms", Description = "Per-operation timeout in ms (default 5000).")]
public int TimeoutMs { get; init; } = 5000;
@@ -45,6 +51,7 @@ public abstract class S7CommandBase : DriverCommandBase
/// collected + whatever <paramref name="tags"/> the subclass declares. Probe
/// disabled — CLI runs are one-shot.
/// </summary>
/// <param name="tags">The tag definitions to include in the options.</param>
protected S7DriverOptions BuildOptions(IReadOnlyList<S7TagDefinition> tags) => new()
{
Host = Host,
@@ -57,5 +64,6 @@ public abstract class S7CommandBase : DriverCommandBase
Probe = new S7ProbeOptions { Enabled = false },
};
/// <summary>Gets the driver instance ID for this CLI session.</summary>
protected string DriverInstanceId => $"s7-cli-{Host}:{Port}";
}
@@ -19,16 +19,19 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Commands;
[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();
@@ -85,6 +88,8 @@ public sealed class BrowseCommand : TwinCATCommandBase
/// <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>
internal static List<(string BrowseName, DriverAttributeInfo Info)> FilterByPrefix(
IReadOnlyList<(string BrowseName, DriverAttributeInfo Info)> source, string? prefix)
=> source
@@ -95,6 +100,8 @@ public sealed class BrowseCommand : TwinCATCommandBase
/// 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>
internal static int PrintLimit(int matchedCount, int max)
=> max <= 0 ? matchedCount : Math.Min(max, matchedCount);
@@ -104,31 +111,42 @@ public sealed class BrowseCommand : TwinCATCommandBase
/// 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>
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) { }
}
}
@@ -13,6 +13,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Commands;
[Command("probe", Description = "Verify the TwinCAT runtime is reachable and a sample symbol reads.")]
public sealed class ProbeCommand : TwinCATTagCommandBase
{
/// <summary>Gets the symbol path to probe in the TwinCAT runtime.</summary>
[CommandOption("symbol", 's', Description =
"Symbol path to probe. System-global examples: " +
"'TwinCAT_SystemInfoVarList._AppInfo.OnlineChangeCnt', 'MAIN.bRunning'. " +
@@ -20,11 +21,13 @@ public sealed class ProbeCommand : TwinCATTagCommandBase
IsRequired = true)]
public string SymbolPath { get; init; } = default!;
/// <summary>Gets the data type to use for reading the symbol.</summary>
[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;
/// <inheritdoc />
public override async ValueTask ExecuteAsync(IConsole console)
{
Validate();
@@ -11,17 +11,20 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Commands;
[Command("read", Description = "Read a single TwinCAT symbol.")]
public sealed class ReadCommand : TwinCATTagCommandBase
{
/// <summary>Gets or sets the TwinCAT symbol path to read.</summary>
[CommandOption("symbol", 's', Description =
"Symbol path. Program scope: 'MAIN.bStart'. Global: 'GVL.Counter'. " +
"Nested UDT member: 'Motor1.Status.Running'. Array element: 'Recipe[3]'.",
IsRequired = true)]
public string SymbolPath { get; init; } = default!;
/// <summary>Gets or sets the data type of the symbol being read.</summary>
[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;
/// <inheritdoc />
public override async ValueTask ExecuteAsync(IConsole console)
{
Validate();
@@ -50,6 +53,10 @@ public sealed class ReadCommand : TwinCATTagCommandBase
}
}
/// <summary>Synthesizes an internal tag name from a symbol path and data type.</summary>
/// <param name="symbolPath">The TwinCAT symbol path.</param>
/// <param name="type">The data type of the symbol.</param>
/// <returns>A synthesized tag name combining the symbol path and type.</returns>
internal static string SynthesiseTagName(string symbolPath, TwinCATDataType type)
=> $"{symbolPath}:{type}";
}
@@ -13,17 +13,21 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Commands;
[Command("subscribe", Description = "Watch a TwinCAT symbol via ADS notification or poll, until Ctrl+C.")]
public sealed class SubscribeCommand : TwinCATTagCommandBase
{
/// <summary>Gets the TwinCAT symbol path to subscribe to.</summary>
[CommandOption("symbol", 's', Description = "Symbol path — same format as `read`.", IsRequired = true)]
public string SymbolPath { get; init; } = default!;
/// <summary>Gets the TwinCAT data type of the symbol.</summary>
[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;
/// <summary>Gets the publishing interval in milliseconds.</summary>
[CommandOption("interval-ms", 'i', Description = "Publishing interval ms (default 1000).")]
public int IntervalMs { get; init; } = 1000;
/// <inheritdoc />
protected override void Validate()
{
base.Validate();
@@ -32,6 +36,7 @@ public sealed class SubscribeCommand : TwinCATTagCommandBase
$"--interval-ms must be greater than 0 (got {IntervalMs}).");
}
/// <inheritdoc />
public override async ValueTask ExecuteAsync(IConsole console)
{
Validate();
@@ -113,6 +118,8 @@ public sealed class SubscribeCommand : TwinCATTagCommandBase
/// 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>
/// <param name="handle">The subscription handle to describe.</param>
/// <returns>A description of the subscription mechanism ("ADS notification" or "polling").</returns>
internal static string DescribeMechanism(ISubscriptionHandle handle) =>
handle.DiagnosticId.StartsWith("twincat-native-sub-", StringComparison.Ordinal)
? "ADS notification"
@@ -13,20 +13,24 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Commands;
[Command("write", Description = "Write a single TwinCAT symbol.")]
public sealed class WriteCommand : TwinCATTagCommandBase
{
/// <summary>Gets the symbol path to write.</summary>
[CommandOption("symbol", 's', Description =
"Symbol path — same format as `read`.", IsRequired = true)]
public string SymbolPath { get; init; } = default!;
/// <summary>Gets the data type of the symbol value.</summary>
[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;
/// <summary>Gets the value to write.</summary>
[CommandOption("value", 'v', Description =
"Value to write. Parsed per --type (booleans accept true/false/1/0).",
IsRequired = true)]
public string Value { get; init; } = default!;
/// <inheritdoc />
public override async ValueTask ExecuteAsync(IConsole console)
{
Validate();
@@ -63,6 +67,8 @@ public sealed class WriteCommand : TwinCATTagCommandBase
}
/// <summary>Parse <c>--value</c> per <see cref="TwinCATDataType"/>, invariant culture.</summary>
/// <param name="raw">The raw string value to parse.</param>
/// <param name="type">The target TwinCAT data type.</param>
internal static object ParseValue(string raw, TwinCATDataType type) => type switch
{
TwinCATDataType.Bool => ParseBool(raw),
@@ -13,27 +13,31 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli;
/// </summary>
public abstract class TwinCATCommandBase : DriverCommandBase
{
/// <summary>Gets the AMS Net ID of the target runtime.</summary>
[CommandOption("ams-net-id", 'n', Description =
"AMS Net ID of the target runtime (e.g. '192.168.1.40.1.1' or '127.0.0.1.1.1' for local).",
IsRequired = true)]
public string AmsNetId { get; init; } = default!;
/// <summary>Gets the AMS port number.</summary>
[CommandOption("ams-port", 'p', Description =
"AMS port. TwinCAT 3 PLC runtime defaults to 851; TwinCAT 2 uses 801.")]
public int AmsPort { get; init; } = 851;
/// <summary>Gets the per-operation timeout in milliseconds.</summary>
[CommandOption("timeout-ms", Description = "Per-operation timeout in ms (default 5000).")]
public int TimeoutMs { get; init; } = 5000;
/// <summary>
/// The per-operation timeout, projected from <see cref="TimeoutMs"/>. The CliFx
/// Gets 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
/// `init` initialiser supplies to this property 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>
/// <inheritdoc />
public override TimeSpan Timeout
{
get => TimeSpan.FromMilliseconds(TimeoutMs);
@@ -41,11 +45,12 @@ public abstract class TwinCATCommandBase : DriverCommandBase
}
/// <summary>
/// Canonical TwinCAT gateway string the driver's <c>TwinCATAmsAddress.TryParse</c>
/// Gets the canonical TwinCAT gateway string the driver's <c>TwinCATAmsAddress.TryParse</c>
/// consumes — shape <c>ads://{AmsNetId}:{AmsPort}</c>.
/// </summary>
protected string Gateway => $"ads://{AmsNetId}:{AmsPort}";
/// <summary>Gets the driver instance ID for this command.</summary>
protected string DriverInstanceId => $"twincat-cli-{AmsNetId}:{AmsPort}";
/// <summary>
@@ -68,7 +73,12 @@ public abstract class TwinCATCommandBase : DriverCommandBase
// 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).
/// <summary>Gets the gateway string for testing.</summary>
internal string GatewayForTest => Gateway;
/// <summary>Gets the driver instance ID for testing.</summary>
internal string DriverInstanceIdForTest => DriverInstanceId;
/// <summary>Validates the command for testing.</summary>
internal void ValidateForTest() => Validate();
}
@@ -12,6 +12,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli;
/// </summary>
public abstract class TwinCATTagCommandBase : TwinCATCommandBase
{
/// <summary>Gets or sets a value indicating whether to use polling instead of native ADS notifications.</summary>
[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).")]
@@ -22,6 +23,7 @@ public abstract class TwinCATTagCommandBase : TwinCATCommandBase
/// the tag list a subclass supplies. Probe disabled, controller-browse disabled,
/// native notifications toggled by <see cref="PollOnly"/>.
/// </summary>
/// <param name="tags">Tag definitions for the driver.</param>
protected TwinCATDriverOptions BuildOptions(IReadOnlyList<TwinCATTagDefinition> tags) => new()
{
Devices = [new TwinCATDeviceOptions(
@@ -35,6 +37,8 @@ public abstract class TwinCATTagCommandBase : TwinCATCommandBase
};
// ---- Test hook ----
/// <summary>Test hook that exposes BuildOptions for unit testing.</summary>
/// <param name="tags">Tag definitions for the driver.</param>
internal TwinCATDriverOptions BuildOptionsForTest(IReadOnlyList<TwinCATTagDefinition> tags)
=> BuildOptions(tags);
}