bd6c0b4d3d
Add missing <returns>/<param>/<summary>/<typeparam> tags and clean up misused inheritdoc across 481 files so the documented API surface is complete. Documentation-only (zero code lines changed). The 131 remaining findings are inheritdoc-style warnings deliberately left to preserve hand-written implementation rationale (plan-decision notes, race-condition explanations).
177 lines
8.2 KiB
C#
177 lines
8.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>
|
|
/// <param name="tagName">The tag name to include in the output.</param>
|
|
/// <param name="snapshot">The data value snapshot to format.</param>
|
|
/// <returns>A multi-line string representation of the tag and its value.</returns>
|
|
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>
|
|
/// <param name="tagName">The tag name to include in the output.</param>
|
|
/// <param name="result">The write result to format.</param>
|
|
/// <returns>A single-line string showing the tag name and write status.</returns>
|
|
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>
|
|
/// <param name="tagNames">The list of tag names to include as rows.</param>
|
|
/// <param name="snapshots">The list of data value snapshots to format.</param>
|
|
/// <returns>An aligned table string with tag, value, status, and source-time columns.</returns>
|
|
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 = rows.Length == 0 ? "TAG".Length : Math.Max("TAG".Length, rows.Max(r => r.Tag.Length));
|
|
int valW = rows.Length == 0 ? "VALUE".Length : Math.Max("VALUE".Length, rows.Max(r => r.Value.Length));
|
|
int statW = rows.Length == 0 ? "STATUS".Length : Math.Max("STATUS".Length, rows.Max(r => r.Status.Length));
|
|
// source-time is the right-most column, so it is intentionally not measured or padded;
|
|
// when a snapshot has a non-null SourceTimestampUtc the cell is 24 chars (ISO-8601 to ms),
|
|
// and when the timestamp is null FormatTimestamp emits "-" — the resulting unalignment is
|
|
// harmless because nothing is appended after this column.
|
|
|
|
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();
|
|
}
|
|
|
|
/// <summary>Formats a value for console output, handling null, bool, string, and formattable types.</summary>
|
|
/// <param name="value">The value to format.</param>
|
|
/// <returns>A formatted string representation of the value.</returns>
|
|
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>",
|
|
};
|
|
|
|
/// <summary>Formats an OPC UA status code as a hexadecimal string with named severity classification.</summary>
|
|
/// <param name="statusCode">The OPC UA status code to format.</param>
|
|
/// <returns>A formatted status string like "0x00000000 (Good)" or "0x80050000 (BadCommunicationError)".</returns>
|
|
public static string FormatStatus(uint statusCode)
|
|
{
|
|
// OPC UA status codes carry sub-code and flag bits in the low 16 bits (info type,
|
|
// structure-changed, semantics-changed, limit bits, overflow, etc.). To ensure
|
|
// that e.g. 0x80050001 still reads as "BadCommunicationError" rather than bare hex,
|
|
// named codes are matched against the high-word mask (code & 0xFFFF0000). When no
|
|
// named match is found the severity class (top 2 bits) provides a meaningful fallback
|
|
// so operators always see at least Good / Uncertain / Bad rather than raw hex.
|
|
// Numeric codes are the canonical values from the OPC Foundation Opc.Ua.StatusCodes
|
|
// table; keep them in sync with that table if this list is extended.
|
|
var masked = statusCode & 0xFFFF0000u;
|
|
var name = masked switch
|
|
{
|
|
0x00000000u => "Good",
|
|
0x80000000u => "Bad",
|
|
0x80020000u => "BadInternalError",
|
|
0x80050000u => "BadCommunicationError",
|
|
0x800A0000u => "BadTimeout",
|
|
0x80310000u => "BadNoCommunication",
|
|
0x80320000u => "BadWaitingForInitialData",
|
|
0x80340000u => "BadNodeIdUnknown",
|
|
0x80330000u => "BadNodeIdInvalid",
|
|
0x803B0000u => "BadNotWritable",
|
|
0x803C0000u => "BadOutOfRange",
|
|
0x803D0000u => "BadNotSupported",
|
|
0x808B0000u => "BadDeviceFailure",
|
|
0x80740000u => "BadTypeMismatch",
|
|
0x40000000u => "Uncertain",
|
|
_ => null,
|
|
};
|
|
|
|
if (name is null)
|
|
{
|
|
// Severity fallback: top 2 bits identify the quality class even for unknown
|
|
// sub-codes. 0x80000000 and 0xC0000000 (reserved quality) both map to "Bad".
|
|
name = (statusCode & 0xC0000000u) switch
|
|
{
|
|
0x00000000u => "Good",
|
|
0x40000000u => "Uncertain",
|
|
_ => "Bad",
|
|
};
|
|
}
|
|
|
|
return name is null
|
|
? $"0x{statusCode:X8}"
|
|
: $"0x{statusCode:X8} ({name})";
|
|
}
|
|
|
|
/// <summary>Formats a UTC timestamp as an ISO 8601 string, or "-" if null.</summary>
|
|
/// <param name="ts">The timestamp to format, or null.</param>
|
|
/// <returns>An ISO 8601 formatted string or "-".</returns>
|
|
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);
|
|
}
|
|
}
|