feat(cli): historyupdate command (insert/replace/update/delete-raw/delete-at-time)

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-09 12:47:53 -04:00
parent ff7c6c531a
commit 70dde115f0
9 changed files with 486 additions and 0 deletions
@@ -0,0 +1,246 @@
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"];
/// <summary>
/// Creates the history-update command used to modify stored history for a node.
/// </summary>
/// <param name="factory">The factory that creates the shared client service for the command run.</param>
public HistoryUpdateCommand(IOpcUaClientServiceFactory factory) : base(factory)
{
}
/// <summary>Gets the historized node to modify.</summary>
[CommandOption("node", 'n', Description = "Node ID (e.g. ns=2;s=MyNode)", IsRequired = true)]
public string NodeId { get; init; } = default!;
/// <summary>Gets the update mode.</summary>
[CommandOption("mode", Description = "insert | replace | update | delete-raw | delete-at-time", IsRequired = true)]
public string Mode { get; init; } = default!;
/// <summary>Gets the values to write, paired positionally with <see cref="Times"/>.</summary>
[CommandOption("value", Description = "Value to write (repeatable; pairs positionally with --time) for insert/replace/update")]
public IReadOnlyList<string>? Values { get; init; }
/// <summary>Gets the source timestamps (ISO 8601 UTC), repeatable.</summary>
[CommandOption("time", Description = "ISO 8601 UTC timestamp (repeatable) for insert/replace/update/delete-at-time")]
public IReadOnlyList<string>? Times { get; init; }
/// <summary>Gets the value type used to parse <see cref="Values"/>.</summary>
[CommandOption("type", Description = "Value type: double (default), int64, bool, string, datetime")]
public string Type { get; init; } = "double";
/// <summary>Gets the start of the range for delete-raw.</summary>
[CommandOption("start", Description = "Range start (ISO 8601 UTC) for delete-raw")]
public string? StartTime { get; init; }
/// <summary>Gets the end of the range for delete-raw.</summary>
[CommandOption("end", Description = "Range end (ISO 8601 UTC) for delete-raw")]
public string? EndTime { get; init; }
/// <summary>
/// Validates the options and builds the HistoryUpdate details for the selected mode.
/// </summary>
/// <returns>The details object to send.</returns>
/// <exception cref="CommandException">Thrown when options are missing, malformed or inconsistent.</exception>
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 };
}
}
}
/// <inheritdoc />
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"}");
var anyBad = false;
var ops = outcome.OperationResults.Count > 0 ? outcome.OperationResults[0] : [];
if (ops.Count > 0)
{
for (var i = 0; i < ops.Count; i++)
{
var time = i < times.Count ? times[i] : "-";
anyBad |= StatusCode.IsBad(ops[i]);
await console.Output.WriteLineAsync($"{i,-6} {time,-35} {StatusCodes.GetBrowseName(ops[i].Code)}");
}
}
else if (outcome.ResultStatus.Count > 0)
{
// delete-raw reports a single result-level status for the whole range.
anyBad |= StatusCode.IsBad(outcome.ResultStatus[0]);
await console.Output.WriteLineAsync(
$"{0,-6} {(times.Count > 0 ? times[0] : "-"),-35} {StatusCodes.GetBrowseName(outcome.ResultStatus[0].Code)}");
}
var overall = StatusCodes.GetBrowseName(outcome.ServiceResult.Code);
await console.Output.WriteLineAsync($"Service result: {overall}");
anyBad |= StatusCode.IsBad(outcome.ServiceResult);
if (outcome.ResultStatus.Count > 0 && StatusCode.IsBad(outcome.ResultStatus[0])) anyBad = true;
if (anyBad)
throw new CommandException("HistoryUpdate reported one or more Bad results.");
}
finally
{
if (service != null)
{
await service.DisconnectAsync();
service.Dispose();
}
}
}
private static List<string> 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()}.");
}
}
@@ -131,6 +131,26 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
return references != null && references.Count > 0;
}
/// <inheritdoc />
public async Task<Models.HistoryUpdateOutcome> HistoryUpdateAsync(HistoryUpdateDetails details, CancellationToken ct)
{
var response = await _session.HistoryUpdateAsync(
null,
new ExtensionObjectCollection { new ExtensionObject(details) },
ct).ConfigureAwait(false);
var statuses = new List<StatusCode>();
var ops = new List<IReadOnlyList<StatusCode>>();
if (response.Results != null)
foreach (var r in response.Results)
{
statuses.Add(r.StatusCode);
ops.Add(r.OperationResults?.ToArray() ?? Array.Empty<StatusCode>());
}
return new Models.HistoryUpdateOutcome(response.ResponseHeader.ServiceResult, statuses, ops);
}
/// <inheritdoc />
public async Task<IReadOnlyList<DataValue>> HistoryReadRawAsync(
NodeId nodeId, DateTime startTime, DateTime endTime, int maxValues, CancellationToken ct)
@@ -110,6 +110,15 @@ internal interface ISessionAdapter : IDisposable
Task<IReadOnlyList<DataValue>> HistoryReadRawAsync(NodeId nodeId, DateTime startTime, DateTime endTime,
int maxValues, CancellationToken ct = default);
/// <summary>
/// Sends a HistoryUpdate request for the supplied details.
/// </summary>
/// <param name="details">The history update details.</param>
/// <param name="ct">The cancellation token that aborts the call.</param>
/// <returns>The overall service result and per-item status codes.</returns>
Task<Models.HistoryUpdateOutcome> HistoryUpdateAsync(HistoryUpdateDetails details, CancellationToken ct = default)
=> throw new NotSupportedException("HistoryUpdate is not supported by this session adapter.");
/// <summary>
/// Reads processed/aggregate historical data.
/// </summary>
@@ -193,6 +193,15 @@ public interface IOpcUaClientService : IDisposable
Task<IReadOnlyList<DataValue>> HistoryReadRawAsync(NodeId nodeId, DateTime startTime, DateTime endTime,
int maxValues = 1000, CancellationToken ct = default);
/// <summary>
/// Sends a HistoryUpdate request (insert/replace/update or delete) for the supplied details.
/// </summary>
/// <param name="details">The history update details (UpdateDataDetails, DeleteRawModifiedDetails, DeleteAtTimeDetails...).</param>
/// <param name="ct">The cancellation token that aborts the call.</param>
/// <returns>The overall service result and per-item status codes.</returns>
Task<HistoryUpdateOutcome> HistoryUpdateAsync(HistoryUpdateDetails details, CancellationToken ct = default)
=> throw new NotSupportedException("HistoryUpdate is not supported by this client service.");
/// <summary>
/// Reads aggregate historical values for a historized node using an OPC UA aggregate function.
/// </summary>
@@ -0,0 +1,15 @@
using Opc.Ua;
namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Models;
/// <summary>
/// Result of a HistoryUpdate service call: the overall service result plus, per history-update result,
/// the operation-level status codes (one per value / requested time) and the result's own status code.
/// </summary>
/// <param name="ServiceResult">The service-level result from the response header.</param>
/// <param name="ResultStatus">The per-details-entry status codes.</param>
/// <param name="OperationResults">Per-details-entry operation status codes (one per value or requested time; may be empty).</param>
public sealed record HistoryUpdateOutcome(
StatusCode ServiceResult,
IReadOnlyList<StatusCode> ResultStatus,
IReadOnlyList<IReadOnlyList<StatusCode>> OperationResults);
@@ -559,6 +559,14 @@ public sealed class OpcUaClientService : IOpcUaClientService
return await _session!.HistoryReadRawAsync(nodeId, startTime, endTime, maxValues, ct);
}
/// <inheritdoc />
public async Task<HistoryUpdateOutcome> HistoryUpdateAsync(HistoryUpdateDetails details, CancellationToken ct = default)
{
ThrowIfDisposed();
ThrowIfNotConnected();
return await _session!.HistoryUpdateAsync(details, ct);
}
/// <inheritdoc />
public async Task<IReadOnlyList<DataValue>> HistoryReadAggregateAsync(
NodeId nodeId, DateTime startTime, DateTime endTime, AggregateType aggregate,