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:
Joseph Doherty
2026-04-21 08:44:53 -04:00
parent ff50aac59f
commit 4dc685a365
21 changed files with 1279 additions and 0 deletions

View File

@@ -28,6 +28,8 @@
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Analyzers/ZB.MOM.WW.OtOpcUa.Analyzers.csproj"/>
</Folder>
<Folder Name="/tests/">
@@ -52,6 +54,8 @@
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.csproj"/>

93
docs/Driver.S7.Cli.md Normal file
View File

@@ -0,0 +1,93 @@
# `otopcua-s7-cli` — Siemens S7 test client
Ad-hoc probe / read / write / subscribe tool for Siemens S7-300 / S7-400 /
S7-1200 / S7-1500 (and compatible soft-PLCs) over S7comm / ISO-on-TCP port 102.
Uses the **same** `S7Driver` the OtOpcUa server does (S7.Net under the hood).
Fourth of four driver test-client CLIs.
## Build + run
```powershell
dotnet run --project src/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli -- --help
```
## Common flags
| Flag | Default | Purpose |
|---|---|---|
| `-h` / `--host` | **required** | PLC IP or hostname |
| `-p` / `--port` | `102` | ISO-on-TCP port (rarely changes) |
| `-c` / `--cpu` | `S71500` | S7200 / S7200Smart / S7300 / S7400 / S71200 / S71500 |
| `--rack` | `0` | Hardware rack (S7-400 distributed setups only) |
| `--slot` | `0` | CPU slot (S7-300 = 2, S7-400 = 2 or 3, S7-1200/1500 = 0) |
| `--timeout-ms` | `5000` | Per-operation timeout |
| `--verbose` | off | Serilog debug output |
## PUT/GET must be enabled
S7-1200 / S7-1500 ship with PUT/GET communication **disabled** by default.
Enable it in TIA Portal: *Device config → Protection & Security → Connection
mechanisms → "Permit access with PUT/GET communication from remote partner"*.
Without it the CLI's first read will surface `BadNotSupported`.
## S7 address grammar cheat sheet
| Form | Meaning |
|---|---|
| `DB1.DBW0` | DB number 1, word offset 0 |
| `DB1.DBD4` | DB number 1, dword offset 4 |
| `DB1.DBX2.3` | DB number 1, byte 2, bit 3 |
| `DB10.STRING[0]` | DB 10 string starting at offset 0 |
| `M0.0` | Merker bit 0.0 |
| `MW0` / `MD4` | Merker word / dword |
| `IW4` | Input word 4 |
| `QD8` | Output dword 8 |
## Commands
### `probe`
```powershell
# S7-1500 — default probe MW0
otopcua-s7-cli probe -h 192.168.1.30
# S7-300 (slot 2)
otopcua-s7-cli probe -h 192.168.1.31 -c S7300 --slot 2 -a DB1.DBW0
```
### `read`
```powershell
# DB word
otopcua-s7-cli read -h 192.168.1.30 -a DB1.DBW0 -t Int16
# Float32 from DB dword
otopcua-s7-cli read -h 192.168.1.30 -a DB1.DBD4 -t Float32
# Merker bit
otopcua-s7-cli read -h 192.168.1.30 -a M0.0 -t Bool
# 80-char S7 string
otopcua-s7-cli read -h 192.168.1.30 -a DB10.STRING[0] -t String --string-length 80
```
### `write`
```powershell
otopcua-s7-cli write -h 192.168.1.30 -a DB1.DBW0 -t Int16 -v 42
otopcua-s7-cli write -h 192.168.1.30 -a DB1.DBD4 -t Float32 -v 3.14
otopcua-s7-cli write -h 192.168.1.30 -a M0.0 -t Bool -v true
```
**Writes to M / Q are real** — they drive the PLC program. Be careful what you
flip on a running machine.
### `subscribe`
```powershell
otopcua-s7-cli subscribe -h 192.168.1.30 -a DB1.DBW0 -t Int16 -i 500
```
S7comm has no native push — the CLI polls through `PollGroupEngine` just like
Modbus / AB.

101
docs/Driver.TwinCAT.Cli.md Normal file
View File

@@ -0,0 +1,101 @@
# `otopcua-twincat-cli` — Beckhoff TwinCAT test client
Ad-hoc probe / read / write / subscribe tool for Beckhoff TwinCAT 2 / TwinCAT 3
runtimes via ADS. Uses the **same** `TwinCATDriver` the OtOpcUa server does
(`Beckhoff.TwinCAT.Ads` package). Native ADS notifications by default;
`--poll-only` falls back to the shared `PollGroupEngine`.
Fifth (final) of the driver test-client CLIs.
## Build + run
```powershell
dotnet run --project src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli -- --help
```
## Prerequisite: AMS router
The `Beckhoff.TwinCAT.Ads` library needs a reachable AMS router to open ADS
sessions. Pick one:
1. **Local TwinCAT XAR** — install the free TwinCAT 3 XAR Engineering install
on the machine running the CLI; it ships the router.
2. **Beckhoff.TwinCAT.Ads.TcpRouter** — standalone NuGet router. Run in a
sidecar process when no XAR is installed.
3. **Remote AMS route** — any Windows box with TwinCAT installed, with an AMS
route authorised to the CLI host.
The CLI compiles + runs without a router, but every wire call fails with a
transport error until one is reachable.
## Common flags
| Flag | Default | Purpose |
|---|---|---|
| `-n` / `--ams-net-id` | **required** | AMS Net ID (e.g. `192.168.1.40.1.1`) |
| `-p` / `--ams-port` | `851` | AMS port (TwinCAT 3 PLC = 851, TwinCAT 2 = 801) |
| `--timeout-ms` | `5000` | Per-operation timeout |
| `--poll-only` | off | Disable native ADS notifications, use `PollGroupEngine` instead |
| `--verbose` | off | Serilog debug output |
## Data types
TwinCAT exposes the IEC 61131-3 atomic set: `Bool`, `SInt`, `USInt`, `Int`,
`UInt`, `DInt`, `UDInt`, `LInt`, `ULInt`, `Real`, `LReal`, `String`, `WString`,
`Time`, `Date`, `DateTime`, `TimeOfDay`. The four IEC time/date variants
marshal as `UDINT` on the wire — CLI takes a numeric raw value and lets the
caller interpret semantics.
## Commands
### `probe`
```powershell
# Local TwinCAT 3, probe a canonical global
otopcua-twincat-cli probe -n 127.0.0.1.1.1 -s "TwinCAT_SystemInfoVarList._AppInfo.OnlineChangeCnt"
# Remote, probe a project variable
otopcua-twincat-cli probe -n 192.168.1.40.1.1 -s MAIN.bRunning --type Bool
```
### `read`
```powershell
# Bool symbol
otopcua-twincat-cli read -n 192.168.1.40.1.1 -s MAIN.bStart -t Bool
# Counter
otopcua-twincat-cli read -n 192.168.1.40.1.1 -s GVL.Counter -t DInt
# Nested UDT member
otopcua-twincat-cli read -n 192.168.1.40.1.1 -s Motor1.Status.Running -t Bool
# Array element
otopcua-twincat-cli read -n 192.168.1.40.1.1 -s "Recipe[3]" -t Real
# WString
otopcua-twincat-cli read -n 192.168.1.40.1.1 -s GVL.sMessage -t WString
```
### `write`
```powershell
otopcua-twincat-cli write -n 192.168.1.40.1.1 -s MAIN.bStart -t Bool -v true
otopcua-twincat-cli write -n 192.168.1.40.1.1 -s GVL.Counter -t DInt -v 42
otopcua-twincat-cli write -n 192.168.1.40.1.1 -s GVL.sMessage -t WString -v "running"
```
Structure writes refused — drop to driver config JSON for those.
### `subscribe`
```powershell
# Native ADS notifications (default) — PLC pushes on its own cycle
otopcua-twincat-cli subscribe -n 192.168.1.40.1.1 -s GVL.Counter -t DInt -i 500
# Fall back to polling for runtimes where native notifications are constrained
otopcua-twincat-cli subscribe -n 192.168.1.40.1.1 -s GVL.Counter -t DInt -i 500 --poll-only
```
The subscribe banner announces which mechanism is in play — "ADS notification"
or "polling" — so it's obvious in screen-recorded bug reports.

View 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);
}
}
}

View 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}";
}

View File

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

View 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."),
};
}

View 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);

View 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}";
}

View File

@@ -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>

View File

@@ -0,0 +1,58 @@
using CliFx.Attributes;
using CliFx.Infrastructure;
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Commands;
/// <summary>
/// Probes a TwinCAT runtime: opens an ADS session, reads one symbol, prints driver health.
/// Use this first after configuring a new AMS route — it'll surface "no route" /
/// "port unreachable" / "AMS router down" errors up-front before you bring the OtOpcUa
/// server near the endpoint.
/// </summary>
[Command("probe", Description = "Verify the TwinCAT runtime is reachable and a sample symbol reads.")]
public sealed class ProbeCommand : TwinCATCommandBase
{
[CommandOption("symbol", 's', Description =
"Symbol path to probe. System-global examples: " +
"'TwinCAT_SystemInfoVarList._AppInfo.OnlineChangeCnt', 'MAIN.bRunning'. " +
"User-project: a GVL or program variable.",
IsRequired = true)]
public string SymbolPath { get; init; } = default!;
[CommandOption("type", Description = "Data type (default DInt — TwinCAT DINT maps to int32).")]
public TwinCATDataType DataType { get; init; } = TwinCATDataType.DInt;
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();
var ct = console.RegisterCancellationHandler();
var probeTag = new TwinCATTagDefinition(
Name: "__probe",
DeviceHostAddress: Gateway,
SymbolPath: SymbolPath,
DataType: DataType,
Writable: false);
var options = BuildOptions([probeTag]);
await using var driver = new TwinCATDriver(options, DriverInstanceId);
try
{
await driver.InitializeAsync("{}", ct);
var snapshot = await driver.ReadAsync(["__probe"], ct);
var health = driver.GetHealth();
await console.Output.WriteLineAsync($"AMS: {AmsNetId}:{AmsPort}");
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(SymbolPath, snapshot[0]));
}
finally
{
await driver.ShutdownAsync(CancellationToken.None);
}
}
}

View File

@@ -0,0 +1,54 @@
using CliFx.Attributes;
using CliFx.Infrastructure;
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Commands;
/// <summary>
/// Read one TwinCAT symbol by path. Structure writes/reads are out of scope — fan the
/// member list into individual reads if you need them.
/// </summary>
[Command("read", Description = "Read a single TwinCAT symbol.")]
public sealed class ReadCommand : TwinCATCommandBase
{
[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!;
[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)
{
ConfigureLogging();
var ct = console.RegisterCancellationHandler();
var tagName = SynthesiseTagName(SymbolPath, DataType);
var tag = new TwinCATTagDefinition(
Name: tagName,
DeviceHostAddress: Gateway,
SymbolPath: SymbolPath,
DataType: DataType,
Writable: false);
var options = BuildOptions([tag]);
await using var driver = new TwinCATDriver(options, DriverInstanceId);
try
{
await driver.InitializeAsync("{}", ct);
var snapshot = await driver.ReadAsync([tagName], ct);
await console.Output.WriteLineAsync(SnapshotFormatter.Format(SymbolPath, snapshot[0]));
}
finally
{
await driver.ShutdownAsync(CancellationToken.None);
}
}
internal static string SynthesiseTagName(string symbolPath, TwinCATDataType type)
=> $"{symbolPath}:{type}";
}

View File

@@ -0,0 +1,78 @@
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.TwinCAT.Cli.Commands;
/// <summary>
/// Watch a TwinCAT symbol until Ctrl+C. Native ADS notifications by default (TwinCAT
/// 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
{
[CommandOption("symbol", 's', Description = "Symbol path — same format as `read`.", IsRequired = true)]
public string SymbolPath { get; init; } = default!;
[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;
[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(SymbolPath, DataType);
var tag = new TwinCATTagDefinition(
Name: tagName,
DeviceHostAddress: Gateway,
SymbolPath: SymbolPath,
DataType: DataType,
Writable: false);
var options = BuildOptions([tag]);
await using var driver = new TwinCATDriver(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);
var mode = PollOnly ? "polling" : "ADS notification";
await console.Output.WriteLineAsync(
$"Subscribed to {SymbolPath} @ {IntervalMs}ms ({mode}). 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);
}
}
}

View File

@@ -0,0 +1,94 @@
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.TwinCAT.Cli.Commands;
/// <summary>
/// Write one value to a TwinCAT symbol. Structure writes refused — drop to driver config
/// JSON for those.
/// </summary>
[Command("write", Description = "Write a single TwinCAT symbol.")]
public sealed class WriteCommand : TwinCATCommandBase
{
[CommandOption("symbol", 's', Description =
"Symbol path — same format as `read`.", IsRequired = true)]
public string SymbolPath { get; init; } = default!;
[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;
[CommandOption("value", 'v', Description =
"Value to write. Parsed per --type (booleans accept true/false/1/0).",
IsRequired = true)]
public string Value { get; init; } = default!;
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();
var ct = console.RegisterCancellationHandler();
if (DataType == TwinCATDataType.Structure)
throw new CliFx.Exceptions.CommandException(
"Structure (UDT) writes need an explicit member layout — drop to the driver's " +
"config JSON for those. The CLI covers atomic types only.");
var tagName = ReadCommand.SynthesiseTagName(SymbolPath, DataType);
var tag = new TwinCATTagDefinition(
Name: tagName,
DeviceHostAddress: Gateway,
SymbolPath: SymbolPath,
DataType: DataType,
Writable: true);
var options = BuildOptions([tag]);
var parsed = ParseValue(Value, DataType);
await using var driver = new TwinCATDriver(options, DriverInstanceId);
try
{
await driver.InitializeAsync("{}", ct);
var results = await driver.WriteAsync([new WriteRequest(tagName, parsed)], ct);
await console.Output.WriteLineAsync(SnapshotFormatter.FormatWrite(SymbolPath, results[0]));
}
finally
{
await driver.ShutdownAsync(CancellationToken.None);
}
}
/// <summary>Parse <c>--value</c> per <see cref="TwinCATDataType"/>, invariant culture.</summary>
internal static object ParseValue(string raw, TwinCATDataType type) => type switch
{
TwinCATDataType.Bool => ParseBool(raw),
TwinCATDataType.SInt => sbyte.Parse(raw, CultureInfo.InvariantCulture),
TwinCATDataType.USInt => byte.Parse(raw, CultureInfo.InvariantCulture),
TwinCATDataType.Int => short.Parse(raw, CultureInfo.InvariantCulture),
TwinCATDataType.UInt => ushort.Parse(raw, CultureInfo.InvariantCulture),
TwinCATDataType.DInt => int.Parse(raw, CultureInfo.InvariantCulture),
TwinCATDataType.UDInt => uint.Parse(raw, CultureInfo.InvariantCulture),
TwinCATDataType.LInt => long.Parse(raw, CultureInfo.InvariantCulture),
TwinCATDataType.ULInt => ulong.Parse(raw, CultureInfo.InvariantCulture),
TwinCATDataType.Real => float.Parse(raw, CultureInfo.InvariantCulture),
TwinCATDataType.LReal => double.Parse(raw, CultureInfo.InvariantCulture),
TwinCATDataType.String or TwinCATDataType.WString => raw,
// IEC 61131-3 time/date types are stored as UDINT on the wire — accept a numeric raw
// value + let the caller handle the encoding semantics.
TwinCATDataType.Time or TwinCATDataType.Date
or TwinCATDataType.DateTime or TwinCATDataType.TimeOfDay
=> uint.Parse(raw, CultureInfo.InvariantCulture),
_ => 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."),
};
}

View File

@@ -0,0 +1,12 @@
using CliFx;
return await new CliApplicationBuilder()
.AddCommandsFromThisAssembly()
.SetExecutableName("otopcua-twincat-cli")
.SetDescription(
"OtOpcUa TwinCAT test-client — ad-hoc probe + ADS symbolic reads/writes + " +
"subscriptions against Beckhoff TwinCAT 2/3 runtimes. Requires a reachable AMS " +
"router (local TwinCAT XAR or the Beckhoff.TwinCAT.Ads.TcpRouter NuGet). Addresses " +
"use symbolic paths: MAIN.bStart, GVL.Counter, Motor1.Status.Running.")
.Build()
.RunAsync(args);

View File

@@ -0,0 +1,62 @@
using CliFx.Attributes;
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.
/// </summary>
public abstract class TwinCATCommandBase : DriverCommandBase
{
[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!;
[CommandOption("ams-port", 'p', Description =
"AMS port. TwinCAT 3 PLC runtime defaults to 851; TwinCAT 2 uses 801.")]
public int AmsPort { get; init; } = 851;
[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 />
public override TimeSpan Timeout
{
get => TimeSpan.FromMilliseconds(TimeoutMs);
init { /* driven by TimeoutMs */ }
}
/// <summary>
/// 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>
/// 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}";
}

View File

@@ -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.TwinCAT.Cli</RootNamespace>
<AssemblyName>otopcua-twincat-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.TwinCAT\ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.csproj"/>
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Tests"/>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,117 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Commands;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Tests;
/// <summary>
/// Covers <see cref="WriteCommand.ParseValue"/> across every S7 atomic type.
/// </summary>
[Trait("Category", "Unit")]
public sealed class WriteCommandParseValueTests
{
[Theory]
[InlineData("true", true)]
[InlineData("0", false)]
[InlineData("yes", true)]
[InlineData("OFF", false)]
public void ParseValue_Bool_accepts_common_aliases(string raw, bool expected)
{
WriteCommand.ParseValue(raw, S7DataType.Bool).ShouldBe(expected);
}
[Fact]
public void ParseValue_Bool_rejects_garbage()
{
Should.Throw<CliFx.Exceptions.CommandException>(
() => WriteCommand.ParseValue("maybe", S7DataType.Bool));
}
[Fact]
public void ParseValue_Byte_ranges()
{
WriteCommand.ParseValue("0", S7DataType.Byte).ShouldBe((byte)0);
WriteCommand.ParseValue("255", S7DataType.Byte).ShouldBe((byte)255);
}
[Fact]
public void ParseValue_Int16_signed_range()
{
WriteCommand.ParseValue("-32768", S7DataType.Int16).ShouldBe((short)-32768);
}
[Fact]
public void ParseValue_UInt16_unsigned_max()
{
WriteCommand.ParseValue("65535", S7DataType.UInt16).ShouldBe((ushort)65535);
}
[Fact]
public void ParseValue_Int32_parses_negative()
{
WriteCommand.ParseValue("-2147483648", S7DataType.Int32).ShouldBe(int.MinValue);
}
[Fact]
public void ParseValue_UInt32_parses_max()
{
WriteCommand.ParseValue("4294967295", S7DataType.UInt32).ShouldBe(uint.MaxValue);
}
[Fact]
public void ParseValue_Int64_parses_min()
{
WriteCommand.ParseValue("-9223372036854775808", S7DataType.Int64).ShouldBe(long.MinValue);
}
[Fact]
public void ParseValue_UInt64_parses_max()
{
WriteCommand.ParseValue("18446744073709551615", S7DataType.UInt64).ShouldBe(ulong.MaxValue);
}
[Fact]
public void ParseValue_Float32_invariant_culture()
{
WriteCommand.ParseValue("3.14", S7DataType.Float32).ShouldBe(3.14f);
}
[Fact]
public void ParseValue_Float64_higher_precision()
{
WriteCommand.ParseValue("2.718281828", S7DataType.Float64).ShouldBeOfType<double>();
}
[Fact]
public void ParseValue_String_passthrough()
{
WriteCommand.ParseValue("hallo siemens", S7DataType.String).ShouldBe("hallo siemens");
}
[Fact]
public void ParseValue_DateTime_parses_roundtrip_form()
{
var result = WriteCommand.ParseValue("2026-04-21T12:34:56Z", S7DataType.DateTime);
result.ShouldBeOfType<DateTime>();
((DateTime)result).Year.ShouldBe(2026);
}
[Fact]
public void ParseValue_non_numeric_for_numeric_types_throws()
{
Should.Throw<FormatException>(
() => WriteCommand.ParseValue("xyz", S7DataType.Int16));
}
[Theory]
[InlineData("DB1.DBW0", S7DataType.Int16, "DB1.DBW0:Int16")]
[InlineData("M0.0", S7DataType.Bool, "M0.0:Bool")]
[InlineData("IW4", S7DataType.UInt16, "IW4:UInt16")]
[InlineData("QD8", S7DataType.UInt32, "QD8:UInt32")]
[InlineData("DB10.STRING[0]", S7DataType.String, "DB10.STRING[0]:String")]
public void SynthesiseTagName_preserves_S7_address_verbatim(
string address, S7DataType type, string expected)
{
ReadCommand.SynthesiseTagName(address, type).ShouldBe(expected);
}
}

View File

@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3" Version="1.1.0"/>
<PackageReference Include="Shouldly" Version="4.3.0"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.S7.Cli\ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.csproj"/>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,142 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Commands;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Tests;
/// <summary>
/// Covers <see cref="WriteCommand.ParseValue"/> for the IEC 61131-3 atomic types
/// TwinCAT exposes. Wider matrix than AB CIP because IEC adds WSTRING + the four
/// TIME/DATE variants that all marshal as UDINT on the wire.
/// </summary>
[Trait("Category", "Unit")]
public sealed class WriteCommandParseValueTests
{
[Theory]
[InlineData("true", true)]
[InlineData("0", false)]
[InlineData("on", true)]
[InlineData("NO", false)]
public void ParseValue_Bool_accepts_common_aliases(string raw, bool expected)
{
WriteCommand.ParseValue(raw, TwinCATDataType.Bool).ShouldBe(expected);
}
[Fact]
public void ParseValue_Bool_rejects_garbage()
{
Should.Throw<CliFx.Exceptions.CommandException>(
() => WriteCommand.ParseValue("maybe", TwinCATDataType.Bool));
}
[Fact]
public void ParseValue_SInt_signed_byte()
{
WriteCommand.ParseValue("-128", TwinCATDataType.SInt).ShouldBe((sbyte)-128);
}
[Fact]
public void ParseValue_USInt_unsigned_byte()
{
WriteCommand.ParseValue("255", TwinCATDataType.USInt).ShouldBe((byte)255);
}
[Fact]
public void ParseValue_Int_signed_16bit()
{
WriteCommand.ParseValue("-32768", TwinCATDataType.Int).ShouldBe((short)-32768);
}
[Fact]
public void ParseValue_UInt_unsigned_16bit()
{
WriteCommand.ParseValue("65535", TwinCATDataType.UInt).ShouldBe((ushort)65535);
}
[Fact]
public void ParseValue_DInt_int32_bounds()
{
WriteCommand.ParseValue("-2147483648", TwinCATDataType.DInt).ShouldBe(int.MinValue);
}
[Fact]
public void ParseValue_UDInt_uint32_max()
{
WriteCommand.ParseValue("4294967295", TwinCATDataType.UDInt).ShouldBe(uint.MaxValue);
}
[Fact]
public void ParseValue_LInt_int64_min()
{
WriteCommand.ParseValue("-9223372036854775808", TwinCATDataType.LInt).ShouldBe(long.MinValue);
}
[Fact]
public void ParseValue_ULInt_uint64_max()
{
WriteCommand.ParseValue("18446744073709551615", TwinCATDataType.ULInt).ShouldBe(ulong.MaxValue);
}
[Fact]
public void ParseValue_Real_invariant_culture()
{
WriteCommand.ParseValue("3.14", TwinCATDataType.Real).ShouldBe(3.14f);
}
[Fact]
public void ParseValue_LReal_higher_precision()
{
WriteCommand.ParseValue("2.718281828", TwinCATDataType.LReal).ShouldBeOfType<double>();
}
[Fact]
public void ParseValue_String_passthrough()
{
WriteCommand.ParseValue("hallo beckhoff", TwinCATDataType.String).ShouldBe("hallo beckhoff");
}
[Fact]
public void ParseValue_WString_passthrough()
{
// CLI layer doesn't distinguish UTF-8 input; the driver handles the WSTRING
// encoding on the wire.
WriteCommand.ParseValue("überstall", TwinCATDataType.WString).ShouldBe("überstall");
}
[Theory]
[InlineData(TwinCATDataType.Time)]
[InlineData(TwinCATDataType.Date)]
[InlineData(TwinCATDataType.DateTime)]
[InlineData(TwinCATDataType.TimeOfDay)]
public void ParseValue_IEC_date_time_variants_land_on_uint32(TwinCATDataType type)
{
// IEC 61131-3 TIME / DATE / DT / TOD all marshal as UDINT on the wire; the CLI
// accepts a numeric raw value and lets the caller handle the encoding.
WriteCommand.ParseValue("1234567", type).ShouldBeOfType<uint>();
}
[Fact]
public void ParseValue_Structure_refused()
{
Should.Throw<CliFx.Exceptions.CommandException>(
() => WriteCommand.ParseValue("42", TwinCATDataType.Structure));
}
[Fact]
public void ParseValue_non_numeric_for_numeric_types_throws()
{
Should.Throw<FormatException>(
() => WriteCommand.ParseValue("xyz", TwinCATDataType.DInt));
}
[Theory]
[InlineData("MAIN.bStart", TwinCATDataType.Bool, "MAIN.bStart:Bool")]
[InlineData("GVL.Counter", TwinCATDataType.DInt, "GVL.Counter:DInt")]
[InlineData("Motor1.Status.Running", TwinCATDataType.Bool, "Motor1.Status.Running:Bool")]
[InlineData("Recipe[3]", TwinCATDataType.Real, "Recipe[3]:Real")]
public void SynthesiseTagName_preserves_symbolic_path_verbatim(
string symbol, TwinCATDataType type, string expected)
{
ReadCommand.SynthesiseTagName(symbol, type).ShouldBe(expected);
}
}

View File

@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3" Version="1.1.0"/>
<PackageReference Include="Shouldly" Version="4.3.0"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli\ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.csproj"/>
</ItemGroup>
</Project>