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