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:
Joseph Doherty
2026-04-21 08:15:14 -04:00
parent b644b26310
commit 5dac2e9375
17 changed files with 1086 additions and 0 deletions

View 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();
}
}

View 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: &lt;name&gt;
/// Value: &lt;value&gt;
/// 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 &lt;tag&gt;: 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);
}
}

View File

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