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 /// /// 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|...). /// 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. /// 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 = 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 => "", bool b => b ? "true" : "false", string s => $"\"{s}\"", IFormattable f => f.ToString(null, CultureInfo.InvariantCulture), _ => value.ToString() ?? "", }; 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); } }