Merge pull request 'Task #249 — Driver test-client CLIs: shared lib + Modbus CLI first' (#203) from task-249-driver-cli-common-modbus into v2
This commit was merged in pull request #203.
This commit is contained in:
@@ -24,6 +24,8 @@
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.Shared/ZB.MOM.WW.OtOpcUa.Client.Shared.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.CLI/ZB.MOM.WW.OtOpcUa.Client.CLI.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.UI/ZB.MOM.WW.OtOpcUa.Client.UI.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Analyzers/ZB.MOM.WW.OtOpcUa.Analyzers.csproj"/>
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
@@ -44,6 +46,8 @@
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.Tests/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.csproj"/>
|
||||
|
||||
121
docs/Driver.Modbus.Cli.md
Normal file
121
docs/Driver.Modbus.Cli.md
Normal file
@@ -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 <tag>: 0x... (Good | BadCommunicationError | …)`.
|
||||
- `subscribe` emits one line per change: `[HH:mm:ss.fff] <tag> = <value> (<status>)`.
|
||||
|
||||
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.
|
||||
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>
|
||||
@@ -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: <null>");
|
||||
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<ArgumentException>(() => 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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3" Version="1.1.0"/>
|
||||
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.Cli.Common\ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Covers the <c>--value</c> string → CLR type parser inside
|
||||
/// <see cref="WriteCommand.ParseValue"/>. 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.
|
||||
/// </summary>
|
||||
[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<CliFx.Exceptions.CommandException>(
|
||||
() => 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<ushort>();
|
||||
WriteCommand.ParseValue("65535", ModbusDataType.Bcd16).ShouldBeOfType<ushort>();
|
||||
}
|
||||
|
||||
[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>();
|
||||
((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<FormatException>(
|
||||
() => WriteCommand.ParseValue("not-a-number", ModbusDataType.Int32));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3" Version="1.1.0"/>
|
||||
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli\ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user