Phase 0 — mechanical rename ZB.MOM.WW.LmxOpcUa.* → ZB.MOM.WW.OtOpcUa.*
Renames all 11 projects (5 src + 6 tests), the .slnx solution file, all source-file namespaces, all axaml namespace references, and all v1 documentation references in CLAUDE.md and docs/*.md (excluding docs/v2/ which is already in OtOpcUa form). Also updates the TopShelf service registration name from "LmxOpcUa" to "OtOpcUa" per Phase 0 Task 0.6.
Preserves runtime identifiers per Phase 0 Out-of-Scope rules to avoid breaking v1/v2 client trust during coexistence: OPC UA `ApplicationUri` defaults (`urn:{GalaxyName}:LmxOpcUa`), server `EndpointPath` (`/LmxOpcUa`), `ServerName` default (feeds cert subject CN), `MxAccessConfiguration.ClientName` default (defensive — stays "LmxOpcUa" for MxAccess audit-trail consistency), client OPC UA identifiers (`ApplicationName = "LmxOpcUaClient"`, `ApplicationUri = "urn:localhost:LmxOpcUaClient"`, cert directory `%LocalAppData%\LmxOpcUaClient\pki\`), and the `LmxOpcUaServer` class name (class rename out of Phase 0 scope per Task 0.5 sed pattern; happens in Phase 1 alongside `LmxNodeManager → GenericDriverNodeManager` Core extraction). 23 LmxOpcUa references retained, all enumerated and justified in `docs/v2/implementation/exit-gate-phase-0.md`.
Build clean: 0 errors, 30 warnings (lower than baseline 167). Tests at strict improvement over baseline: 821 passing / 1 failing vs baseline 820 / 2 (one flaky pre-existing failure passed this run; the other still fails — both pre-existing and unrelated to the rename). `Client.UI.Tests`, `Historian.Aveva.Tests`, `Client.Shared.Tests`, `IntegrationTests` all match baseline exactly. Exit gate compliance results recorded in `docs/v2/implementation/exit-gate-phase-0.md` with all 7 checks PASS or DEFERRED-to-PR-review (#7 service install verification needs Windows service permissions on the reviewer's box).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
103
src/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/AlarmsCommand.cs
Normal file
103
src/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/AlarmsCommand.cs
Normal file
@@ -0,0 +1,103 @@
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Infrastructure;
|
||||
using ZB.MOM.WW.OtOpcUa.Client.CLI.Helpers;
|
||||
using ZB.MOM.WW.OtOpcUa.Client.Shared;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Client.CLI.Commands;
|
||||
|
||||
[Command("alarms", Description = "Subscribe to alarm events")]
|
||||
public class AlarmsCommand : CommandBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates the alarm-monitoring command used to stream OPC UA condition events to the terminal.
|
||||
/// </summary>
|
||||
/// <param name="factory">The factory that creates the shared client service for the command run.</param>
|
||||
public AlarmsCommand(IOpcUaClientServiceFactory factory) : base(factory)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the optional source node whose alarm events should be monitored instead of the server root.
|
||||
/// </summary>
|
||||
[CommandOption("node", 'n', Description = "Node ID to monitor for events (default: Server node)")]
|
||||
public string? NodeId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the publishing interval, in milliseconds, for the alarm subscription.
|
||||
/// </summary>
|
||||
[CommandOption("interval", 'i', Description = "Publishing interval in milliseconds")]
|
||||
public int Interval { get; init; } = 1000;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether retained alarm conditions should be refreshed immediately after subscribing.
|
||||
/// </summary>
|
||||
[CommandOption("refresh", Description = "Request a ConditionRefresh after subscribing")]
|
||||
public bool Refresh { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Connects to the server, subscribes to alarm events, and streams operator-facing alarm state changes to the console.
|
||||
/// </summary>
|
||||
/// <param name="console">The CLI console used for output and cancellation handling.</param>
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
ConfigureLogging();
|
||||
IOpcUaClientService? service = null;
|
||||
try
|
||||
{
|
||||
var ct = console.RegisterCancellationHandler();
|
||||
(service, _) = await CreateServiceAndConnectAsync(ct);
|
||||
|
||||
var sourceNodeId = NodeIdParser.Parse(NodeId);
|
||||
|
||||
service.AlarmEvent += (_, e) =>
|
||||
{
|
||||
console.Output.WriteLine($"[{e.Time:O}] ALARM {e.SourceName}");
|
||||
console.Output.WriteLine($" Condition: {e.ConditionName}");
|
||||
var activeStr = e.ActiveState ? "Active" : "Inactive";
|
||||
var ackedStr = e.AckedState ? "Acknowledged" : "Unacknowledged";
|
||||
console.Output.WriteLine($" State: {activeStr}, {ackedStr}");
|
||||
console.Output.WriteLine($" Severity: {e.Severity}");
|
||||
if (!string.IsNullOrEmpty(e.Message))
|
||||
console.Output.WriteLine($" Message: {e.Message}");
|
||||
console.Output.WriteLine($" Retain: {e.Retain}");
|
||||
console.Output.WriteLine();
|
||||
};
|
||||
|
||||
await service.SubscribeAlarmsAsync(sourceNodeId, Interval, ct);
|
||||
await console.Output.WriteLineAsync(
|
||||
$"Subscribed to alarm events (interval: {Interval}ms). Press Ctrl+C to stop.");
|
||||
|
||||
if (Refresh)
|
||||
try
|
||||
{
|
||||
await service.RequestConditionRefreshAsync(ct);
|
||||
await console.Output.WriteLineAsync("Condition refresh requested.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await console.Output.WriteLineAsync($"Condition refresh not supported: {ex.Message}");
|
||||
}
|
||||
|
||||
// Wait until cancellation
|
||||
try
|
||||
{
|
||||
await Task.Delay(Timeout.Infinite, ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Expected on Ctrl+C
|
||||
}
|
||||
|
||||
await service.UnsubscribeAlarmsAsync();
|
||||
await console.Output.WriteLineAsync("Unsubscribed.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (service != null)
|
||||
{
|
||||
await service.DisconnectAsync();
|
||||
service.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
97
src/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/BrowseCommand.cs
Normal file
97
src/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/BrowseCommand.cs
Normal file
@@ -0,0 +1,97 @@
|
||||
using CliFx.Attributes;
|
||||
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("browse", Description = "Browse the OPC UA address space")]
|
||||
public class BrowseCommand : CommandBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates the browse command used to inspect the server address space from the terminal.
|
||||
/// </summary>
|
||||
/// <param name="factory">The factory that creates the shared client service for the command run.</param>
|
||||
public BrowseCommand(IOpcUaClientServiceFactory factory) : base(factory)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the optional starting node for the browse, defaulting to the Objects folder.
|
||||
/// </summary>
|
||||
[CommandOption("node", 'n', Description = "Node ID to browse (default: Objects folder)")]
|
||||
public string? NodeId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the maximum browse depth the command should traverse.
|
||||
/// </summary>
|
||||
[CommandOption("depth", 'd', Description = "Maximum browse depth")]
|
||||
public int Depth { get; init; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether child nodes should be traversed recursively.
|
||||
/// </summary>
|
||||
[CommandOption("recursive", 'r', Description = "Browse recursively (uses --depth as max depth)")]
|
||||
public bool Recursive { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Connects to the server and prints a tree view of the requested address-space branch.
|
||||
/// </summary>
|
||||
/// <param name="console">The CLI console used for output and cancellation handling.</param>
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
ConfigureLogging();
|
||||
IOpcUaClientService? service = null;
|
||||
try
|
||||
{
|
||||
var ct = console.RegisterCancellationHandler();
|
||||
(service, _) = await CreateServiceAndConnectAsync(ct);
|
||||
|
||||
var startNode = NodeIdParser.Parse(NodeId);
|
||||
var maxDepth = Recursive ? Depth : 1;
|
||||
|
||||
await BrowseNodeAsync(service, console, startNode, maxDepth, 0, ct);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (service != null)
|
||||
{
|
||||
await service.DisconnectAsync();
|
||||
service.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task BrowseNodeAsync(
|
||||
IOpcUaClientService service,
|
||||
IConsole console,
|
||||
NodeId? nodeId,
|
||||
int maxDepth,
|
||||
int currentDepth,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var indent = new string(' ', currentDepth * 2);
|
||||
var results = await service.BrowseAsync(nodeId, ct);
|
||||
|
||||
foreach (var result in results)
|
||||
{
|
||||
var marker = result.NodeClass switch
|
||||
{
|
||||
"Object" => "[Object]",
|
||||
"Variable" => "[Variable]",
|
||||
"Method" => "[Method]",
|
||||
_ => $"[{result.NodeClass}]"
|
||||
};
|
||||
|
||||
await console.Output.WriteLineAsync(
|
||||
$"{indent}{marker} {result.DisplayName} (NodeId: {result.NodeId})");
|
||||
|
||||
if (currentDepth + 1 < maxDepth && result.HasChildren)
|
||||
{
|
||||
var childNodeId = NodeIdParser.Parse(result.NodeId);
|
||||
await BrowseNodeAsync(service, console, childNodeId, maxDepth, currentDepth + 1, ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
45
src/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/ConnectCommand.cs
Normal file
45
src/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/ConnectCommand.cs
Normal file
@@ -0,0 +1,45 @@
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Infrastructure;
|
||||
using ZB.MOM.WW.OtOpcUa.Client.Shared;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Client.CLI.Commands;
|
||||
|
||||
[Command("connect", Description = "Test connection to an OPC UA server")]
|
||||
public class ConnectCommand : CommandBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates the connectivity test command used to verify endpoint reachability and negotiated security settings.
|
||||
/// </summary>
|
||||
/// <param name="factory">The factory that creates the shared client service for the command run.</param>
|
||||
public ConnectCommand(IOpcUaClientServiceFactory factory) : base(factory)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Connects to the server and prints the negotiated endpoint details for operator verification.
|
||||
/// </summary>
|
||||
/// <param name="console">The CLI console used for output and cancellation handling.</param>
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
ConfigureLogging();
|
||||
IOpcUaClientService? service = null;
|
||||
try
|
||||
{
|
||||
(service, var info) = await CreateServiceAndConnectAsync(console.RegisterCancellationHandler());
|
||||
|
||||
await console.Output.WriteLineAsync($"Connected to: {info.EndpointUrl}");
|
||||
await console.Output.WriteLineAsync($"Server: {info.ServerName}");
|
||||
await console.Output.WriteLineAsync($"Security Mode: {info.SecurityMode}");
|
||||
await console.Output.WriteLineAsync($"Security Policy: {info.SecurityPolicyUri}");
|
||||
await console.Output.WriteLineAsync("Connection successful.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (service != null)
|
||||
{
|
||||
await service.DisconnectAsync();
|
||||
service.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
135
src/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/HistoryReadCommand.cs
Normal file
135
src/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/HistoryReadCommand.cs
Normal file
@@ -0,0 +1,135 @@
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Infrastructure;
|
||||
using Opc.Ua;
|
||||
using ZB.MOM.WW.OtOpcUa.Client.CLI.Helpers;
|
||||
using ZB.MOM.WW.OtOpcUa.Client.Shared;
|
||||
using ZB.MOM.WW.OtOpcUa.Client.Shared.Models;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Client.CLI.Commands;
|
||||
|
||||
[Command("historyread", Description = "Read historical data from a node")]
|
||||
public class HistoryReadCommand : CommandBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates the historical-data command used to inspect raw or aggregate history for a node.
|
||||
/// </summary>
|
||||
/// <param name="factory">The factory that creates the shared client service for the command run.</param>
|
||||
public HistoryReadCommand(IOpcUaClientServiceFactory factory) : base(factory)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the historized node to query.
|
||||
/// </summary>
|
||||
[CommandOption("node", 'n', Description = "Node ID (e.g. ns=2;s=MyNode)", IsRequired = true)]
|
||||
public string NodeId { get; init; } = default!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the optional history start time string supplied by the operator.
|
||||
/// </summary>
|
||||
[CommandOption("start", Description = "Start time (ISO 8601 or date string, default: 24 hours ago)")]
|
||||
public string? StartTime { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the optional history end time string supplied by the operator.
|
||||
/// </summary>
|
||||
[CommandOption("end", Description = "End time (ISO 8601 or date string, default: now)")]
|
||||
public string? EndTime { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the maximum number of raw values the command should print.
|
||||
/// </summary>
|
||||
[CommandOption("max", Description = "Maximum number of values to return")]
|
||||
public int MaxValues { get; init; } = 1000;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the optional aggregate name used when the operator wants processed history instead of raw samples.
|
||||
/// </summary>
|
||||
[CommandOption("aggregate", Description = "Aggregate function: Average, Minimum, Maximum, Count, Start, End, StandardDeviation")]
|
||||
public string? Aggregate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the aggregate bucket interval, in milliseconds, for processed history reads.
|
||||
/// </summary>
|
||||
[CommandOption("interval", Description = "Processing interval in milliseconds for aggregates")]
|
||||
public double IntervalMs { get; init; } = 3600000;
|
||||
|
||||
/// <summary>
|
||||
/// Connects to the server and prints raw or processed historical values for the requested node.
|
||||
/// </summary>
|
||||
/// <param name="console">The CLI console used for output and cancellation handling.</param>
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
ConfigureLogging();
|
||||
IOpcUaClientService? service = null;
|
||||
try
|
||||
{
|
||||
var ct = console.RegisterCancellationHandler();
|
||||
(service, _) = await CreateServiceAndConnectAsync(ct);
|
||||
|
||||
var nodeId = NodeIdParser.ParseRequired(NodeId);
|
||||
var start = string.IsNullOrEmpty(StartTime)
|
||||
? DateTime.UtcNow.AddHours(-24)
|
||||
: DateTime.Parse(StartTime).ToUniversalTime();
|
||||
var end = string.IsNullOrEmpty(EndTime)
|
||||
? DateTime.UtcNow
|
||||
: DateTime.Parse(EndTime).ToUniversalTime();
|
||||
|
||||
IReadOnlyList<DataValue> values;
|
||||
|
||||
if (string.IsNullOrEmpty(Aggregate))
|
||||
{
|
||||
await console.Output.WriteLineAsync(
|
||||
$"History for {NodeId} ({start:yyyy-MM-dd HH:mm} -> {end:yyyy-MM-dd HH:mm})");
|
||||
values = await service.HistoryReadRawAsync(nodeId, start, end, MaxValues, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
var aggregateType = ParseAggregateType(Aggregate);
|
||||
await console.Output.WriteLineAsync(
|
||||
$"History for {NodeId} ({Aggregate}, interval={IntervalMs}ms)");
|
||||
values = await service.HistoryReadAggregateAsync(
|
||||
nodeId, start, end, aggregateType, IntervalMs, ct);
|
||||
}
|
||||
|
||||
await console.Output.WriteLineAsync();
|
||||
await console.Output.WriteLineAsync($"{"Timestamp",-35} {"Value",-15} {"Status"}");
|
||||
|
||||
foreach (var dv in values)
|
||||
{
|
||||
var status = StatusCode.IsGood(dv.StatusCode) ? "Good"
|
||||
: StatusCode.IsBad(dv.StatusCode) ? "Bad"
|
||||
: "Uncertain";
|
||||
await console.Output.WriteLineAsync(
|
||||
$"{dv.SourceTimestamp.ToString("O"),-35} {dv.Value,-15} {status}");
|
||||
}
|
||||
|
||||
await console.Output.WriteLineAsync();
|
||||
await console.Output.WriteLineAsync($"{values.Count} values returned.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (service != null)
|
||||
{
|
||||
await service.DisconnectAsync();
|
||||
service.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static AggregateType ParseAggregateType(string name)
|
||||
{
|
||||
return name.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"average" or "avg" => AggregateType.Average,
|
||||
"minimum" or "min" => AggregateType.Minimum,
|
||||
"maximum" or "max" => AggregateType.Maximum,
|
||||
"count" => AggregateType.Count,
|
||||
"start" or "first" => AggregateType.Start,
|
||||
"end" or "last" => AggregateType.End,
|
||||
"standarddeviation" or "stddev" or "stdev" => AggregateType.StandardDeviation,
|
||||
_ => throw new ArgumentException(
|
||||
$"Unknown aggregate: '{name}'. Supported: Average, Minimum, Maximum, Count, Start, End, StandardDeviation")
|
||||
};
|
||||
}
|
||||
}
|
||||
56
src/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/ReadCommand.cs
Normal file
56
src/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/ReadCommand.cs
Normal file
@@ -0,0 +1,56 @@
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Infrastructure;
|
||||
using ZB.MOM.WW.OtOpcUa.Client.CLI.Helpers;
|
||||
using ZB.MOM.WW.OtOpcUa.Client.Shared;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Client.CLI.Commands;
|
||||
|
||||
[Command("read", Description = "Read a value from a node")]
|
||||
public class ReadCommand : CommandBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates the point-read command used to inspect the current value of a node.
|
||||
/// </summary>
|
||||
/// <param name="factory">The factory that creates the shared client service for the command run.</param>
|
||||
public ReadCommand(IOpcUaClientServiceFactory factory) : base(factory)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the node whose current value should be read.
|
||||
/// </summary>
|
||||
[CommandOption("node", 'n', Description = "Node ID (e.g. ns=2;s=MyNode)", IsRequired = true)]
|
||||
public string NodeId { get; init; } = default!;
|
||||
|
||||
/// <summary>
|
||||
/// Connects to the server and prints the current value, status, and timestamps for the requested node.
|
||||
/// </summary>
|
||||
/// <param name="console">The CLI console used for output and cancellation handling.</param>
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
ConfigureLogging();
|
||||
IOpcUaClientService? service = null;
|
||||
try
|
||||
{
|
||||
var ct = console.RegisterCancellationHandler();
|
||||
(service, _) = await CreateServiceAndConnectAsync(ct);
|
||||
|
||||
var nodeId = NodeIdParser.ParseRequired(NodeId);
|
||||
var value = await service.ReadValueAsync(nodeId, ct);
|
||||
|
||||
await console.Output.WriteLineAsync($"Node: {NodeId}");
|
||||
await console.Output.WriteLineAsync($"Value: {value.Value}");
|
||||
await console.Output.WriteLineAsync($"Status: {value.StatusCode}");
|
||||
await console.Output.WriteLineAsync($"Source Time: {value.SourceTimestamp:O}");
|
||||
await console.Output.WriteLineAsync($"Server Time: {value.ServerTimestamp:O}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (service != null)
|
||||
{
|
||||
await service.DisconnectAsync();
|
||||
service.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Infrastructure;
|
||||
using ZB.MOM.WW.OtOpcUa.Client.Shared;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Client.CLI.Commands;
|
||||
|
||||
[Command("redundancy", Description = "Read redundancy state from an OPC UA server")]
|
||||
public class RedundancyCommand : CommandBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates the redundancy-inspection command used to display service-level and partner-server status.
|
||||
/// </summary>
|
||||
/// <param name="factory">The factory that creates the shared client service for the command run.</param>
|
||||
public RedundancyCommand(IOpcUaClientServiceFactory factory) : base(factory)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Connects to the server and prints redundancy mode, service level, and partner-server identity data.
|
||||
/// </summary>
|
||||
/// <param name="console">The CLI console used for output and cancellation handling.</param>
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
ConfigureLogging();
|
||||
IOpcUaClientService? service = null;
|
||||
try
|
||||
{
|
||||
var ct = console.RegisterCancellationHandler();
|
||||
(service, _) = await CreateServiceAndConnectAsync(ct);
|
||||
|
||||
var info = await service.GetRedundancyInfoAsync(ct);
|
||||
|
||||
await console.Output.WriteLineAsync($"Redundancy Mode: {info.Mode}");
|
||||
await console.Output.WriteLineAsync($"Service Level: {info.ServiceLevel}");
|
||||
|
||||
if (info.ServerUris.Length > 0)
|
||||
{
|
||||
await console.Output.WriteLineAsync("Server URIs:");
|
||||
foreach (var uri in info.ServerUris) await console.Output.WriteLineAsync($" - {uri}");
|
||||
}
|
||||
|
||||
await console.Output.WriteLineAsync($"Application URI: {info.ApplicationUri}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (service != null)
|
||||
{
|
||||
await service.DisconnectAsync();
|
||||
service.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
203
src/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/SubscribeCommand.cs
Normal file
203
src/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/SubscribeCommand.cs
Normal file
@@ -0,0 +1,203 @@
|
||||
using System.Collections.Concurrent;
|
||||
using CliFx.Attributes;
|
||||
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("subscribe", Description = "Monitor a node for value changes")]
|
||||
public class SubscribeCommand : CommandBase
|
||||
{
|
||||
public SubscribeCommand(IOpcUaClientServiceFactory factory) : base(factory)
|
||||
{
|
||||
}
|
||||
|
||||
[CommandOption("node", 'n', Description = "Node ID to monitor", IsRequired = true)]
|
||||
public string NodeId { get; init; } = default!;
|
||||
|
||||
[CommandOption("interval", 'i', Description = "Sampling interval in milliseconds")]
|
||||
public int Interval { get; init; } = 1000;
|
||||
|
||||
[CommandOption("recursive", 'r', Description = "Browse recursively from --node and subscribe to every Variable found")]
|
||||
public bool Recursive { get; init; }
|
||||
|
||||
[CommandOption("max-depth", Description = "Maximum recursion depth when --recursive is set")]
|
||||
public int MaxDepth { get; init; } = 10;
|
||||
|
||||
[CommandOption("quiet", 'q', Description = "Suppress per-update output; only print a final summary on Ctrl+C")]
|
||||
public bool Quiet { get; init; }
|
||||
|
||||
[CommandOption("duration", Description = "Auto-exit after N seconds and print summary (0 = run until Ctrl+C)")]
|
||||
public int DurationSeconds { get; init; } = 0;
|
||||
|
||||
[CommandOption("summary-file", Description = "Write summary to this file path on exit (in addition to stdout)")]
|
||||
public string? SummaryFile { get; init; }
|
||||
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
ConfigureLogging();
|
||||
IOpcUaClientService? service = null;
|
||||
try
|
||||
{
|
||||
var ct = console.RegisterCancellationHandler();
|
||||
(service, _) = await CreateServiceAndConnectAsync(ct);
|
||||
|
||||
var rootNodeId = NodeIdParser.ParseRequired(NodeId);
|
||||
|
||||
var targets = new List<(NodeId nodeId, string displayPath)>();
|
||||
if (Recursive)
|
||||
{
|
||||
await console.Output.WriteLineAsync($"Browsing subtree of {NodeId} (max depth {MaxDepth})...");
|
||||
await CollectVariablesAsync(service, rootNodeId, NodeId, MaxDepth, 0, targets, ct);
|
||||
await console.Output.WriteLineAsync($"Found {targets.Count} variable nodes.");
|
||||
}
|
||||
else
|
||||
{
|
||||
targets.Add((rootNodeId, NodeId));
|
||||
}
|
||||
|
||||
var lastStatus = new ConcurrentDictionary<string, (StatusCode Status, DateTime LastUpdate, object? Value)>();
|
||||
var updateCount = new ConcurrentDictionary<string, int>();
|
||||
var everBad = new ConcurrentDictionary<string, byte>();
|
||||
var displayNameByNodeId = targets.ToDictionary(t => t.nodeId.ToString(), t => t.displayPath);
|
||||
|
||||
service.DataChanged += (_, e) =>
|
||||
{
|
||||
var key = e.NodeId.ToString();
|
||||
lastStatus[key] = (e.Value.StatusCode, DateTime.UtcNow, e.Value.Value);
|
||||
updateCount.AddOrUpdate(key, 1, (_, v) => v + 1);
|
||||
if (!StatusCode.IsGood(e.Value.StatusCode))
|
||||
everBad.TryAdd(key, 0);
|
||||
if (!Quiet)
|
||||
{
|
||||
console.Output.WriteLine(
|
||||
$"[{e.Value.SourceTimestamp:O}] {displayNameByNodeId.GetValueOrDefault(key, key)} = {e.Value.Value} ({e.Value.StatusCode})");
|
||||
}
|
||||
};
|
||||
|
||||
var subscribed = 0;
|
||||
foreach (var (nodeId, _) in targets)
|
||||
{
|
||||
try
|
||||
{
|
||||
await service.SubscribeAsync(nodeId, Interval, ct);
|
||||
subscribed++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await console.Output.WriteLineAsync($" FAILED to subscribe {nodeId}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
await console.Output.WriteLineAsync(
|
||||
$"Subscribed to {subscribed}/{targets.Count} nodes (interval: {Interval}ms). Press Ctrl+C to stop and print summary.");
|
||||
|
||||
try
|
||||
{
|
||||
if (DurationSeconds > 0)
|
||||
await Task.Delay(TimeSpan.FromSeconds(DurationSeconds), ct);
|
||||
else
|
||||
await Task.Delay(Timeout.Infinite, ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
|
||||
// Summary
|
||||
var summary = new List<string>();
|
||||
summary.Add("");
|
||||
summary.Add("==================== SUMMARY ====================");
|
||||
var good = new List<string>();
|
||||
var bad = new List<string>();
|
||||
var never = new List<string>();
|
||||
foreach (var (nodeId, display) in targets)
|
||||
{
|
||||
var key = nodeId.ToString();
|
||||
if (!lastStatus.TryGetValue(key, out var entry))
|
||||
{
|
||||
never.Add(display);
|
||||
continue;
|
||||
}
|
||||
if (StatusCode.IsGood(entry.Status))
|
||||
good.Add($"{display} = {entry.Value} ({entry.Status})");
|
||||
else
|
||||
bad.Add($"{display} = {entry.Value} ({entry.Status})");
|
||||
}
|
||||
|
||||
var neverWentBad = targets
|
||||
.Where(t => !everBad.ContainsKey(t.nodeId.ToString()))
|
||||
.Select(t => t.displayPath)
|
||||
.ToList();
|
||||
var didGoBad = targets.Count - neverWentBad.Count;
|
||||
|
||||
summary.Add($"Total subscribed: {targets.Count}");
|
||||
summary.Add($" Ever went BAD during window: {didGoBad}");
|
||||
summary.Add($" NEVER went bad (suspect): {neverWentBad.Count}");
|
||||
summary.Add($" Last status GOOD: {good.Count}");
|
||||
summary.Add($" Last status NOT-GOOD: {bad.Count}");
|
||||
summary.Add($" No update received at all: {never.Count}");
|
||||
|
||||
if (neverWentBad.Count > 0 && neverWentBad.Count < targets.Count)
|
||||
{
|
||||
summary.Add("");
|
||||
summary.Add("--- Nodes that NEVER received a bad-quality update (suspect) ---");
|
||||
foreach (var line in neverWentBad) summary.Add($" {line}");
|
||||
}
|
||||
if (never.Count > 0)
|
||||
{
|
||||
summary.Add("");
|
||||
summary.Add("--- Nodes that never received an update at all ---");
|
||||
foreach (var line in never) summary.Add($" {line}");
|
||||
}
|
||||
|
||||
foreach (var line in summary) await console.Output.WriteLineAsync(line);
|
||||
if (!string.IsNullOrEmpty(SummaryFile))
|
||||
{
|
||||
try { await File.WriteAllLinesAsync(SummaryFile, summary); }
|
||||
catch (Exception ex) { await console.Output.WriteLineAsync($"Failed to write summary file: {ex.Message}"); }
|
||||
}
|
||||
|
||||
foreach (var (nodeId, _) in targets)
|
||||
{
|
||||
try { await service.UnsubscribeAsync(nodeId); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (service != null)
|
||||
{
|
||||
await service.DisconnectAsync();
|
||||
service.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task CollectVariablesAsync(
|
||||
IOpcUaClientService service,
|
||||
NodeId? parent,
|
||||
string parentPath,
|
||||
int maxDepth,
|
||||
int currentDepth,
|
||||
List<(NodeId nodeId, string displayPath)> into,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (currentDepth >= maxDepth) return;
|
||||
var children = await service.BrowseAsync(parent, ct);
|
||||
foreach (var child in children)
|
||||
{
|
||||
var nodeId = NodeIdParser.Parse(child.NodeId);
|
||||
if (nodeId is null) continue;
|
||||
var childPath = $"{parentPath}/{child.DisplayName}";
|
||||
if (child.NodeClass == "Variable")
|
||||
{
|
||||
into.Add((nodeId, childPath));
|
||||
}
|
||||
if (child.HasChildren)
|
||||
{
|
||||
await CollectVariablesAsync(service, nodeId, childPath, maxDepth, currentDepth + 1, into, ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
68
src/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/WriteCommand.cs
Normal file
68
src/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/WriteCommand.cs
Normal file
@@ -0,0 +1,68 @@
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Infrastructure;
|
||||
using Opc.Ua;
|
||||
using ZB.MOM.WW.OtOpcUa.Client.CLI.Helpers;
|
||||
using ZB.MOM.WW.OtOpcUa.Client.Shared;
|
||||
using ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Client.CLI.Commands;
|
||||
|
||||
[Command("write", Description = "Write a value to a node")]
|
||||
public class WriteCommand : CommandBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates the write command used to update a node from the terminal.
|
||||
/// </summary>
|
||||
/// <param name="factory">The factory that creates the shared client service for the command run.</param>
|
||||
public WriteCommand(IOpcUaClientServiceFactory factory) : base(factory)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the node whose value should be updated.
|
||||
/// </summary>
|
||||
[CommandOption("node", 'n', Description = "Node ID (e.g. ns=2;s=MyNode)", IsRequired = true)]
|
||||
public string NodeId { get; init; } = default!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the raw operator-entered value that will be converted to the node's runtime type before writing.
|
||||
/// </summary>
|
||||
[CommandOption("value", 'v', Description = "Value to write", IsRequired = true)]
|
||||
public string Value { get; init; } = default!;
|
||||
|
||||
/// <summary>
|
||||
/// Connects to the server, converts the supplied value to the node's current data type, and issues the write.
|
||||
/// </summary>
|
||||
/// <param name="console">The CLI console used for output and cancellation handling.</param>
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
ConfigureLogging();
|
||||
IOpcUaClientService? service = null;
|
||||
try
|
||||
{
|
||||
var ct = console.RegisterCancellationHandler();
|
||||
(service, _) = await CreateServiceAndConnectAsync(ct);
|
||||
|
||||
var nodeId = NodeIdParser.ParseRequired(NodeId);
|
||||
|
||||
// Read current value to determine type for conversion
|
||||
var currentValue = await service.ReadValueAsync(nodeId, ct);
|
||||
var typedValue = ValueConverter.ConvertValue(Value, currentValue.Value);
|
||||
|
||||
var statusCode = await service.WriteValueAsync(nodeId, typedValue, ct);
|
||||
|
||||
if (StatusCode.IsGood(statusCode))
|
||||
await console.Output.WriteLineAsync($"Write successful: {NodeId} = {typedValue}");
|
||||
else
|
||||
await console.Output.WriteLineAsync($"Write failed: {statusCode}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (service != null)
|
||||
{
|
||||
await service.DisconnectAsync();
|
||||
service.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user