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; namespace ZB.MOM.WW.OtOpcUa.Client.CLI.Commands; [Command("historyupdate", Description = "Insert, replace, update or delete historical data on a node")] public class HistoryUpdateCommand : CommandBase { private static readonly string[] Modes = ["insert", "replace", "update", "delete-raw", "delete-at-time"]; /// /// Creates the history-update command used to modify stored history for a node. /// /// The factory that creates the shared client service for the command run. public HistoryUpdateCommand(IOpcUaClientServiceFactory factory) : base(factory) { } /// Gets the historized node to modify. [CommandOption("node", 'n', Description = "Node ID (e.g. ns=2;s=MyNode)", IsRequired = true)] public string NodeId { get; init; } = default!; /// Gets the update mode. [CommandOption("mode", Description = "insert | replace | update | delete-raw | delete-at-time", IsRequired = true)] public string Mode { get; init; } = default!; /// Gets the values to write, paired positionally with . [CommandOption("value", Description = "Value to write (repeatable; pairs positionally with --time) for insert/replace/update")] public IReadOnlyList? Values { get; init; } /// Gets the source timestamps (ISO 8601 UTC), repeatable. [CommandOption("time", Description = "ISO 8601 UTC timestamp (repeatable) for insert/replace/update/delete-at-time")] public IReadOnlyList? Times { get; init; } /// Gets the value type used to parse . [CommandOption("type", Description = "Value type: double (default), int64, bool, string, datetime")] public string Type { get; init; } = "double"; /// Gets the start of the range for delete-raw. [CommandOption("start", Description = "Range start (ISO 8601 UTC) for delete-raw")] public string? StartTime { get; init; } /// Gets the end of the range for delete-raw. [CommandOption("end", Description = "Range end (ISO 8601 UTC) for delete-raw")] public string? EndTime { get; init; } /// /// Validates the options and builds the HistoryUpdate details for the selected mode. /// /// The details object to send. /// Thrown when options are missing, malformed or inconsistent. public HistoryUpdateDetails BuildDetails() { NodeId nodeId; try { nodeId = NodeIdParser.ParseRequired(NodeId); } catch (Exception ex) when (ex is FormatException or ArgumentException) { throw new CommandException($"Invalid --node value: {ex.Message}"); } var mode = (Mode ?? string.Empty).Trim().ToLowerInvariant(); if (Array.IndexOf(Modes, mode) < 0) throw new CommandException( $"Invalid --mode value '{Mode}'. Supported: {string.Join(", ", Modes)}."); var values = Values ?? []; var times = Times ?? []; var hasRange = !string.IsNullOrEmpty(StartTime) || !string.IsNullOrEmpty(EndTime); switch (mode) { case "insert": case "replace": case "update": { if (hasRange) throw new CommandException($"--start/--end apply only to --mode delete-raw (mode was {mode})."); if (values.Count == 0) throw new CommandException($"--mode {mode} requires at least one --value/--time pair."); if (values.Count != times.Count) throw new CommandException( $"--value and --time must be supplied in pairs ({values.Count} value(s), {times.Count} time(s))."); var updates = new DataValueCollection(); for (var i = 0; i < values.Count; i++) { var ts = ParseTime(times[i], "--time"); updates.Add(new DataValue(new Variant(ParseValue(values[i], Type)), StatusCodes.Good, ts, ts)); } return new UpdateDataDetails { NodeId = nodeId, PerformInsertReplace = mode switch { "insert" => PerformUpdateType.Insert, "replace" => PerformUpdateType.Replace, _ => PerformUpdateType.Update }, UpdateValues = updates }; } case "delete-raw": { if (values.Count > 0 || times.Count > 0) throw new CommandException("--mode delete-raw takes --start/--end, not --value/--time."); if (string.IsNullOrEmpty(StartTime) || string.IsNullOrEmpty(EndTime)) throw new CommandException("--mode delete-raw requires both --start and --end."); var start = ParseTime(StartTime, "--start"); var end = ParseTime(EndTime, "--end"); if (end <= start) throw new CommandException("--end must be later than --start."); return new DeleteRawModifiedDetails { NodeId = nodeId, IsDeleteModified = false, StartTime = start, EndTime = end }; } default: // delete-at-time { if (hasRange) throw new CommandException("--start/--end apply only to --mode delete-raw."); if (values.Count > 0) throw new CommandException("--mode delete-at-time takes --time only; --value is not allowed."); if (times.Count == 0) throw new CommandException("--mode delete-at-time requires at least one --time."); var req = new DateTimeCollection(); foreach (var t in times) req.Add(ParseTime(t, "--time")); return new DeleteAtTimeDetails { NodeId = nodeId, ReqTimes = req }; } } } /// public override async ValueTask ExecuteAsync(IConsole console) { ConfigureLogging(); var details = BuildDetails(); var times = DescribeTimes(details); IOpcUaClientService? service = null; try { var ct = console.RegisterCancellationHandler(); (service, _) = await CreateServiceAndConnectAsync(ct); var outcome = await service.HistoryUpdateAsync(details, ct); await console.Output.WriteLineAsync($"HistoryUpdate {Mode.Trim().ToLowerInvariant()} on {NodeId}"); await console.Output.WriteLineAsync($"{"Index",-6} {"Time",-35} {"Status"}"); // Exit non-zero unless EVERY expected result came back Good: a Bad or Uncertain item, a Bad result or // service status, or a Good service result that is missing results (the server answered fewer items // than were sent) all fail the command. var failed = false; var expected = ExpectedOperationCount(details); var ops = outcome.OperationResults.Count > 0 ? outcome.OperationResults[0] : []; if (expected > 0) { for (var i = 0; i < Math.Max(expected, ops.Count); i++) { var time = i < times.Count ? times[i] : "-"; string status; if (i < ops.Count) { failed |= !StatusCode.IsGood(ops[i]); status = StatusName(ops[i]); } else { failed = true; status = "(no result returned)"; } await console.Output.WriteLineAsync($"{i,-6} {time,-35} {status}"); } } else if (outcome.ResultStatus.Count > 0) { // delete-raw reports a single result-level status for the whole range. await console.Output.WriteLineAsync( $"{0,-6} {(times.Count > 0 ? times[0] : "-"),-35} {StatusName(outcome.ResultStatus[0])}"); } if (outcome.ResultStatus.Count == 0) { failed = true; await console.Output.WriteLineAsync("No history-update result was returned for the node."); } else { // Print the node-level result too: a node the server refuses outright (e.g. BadUserAccessDenied) // carries its reason HERE with no per-item results, so the table alone shows only "(no result // returned)" and hides why. await console.Output.WriteLineAsync($"Node result: {StatusName(outcome.ResultStatus[0])}"); failed |= !StatusCode.IsGood(outcome.ResultStatus[0]); } await console.Output.WriteLineAsync($"Service result: {StatusName(outcome.ServiceResult)}"); failed |= !StatusCode.IsGood(outcome.ServiceResult); if (failed) throw new CommandException( "HistoryUpdate did not report Good for every item (a Bad or Uncertain result, or a missing result)."); } finally { if (service != null) { await service.DisconnectAsync(); service.Dispose(); } } } /// How many per-item operation results the server owes for (0 for delete-raw). private static int ExpectedOperationCount(HistoryUpdateDetails details) => details switch { UpdateDataDetails u => u.UpdateValues.Count, DeleteAtTimeDetails d => d.ReqTimes.Count, _ => 0 }; /// /// The SDK's symbolic name for , or its hex value when the SDK has none — a status is /// never printed blank. /// /// The status code to describe. /// The status name, or 0xXXXXXXXX. private static string StatusName(StatusCode code) { var name = StatusCodes.GetBrowseName(code.Code); if (string.IsNullOrEmpty(name)) name = StatusCodes.GetBrowseName(code.Code & 0xFFFF0000u); return string.IsNullOrEmpty(name) ? $"0x{code.Code:X8}" : name; } private static List DescribeTimes(HistoryUpdateDetails details) { return details switch { UpdateDataDetails u => u.UpdateValues.Select(v => v.SourceTimestamp.ToString("O")).ToList(), DeleteAtTimeDetails d => d.ReqTimes.Select(t => t.ToString("O")).ToList(), DeleteRawModifiedDetails r => [$"{r.StartTime:O}..{r.EndTime:O}"], _ => [] }; } private static DateTime ParseTime(string text, string option) { if (!DateTime.TryParse(text, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var parsed)) throw new CommandException( $"Invalid {option} value '{text}'. Expected ISO 8601 UTC format, e.g. 2026-01-15T08:00:00Z."); return parsed; } private static object ParseValue(string text, string type) { var inv = CultureInfo.InvariantCulture; switch ((type ?? "double").Trim().ToLowerInvariant()) { case "double": if (double.TryParse(text, NumberStyles.Float, inv, out var d)) return d; break; case "int64": if (long.TryParse(text, NumberStyles.Integer, inv, out var l)) return l; break; case "bool": if (bool.TryParse(text, out var b)) return b; if (text == "1") return true; if (text == "0") return false; break; case "string": return text; case "datetime": return ParseTime(text, "--value"); default: throw new CommandException( $"Invalid --type value '{type}'. Supported: double, int64, bool, string, datetime."); } throw new CommandException($"Invalid --value '{text}' for --type {type?.Trim().ToLowerInvariant()}."); } }