Files
lmxopcua/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/SnapshotFormatter.cs
T
Joseph Doherty bd6c0b4d3d docs: complete XML doc comments via fixdocs (2757 to 131 findings)
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).
2026-06-03 12:34:34 -04:00

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