using System.Globalization; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.Cli.Common; /// /// Renders + payloads as the /// plain-text lines every driver CLI prints to its console. Matches the one-field-per-line /// style the existing OPC UA otopcua-cli uses so combined runs (read a tag via both /// CLIs side-by-side) look coherent. /// public static class SnapshotFormatter { /// /// Single-tag multi-line render. Shape: /// /// Tag: <name> /// Value: <value> /// Status: 0x... (Good|BadCommunicationError|...) /// Source Time: 2026-04-21T12:34:56.789Z /// Server Time: 2026-04-21T12:34:56.790Z /// /// /// The tag name to include in the output. /// The data value snapshot to format. /// A multi-line string representation of the tag and its value. 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); } /// /// Write-result render, one line: Write <tag>: 0x... (Good|...). /// /// The tag name to include in the output. /// The write result to format. /// A single-line string showing the tag name and write status. public static string FormatWrite(string tagName, WriteResult result) { ArgumentNullException.ThrowIfNull(result); return $"Write {tagName}: {FormatStatus(result.StatusCode)}"; } /// /// Table-style render for batch reads. Emits an aligned 4-column layout: /// tag / value / status / source-time. /// /// The list of tag names to include as rows. /// The list of data value snapshots to format. /// An aligned table string with tag, value, status, and source-time columns. public static string FormatTable( IReadOnlyList tagNames, IReadOnlyList 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(); } /// Formats a value for console output, handling null, bool, string, and formattable types. /// The value to format. /// A formatted string representation of the value. public static string FormatValue(object? value) => value switch { null => "", bool b => b ? "true" : "false", string s => $"\"{s}\"", IFormattable f => f.ToString(null, CultureInfo.InvariantCulture), _ => value.ToString() ?? "", }; /// Formats an OPC UA status code as a hexadecimal string with named severity classification. /// The OPC UA status code to format. /// A formatted status string like "0x00000000 (Good)" or "0x80050000 (BadCommunicationError)". 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})"; } /// Formats a UTC timestamp as an ISO 8601 string, or "-" if null. /// The timestamp to format, or null. /// An ISO 8601 formatted string or "-". 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); } }