diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx
index 2750269..854e97a 100644
--- a/ZB.MOM.WW.OtOpcUa.slnx
+++ b/ZB.MOM.WW.OtOpcUa.slnx
@@ -28,6 +28,8 @@
+
+
@@ -52,6 +54,8 @@
+
+
diff --git a/docs/Driver.S7.Cli.md b/docs/Driver.S7.Cli.md
new file mode 100644
index 0000000..f18dae0
--- /dev/null
+++ b/docs/Driver.S7.Cli.md
@@ -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.
diff --git a/docs/Driver.TwinCAT.Cli.md b/docs/Driver.TwinCAT.Cli.md
new file mode 100644
index 0000000..0cbd882
--- /dev/null
+++ b/docs/Driver.TwinCAT.Cli.md
@@ -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.
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/Commands/ProbeCommand.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/Commands/ProbeCommand.cs
new file mode 100644
index 0000000..2a93f48
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/Commands/ProbeCommand.cs
@@ -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;
+
+///
+/// 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
+/// BadNotSupported — PUT/GET communication has to be enabled in the hardware
+/// config for any S7-1200/1500 for the driver to get past the handshake.
+///
+[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);
+ }
+ }
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/Commands/ReadCommand.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/Commands/ReadCommand.cs
new file mode 100644
index 0000000..36a43ae
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/Commands/ReadCommand.cs
@@ -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;
+
+///
+/// Read one S7 address (DB / M / I / Q area). Addresses use S7.Net grammar — the driver
+/// parses them via S7AddressParser so whatever the server accepts the CLI accepts
+/// too.
+///
+[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);
+ }
+ }
+
+ /// Tag-name key used internally. Address + type is already unique.
+ internal static string SynthesiseTagName(string address, S7DataType type)
+ => $"{address}:{type}";
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/Commands/SubscribeCommand.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/Commands/SubscribeCommand.cs
new file mode 100644
index 0000000..e69e586
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/Commands/SubscribeCommand.cs
@@ -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;
+
+///
+/// Watch an S7 address via polled subscription until Ctrl+C. S7comm has no native push
+/// model so this goes through PollGroupEngine same as Modbus / AB.
+///
+[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);
+ }
+ }
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/Commands/WriteCommand.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/Commands/WriteCommand.cs
new file mode 100644
index 0000000..bc84d52
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/Commands/WriteCommand.cs
@@ -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;
+
+///
+/// Write one value to an S7 address. Mirrors '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.
+///
+[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);
+ }
+ }
+
+ /// Parse --value per , invariant culture throughout.
+ 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."),
+ };
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/Program.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/Program.cs
new file mode 100644
index 0000000..3f7c6f0
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/Program.cs
@@ -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);
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/S7CommandBase.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/S7CommandBase.cs
new file mode 100644
index 0000000..602e354
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/S7CommandBase.cs
@@ -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;
+
+///
+/// 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 so each command can synthesise an
+/// on demand.
+///
+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;
+
+ ///
+ public override TimeSpan Timeout
+ {
+ get => TimeSpan.FromMilliseconds(TimeoutMs);
+ init { /* driven by TimeoutMs */ }
+ }
+
+ ///
+ /// Build an with the endpoint fields this base
+ /// collected + whatever the subclass declares. Probe
+ /// disabled — CLI runs are one-shot.
+ ///
+ protected S7DriverOptions BuildOptions(IReadOnlyList 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}";
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.csproj b/src/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.csproj
new file mode 100644
index 0000000..0710646
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.csproj
@@ -0,0 +1,29 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+ latest
+ true
+ true
+ $(NoWarn);CS1591
+ ZB.MOM.WW.OtOpcUa.Driver.S7.Cli
+ otopcua-s7-cli
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli/Commands/ProbeCommand.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli/Commands/ProbeCommand.cs
new file mode 100644
index 0000000..19d6811
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli/Commands/ProbeCommand.cs
@@ -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;
+
+///
+/// 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.
+///
+[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);
+ }
+ }
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli/Commands/ReadCommand.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli/Commands/ReadCommand.cs
new file mode 100644
index 0000000..9222c73
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli/Commands/ReadCommand.cs
@@ -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;
+
+///
+/// Read one TwinCAT symbol by path. Structure writes/reads are out of scope — fan the
+/// member list into individual reads if you need them.
+///
+[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}";
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli/Commands/SubscribeCommand.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli/Commands/SubscribeCommand.cs
new file mode 100644
index 0000000..f186fdb
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli/Commands/SubscribeCommand.cs
@@ -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;
+
+///
+/// Watch a TwinCAT symbol until Ctrl+C. Native ADS notifications by default (TwinCAT
+/// pushes on its own cycle); pass --poll-only to fall through to PollGroupEngine.
+///
+[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);
+ }
+ }
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli/Commands/WriteCommand.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli/Commands/WriteCommand.cs
new file mode 100644
index 0000000..83e208d
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli/Commands/WriteCommand.cs
@@ -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;
+
+///
+/// Write one value to a TwinCAT symbol. Structure writes refused — drop to driver config
+/// JSON for those.
+///
+[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);
+ }
+ }
+
+ /// Parse --value per , invariant culture.
+ 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."),
+ };
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli/Program.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli/Program.cs
new file mode 100644
index 0000000..cf8c279
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli/Program.cs
@@ -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);
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli/TwinCATCommandBase.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli/TwinCATCommandBase.cs
new file mode 100644
index 0000000..17deb44
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli/TwinCATCommandBase.cs
@@ -0,0 +1,62 @@
+using CliFx.Attributes;
+using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli;
+
+///
+/// Base for every TwinCAT CLI command. Carries the AMS target options
+/// (--ams-net-id + --ams-port) + the notification-mode toggle that the
+/// driver itself takes. Exposes so each command can build a
+/// single-device / single-tag from flag input.
+///
+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; }
+
+ ///
+ public override TimeSpan Timeout
+ {
+ get => TimeSpan.FromMilliseconds(TimeoutMs);
+ init { /* driven by TimeoutMs */ }
+ }
+
+ ///
+ /// Canonical TwinCAT gateway string the driver's TwinCATAmsAddress.TryParse
+ /// consumes — shape ads://{AmsNetId}:{AmsPort}.
+ ///
+ protected string Gateway => $"ads://{AmsNetId}:{AmsPort}";
+
+ ///
+ /// Build a with the AMS target this base collected +
+ /// the tag list a subclass supplies. Probe disabled, controller-browse disabled,
+ /// native notifications toggled by .
+ ///
+ protected TwinCATDriverOptions BuildOptions(IReadOnlyList 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}";
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.csproj b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.csproj
new file mode 100644
index 0000000..46e05b9
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.csproj
@@ -0,0 +1,29 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+ latest
+ true
+ true
+ $(NoWarn);CS1591
+ ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli
+ otopcua-twincat-cli
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Tests/WriteCommandParseValueTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Tests/WriteCommandParseValueTests.cs
new file mode 100644
index 0000000..904e060
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Tests/WriteCommandParseValueTests.cs
@@ -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;
+
+///
+/// Covers across every S7 atomic type.
+///
+[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(
+ () => 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();
+ }
+
+ [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)result).Year.ShouldBe(2026);
+ }
+
+ [Fact]
+ public void ParseValue_non_numeric_for_numeric_types_throws()
+ {
+ Should.Throw(
+ () => 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);
+ }
+}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Tests.csproj b/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Tests.csproj
new file mode 100644
index 0000000..322842e
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Tests.csproj
@@ -0,0 +1,26 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+ true
+ ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Tests
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Tests/WriteCommandParseValueTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Tests/WriteCommandParseValueTests.cs
new file mode 100644
index 0000000..53f10a1
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Tests/WriteCommandParseValueTests.cs
@@ -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;
+
+///
+/// Covers 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.
+///
+[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(
+ () => 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();
+ }
+
+ [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();
+ }
+
+ [Fact]
+ public void ParseValue_Structure_refused()
+ {
+ Should.Throw(
+ () => WriteCommand.ParseValue("42", TwinCATDataType.Structure));
+ }
+
+ [Fact]
+ public void ParseValue_non_numeric_for_numeric_types_throws()
+ {
+ Should.Throw(
+ () => 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);
+ }
+}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Tests.csproj b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Tests.csproj
new file mode 100644
index 0000000..e09a20c
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Tests.csproj
@@ -0,0 +1,26 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+ true
+ ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Tests
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+