diff --git a/docs/Client.CLI.md b/docs/Client.CLI.md
index 3ca40537..f5b659c1 100644
--- a/docs/Client.CLI.md
+++ b/docs/Client.CLI.md
@@ -221,6 +221,38 @@ otopcua-cli historyread -u opc.tcp://localhost:4840/OtOpcUa \
| `--aggregate` | Aggregate function: Average, Minimum, Maximum, Count, Start, End, StandardDeviation |
| `--interval` | Processing interval in milliseconds for aggregates (default: 3600000) |
+### historyupdate
+
+Inserts, replaces, updates or deletes stored history on a historized node via the OPC UA `HistoryUpdate`
+service (requires a server whose historian backend supports updates and a session holding the history-write
+permission). Prints one line per item (`index time StatusCodeName`) followed by the overall service
+result; any Bad item or service result exits non-zero.
+
+```bash
+# Insert two values
+otopcua-cli historyupdate -u opc.tcp://localhost:4840/OtOpcUa -n "ns=2;s=Plant/Modbus/dev1/Speed" \
+ --mode insert --value 1.5 --time 2026-01-15T08:00:00Z --value 2.5 --time 2026-01-15T09:00:00Z
+
+# Replace an int64 value
+otopcua-cli historyupdate -u ... -n "..." --mode replace --type int64 --value 42 --time 2026-01-15T08:00:00Z
+
+# Delete a raw range / specific timestamps
+otopcua-cli historyupdate -u ... -n "..." --mode delete-raw --start 2026-01-15T08:00:00Z --end 2026-01-15T10:00:00Z
+otopcua-cli historyupdate -u ... -n "..." --mode delete-at-time --time 2026-01-15T08:00:00Z
+```
+
+| Flag | Description |
+|------|-------------|
+| `-n` / `--node` | Node ID to modify (required) |
+| `--mode` | `insert`, `replace`, `update`, `delete-raw` or `delete-at-time` (required) |
+| `--value` | Value to write; repeatable, paired positionally with `--time` (insert/replace/update) |
+| `--time` | ISO 8601 UTC timestamp; repeatable (insert/replace/update/delete-at-time) |
+| `--type` | `double` (default), `int64`, `bool`, `string`, `datetime` - how `--value` is parsed |
+| `--start` / `--end` | Range for `delete-raw` (both required; `--end` later than `--start`) |
+
+`delete-raw` is sent with `IsDeleteModified=false`. Invalid or inconsistent options (unknown mode, unpaired
+value/time, unparsable value, options that do not apply to the mode) fail before any connection is made.
+
#### Aggregate mapping
| Name | Aliases | OPC UA Node ID |
diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/HistoryUpdateCommand.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/HistoryUpdateCommand.cs
new file mode 100644
index 00000000..12669527
--- /dev/null
+++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/HistoryUpdateCommand.cs
@@ -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"];
+
+ ///
+ /// 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"}");
+
+ 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 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()}.");
+ }
+}
diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultSessionAdapter.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultSessionAdapter.cs
index 1d25779e..40a2205e 100644
--- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultSessionAdapter.cs
+++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultSessionAdapter.cs
@@ -131,6 +131,26 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
return references != null && references.Count > 0;
}
+ ///
+ public async Task HistoryUpdateAsync(HistoryUpdateDetails details, CancellationToken ct)
+ {
+ var response = await _session.HistoryUpdateAsync(
+ null,
+ new ExtensionObjectCollection { new ExtensionObject(details) },
+ ct).ConfigureAwait(false);
+
+ var statuses = new List();
+ var ops = new List>();
+ if (response.Results != null)
+ foreach (var r in response.Results)
+ {
+ statuses.Add(r.StatusCode);
+ ops.Add(r.OperationResults?.ToArray() ?? Array.Empty());
+ }
+
+ return new Models.HistoryUpdateOutcome(response.ResponseHeader.ServiceResult, statuses, ops);
+ }
+
///
public async Task> HistoryReadRawAsync(
NodeId nodeId, DateTime startTime, DateTime endTime, int maxValues, CancellationToken ct)
diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/ISessionAdapter.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/ISessionAdapter.cs
index 8af4e721..7fc340fe 100644
--- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/ISessionAdapter.cs
+++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/ISessionAdapter.cs
@@ -110,6 +110,15 @@ internal interface ISessionAdapter : IDisposable
Task> HistoryReadRawAsync(NodeId nodeId, DateTime startTime, DateTime endTime,
int maxValues, CancellationToken ct = default);
+ ///
+ /// Sends a HistoryUpdate request for the supplied details.
+ ///
+ /// The history update details.
+ /// The cancellation token that aborts the call.
+ /// The overall service result and per-item status codes.
+ Task HistoryUpdateAsync(HistoryUpdateDetails details, CancellationToken ct = default)
+ => throw new NotSupportedException("HistoryUpdate is not supported by this session adapter.");
+
///
/// Reads processed/aggregate historical data.
///
diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/IOpcUaClientService.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/IOpcUaClientService.cs
index 6874816f..c29ea0a3 100644
--- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/IOpcUaClientService.cs
+++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/IOpcUaClientService.cs
@@ -193,6 +193,15 @@ public interface IOpcUaClientService : IDisposable
Task> HistoryReadRawAsync(NodeId nodeId, DateTime startTime, DateTime endTime,
int maxValues = 1000, CancellationToken ct = default);
+ ///
+ /// Sends a HistoryUpdate request (insert/replace/update or delete) for the supplied details.
+ ///
+ /// The history update details (UpdateDataDetails, DeleteRawModifiedDetails, DeleteAtTimeDetails...).
+ /// The cancellation token that aborts the call.
+ /// The overall service result and per-item status codes.
+ Task HistoryUpdateAsync(HistoryUpdateDetails details, CancellationToken ct = default)
+ => throw new NotSupportedException("HistoryUpdate is not supported by this client service.");
+
///
/// Reads aggregate historical values for a historized node using an OPC UA aggregate function.
///
diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Models/HistoryUpdateOutcome.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Models/HistoryUpdateOutcome.cs
new file mode 100644
index 00000000..318f72a9
--- /dev/null
+++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Models/HistoryUpdateOutcome.cs
@@ -0,0 +1,15 @@
+using Opc.Ua;
+
+namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Models;
+
+///
+/// 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.
+///
+/// The service-level result from the response header.
+/// The per-details-entry status codes.
+/// Per-details-entry operation status codes (one per value or requested time; may be empty).
+public sealed record HistoryUpdateOutcome(
+ StatusCode ServiceResult,
+ IReadOnlyList ResultStatus,
+ IReadOnlyList> OperationResults);
diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/OpcUaClientService.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/OpcUaClientService.cs
index 88cb407c..0c8b4e49 100644
--- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/OpcUaClientService.cs
+++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/OpcUaClientService.cs
@@ -559,6 +559,14 @@ public sealed class OpcUaClientService : IOpcUaClientService
return await _session!.HistoryReadRawAsync(nodeId, startTime, endTime, maxValues, ct);
}
+ ///
+ public async Task HistoryUpdateAsync(HistoryUpdateDetails details, CancellationToken ct = default)
+ {
+ ThrowIfDisposed();
+ ThrowIfNotConnected();
+ return await _session!.HistoryUpdateAsync(details, ct);
+ }
+
///
public async Task> HistoryReadAggregateAsync(
NodeId nodeId, DateTime startTime, DateTime endTime, AggregateType aggregate,
diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/Fakes/FakeOpcUaClientService.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/Fakes/FakeOpcUaClientService.cs
index aeb22446..5c950179 100644
--- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/Fakes/FakeOpcUaClientService.cs
+++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/Fakes/FakeOpcUaClientService.cs
@@ -326,6 +326,20 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
return Task.FromResult(HistoryReadResult);
}
+ /// Gets the details passed to HistoryUpdateAsync.
+ public List HistoryUpdateCalls { get; } = [];
+
+ /// Gets or sets the outcome returned by HistoryUpdateAsync.
+ public HistoryUpdateOutcome HistoryUpdateResult { get; set; } = new(
+ StatusCodes.Good, [StatusCodes.Good], [new List { StatusCodes.Good }]);
+
+ ///
+ public Task HistoryUpdateAsync(HistoryUpdateDetails details, CancellationToken ct = default)
+ {
+ HistoryUpdateCalls.Add(details);
+ return Task.FromResult(HistoryUpdateResult);
+ }
+
///
public Task> HistoryReadAggregateAsync(
NodeId nodeId, DateTime startTime, DateTime endTime, AggregateType aggregate,
diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/HistoryUpdateCommandTests.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/HistoryUpdateCommandTests.cs
new file mode 100644
index 00000000..9f299457
--- /dev/null
+++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/HistoryUpdateCommandTests.cs
@@ -0,0 +1,133 @@
+using CliFx.Exceptions;
+using Opc.Ua;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Client.CLI.Commands;
+using ZB.MOM.WW.OtOpcUa.Client.CLI.Tests.Fakes;
+using ZB.MOM.WW.OtOpcUa.Client.Shared.Models;
+
+namespace ZB.MOM.WW.OtOpcUa.Client.CLI.Tests;
+
+public class HistoryUpdateCommandTests
+{
+ private static HistoryUpdateCommand Make(FakeOpcUaClientService? svc = null, string mode = "insert",
+ string[]? values = null, string[]? times = null, string type = "double", string? start = null,
+ string? end = null, string node = "ns=2;s=HistNode")
+ => new(new FakeOpcUaClientServiceFactory(svc ?? new FakeOpcUaClientService()))
+ {
+ Url = "opc.tcp://localhost:4840",
+ NodeId = node,
+ Mode = mode,
+ Values = values,
+ Times = times,
+ Type = type,
+ StartTime = start,
+ EndTime = end
+ };
+
+ /// Insert builds UpdateDataDetails with Insert.
+ [Fact]
+ public void Insert_BuildsUpdateDataDetails()
+ {
+ var d = Make(values: ["1.5", "2.5"], times: ["2026-01-15T08:00:00Z", "2026-01-15T09:00:00Z"])
+ .BuildDetails().ShouldBeOfType();
+ d.PerformInsertReplace.ShouldBe(PerformUpdateType.Insert);
+ d.UpdateValues.Count.ShouldBe(2);
+ d.UpdateValues[1].Value.ShouldBe(2.5);
+ d.UpdateValues[0].SourceTimestamp.ShouldBe(new DateTime(2026, 1, 15, 8, 0, 0, DateTimeKind.Utc));
+ d.NodeId.Identifier.ShouldBe("HistNode");
+ }
+
+ /// Replace and update map to their update types.
+ [Theory]
+ [InlineData("replace", PerformUpdateType.Replace)]
+ [InlineData("update", PerformUpdateType.Update)]
+ public void ReplaceAndUpdate_MapPerformType(string mode, PerformUpdateType expected)
+ {
+ var d = Make(mode: mode, values: ["1"], times: ["2026-01-15T08:00:00Z"])
+ .BuildDetails().ShouldBeOfType();
+ d.PerformInsertReplace.ShouldBe(expected);
+ }
+
+ /// Each --type parses to the right CLR type.
+ [Theory]
+ [InlineData("double", "1.5", typeof(double))]
+ [InlineData("int64", "42", typeof(long))]
+ [InlineData("bool", "true", typeof(bool))]
+ [InlineData("string", "hello", typeof(string))]
+ [InlineData("datetime", "2026-01-15T08:00:00Z", typeof(DateTime))]
+ public void Type_ParsesValue(string type, string value, Type expected)
+ {
+ var d = Make(values: [value], times: ["2026-01-15T08:00:00Z"], type: type)
+ .BuildDetails().ShouldBeOfType();
+ d.UpdateValues[0].Value.ShouldBeOfType(expected);
+ }
+
+ /// delete-raw builds DeleteRawModifiedDetails with IsDeleteModified=false.
+ [Fact]
+ public void DeleteRaw_BuildsDetails()
+ {
+ var d = Make(mode: "delete-raw", start: "2026-01-15T08:00:00Z", end: "2026-01-15T09:00:00Z")
+ .BuildDetails().ShouldBeOfType();
+ d.IsDeleteModified.ShouldBeFalse();
+ d.StartTime.ShouldBe(new DateTime(2026, 1, 15, 8, 0, 0, DateTimeKind.Utc));
+ d.EndTime.ShouldBe(new DateTime(2026, 1, 15, 9, 0, 0, DateTimeKind.Utc));
+ }
+
+ /// delete-at-time builds DeleteAtTimeDetails.
+ [Fact]
+ public void DeleteAtTime_BuildsDetails()
+ {
+ var d = Make(mode: "delete-at-time", times: ["2026-01-15T08:00:00Z", "2026-01-15T09:00:00Z"])
+ .BuildDetails().ShouldBeOfType();
+ d.ReqTimes.Count.ShouldBe(2);
+ }
+
+ /// Invalid combinations are rejected with a CommandException.
+ [Fact]
+ public void InvalidArguments_Throw()
+ {
+ var t = new[] { "2026-01-15T08:00:00Z" };
+ Should.Throw(() => Make(mode: "bogus").BuildDetails());
+ Should.Throw(() => Make(values: ["1", "2"], times: t).BuildDetails());
+ Should.Throw(() => Make(values: null, times: t).BuildDetails());
+ Should.Throw(() => Make(values: ["abc"], times: t).BuildDetails());
+ Should.Throw(() => Make(values: ["1"], times: ["notatime"]).BuildDetails());
+ Should.Throw(() => Make(values: ["1"], times: t, type: "float").BuildDetails());
+ Should.Throw(() => Make(mode: "delete-raw", start: "2026-01-15T08:00:00Z").BuildDetails());
+ Should.Throw(() => Make(mode: "delete-raw", start: "2026-01-15T09:00:00Z",
+ end: "2026-01-15T08:00:00Z").BuildDetails());
+ Should.Throw(() => Make(mode: "delete-at-time").BuildDetails());
+ Should.Throw(() => Make(mode: "delete-at-time", values: ["1"], times: t).BuildDetails());
+ Should.Throw(() => Make(node: "").BuildDetails());
+ }
+
+ /// Execute sends the details and prints one line per item with the status name.
+ [Fact]
+ public async Task Execute_PrintsPerItemStatus()
+ {
+ var svc = new FakeOpcUaClientService();
+ var cmd = Make(svc, values: ["1"], times: ["2026-01-15T08:00:00Z"]);
+ using var console = TestConsoleHelper.CreateConsole();
+ await cmd.ExecuteAsync(console);
+ svc.HistoryUpdateCalls.Count.ShouldBe(1);
+ var output = TestConsoleHelper.GetOutput(console);
+ output.ShouldContain("Good");
+ output.ShouldContain("Service result: Good");
+ }
+
+ /// A Bad item fails the command (non-zero exit) after printing.
+ [Fact]
+ public async Task Execute_BadItem_Throws()
+ {
+ var svc = new FakeOpcUaClientService
+ {
+ HistoryUpdateResult = new HistoryUpdateOutcome(StatusCodes.Good, [StatusCodes.Good],
+ [new List { StatusCodes.BadEntryExists }])
+ };
+ var cmd = Make(svc, values: ["1"], times: ["2026-01-15T08:00:00Z"]);
+ using var console = TestConsoleHelper.CreateConsole();
+ await Should.ThrowAsync(async () => await cmd.ExecuteAsync(console));
+ TestConsoleHelper.GetOutput(console).ShouldContain("BadEntryExists");
+ }
+}