5fdf205caa
Gate: Primary-only typed recording with real status, raw+UNS HistoryRead/Processed parity, HistoryUpdate insert/duplicate/replace/delete + WriteOperate-only and anonymous denied, failover at 1x, Modbus device loss stored as BadCommunicationError then a BadNoCommunication outage marker. Gate found the CLI hid the node-level refusal code (fixed + pinned) and a pre-existing OpcUaClient comms-loss gap (filed separately). docker-dev compose gains ServerHistorian__Backend / __SqlServer__ConnectionString (default Gateway). Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
294 lines
13 KiB
C#
294 lines
13 KiB
C#
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"}");
|
|
|
|
// 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();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>How many per-item operation results the server owes for <paramref name="details"/> (0 for delete-raw).</summary>
|
|
private static int ExpectedOperationCount(HistoryUpdateDetails details) => details switch
|
|
{
|
|
UpdateDataDetails u => u.UpdateValues.Count,
|
|
DeleteAtTimeDetails d => d.ReqTimes.Count,
|
|
_ => 0
|
|
};
|
|
|
|
/// <summary>
|
|
/// The SDK's symbolic name for <paramref name="code"/>, or its hex value when the SDK has none — a status is
|
|
/// never printed blank.
|
|
/// </summary>
|
|
/// <param name="code">The status code to describe.</param>
|
|
/// <returns>The status name, or <c>0xXXXXXXXX</c>.</returns>
|
|
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<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()}.");
|
|
}
|
|
}
|