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