Task #249 — Driver test-client CLIs: shared lib + Modbus CLI first
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) <noreply@anthropic.com>
This commit is contained in:
60
src/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/DriverCommandBase.cs
Normal file
60
src/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/DriverCommandBase.cs
Normal file
@@ -0,0 +1,60 @@
|
||||
using CliFx;
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Infrastructure;
|
||||
using Serilog;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Each driver CLI sub-classes this with its own protocol-specific base (e.g.
|
||||
/// <c>ModbusCommandBase</c>) that adds host/port/unit-id + a <c>BuildDriver()</c>
|
||||
/// factory. That second layer is the point where the driver's <c>{Driver}DriverOptions</c>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Why a shared base at all — without this every CLI re-authored the same ~40 lines
|
||||
/// of Serilog wiring + cancel-token plumbing + verbose flag.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public abstract class DriverCommandBase : ICommand
|
||||
{
|
||||
/// <summary>
|
||||
/// Enable Serilog debug-level output. Leave off for clean one-line-per-call output;
|
||||
/// switch on when diagnosing a connect / PDU-framing / retry problem.
|
||||
/// </summary>
|
||||
[CommandOption("verbose", Description = "Enable verbose/debug Serilog output")]
|
||||
public bool Verbose { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Request-level timeout used by the driver's <c>Initialize</c> / <c>Read</c> /
|
||||
/// <c>Write</c> / probe calls. Defaults per-protocol (Modbus: 2s, AB: 5s, S7: 5s,
|
||||
/// TwinCAT: 5s) — each driver CLI overrides this property with the appropriate
|
||||
/// <c>[CommandOption]</c> default.
|
||||
/// </summary>
|
||||
public abstract TimeSpan Timeout { get; init; }
|
||||
|
||||
public abstract ValueTask ExecuteAsync(IConsole console);
|
||||
|
||||
/// <summary>
|
||||
/// Configures the process-global Serilog logger. Commands call this at the top of
|
||||
/// <see cref="ExecuteAsync"/> so driver-internal <c>Log.Logger</c> writes land on the
|
||||
/// same sink as the CLI's operator-facing output.
|
||||
/// </summary>
|
||||
protected void ConfigureLogging()
|
||||
{
|
||||
var config = new LoggerConfiguration();
|
||||
if (Verbose)
|
||||
config.MinimumLevel.Debug().WriteTo.Console();
|
||||
else
|
||||
config.MinimumLevel.Warning().WriteTo.Console();
|
||||
Log.Logger = config.CreateLogger();
|
||||
}
|
||||
}
|
||||
131
src/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/SnapshotFormatter.cs
Normal file
131
src/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/SnapshotFormatter.cs
Normal file
@@ -0,0 +1,131 @@
|
||||
using System.Globalization;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Renders <see cref="DataValueSnapshot"/> + <see cref="WriteResult"/> payloads as the
|
||||
/// plain-text lines every driver CLI prints to its console. Matches the one-field-per-line
|
||||
/// style the existing OPC UA <c>otopcua-cli</c> uses so combined runs (read a tag via both
|
||||
/// CLIs side-by-side) look coherent.
|
||||
/// </summary>
|
||||
public static class SnapshotFormatter
|
||||
{
|
||||
/// <summary>
|
||||
/// Single-tag multi-line render. Shape:
|
||||
/// <code>
|
||||
/// Tag: <name>
|
||||
/// Value: <value>
|
||||
/// Status: 0x... (Good|BadCommunicationError|...)
|
||||
/// Source Time: 2026-04-21T12:34:56.789Z
|
||||
/// Server Time: 2026-04-21T12:34:56.790Z
|
||||
/// </code>
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Write-result render, one line: <c>Write <tag>: 0x... (Good|...)</c>.
|
||||
/// </summary>
|
||||
public static string FormatWrite(string tagName, WriteResult result)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(result);
|
||||
return $"Write {tagName}: {FormatStatus(result.StatusCode)}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Table-style render for batch reads. Emits an aligned 4-column layout:
|
||||
/// tag / value / status / source-time.
|
||||
/// </summary>
|
||||
public static string FormatTable(
|
||||
IReadOnlyList<string> tagNames, IReadOnlyList<DataValueSnapshot> 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 => "<null>",
|
||||
bool b => b ? "true" : "false",
|
||||
string s => $"\"{s}\"",
|
||||
IFormattable f => f.ToString(null, CultureInfo.InvariantCulture),
|
||||
_ => value.ToString() ?? "<null>",
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Cli.Common</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CliFx" Version="2.3.6"/>
|
||||
<PackageReference Include="Serilog" Version="4.2.0"/>
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Probes a Modbus-TCP endpoint: opens a socket via <see cref="ModbusDriver"/>'s
|
||||
/// <c>InitializeAsync</c>, issues a single FC03 at the configured probe address, and
|
||||
/// prints the driver's <c>GetHealth()</c>. Fastest way to answer "is the PLC up + talking
|
||||
/// Modbus on this host:port?".
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Read one Modbus register / coil. Operator specifies the address via
|
||||
/// <c>--region</c> + <c>--address</c> + <c>--type</c>; the CLI synthesises a single
|
||||
/// <see cref="ModbusTagDefinition"/>, spins up the driver, reads once, prints the snapshot,
|
||||
/// and shuts down. Multi-register types (Int32 / Float32 / String / BCD32) respect
|
||||
/// <c>--byte-order</c> the same way real driver configs do.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a human-readable tag name matching the operator's conceptual model
|
||||
/// (<c>HR[100]</c>, <c>Coil[5]</c>, <c>IR[42]</c>) — the driver treats the name
|
||||
/// purely as a lookup key, so any stable string works.
|
||||
/// </summary>
|
||||
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}";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Long-running poll of one Modbus register via the driver's <c>ISubscribable</c> surface
|
||||
/// (under the hood: <c>PollGroupEngine</c>). 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.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
}
|
||||
}
|
||||
118
src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Commands/WriteCommand.cs
Normal file
118
src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Commands/WriteCommand.cs
Normal file
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Write one value to a Modbus coil or holding register. Mirrors <see cref="ReadCommand"/>'s
|
||||
/// region / address / type flags + adds <c>--value</c>. Input parsing respects the
|
||||
/// declared <c>--type</c> so you can write <c>--value=3.14 --type=Float32</c> without
|
||||
/// hex-encoding floats. The write is non-idempotent by default (driver's
|
||||
/// <c>WriteIdempotent=false</c>) — replay is the operator's choice, not the driver's.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse the operator's <c>--value</c> string into the CLR type the driver expects
|
||||
/// for the declared <see cref="ModbusDataType"/>. Uses invariant culture everywhere
|
||||
/// so <c>3.14</c> and <c>3,14</c> don't swap meaning between runs.
|
||||
/// </summary>
|
||||
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."),
|
||||
};
|
||||
}
|
||||
60
src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/ModbusCommandBase.cs
Normal file
60
src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/ModbusCommandBase.cs
Normal file
@@ -0,0 +1,60 @@
|
||||
using CliFx.Attributes;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli;
|
||||
|
||||
/// <summary>
|
||||
/// Base for every Modbus CLI command. Carries the Modbus-TCP endpoint options
|
||||
/// (host / port / unit-id) on top of <see cref="DriverCommandBase"/>'s verbose + timeout
|
||||
/// + logging helpers, and exposes <see cref="BuildOptions"/> so each command can turn its
|
||||
/// parsed flags into a <see cref="ModbusDriverOptions"/> ready to hand to the driver ctor.
|
||||
/// </summary>
|
||||
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; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public override TimeSpan Timeout
|
||||
{
|
||||
get => TimeSpan.FromMilliseconds(TimeoutMs);
|
||||
init { /* driven by TimeoutMs property; setter required to satisfy base's init contract */ }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Construct a <see cref="ModbusDriverOptions"/> with the endpoint fields this base
|
||||
/// collected + whatever <paramref name="tags"/> 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.
|
||||
/// </summary>
|
||||
protected ModbusDriverOptions BuildOptions(IReadOnlyList<ModbusTagDefinition> tags) => new()
|
||||
{
|
||||
Host = Host,
|
||||
Port = Port,
|
||||
UnitId = UnitId,
|
||||
Timeout = Timeout,
|
||||
AutoReconnect = !DisableAutoReconnect,
|
||||
Tags = tags,
|
||||
Probe = new ModbusProbeOptions { Enabled = false },
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Short instance id used in Serilog output so operators running the CLI against
|
||||
/// multiple endpoints in parallel can distinguish the logs.
|
||||
/// </summary>
|
||||
protected string DriverInstanceId => $"modbus-cli-{Host}:{Port}";
|
||||
}
|
||||
11
src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Program.cs
Normal file
11
src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Program.cs
Normal file
@@ -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);
|
||||
@@ -0,0 +1,29 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli</RootNamespace>
|
||||
<AssemblyName>otopcua-modbus-cli</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CliFx" Version="2.3.6"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Cli.Common\ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Modbus\ZB.MOM.WW.OtOpcUa.Driver.Modbus.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user