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:
@@ -221,6 +221,38 @@ otopcua-cli historyread -u opc.tcp://localhost:4840/OtOpcUa \
|
|||||||
| `--aggregate` | Aggregate function: Average, Minimum, Maximum, Count, Start, End, StandardDeviation |
|
| `--aggregate` | Aggregate function: Average, Minimum, Maximum, Count, Start, End, StandardDeviation |
|
||||||
| `--interval` | Processing interval in milliseconds for aggregates (default: 3600000) |
|
| `--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
|
#### Aggregate mapping
|
||||||
|
|
||||||
| Name | Aliases | OPC UA Node ID |
|
| Name | Aliases | OPC UA Node ID |
|
||||||
|
|||||||
@@ -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;
|
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 />
|
/// <inheritdoc />
|
||||||
public async Task<IReadOnlyList<DataValue>> HistoryReadRawAsync(
|
public async Task<IReadOnlyList<DataValue>> HistoryReadRawAsync(
|
||||||
NodeId nodeId, DateTime startTime, DateTime endTime, int maxValues, CancellationToken ct)
|
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,
|
Task<IReadOnlyList<DataValue>> HistoryReadRawAsync(NodeId nodeId, DateTime startTime, DateTime endTime,
|
||||||
int maxValues, CancellationToken ct = default);
|
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>
|
/// <summary>
|
||||||
/// Reads processed/aggregate historical data.
|
/// Reads processed/aggregate historical data.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -193,6 +193,15 @@ public interface IOpcUaClientService : IDisposable
|
|||||||
Task<IReadOnlyList<DataValue>> HistoryReadRawAsync(NodeId nodeId, DateTime startTime, DateTime endTime,
|
Task<IReadOnlyList<DataValue>> HistoryReadRawAsync(NodeId nodeId, DateTime startTime, DateTime endTime,
|
||||||
int maxValues = 1000, CancellationToken ct = default);
|
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>
|
/// <summary>
|
||||||
/// Reads aggregate historical values for a historized node using an OPC UA aggregate function.
|
/// Reads aggregate historical values for a historized node using an OPC UA aggregate function.
|
||||||
/// </summary>
|
/// </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);
|
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 />
|
/// <inheritdoc />
|
||||||
public async Task<IReadOnlyList<DataValue>> HistoryReadAggregateAsync(
|
public async Task<IReadOnlyList<DataValue>> HistoryReadAggregateAsync(
|
||||||
NodeId nodeId, DateTime startTime, DateTime endTime, AggregateType aggregate,
|
NodeId nodeId, DateTime startTime, DateTime endTime, AggregateType aggregate,
|
||||||
|
|||||||
@@ -326,6 +326,20 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
|
|||||||
return Task.FromResult(HistoryReadResult);
|
return Task.FromResult(HistoryReadResult);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Gets the details passed to HistoryUpdateAsync.</summary>
|
||||||
|
public List<HistoryUpdateDetails> HistoryUpdateCalls { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>Gets or sets the outcome returned by HistoryUpdateAsync.</summary>
|
||||||
|
public HistoryUpdateOutcome HistoryUpdateResult { get; set; } = new(
|
||||||
|
StatusCodes.Good, [StatusCodes.Good], [new List<StatusCode> { StatusCodes.Good }]);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<HistoryUpdateOutcome> HistoryUpdateAsync(HistoryUpdateDetails details, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
HistoryUpdateCalls.Add(details);
|
||||||
|
return Task.FromResult(HistoryUpdateResult);
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<IReadOnlyList<DataValue>> HistoryReadAggregateAsync(
|
public Task<IReadOnlyList<DataValue>> HistoryReadAggregateAsync(
|
||||||
NodeId nodeId, DateTime startTime, DateTime endTime, AggregateType aggregate,
|
NodeId nodeId, DateTime startTime, DateTime endTime, AggregateType aggregate,
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Insert builds UpdateDataDetails with Insert.</summary>
|
||||||
|
[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<UpdateDataDetails>();
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Replace and update map to their update types.</summary>
|
||||||
|
[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<UpdateDataDetails>();
|
||||||
|
d.PerformInsertReplace.ShouldBe(expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Each --type parses to the right CLR type.</summary>
|
||||||
|
[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<UpdateDataDetails>();
|
||||||
|
d.UpdateValues[0].Value.ShouldBeOfType(expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>delete-raw builds DeleteRawModifiedDetails with IsDeleteModified=false.</summary>
|
||||||
|
[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<DeleteRawModifiedDetails>();
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>delete-at-time builds DeleteAtTimeDetails.</summary>
|
||||||
|
[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<DeleteAtTimeDetails>();
|
||||||
|
d.ReqTimes.Count.ShouldBe(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Invalid combinations are rejected with a CommandException.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void InvalidArguments_Throw()
|
||||||
|
{
|
||||||
|
var t = new[] { "2026-01-15T08:00:00Z" };
|
||||||
|
Should.Throw<CommandException>(() => Make(mode: "bogus").BuildDetails());
|
||||||
|
Should.Throw<CommandException>(() => Make(values: ["1", "2"], times: t).BuildDetails());
|
||||||
|
Should.Throw<CommandException>(() => Make(values: null, times: t).BuildDetails());
|
||||||
|
Should.Throw<CommandException>(() => Make(values: ["abc"], times: t).BuildDetails());
|
||||||
|
Should.Throw<CommandException>(() => Make(values: ["1"], times: ["notatime"]).BuildDetails());
|
||||||
|
Should.Throw<CommandException>(() => Make(values: ["1"], times: t, type: "float").BuildDetails());
|
||||||
|
Should.Throw<CommandException>(() => Make(mode: "delete-raw", start: "2026-01-15T08:00:00Z").BuildDetails());
|
||||||
|
Should.Throw<CommandException>(() => Make(mode: "delete-raw", start: "2026-01-15T09:00:00Z",
|
||||||
|
end: "2026-01-15T08:00:00Z").BuildDetails());
|
||||||
|
Should.Throw<CommandException>(() => Make(mode: "delete-at-time").BuildDetails());
|
||||||
|
Should.Throw<CommandException>(() => Make(mode: "delete-at-time", values: ["1"], times: t).BuildDetails());
|
||||||
|
Should.Throw<CommandException>(() => Make(node: "").BuildDetails());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Execute sends the details and prints one line per item with the status name.</summary>
|
||||||
|
[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");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A Bad item fails the command (non-zero exit) after printing.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Execute_BadItem_Throws()
|
||||||
|
{
|
||||||
|
var svc = new FakeOpcUaClientService
|
||||||
|
{
|
||||||
|
HistoryUpdateResult = new HistoryUpdateOutcome(StatusCodes.Good, [StatusCodes.Good],
|
||||||
|
[new List<StatusCode> { StatusCodes.BadEntryExists }])
|
||||||
|
};
|
||||||
|
var cmd = Make(svc, values: ["1"], times: ["2026-01-15T08:00:00Z"]);
|
||||||
|
using var console = TestConsoleHelper.CreateConsole();
|
||||||
|
await Should.ThrowAsync<CommandException>(async () => await cmd.ExecuteAsync(console));
|
||||||
|
TestConsoleHelper.GetOutput(console).ShouldContain("BadEntryExists");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user