From 5dac2e93754f2098f7cc5dfe7c10766e7a4c4601 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 21 Apr 2026 08:15:14 -0400 Subject: [PATCH] =?UTF-8?q?Task=20#249=20=E2=80=94=20Driver=20test-client?= =?UTF-8?q?=20CLIs:=20shared=20lib=20+=20Modbus=20CLI=20first?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the v1 otopcua-cli value prop (ad-hoc shell-level PLC validation) for the Modbus-TCP driver, and lays down the shared scaffolding that AB CIP, AB Legacy, S7, and TwinCAT CLIs will build on. New projects: - src/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/ — DriverCommandBase (verbose flag + Serilog config) + SnapshotFormatter (single-tag + table + write-result renders with invariant-culture value formatting + OPC UA status-code shortnames + UTC-normalised timestamps). - src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/ — otopcua-modbus-cli executable. Commands: probe, read, write, subscribe. ModbusCommandBase carries the host/port/unit-id flags + builds ModbusDriverOptions with Probe.Enabled =false (CLI runs are one-shot; driver-internal keep-alive would race). Commands + coverage: - probe single FC03 + GetHealth() + pretty-print - read region × address × type synth into one driver tag - write same shape + --value parsed per --type - subscribe polled-subscription stream until Ctrl+C Tests (38 total): - 16 SnapshotFormatterTests covering: status-code shortnames, unknown codes fall back to hex, null value + timestamp placeholders, bool lowercase, float invariant culture, string quoting, write-result shape, aligned table columns, mismatched-length rejection, UTC normalisation. - 22 Modbus CLI tests: · ReadCommandTests.SynthesiseTagName (5 theory cases) · WriteCommandParseValueTests (17 cases: bool aliases, unknown rejected, Int16 bounds, UInt16/Bcd16 type, Float32/64 invariant culture, String passthrough, BitInRegister, Int32 MinValue, non-numeric reject) Wiring: - ZB.MOM.WW.OtOpcUa.slnx grew 4 entries (2 src + 2 tests). - docs/Driver.Modbus.Cli.md — operator-facing runbook with examples per command + output format + typical workflows. Regression: full-solution build clean; shared-lib tests 16/0, Modbus CLI tests 22/0. Next up: repeat the pattern for AB CIP (shares ~40% more with Modbus via libplctag), then AB Legacy, S7, TwinCAT. The shared base stays as-is unless one of those exposes a gap the Modbus-first pass missed. Co-Authored-By: Claude Opus 4.7 (1M context) --- ZB.MOM.WW.OtOpcUa.slnx | 4 + docs/Driver.Modbus.Cli.md | 121 ++++++++++++++++ .../DriverCommandBase.cs | 60 ++++++++ .../SnapshotFormatter.cs | 131 ++++++++++++++++++ ...ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.csproj | 24 ++++ .../Commands/ProbeCommand.cs | 55 ++++++++ .../Commands/ReadCommand.cs | 95 +++++++++++++ .../Commands/SubscribeCommand.cs | 92 ++++++++++++ .../Commands/WriteCommand.cs | 118 ++++++++++++++++ .../ModbusCommandBase.cs | 60 ++++++++ .../Program.cs | 11 ++ ...ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.csproj | 29 ++++ .../SnapshotFormatterTests.cs | 123 ++++++++++++++++ ....WW.OtOpcUa.Driver.Cli.Common.Tests.csproj | 26 ++++ .../ReadCommandTests.cs | 21 +++ .../WriteCommandParseValueTests.cs | 90 ++++++++++++ ....WW.OtOpcUa.Driver.Modbus.Cli.Tests.csproj | 26 ++++ 17 files changed, 1086 insertions(+) create mode 100644 docs/Driver.Modbus.Cli.md create mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/DriverCommandBase.cs create mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/SnapshotFormatter.cs create mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.csproj create mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Commands/ProbeCommand.cs create mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Commands/ReadCommand.cs create mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Commands/SubscribeCommand.cs create mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Commands/WriteCommand.cs create mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/ModbusCommandBase.cs create mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Program.cs create mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.csproj create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.Tests/SnapshotFormatterTests.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.Tests/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.Tests.csproj create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests/ReadCommandTests.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests/WriteCommandParseValueTests.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests.csproj diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx index 25ec612..a51c259 100644 --- a/ZB.MOM.WW.OtOpcUa.slnx +++ b/ZB.MOM.WW.OtOpcUa.slnx @@ -24,6 +24,8 @@ + + @@ -44,6 +46,8 @@ + + diff --git a/docs/Driver.Modbus.Cli.md b/docs/Driver.Modbus.Cli.md new file mode 100644 index 0000000..4a3bf40 --- /dev/null +++ b/docs/Driver.Modbus.Cli.md @@ -0,0 +1,121 @@ +# `otopcua-modbus-cli` — Modbus-TCP test client + +Ad-hoc probe / read / write / subscribe tool for talking to Modbus-TCP devices +through the **same** `ModbusDriver` the OtOpcUa server uses. Mirrors the v1 +OPC UA `otopcua-cli` shape so the muscle memory carries over: drop to a shell, +point at a PLC, watch registers move. + +First of four driver test-client CLIs (Modbus → AB CIP → AB Legacy → S7 → +TwinCAT). Built on the shared `ZB.MOM.WW.OtOpcUa.Driver.Cli.Common` library +so each downstream CLI inherits verbose/log wiring + snapshot formatting +without copy-paste. + +## Build + run + +```powershell +dotnet build src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli +dotnet run --project src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli -- --help +``` + +Or publish a self-contained binary: + +```powershell +dotnet publish src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli -c Release -o publish/modbus-cli +publish/modbus-cli/otopcua-modbus-cli.exe --help +``` + +## Common flags + +Every command accepts: + +| Flag | Default | Purpose | +|---|---|---| +| `-h` / `--host` | **required** | Modbus-TCP server hostname or IP | +| `-p` / `--port` | `502` | TCP port | +| `-U` / `--unit-id` | `1` | Modbus unit / slave ID | +| `--timeout-ms` | `2000` | Per-PDU timeout | +| `--disable-reconnect` | off | Turn off mid-transaction reconnect-and-retry | +| `--verbose` | off | Serilog debug output | + +## Commands + +### `probe` — is the PLC up? + +Connects, reads one holding register, prints driver health. Fastest sanity +check after swapping a network cable or deploying a new device. + +```powershell +otopcua-modbus-cli probe -h 192.168.1.10 +otopcua-modbus-cli probe -h 192.168.1.10 --probe-address 100 # device locks HR[0] +``` + +### `read` — single register / coil / string + +Synthesises a one-tag driver config on the fly from `--region` + `--address` ++ `--type` flags. + +```powershell +# Holding register as UInt16 +otopcua-modbus-cli read -h 192.168.1.10 -r HoldingRegisters -a 100 -t UInt16 + +# Float32 with word-swap (CDAB) — common on Siemens / some AB families +otopcua-modbus-cli read -h 192.168.1.10 -r HoldingRegisters -a 200 -t Float32 --byte-order WordSwap + +# Single bit out of a packed holding register +otopcua-modbus-cli read -h 192.168.1.10 -r HoldingRegisters -a 10 -t BitInRegister --bit-index 3 + +# 40-char ASCII string — DirectLOGIC packs the first char in the low byte +otopcua-modbus-cli read -h 192.168.1.10 -r HoldingRegisters -a 300 -t String --string-length 40 --string-byte-order LowByteFirst + +# Discrete input / coil +otopcua-modbus-cli read -h 192.168.1.10 -r DiscreteInputs -a 5 -t Bool +``` + +### `write` — single value + +Same flag shape as `read` plus `-v` / `--value`. Values parse per `--type` +using invariant culture (period as decimal separator). Booleans accept +`true`/`false`/`1`/`0`/`yes`/`no`/`on`/`off`. + +```powershell +otopcua-modbus-cli write -h 192.168.1.10 -r HoldingRegisters -a 100 -t UInt16 -v 42 +otopcua-modbus-cli write -h 192.168.1.10 -r HoldingRegisters -a 200 -t Float32 -v 3.14 +otopcua-modbus-cli write -h 192.168.1.10 -r Coils -a 5 -t Bool -v on +``` + +**Writes are non-idempotent by default** — a timeout after the device +already applied the write will NOT auto-retry. This matches the driver's +production contract (plan decisions #44 + #45). + +### `subscribe` — watch a register until Ctrl+C + +Uses the driver's `ISubscribable` surface (polling under the hood via +`PollGroupEngine`). Prints every data-change event with a timestamp. + +```powershell +otopcua-modbus-cli subscribe -h 192.168.1.10 -r HoldingRegisters -a 100 -t Int16 -i 500 +``` + +## Output format + +- `probe` / `read` emit a multi-line per-tag block: `Tag / Value / Status / + Source Time / Server Time`. +- `write` emits one line: `Write : 0x... (Good | BadCommunicationError | …)`. +- `subscribe` emits one line per change: `[HH:mm:ss.fff] = ()`. + +Status codes are rendered as `0xXXXXXXXX (Name)` for the OPC UA shortlist +(`Good`, `BadCommunicationError`, `BadTimeout`, `BadNodeIdUnknown`, +`BadTypeMismatch`, `Uncertain`, …). Unknown codes fall back to bare hex. + +## Typical workflows + +**"Is the PLC alive?"** → `probe`. + +**"Does my recipe write land?"** → `write` + `read` back against the same +address. + +**"Why is tag X flipping?"** → `subscribe` + wait for the operator scenario. + +**"What's the right byte order for this family?"** → `read` with +`--byte-order BigEndian`, then with `--byte-order WordSwap`. The one that +gives plausible values is the correct one for that device. diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/DriverCommandBase.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/DriverCommandBase.cs new file mode 100644 index 0000000..a745322 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/DriverCommandBase.cs @@ -0,0 +1,60 @@ +using CliFx; +using CliFx.Attributes; +using CliFx.Infrastructure; +using Serilog; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Cli.Common; + +/// +/// Shared base for every driver test-client command (Modbus / AB CIP / AB Legacy / S7 / +/// TwinCAT). Carries the options that are meaningful regardless of protocol — verbose +/// logging + the standard timeout — plus helpers every command implementation wants: +/// Serilog configuration + cancellation-token capture. +/// +/// +/// +/// Each driver CLI sub-classes this with its own protocol-specific base (e.g. +/// ModbusCommandBase) that adds host/port/unit-id + a BuildDriver() +/// factory. That second layer is the point where the driver's {Driver}DriverOptions +/// type plugs in; keeping it out of this common base lets each driver CLI stay a thin +/// executable with no dependency on the other drivers' projects. +/// +/// +/// Why a shared base at all — without this every CLI re-authored the same ~40 lines +/// of Serilog wiring + cancel-token plumbing + verbose flag. +/// +/// +public abstract class DriverCommandBase : ICommand +{ + /// + /// Enable Serilog debug-level output. Leave off for clean one-line-per-call output; + /// switch on when diagnosing a connect / PDU-framing / retry problem. + /// + [CommandOption("verbose", Description = "Enable verbose/debug Serilog output")] + public bool Verbose { get; init; } + + /// + /// Request-level timeout used by the driver's Initialize / Read / + /// Write / probe calls. Defaults per-protocol (Modbus: 2s, AB: 5s, S7: 5s, + /// TwinCAT: 5s) — each driver CLI overrides this property with the appropriate + /// [CommandOption] default. + /// + public abstract TimeSpan Timeout { get; init; } + + public abstract ValueTask ExecuteAsync(IConsole console); + + /// + /// Configures the process-global Serilog logger. Commands call this at the top of + /// so driver-internal Log.Logger writes land on the + /// same sink as the CLI's operator-facing output. + /// + protected void ConfigureLogging() + { + var config = new LoggerConfiguration(); + if (Verbose) + config.MinimumLevel.Debug().WriteTo.Console(); + else + config.MinimumLevel.Warning().WriteTo.Console(); + Log.Logger = config.CreateLogger(); + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/SnapshotFormatter.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/SnapshotFormatter.cs new file mode 100644 index 0000000..c6c4b0e --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/SnapshotFormatter.cs @@ -0,0 +1,131 @@ +using System.Globalization; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Cli.Common; + +/// +/// Renders + payloads as the +/// plain-text lines every driver CLI prints to its console. Matches the one-field-per-line +/// style the existing OPC UA otopcua-cli uses so combined runs (read a tag via both +/// CLIs side-by-side) look coherent. +/// +public static class SnapshotFormatter +{ + /// + /// Single-tag multi-line render. Shape: + /// + /// Tag: <name> + /// Value: <value> + /// Status: 0x... (Good|BadCommunicationError|...) + /// Source Time: 2026-04-21T12:34:56.789Z + /// Server Time: 2026-04-21T12:34:56.790Z + /// + /// + public static string Format(string tagName, DataValueSnapshot snapshot) + { + ArgumentNullException.ThrowIfNull(snapshot); + var lines = new[] + { + $"Tag: {tagName}", + $"Value: {FormatValue(snapshot.Value)}", + $"Status: {FormatStatus(snapshot.StatusCode)}", + $"Source Time: {FormatTimestamp(snapshot.SourceTimestampUtc)}", + $"Server Time: {FormatTimestamp(snapshot.ServerTimestampUtc)}", + }; + return string.Join(Environment.NewLine, lines); + } + + /// + /// Write-result render, one line: Write <tag>: 0x... (Good|...). + /// + public static string FormatWrite(string tagName, WriteResult result) + { + ArgumentNullException.ThrowIfNull(result); + return $"Write {tagName}: {FormatStatus(result.StatusCode)}"; + } + + /// + /// Table-style render for batch reads. Emits an aligned 4-column layout: + /// tag / value / status / source-time. + /// + public static string FormatTable( + IReadOnlyList tagNames, IReadOnlyList snapshots) + { + ArgumentNullException.ThrowIfNull(tagNames); + ArgumentNullException.ThrowIfNull(snapshots); + if (tagNames.Count != snapshots.Count) + throw new ArgumentException( + $"tagNames ({tagNames.Count}) and snapshots ({snapshots.Count}) must be the same length"); + + var rows = tagNames.Select((t, i) => new + { + Tag = t, + Value = FormatValue(snapshots[i].Value), + Status = FormatStatus(snapshots[i].StatusCode), + Time = FormatTimestamp(snapshots[i].SourceTimestampUtc), + }).ToArray(); + + int tagW = Math.Max("TAG".Length, rows.Max(r => r.Tag.Length)); + int valW = Math.Max("VALUE".Length, rows.Max(r => r.Value.Length)); + int statW = Math.Max("STATUS".Length, rows.Max(r => r.Status.Length)); + // source-time column is fixed-width (ISO-8601 to ms) so no max-measurement needed. + + var sb = new System.Text.StringBuilder(); + sb.Append("TAG".PadRight(tagW)).Append(" ") + .Append("VALUE".PadRight(valW)).Append(" ") + .Append("STATUS".PadRight(statW)).Append(" ") + .Append("SOURCE TIME").AppendLine(); + sb.Append(new string('-', tagW)).Append(" ") + .Append(new string('-', valW)).Append(" ") + .Append(new string('-', statW)).Append(" ") + .Append(new string('-', "SOURCE TIME".Length)).AppendLine(); + foreach (var r in rows) + { + sb.Append(r.Tag.PadRight(tagW)).Append(" ") + .Append(r.Value.PadRight(valW)).Append(" ") + .Append(r.Status.PadRight(statW)).Append(" ") + .Append(r.Time).AppendLine(); + } + return sb.ToString().TrimEnd(); + } + + public static string FormatValue(object? value) => value switch + { + null => "", + bool b => b ? "true" : "false", + string s => $"\"{s}\"", + IFormattable f => f.ToString(null, CultureInfo.InvariantCulture), + _ => value.ToString() ?? "", + }; + + public static string FormatStatus(uint statusCode) + { + // Match the OPC UA shorthand for the statuses most-likely to land in a CLI run. + // Anything outside this short-list surfaces as hex — operators can cross-reference + // against OPC UA Part 6 § 7.34 (StatusCode tables) or Core.Abstractions status mappers. + var name = statusCode switch + { + 0x00000000u => "Good", + 0x80000000u => "Bad", + 0x80050000u => "BadCommunicationError", + 0x80060000u => "BadTimeout", + 0x80070000u => "BadNoCommunication", + 0x80080000u => "BadWaitingForInitialData", + 0x80340000u => "BadNodeIdUnknown", + 0x80350000u => "BadNodeIdInvalid", + 0x80740000u => "BadTypeMismatch", + 0x40000000u => "Uncertain", + _ => null, + }; + return name is null + ? $"0x{statusCode:X8}" + : $"0x{statusCode:X8} ({name})"; + } + + public static string FormatTimestamp(DateTime? ts) + { + if (ts is null) return "-"; + var utc = ts.Value.Kind == DateTimeKind.Utc ? ts.Value : ts.Value.ToUniversalTime(); + return utc.ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture); + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.csproj b/src/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.csproj new file mode 100644 index 0000000..50e7e02 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.csproj @@ -0,0 +1,24 @@ + + + + net10.0 + enable + enable + latest + true + true + $(NoWarn);CS1591 + ZB.MOM.WW.OtOpcUa.Driver.Cli.Common + + + + + + + + + + + + + diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Commands/ProbeCommand.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Commands/ProbeCommand.cs new file mode 100644 index 0000000..f3fdb59 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Commands/ProbeCommand.cs @@ -0,0 +1,55 @@ +using CliFx.Attributes; +using CliFx.Infrastructure; +using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Commands; + +/// +/// Probes a Modbus-TCP endpoint: opens a socket via 's +/// InitializeAsync, issues a single FC03 at the configured probe address, and +/// prints the driver's GetHealth(). Fastest way to answer "is the PLC up + talking +/// Modbus on this host:port?". +/// +[Command("probe", Description = "Verify the Modbus-TCP endpoint is reachable and speaks Modbus.")] +public sealed class ProbeCommand : ModbusCommandBase +{ + [CommandOption("probe-address", Description = + "Holding-register address used as the cheap-read probe (default 0). Some PLCs lock " + + "register 0 — set this to a known-good address on your device.")] + public ushort ProbeAddress { get; init; } + + public override async ValueTask ExecuteAsync(IConsole console) + { + ConfigureLogging(); + var ct = console.RegisterCancellationHandler(); + + // Build with one probe tag + Probe.Enabled=false so InitializeAsync connects the + // transport, we issue a single read to verify the device responds, then shut down. + var probeTag = new ModbusTagDefinition( + Name: "__probe", + Region: ModbusRegion.HoldingRegisters, + Address: ProbeAddress, + DataType: ModbusDataType.UInt16); + var options = BuildOptions([probeTag]); + + await using var driver = new ModbusDriver(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} (unit {UnitId})"); + 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($"HR[{ProbeAddress}]", snapshot[0])); + } + finally + { + await driver.ShutdownAsync(CancellationToken.None); + } + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Commands/ReadCommand.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Commands/ReadCommand.cs new file mode 100644 index 0000000..544076e --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Commands/ReadCommand.cs @@ -0,0 +1,95 @@ +using CliFx.Attributes; +using CliFx.Infrastructure; +using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Commands; + +/// +/// Read one Modbus register / coil. Operator specifies the address via +/// --region + --address + --type; the CLI synthesises a single +/// , spins up the driver, reads once, prints the snapshot, +/// and shuts down. Multi-register types (Int32 / Float32 / String / BCD32) respect +/// --byte-order the same way real driver configs do. +/// +[Command("read", Description = "Read a single Modbus register or coil.")] +public sealed class ReadCommand : ModbusCommandBase +{ + [CommandOption("region", 'r', Description = + "Coils / DiscreteInputs / InputRegisters / HoldingRegisters", IsRequired = true)] + public ModbusRegion Region { get; init; } + + [CommandOption("address", 'a', Description = + "Zero-based address within the region.", IsRequired = true)] + public ushort Address { get; init; } + + [CommandOption("type", 't', Description = + "Bool / Int16 / UInt16 / Int32 / UInt32 / Int64 / UInt64 / Float32 / Float64 / " + + "BitInRegister / String / Bcd16 / Bcd32", IsRequired = true)] + public ModbusDataType DataType { get; init; } + + [CommandOption("byte-order", Description = + "BigEndian (default, spec ABCD) or WordSwap (CDAB). Ignored for single-register types.")] + public ModbusByteOrder ByteOrder { get; init; } = ModbusByteOrder.BigEndian; + + [CommandOption("bit-index", Description = + "For type=BitInRegister: bit 0-15 LSB-first.")] + public byte BitIndex { get; init; } + + [CommandOption("string-length", Description = + "For type=String: character count (2 per register, rounded up).")] + public ushort StringLength { get; init; } + + [CommandOption("string-byte-order", Description = + "For type=String: HighByteFirst (standard) or LowByteFirst (DirectLOGIC et al).")] + public ModbusStringByteOrder StringByteOrder { get; init; } = ModbusStringByteOrder.HighByteFirst; + + public override async ValueTask ExecuteAsync(IConsole console) + { + ConfigureLogging(); + var ct = console.RegisterCancellationHandler(); + + var tagName = SynthesiseTagName(Region, Address, DataType); + var tag = new ModbusTagDefinition( + Name: tagName, + Region: Region, + Address: Address, + DataType: DataType, + Writable: false, + ByteOrder: ByteOrder, + BitIndex: BitIndex, + StringLength: StringLength, + StringByteOrder: StringByteOrder); + var options = BuildOptions([tag]); + + await using var driver = new ModbusDriver(options, DriverInstanceId); + try + { + await driver.InitializeAsync("{}", ct); + var snapshot = await driver.ReadAsync([tagName], ct); + await console.Output.WriteLineAsync(SnapshotFormatter.Format(tagName, snapshot[0])); + } + finally + { + await driver.ShutdownAsync(CancellationToken.None); + } + } + + /// + /// Builds a human-readable tag name matching the operator's conceptual model + /// (HR[100], Coil[5], IR[42]) — the driver treats the name + /// purely as a lookup key, so any stable string works. + /// + internal static string SynthesiseTagName( + ModbusRegion region, ushort address, ModbusDataType type) + { + var prefix = region switch + { + ModbusRegion.Coils => "Coil", + ModbusRegion.DiscreteInputs => "DI", + ModbusRegion.InputRegisters => "IR", + ModbusRegion.HoldingRegisters => "HR", + _ => "Reg", + }; + return $"{prefix}[{address}]:{type}"; + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Commands/SubscribeCommand.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Commands/SubscribeCommand.cs new file mode 100644 index 0000000..b536351 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Commands/SubscribeCommand.cs @@ -0,0 +1,92 @@ +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.Modbus.Cli.Commands; + +/// +/// Long-running poll of one Modbus register via the driver's ISubscribable surface +/// (under the hood: PollGroupEngine). Prints each data-change event until the +/// operator Ctrl+C's the CLI. Useful for watching a changing PLC signal during +/// commissioning or while reproducing a customer bug. +/// +[Command("subscribe", Description = "Watch a Modbus register via polled subscription until Ctrl+C.")] +public sealed class SubscribeCommand : ModbusCommandBase +{ + [CommandOption("region", 'r', Description = + "Coils / DiscreteInputs / InputRegisters / HoldingRegisters", IsRequired = true)] + public ModbusRegion Region { get; init; } + + [CommandOption("address", 'a', Description = "Zero-based address within the region.", IsRequired = true)] + public ushort Address { get; init; } + + [CommandOption("type", 't', Description = + "Bool / Int16 / UInt16 / Int32 / UInt32 / Int64 / UInt64 / Float32 / Float64 / " + + "BitInRegister / String / Bcd16 / Bcd32", IsRequired = true)] + public ModbusDataType DataType { get; init; } + + [CommandOption("interval-ms", 'i', Description = + "Publishing interval in milliseconds (default 1000). The PollGroupEngine enforces " + + "a floor of ~250ms; values below it get rounded up.")] + public int IntervalMs { get; init; } = 1000; + + [CommandOption("byte-order", Description = + "BigEndian (default) or WordSwap.")] + public ModbusByteOrder ByteOrder { get; init; } = ModbusByteOrder.BigEndian; + + public override async ValueTask ExecuteAsync(IConsole console) + { + ConfigureLogging(); + var ct = console.RegisterCancellationHandler(); + + var tagName = ReadCommand.SynthesiseTagName(Region, Address, DataType); + var tag = new ModbusTagDefinition( + Name: tagName, + Region: Region, + Address: Address, + DataType: DataType, + Writable: false, + ByteOrder: ByteOrder); + var options = BuildOptions([tag]); + + await using var driver = new ModbusDriver(options, DriverInstanceId); + ISubscriptionHandle? handle = null; + try + { + await driver.InitializeAsync("{}", ct); + + // Route every data-change event to the CliFx console (not System.Console — the + // analyzer flags it + IConsole is the testable abstraction). + 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 {tagName} @ {IntervalMs}ms. Ctrl+C to stop."); + try + { + await Task.Delay(System.Threading.Timeout.InfiniteTimeSpan, ct); + } + catch (OperationCanceledException) + { + // Expected on Ctrl+C — fall through to the unsubscribe in finally. + } + } + 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.Modbus.Cli/Commands/WriteCommand.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Commands/WriteCommand.cs new file mode 100644 index 0000000..507daf5 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Commands/WriteCommand.cs @@ -0,0 +1,118 @@ +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.Modbus.Cli.Commands; + +/// +/// Write one value to a Modbus coil or holding register. Mirrors 's +/// region / address / type flags + adds --value. Input parsing respects the +/// declared --type so you can write --value=3.14 --type=Float32 without +/// hex-encoding floats. The write is non-idempotent by default (driver's +/// WriteIdempotent=false) — replay is the operator's choice, not the driver's. +/// +[Command("write", Description = "Write a single Modbus coil or holding register.")] +public sealed class WriteCommand : ModbusCommandBase +{ + [CommandOption("region", 'r', Description = + "Coils or HoldingRegisters (the only writable regions per the protocol spec).", + IsRequired = true)] + public ModbusRegion Region { get; init; } + + [CommandOption("address", 'a', Description = + "Zero-based address within the region.", IsRequired = true)] + public ushort Address { get; init; } + + [CommandOption("type", 't', Description = + "Bool / Int16 / UInt16 / Int32 / UInt32 / Int64 / UInt64 / Float32 / Float64 / " + + "BitInRegister / String / Bcd16 / Bcd32", IsRequired = true)] + public ModbusDataType DataType { get; init; } + + [CommandOption("value", 'v', Description = + "Value to write. Parsed per --type (booleans accept true/false/0/1).", + IsRequired = true)] + public string Value { get; init; } = default!; + + [CommandOption("byte-order", Description = + "BigEndian (default, ABCD) or WordSwap (CDAB). Ignored for single-register types.")] + public ModbusByteOrder ByteOrder { get; init; } = ModbusByteOrder.BigEndian; + + [CommandOption("bit-index", Description = + "For type=BitInRegister: which bit of the holding register (0-15, LSB-first).")] + public byte BitIndex { get; init; } + + [CommandOption("string-length", Description = + "For type=String: character count (2 per register, rounded up).")] + public ushort StringLength { get; init; } + + [CommandOption("string-byte-order", Description = + "For type=String: HighByteFirst (standard) or LowByteFirst (DirectLOGIC).")] + public ModbusStringByteOrder StringByteOrder { get; init; } = ModbusStringByteOrder.HighByteFirst; + + public override async ValueTask ExecuteAsync(IConsole console) + { + ConfigureLogging(); + var ct = console.RegisterCancellationHandler(); + + if (Region is not (ModbusRegion.Coils or ModbusRegion.HoldingRegisters)) + throw new CliFx.Exceptions.CommandException( + $"Region '{Region}' is read-only in the Modbus spec; writes require Coils or HoldingRegisters."); + + var tagName = ReadCommand.SynthesiseTagName(Region, Address, DataType); + var tag = new ModbusTagDefinition( + Name: tagName, + Region: Region, + Address: Address, + DataType: DataType, + Writable: true, + ByteOrder: ByteOrder, + BitIndex: BitIndex, + StringLength: StringLength, + StringByteOrder: StringByteOrder); + var options = BuildOptions([tag]); + + var parsed = ParseValue(Value, DataType); + + await using var driver = new ModbusDriver(options, DriverInstanceId); + try + { + await driver.InitializeAsync("{}", ct); + var results = await driver.WriteAsync([new WriteRequest(tagName, parsed)], ct); + await console.Output.WriteLineAsync(SnapshotFormatter.FormatWrite(tagName, results[0])); + } + finally + { + await driver.ShutdownAsync(CancellationToken.None); + } + } + + /// + /// Parse the operator's --value string into the CLR type the driver expects + /// for the declared . Uses invariant culture everywhere + /// so 3.14 and 3,14 don't swap meaning between runs. + /// + internal static object ParseValue(string raw, ModbusDataType type) => type switch + { + ModbusDataType.Bool or ModbusDataType.BitInRegister => ParseBool(raw), + ModbusDataType.Int16 => short.Parse(raw, CultureInfo.InvariantCulture), + ModbusDataType.UInt16 or ModbusDataType.Bcd16 => ushort.Parse(raw, CultureInfo.InvariantCulture), + ModbusDataType.Int32 => int.Parse(raw, CultureInfo.InvariantCulture), + ModbusDataType.UInt32 or ModbusDataType.Bcd32 => uint.Parse(raw, CultureInfo.InvariantCulture), + ModbusDataType.Int64 => long.Parse(raw, CultureInfo.InvariantCulture), + ModbusDataType.UInt64 => ulong.Parse(raw, CultureInfo.InvariantCulture), + ModbusDataType.Float32 => float.Parse(raw, CultureInfo.InvariantCulture), + ModbusDataType.Float64 => double.Parse(raw, CultureInfo.InvariantCulture), + ModbusDataType.String => raw, + _ => 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.Modbus.Cli/ModbusCommandBase.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/ModbusCommandBase.cs new file mode 100644 index 0000000..b01ccdd --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/ModbusCommandBase.cs @@ -0,0 +1,60 @@ +using CliFx.Attributes; +using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli; + +/// +/// Base for every Modbus CLI command. Carries the Modbus-TCP endpoint options +/// (host / port / unit-id) on top of 's verbose + timeout +/// + logging helpers, and exposes so each command can turn its +/// parsed flags into a ready to hand to the driver ctor. +/// +public abstract class ModbusCommandBase : DriverCommandBase +{ + [CommandOption("host", 'h', Description = "Modbus-TCP server hostname or IP", IsRequired = true)] + public string Host { get; init; } = default!; + + [CommandOption("port", 'p', Description = "Modbus-TCP port (default 502)")] + public int Port { get; init; } = 502; + + [CommandOption("unit-id", 'U', Description = "Modbus unit / slave ID (1-247, default 1)")] + public byte UnitId { get; init; } = 1; + + [CommandOption("timeout-ms", Description = "Per-PDU timeout in milliseconds (default 2000)")] + public int TimeoutMs { get; init; } = 2000; + + [CommandOption("disable-reconnect", Description = + "Disable the built-in mid-transaction reconnect-and-retry. Matches the driver's " + + "AutoReconnect=false setting — use when diagnosing socket teardown behaviour.")] + public bool DisableAutoReconnect { get; init; } + + /// + public override TimeSpan Timeout + { + get => TimeSpan.FromMilliseconds(TimeoutMs); + init { /* driven by TimeoutMs property; setter required to satisfy base's init contract */ } + } + + /// + /// Construct a with the endpoint fields this base + /// collected + whatever the subclass declares. Probe is + /// disabled — CLI runs are one-shot, the probe loop would race the operator's + /// command against its own keep-alive reads. + /// + protected ModbusDriverOptions BuildOptions(IReadOnlyList tags) => new() + { + Host = Host, + Port = Port, + UnitId = UnitId, + Timeout = Timeout, + AutoReconnect = !DisableAutoReconnect, + Tags = tags, + Probe = new ModbusProbeOptions { Enabled = false }, + }; + + /// + /// Short instance id used in Serilog output so operators running the CLI against + /// multiple endpoints in parallel can distinguish the logs. + /// + protected string DriverInstanceId => $"modbus-cli-{Host}:{Port}"; +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Program.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Program.cs new file mode 100644 index 0000000..a76e7ba --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Program.cs @@ -0,0 +1,11 @@ +using CliFx; + +return await new CliApplicationBuilder() + .AddCommandsFromThisAssembly() + .SetExecutableName("otopcua-modbus-cli") + .SetDescription( + "OtOpcUa Modbus test-client — ad-hoc connectivity + register reads/writes + polled " + + "subscriptions against Modbus-TCP devices. Mirrors the otopcua-cli shape for v1-style " + + "manual validation against PLCs + the integration fixture. See docs/Driver.Modbus.Cli.md.") + .Build() + .RunAsync(args); diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.csproj b/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.csproj new file mode 100644 index 0000000..0807be1 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.csproj @@ -0,0 +1,29 @@ + + + + Exe + net10.0 + enable + enable + latest + true + true + $(NoWarn);CS1591 + ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli + otopcua-modbus-cli + + + + + + + + + + + + + + + + diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.Tests/SnapshotFormatterTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.Tests/SnapshotFormatterTests.cs new file mode 100644 index 0000000..38a9a82 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.Tests/SnapshotFormatterTests.cs @@ -0,0 +1,123 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.Tests; + +[Trait("Category", "Unit")] +public sealed class SnapshotFormatterTests +{ + private static readonly DateTime FixedTime = + new(2026, 4, 21, 12, 34, 56, 789, DateTimeKind.Utc); + + [Fact] + public void Format_includes_tag_value_status_and_both_timestamps() + { + var snap = new DataValueSnapshot(42, 0u, FixedTime, FixedTime); + var output = SnapshotFormatter.Format("N7:0", snap); + + output.ShouldContain("Tag: N7:0"); + output.ShouldContain("Value: 42"); + output.ShouldContain("Status: 0x00000000 (Good)"); + output.ShouldContain("Source Time: 2026-04-21T12:34:56.789Z"); + output.ShouldContain("Server Time: 2026-04-21T12:34:56.789Z"); + } + + [Theory] + [InlineData(0x00000000u, "Good")] + [InlineData(0x80000000u, "Bad")] + [InlineData(0x80050000u, "BadCommunicationError")] + [InlineData(0x80060000u, "BadTimeout")] + [InlineData(0x80340000u, "BadNodeIdUnknown")] + [InlineData(0x40000000u, "Uncertain")] + public void FormatStatus_names_well_known_status_codes(uint status, string expectedName) + { + SnapshotFormatter.FormatStatus(status).ShouldContain(expectedName); + } + + [Fact] + public void FormatStatus_unknown_codes_fall_back_to_hex_only() + { + // 0xDEADBEEF isn't in the shortlist — just render the hex form, no name. + SnapshotFormatter.FormatStatus(0xDEADBEEFu).ShouldBe("0xDEADBEEF"); + } + + [Fact] + public void FormatValue_renders_null_as_placeholder() + { + var snap = new DataValueSnapshot(null, 0x80050000u, null, FixedTime); + var output = SnapshotFormatter.Format("Orphan", snap); + output.ShouldContain("Value: "); + output.ShouldContain("Source Time: -"); // null timestamp → dash + } + + [Fact] + public void FormatValue_formats_booleans_lowercase() + { + var snap = new DataValueSnapshot(true, 0u, FixedTime, FixedTime); + SnapshotFormatter.Format("Coil", snap).ShouldContain("Value: true"); + } + + [Fact] + public void FormatValue_formats_floats_invariant_culture() + { + // Guards against non-invariant decimal separators (e.g. comma on PL locales) + // that would break cross-platform log diffs. + var snap = new DataValueSnapshot(3.14f, 0u, FixedTime, FixedTime); + SnapshotFormatter.Format("F8:0", snap).ShouldContain("3.14"); + } + + [Fact] + public void FormatValue_quotes_strings() + { + var snap = new DataValueSnapshot("hello", 0u, FixedTime, FixedTime); + SnapshotFormatter.Format("Msg", snap).ShouldContain("\"hello\""); + } + + [Fact] + public void FormatWrite_shows_status_with_tag_name() + { + var result = new WriteResult(0u); + SnapshotFormatter.FormatWrite("Scratch", result) + .ShouldBe("Write Scratch: 0x00000000 (Good)"); + } + + [Fact] + public void FormatTable_aligns_columns_and_includes_header_separator() + { + var names = new[] { "A", "LongerTag" }; + var snaps = new[] + { + new DataValueSnapshot(1, 0u, FixedTime, FixedTime), + new DataValueSnapshot(2, 0u, FixedTime, FixedTime), + }; + var table = SnapshotFormatter.FormatTable(names, snaps); + + table.ShouldContain("TAG"); + table.ShouldContain("VALUE"); + table.ShouldContain("STATUS"); + table.ShouldContain("SOURCE TIME"); + table.ShouldContain("---"); // separator row + table.ShouldContain("LongerTag"); + table.ShouldContain("0x00000000"); + } + + [Fact] + public void FormatTable_rejects_mismatched_lengths() + { + Should.Throw(() => SnapshotFormatter.FormatTable( + new[] { "A", "B" }, + new[] { new DataValueSnapshot(1, 0u, FixedTime, FixedTime) })); + } + + [Fact] + public void FormatTimestamp_normalises_local_kind_to_utc() + { + // Unspecified / Local times must land on UTC in the output — otherwise a CI box in + // UTC+X would emit diffs against dev-laptop runs. + var local = new DateTime(2026, 4, 21, 8, 0, 0, DateTimeKind.Local); + var formatted = SnapshotFormatter.FormatTimestamp(local); + formatted.ShouldEndWith("Z"); + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.Tests/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.Tests.csproj b/tests/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.Tests/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.Tests.csproj new file mode 100644 index 0000000..ee9c791 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.Tests/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.Tests.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + enable + enable + false + true + ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.Tests + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests/ReadCommandTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests/ReadCommandTests.cs new file mode 100644 index 0000000..2b33f20 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests/ReadCommandTests.cs @@ -0,0 +1,21 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Commands; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests; + +[Trait("Category", "Unit")] +public sealed class ReadCommandTests +{ + [Theory] + [InlineData(ModbusRegion.HoldingRegisters, 100, ModbusDataType.UInt16, "HR[100]:UInt16")] + [InlineData(ModbusRegion.Coils, 0, ModbusDataType.Bool, "Coil[0]:Bool")] + [InlineData(ModbusRegion.DiscreteInputs, 42, ModbusDataType.Bool, "DI[42]:Bool")] + [InlineData(ModbusRegion.InputRegisters, 5, ModbusDataType.Int16, "IR[5]:Int16")] + [InlineData(ModbusRegion.HoldingRegisters, 200, ModbusDataType.Float32, "HR[200]:Float32")] + public void SynthesiseTagName_produces_stable_region_prefix_plus_address_plus_type( + ModbusRegion region, ushort address, ModbusDataType type, string expected) + { + ReadCommand.SynthesiseTagName(region, address, type).ShouldBe(expected); + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests/WriteCommandParseValueTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests/WriteCommandParseValueTests.cs new file mode 100644 index 0000000..ccec272 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests/WriteCommandParseValueTests.cs @@ -0,0 +1,90 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Commands; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests; + +/// +/// Covers the --value string → CLR type parser inside +/// . This is the piece that guards against +/// locale surprises (e.g. comma-as-decimal-separator on PL locales), so all numeric +/// paths assert the invariant-culture path. +/// +[Trait("Category", "Unit")] +public sealed class WriteCommandParseValueTests +{ + [Theory] + [InlineData("true", true)] + [InlineData("false", false)] + [InlineData("1", true)] + [InlineData("0", false)] + [InlineData("YES", true)] + [InlineData("No", false)] + [InlineData("on", true)] + [InlineData("off", false)] + public void ParseValue_Bool_accepts_common_aliases(string raw, bool expected) + { + WriteCommand.ParseValue(raw, ModbusDataType.Bool).ShouldBe(expected); + } + + [Fact] + public void ParseValue_Bool_rejects_unknown_strings() + { + Should.Throw( + () => WriteCommand.ParseValue("maybe", ModbusDataType.Bool)); + } + + [Fact] + public void ParseValue_Int16_parses_positive_and_negative() + { + WriteCommand.ParseValue("-32768", ModbusDataType.Int16).ShouldBe((short)-32768); + WriteCommand.ParseValue("32767", ModbusDataType.Int16).ShouldBe((short)32767); + } + + [Fact] + public void ParseValue_UInt16_and_Bcd16_both_yield_ushort() + { + WriteCommand.ParseValue("65535", ModbusDataType.UInt16).ShouldBeOfType(); + WriteCommand.ParseValue("65535", ModbusDataType.Bcd16).ShouldBeOfType(); + } + + [Fact] + public void ParseValue_Float32_uses_invariant_culture_period_as_decimal_separator() + { + WriteCommand.ParseValue("3.14", ModbusDataType.Float32).ShouldBe(3.14f); + } + + [Fact] + public void ParseValue_Float64_handles_larger_precision() + { + var result = WriteCommand.ParseValue("2.718281828", ModbusDataType.Float64); + result.ShouldBeOfType(); + ((double)result).ShouldBe(2.718281828d, 0.0000001d); + } + + [Fact] + public void ParseValue_String_returns_raw_string_unmodified() + { + WriteCommand.ParseValue("hello world", ModbusDataType.String).ShouldBe("hello world"); + } + + [Fact] + public void ParseValue_BitInRegister_accepts_bool_aliases() + { + WriteCommand.ParseValue("true", ModbusDataType.BitInRegister).ShouldBe(true); + WriteCommand.ParseValue("0", ModbusDataType.BitInRegister).ShouldBe(false); + } + + [Fact] + public void ParseValue_Int32_parses_negative_max() + { + WriteCommand.ParseValue("-2147483648", ModbusDataType.Int32).ShouldBe(int.MinValue); + } + + [Fact] + public void ParseValue_rejects_non_numeric_for_numeric_types() + { + Should.Throw( + () => WriteCommand.ParseValue("not-a-number", ModbusDataType.Int32)); + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests.csproj b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests.csproj new file mode 100644 index 0000000..dc3834b --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + enable + enable + false + true + ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + -- 2.49.1