Task #251 — S7 + TwinCAT test-client CLIs (driver CLI suite complete)
Final two of the five driver test clients. Pattern carried forward from #249 (Modbus) + #250 (AB CIP, AB Legacy) — each CLI inherits Driver.Cli.Common for DriverCommandBase + SnapshotFormatter and adds a protocol-specific CommandBase + 4 commands (probe / read / write / subscribe). New projects: - src/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/ — otopcua-s7-cli. S7CommandBase carries host/port/cpu/rack/slot/timeout. Handles all S7 atomic types (Bool, Byte, Int16..UInt64, Float32/64, String, DateTime). DateTime parses via RoundtripKind so "2026-04-21T12:34:56Z" works. - src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli/ — otopcua-twincat-cli. TwinCATCommandBase carries ams-net-id + ams-port + --poll-only toggle (flips UseNativeNotifications=false). Covers the full IEC 61131-3 atomic set: Bool, SInt/USInt, Int/UInt, DInt/UDInt, LInt/ULInt, Real, LReal, String, WString, Time/Date/DateTime/TimeOfDay. Structure writes refused as out-of-scope (same as AB CIP). IEC time/date variants marshal as UDINT on the wire per IEC spec. Subscribe banner announces "ADS notification" vs "polling" so the mechanism is obvious in bug reports. Tests (49 new, 122 cumulative driver-CLI): - S7: 22 tests. Every S7DataType has a happy-path + bounds case. DateTime round-trips an ISO-8601 string. Tag-name synthesis round-trips every S7 address form (DB / M / I / Q, bit/word/dword, strings). - TwinCAT: 27 tests. Full IEC type matrix including WString UTF-8 pass- through + the four IEC time/date variants landing on UDINT. Structure rejection case. Tag-name synthesis for Program scope, GVL scope, nested UDT members, and array elements. Docs: - docs/Driver.S7.Cli.md — address grammar cheat sheet + the PUT/GET-must- be-enabled gotcha every S7-1200/1500 operator hits. - docs/Driver.TwinCAT.Cli.md — AMS router prerequisite (XAR / standalone Router NuGet / remote AMS route) + per-command examples. Wiring: - ZB.MOM.WW.OtOpcUa.slnx grew 4 entries (2 src + 2 tests). Full-solution build clean. Both --help outputs verified end-to-end. Driver CLI suite complete: 5 CLIs (otopcua-{modbus,abcip,ablegacy,s7,twincat}-cli) sharing a common base + formatter. 122 CLI tests cumulative. Every driver family shipped in v2 now has a shell-level ad-hoc validation tool. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
56
src/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/Commands/ProbeCommand.cs
Normal file
56
src/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/Commands/ProbeCommand.cs
Normal file
@@ -0,0 +1,56 @@
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Infrastructure;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Probes an S7 endpoint: connects via S7.Net, reads one merker word, prints health.
|
||||
/// If the PLC is fresh out of TIA Portal the probe will surface
|
||||
/// <c>BadNotSupported</c> — PUT/GET communication has to be enabled in the hardware
|
||||
/// config for any S7-1200/1500 for the driver to get past the handshake.
|
||||
/// </summary>
|
||||
[Command("probe", Description = "Verify the S7 endpoint is reachable and a sample read succeeds.")]
|
||||
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.")]
|
||||
public string Address { get; init; } = "MW0";
|
||||
|
||||
[CommandOption("type", Description = "Probe data type (default Int16).")]
|
||||
public S7DataType DataType { get; init; } = S7DataType.Int16;
|
||||
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
ConfigureLogging();
|
||||
var ct = console.RegisterCancellationHandler();
|
||||
|
||||
var probeTag = new S7TagDefinition(
|
||||
Name: "__probe",
|
||||
Address: Address,
|
||||
DataType: DataType,
|
||||
Writable: false);
|
||||
var options = BuildOptions([probeTag]);
|
||||
|
||||
await using var driver = new S7Driver(options, DriverInstanceId);
|
||||
try
|
||||
{
|
||||
await driver.InitializeAsync("{}", ct);
|
||||
var snapshot = await driver.ReadAsync(["__probe"], ct);
|
||||
var health = driver.GetHealth();
|
||||
|
||||
await console.Output.WriteLineAsync($"Host: {Host}:{Port}");
|
||||
await console.Output.WriteLineAsync($"CPU: {CpuType} rack={Rack} slot={Slot}");
|
||||
await console.Output.WriteLineAsync($"Health: {health.State}");
|
||||
if (health.LastError is { } err)
|
||||
await console.Output.WriteLineAsync($"Last error: {err}");
|
||||
await console.Output.WriteLineAsync();
|
||||
await console.Output.WriteLineAsync(SnapshotFormatter.Format(Address, snapshot[0]));
|
||||
}
|
||||
finally
|
||||
{
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
}
|
||||
61
src/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/Commands/ReadCommand.cs
Normal file
61
src/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/Commands/ReadCommand.cs
Normal file
@@ -0,0 +1,61 @@
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Infrastructure;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Read one S7 address (DB / M / I / Q area). Addresses use S7.Net grammar — the driver
|
||||
/// parses them via <c>S7AddressParser</c> so whatever the server accepts the CLI accepts
|
||||
/// too.
|
||||
/// </summary>
|
||||
[Command("read", Description = "Read a single S7 address.")]
|
||||
public sealed class ReadCommand : S7CommandBase
|
||||
{
|
||||
[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); " +
|
||||
"DB10.STRING[0] (DB10 string). Bit addresses use dot notation.",
|
||||
IsRequired = true)]
|
||||
public string Address { get; init; } = default!;
|
||||
|
||||
[CommandOption("type", 't', Description =
|
||||
"Bool / Byte / Int16 / UInt16 / Int32 / UInt32 / Int64 / UInt64 / Float32 / Float64 / " +
|
||||
"String / DateTime (default Int16).")]
|
||||
public S7DataType DataType { get; init; } = S7DataType.Int16;
|
||||
|
||||
[CommandOption("string-length", Description =
|
||||
"For type=String: S7-string max length (default 254, S7 max).")]
|
||||
public int StringLength { get; init; } = 254;
|
||||
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
ConfigureLogging();
|
||||
var ct = console.RegisterCancellationHandler();
|
||||
|
||||
var tagName = SynthesiseTagName(Address, DataType);
|
||||
var tag = new S7TagDefinition(
|
||||
Name: tagName,
|
||||
Address: Address,
|
||||
DataType: DataType,
|
||||
Writable: false,
|
||||
StringLength: StringLength);
|
||||
var options = BuildOptions([tag]);
|
||||
|
||||
await using var driver = new S7Driver(options, DriverInstanceId);
|
||||
try
|
||||
{
|
||||
await driver.InitializeAsync("{}", ct);
|
||||
var snapshot = await driver.ReadAsync([tagName], ct);
|
||||
await console.Output.WriteLineAsync(SnapshotFormatter.Format(Address, snapshot[0]));
|
||||
}
|
||||
finally
|
||||
{
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Tag-name key used internally. Address + type is already unique.</summary>
|
||||
internal static string SynthesiseTagName(string address, S7DataType type)
|
||||
=> $"{address}:{type}";
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Infrastructure;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Watch an S7 address via polled subscription until Ctrl+C. S7comm has no native push
|
||||
/// model so this goes through <c>PollGroupEngine</c> same as Modbus / AB.
|
||||
/// </summary>
|
||||
[Command("subscribe", Description = "Watch an S7 address via polled subscription until Ctrl+C.")]
|
||||
public sealed class SubscribeCommand : S7CommandBase
|
||||
{
|
||||
[CommandOption("address", 'a', Description = "S7 address — same format as `read`.", IsRequired = true)]
|
||||
public string Address { get; init; } = default!;
|
||||
|
||||
[CommandOption("type", 't', Description =
|
||||
"Bool / Byte / Int16 / UInt16 / Int32 / UInt32 / Int64 / UInt64 / Float32 / Float64 / " +
|
||||
"String / DateTime (default Int16).")]
|
||||
public S7DataType DataType { get; init; } = S7DataType.Int16;
|
||||
|
||||
[CommandOption("interval-ms", 'i', Description = "Publishing interval ms (default 1000).")]
|
||||
public int IntervalMs { get; init; } = 1000;
|
||||
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
ConfigureLogging();
|
||||
var ct = console.RegisterCancellationHandler();
|
||||
|
||||
var tagName = ReadCommand.SynthesiseTagName(Address, DataType);
|
||||
var tag = new S7TagDefinition(
|
||||
Name: tagName,
|
||||
Address: Address,
|
||||
DataType: DataType,
|
||||
Writable: false);
|
||||
var options = BuildOptions([tag]);
|
||||
|
||||
await using var driver = new S7Driver(options, DriverInstanceId);
|
||||
ISubscriptionHandle? handle = null;
|
||||
try
|
||||
{
|
||||
await driver.InitializeAsync("{}", ct);
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
handle = await driver.SubscribeAsync([tagName], TimeSpan.FromMilliseconds(IntervalMs), ct);
|
||||
|
||||
await console.Output.WriteLineAsync(
|
||||
$"Subscribed to {Address} @ {IntervalMs}ms. Ctrl+C to stop.");
|
||||
try
|
||||
{
|
||||
await Task.Delay(System.Threading.Timeout.InfiniteTimeSpan, ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Expected on Ctrl+C.
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (handle is not null)
|
||||
{
|
||||
try { await driver.UnsubscribeAsync(handle, CancellationToken.None); }
|
||||
catch { /* teardown best-effort */ }
|
||||
}
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
}
|
||||
89
src/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/Commands/WriteCommand.cs
Normal file
89
src/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/Commands/WriteCommand.cs
Normal file
@@ -0,0 +1,89 @@
|
||||
using System.Globalization;
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Infrastructure;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Write one value to an S7 address. Mirrors <see cref="ReadCommand"/>'s flag shape.
|
||||
/// Writes to M (merker) bits or Q (output) coils that drive edge-triggered routines
|
||||
/// are real — be careful what you hit on a running PLC.
|
||||
/// </summary>
|
||||
[Command("write", Description = "Write a single S7 address.")]
|
||||
public sealed class WriteCommand : S7CommandBase
|
||||
{
|
||||
[CommandOption("address", 'a', Description =
|
||||
"S7 address — same format as `read`.", IsRequired = true)]
|
||||
public string Address { get; init; } = default!;
|
||||
|
||||
[CommandOption("type", 't', Description =
|
||||
"Bool / Byte / Int16 / UInt16 / Int32 / UInt32 / Int64 / UInt64 / Float32 / Float64 / " +
|
||||
"String / DateTime (default Int16).")]
|
||||
public S7DataType DataType { get; init; } = S7DataType.Int16;
|
||||
|
||||
[CommandOption("value", 'v', Description =
|
||||
"Value to write. Parsed per --type (booleans accept true/false/1/0).",
|
||||
IsRequired = true)]
|
||||
public string Value { get; init; } = default!;
|
||||
|
||||
[CommandOption("string-length", Description =
|
||||
"For type=String: S7-string max length (default 254).")]
|
||||
public int StringLength { get; init; } = 254;
|
||||
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
ConfigureLogging();
|
||||
var ct = console.RegisterCancellationHandler();
|
||||
|
||||
var tagName = ReadCommand.SynthesiseTagName(Address, DataType);
|
||||
var tag = new S7TagDefinition(
|
||||
Name: tagName,
|
||||
Address: Address,
|
||||
DataType: DataType,
|
||||
Writable: true,
|
||||
StringLength: StringLength);
|
||||
var options = BuildOptions([tag]);
|
||||
|
||||
var parsed = ParseValue(Value, DataType);
|
||||
|
||||
await using var driver = new S7Driver(options, DriverInstanceId);
|
||||
try
|
||||
{
|
||||
await driver.InitializeAsync("{}", ct);
|
||||
var results = await driver.WriteAsync([new WriteRequest(tagName, parsed)], ct);
|
||||
await console.Output.WriteLineAsync(SnapshotFormatter.FormatWrite(Address, results[0]));
|
||||
}
|
||||
finally
|
||||
{
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Parse <c>--value</c> per <see cref="S7DataType"/>, invariant culture throughout.</summary>
|
||||
internal static object ParseValue(string raw, S7DataType type) => type switch
|
||||
{
|
||||
S7DataType.Bool => ParseBool(raw),
|
||||
S7DataType.Byte => byte.Parse(raw, CultureInfo.InvariantCulture),
|
||||
S7DataType.Int16 => short.Parse(raw, CultureInfo.InvariantCulture),
|
||||
S7DataType.UInt16 => ushort.Parse(raw, CultureInfo.InvariantCulture),
|
||||
S7DataType.Int32 => int.Parse(raw, CultureInfo.InvariantCulture),
|
||||
S7DataType.UInt32 => uint.Parse(raw, CultureInfo.InvariantCulture),
|
||||
S7DataType.Int64 => long.Parse(raw, CultureInfo.InvariantCulture),
|
||||
S7DataType.UInt64 => ulong.Parse(raw, CultureInfo.InvariantCulture),
|
||||
S7DataType.Float32 => float.Parse(raw, CultureInfo.InvariantCulture),
|
||||
S7DataType.Float64 => double.Parse(raw, CultureInfo.InvariantCulture),
|
||||
S7DataType.String => raw,
|
||||
S7DataType.DateTime => DateTime.Parse(raw, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind),
|
||||
_ => throw new CliFx.Exceptions.CommandException($"Unsupported DataType '{type}' for write."),
|
||||
};
|
||||
|
||||
private static bool ParseBool(string raw) => raw.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"1" or "true" or "on" or "yes" => true,
|
||||
"0" or "false" or "off" or "no" => false,
|
||||
_ => throw new CliFx.Exceptions.CommandException(
|
||||
$"Boolean value '{raw}' is not recognised. Use true/false, 1/0, on/off, or yes/no."),
|
||||
};
|
||||
}
|
||||
11
src/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/Program.cs
Normal file
11
src/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/Program.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
using CliFx;
|
||||
|
||||
return await new CliApplicationBuilder()
|
||||
.AddCommandsFromThisAssembly()
|
||||
.SetExecutableName("otopcua-s7-cli")
|
||||
.SetDescription(
|
||||
"OtOpcUa S7 test-client — ad-hoc probe + S7comm reads/writes + polled subscriptions " +
|
||||
"against Siemens S7-300 / S7-400 / S7-1200 / S7-1500 (and compatible soft-PLCs) via " +
|
||||
"S7.Net / ISO-on-TCP port 102. Addresses use S7.Net syntax: DB1.DBW0, M0.0, IW4, QD8.")
|
||||
.Build()
|
||||
.RunAsync(args);
|
||||
61
src/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/S7CommandBase.cs
Normal file
61
src/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/S7CommandBase.cs
Normal file
@@ -0,0 +1,61 @@
|
||||
using CliFx.Attributes;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||
using S7NetCpuType = global::S7.Net.CpuType;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Cli;
|
||||
|
||||
/// <summary>
|
||||
/// Base for every S7 CLI command. Carries the ISO-on-TCP endpoint options
|
||||
/// (host / port / CPU type / rack / slot) that S7.Net needs for its handshake +
|
||||
/// exposes <see cref="BuildOptions"/> so each command can synthesise an
|
||||
/// <see cref="S7DriverOptions"/> on demand.
|
||||
/// </summary>
|
||||
public abstract class S7CommandBase : DriverCommandBase
|
||||
{
|
||||
[CommandOption("host", 'h', Description = "PLC IP address or hostname.", IsRequired = true)]
|
||||
public string Host { get; init; } = default!;
|
||||
|
||||
[CommandOption("port", 'p', Description = "ISO-on-TCP port (default 102).")]
|
||||
public int Port { get; init; } = 102;
|
||||
|
||||
[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;
|
||||
|
||||
[CommandOption("rack", Description = "Rack number (default 0 — single-rack).")]
|
||||
public short Rack { get; init; } = 0;
|
||||
|
||||
[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;
|
||||
|
||||
[CommandOption("timeout-ms", Description = "Per-operation timeout in ms (default 5000).")]
|
||||
public int TimeoutMs { get; init; } = 5000;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override TimeSpan Timeout
|
||||
{
|
||||
get => TimeSpan.FromMilliseconds(TimeoutMs);
|
||||
init { /* driven by TimeoutMs */ }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build an <see cref="S7DriverOptions"/> with the endpoint fields this base
|
||||
/// collected + whatever <paramref name="tags"/> the subclass declares. Probe
|
||||
/// disabled — CLI runs are one-shot.
|
||||
/// </summary>
|
||||
protected S7DriverOptions BuildOptions(IReadOnlyList<S7TagDefinition> tags) => new()
|
||||
{
|
||||
Host = Host,
|
||||
Port = Port,
|
||||
CpuType = CpuType,
|
||||
Rack = Rack,
|
||||
Slot = Slot,
|
||||
Timeout = Timeout,
|
||||
Tags = tags,
|
||||
Probe = new S7ProbeOptions { Enabled = false },
|
||||
};
|
||||
|
||||
protected string DriverInstanceId => $"s7-cli-{Host}:{Port}";
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.S7.Cli</RootNamespace>
|
||||
<AssemblyName>otopcua-s7-cli</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CliFx" Version="2.3.6"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Cli.Common\ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.S7\ZB.MOM.WW.OtOpcUa.Driver.S7.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Tests"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user