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>
132 lines
5.2 KiB
C#
132 lines
5.2 KiB
C#
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);
|
|
}
|
|
}
|