using System.Globalization; using CliFx.Attributes; using CliFx.Exceptions; using CliFx.Infrastructure; using Opc.Ua; using ZB.MOM.WW.OtOpcUa.Client.CLI.Helpers; using ZB.MOM.WW.OtOpcUa.Client.Shared; using ZB.MOM.WW.OtOpcUa.Client.Shared.Models; namespace ZB.MOM.WW.OtOpcUa.Client.CLI.Commands; [Command("historyread", Description = "Read historical data from a node")] public class HistoryReadCommand : CommandBase { /// /// Creates the historical-data command used to inspect raw or aggregate history for a node. /// /// The factory that creates the shared client service for the command run. public HistoryReadCommand(IOpcUaClientServiceFactory factory) : base(factory) { } /// /// Gets the historized node to query. /// [CommandOption("node", 'n', Description = "Node ID (e.g. ns=2;s=MyNode)", IsRequired = true)] public string NodeId { get; init; } = default!; /// /// Gets the optional history start time string supplied by the operator. /// [CommandOption("start", Description = "Start time in ISO 8601 UTC format, e.g. 2026-01-15T08:00:00Z (default: 24 hours ago)")] public string? StartTime { get; init; } /// /// Gets the optional history end time string supplied by the operator. /// [CommandOption("end", Description = "End time in ISO 8601 UTC format, e.g. 2026-01-15T09:00:00Z (default: now)")] public string? EndTime { get; init; } /// /// Gets the maximum number of raw values the command should print. /// [CommandOption("max", Description = "Maximum number of values to return")] public int MaxValues { get; init; } = 1000; /// /// Gets the optional aggregate name used when the operator wants processed history instead of raw samples. /// [CommandOption("aggregate", Description = "Aggregate function: Average, Minimum, Maximum, Count, Start, End, StandardDeviation")] public string? Aggregate { get; init; } /// /// Gets the aggregate bucket interval, in milliseconds, for processed history reads. /// [CommandOption("interval", Description = "Processing interval in milliseconds for aggregates")] public double IntervalMs { get; init; } = 3600000; /// public override async ValueTask ExecuteAsync(IConsole console) { ConfigureLogging(); if (MaxValues <= 0) throw new CommandException($"--max must be greater than 0 (was {MaxValues})."); if (!string.IsNullOrEmpty(Aggregate) && IntervalMs <= 0) throw new CommandException($"--interval must be greater than 0 (was {IntervalMs})."); NodeId nodeId; try { nodeId = NodeIdParser.ParseRequired(NodeId); } catch (Exception ex) when (ex is FormatException or ArgumentException) { throw new CommandException($"Invalid --node value: {ex.Message}"); } DateTime start, end; try { start = string.IsNullOrEmpty(StartTime) ? DateTime.UtcNow.AddHours(-24) : DateTime.Parse(StartTime, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal); } catch (FormatException ex) { throw new CommandException($"Invalid --start value '{StartTime}': {ex.Message}. Expected ISO 8601 UTC format, e.g. 2026-01-15T08:00:00Z."); } try { end = string.IsNullOrEmpty(EndTime) ? DateTime.UtcNow : DateTime.Parse(EndTime, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal); } catch (FormatException ex) { throw new CommandException($"Invalid --end value '{EndTime}': {ex.Message}. Expected ISO 8601 UTC format, e.g. 2026-01-15T08:00:00Z."); } AggregateType aggregateType = default; if (!string.IsNullOrEmpty(Aggregate)) { try { aggregateType = ParseAggregateType(Aggregate); } catch (ArgumentException ex) { throw new CommandException($"Invalid --aggregate value: {ex.Message}"); } } IOpcUaClientService? service = null; try { var ct = console.RegisterCancellationHandler(); (service, _) = await CreateServiceAndConnectAsync(ct); IReadOnlyList values; if (string.IsNullOrEmpty(Aggregate)) { await console.Output.WriteLineAsync( $"History for {NodeId} ({start:yyyy-MM-dd HH:mm} -> {end:yyyy-MM-dd HH:mm})"); values = await service.HistoryReadRawAsync(nodeId, start, end, MaxValues, ct); } else { await console.Output.WriteLineAsync( $"History for {NodeId} ({Aggregate}, interval={IntervalMs}ms)"); values = await service.HistoryReadAggregateAsync( nodeId, start, end, aggregateType, IntervalMs, ct); } await console.Output.WriteLineAsync(); await console.Output.WriteLineAsync($"{"Timestamp",-35} {"Value",-15} {"Status"}"); foreach (var dv in values) { var status = StatusCode.IsGood(dv.StatusCode) ? "Good" : StatusCode.IsBad(dv.StatusCode) ? "Bad" : "Uncertain"; await console.Output.WriteLineAsync( $"{dv.SourceTimestamp.ToString("O"),-35} {dv.Value,-15} {status}"); } await console.Output.WriteLineAsync(); await console.Output.WriteLineAsync($"{values.Count} values returned."); } finally { if (service != null) { await service.DisconnectAsync(); service.Dispose(); } } } private static AggregateType ParseAggregateType(string name) { return name.Trim().ToLowerInvariant() switch { "average" or "avg" => AggregateType.Average, "minimum" or "min" => AggregateType.Minimum, "maximum" or "max" => AggregateType.Maximum, "count" => AggregateType.Count, "start" or "first" => AggregateType.Start, "end" or "last" => AggregateType.End, "standarddeviation" or "stddev" or "stdev" => AggregateType.StandardDeviation, _ => throw new ArgumentException( $"Unknown aggregate: '{name}'. Supported: Average, Minimum, Maximum, Count, Start, End, StandardDeviation") }; } }