From 8fae2cb79080f91384bed5a30297c38471eb3ec2 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 31 Mar 2026 08:08:57 -0400 Subject: [PATCH] Remove tools/opcuacli-dotnet and point all docs to Client.CLI The standalone CLI tool is superseded by src/ZB.MOM.WW.LmxOpcUa.Client.CLI which uses the shared IOpcUaClientService abstraction. Renames CliTool.md to Client.CLI.md and updates README, CLAUDE.md, Security, and Redundancy docs. Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 11 +- README.md | 20 +- docs/{CliTool.md => Client.CLI.md} | 18 +- docs/Redundancy.md | 6 +- docs/reqs/ClientRequirements.md | 2 +- docs/security.md | 9 +- .../opcuacli-dotnet/Commands/AlarmsCommand.cs | 198 -------------- .../opcuacli-dotnet/Commands/BrowseCommand.cs | 122 --------- .../Commands/ConnectCommand.cs | 43 ---- .../Commands/HistoryReadCommand.cs | 243 ------------------ tools/opcuacli-dotnet/Commands/ReadCommand.cs | 55 ---- .../Commands/RedundancyCommand.cs | 75 ------ .../Commands/SubscribeCommand.cs | 170 ------------ .../opcuacli-dotnet/Commands/WriteCommand.cs | 71 ----- tools/opcuacli-dotnet/OpcUaFailoverHelper.cs | 156 ----------- tools/opcuacli-dotnet/OpcUaHelper.cs | 188 -------------- tools/opcuacli-dotnet/Program.cs | 8 - tools/opcuacli-dotnet/README.md | 183 ------------- tools/opcuacli-dotnet/opcuacli-dotnet.csproj | 16 -- 19 files changed, 29 insertions(+), 1565 deletions(-) rename docs/{CliTool.md => Client.CLI.md} (90%) delete mode 100644 tools/opcuacli-dotnet/Commands/AlarmsCommand.cs delete mode 100644 tools/opcuacli-dotnet/Commands/BrowseCommand.cs delete mode 100644 tools/opcuacli-dotnet/Commands/ConnectCommand.cs delete mode 100644 tools/opcuacli-dotnet/Commands/HistoryReadCommand.cs delete mode 100644 tools/opcuacli-dotnet/Commands/ReadCommand.cs delete mode 100644 tools/opcuacli-dotnet/Commands/RedundancyCommand.cs delete mode 100644 tools/opcuacli-dotnet/Commands/SubscribeCommand.cs delete mode 100644 tools/opcuacli-dotnet/Commands/WriteCommand.cs delete mode 100644 tools/opcuacli-dotnet/OpcUaFailoverHelper.cs delete mode 100644 tools/opcuacli-dotnet/OpcUaHelper.cs delete mode 100644 tools/opcuacli-dotnet/Program.cs delete mode 100644 tools/opcuacli-dotnet/README.md delete mode 100644 tools/opcuacli-dotnet/opcuacli-dotnet.csproj diff --git a/CLAUDE.md b/CLAUDE.md index 83e1e94..83a0623 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -102,14 +102,13 @@ Use the DeepWiki MCP (`mcp__deepwiki`) to query documentation for the OPC UA .NE ## Testing -Use the dotnet OPC UA CLI tool at `tools/opcuacli-dotnet/` for manual testing against the running OPC UA server. Supports connect, read, write, subscribe, and browse commands. See `tools/opcuacli-dotnet/README.md` for usage details. +Use the Client CLI at `src/ZB.MOM.WW.LmxOpcUa.Client.CLI/` for manual testing against the running OPC UA server. Supports connect, read, write, browse, subscribe, historyread, alarms, and redundancy commands. See `docs/Client.CLI.md` for full documentation. ```bash -cd tools/opcuacli-dotnet -dotnet run -- connect -u opc.tcp://localhost:4840 -dotnet run -- browse -u opc.tcp://localhost:4840 -r -d 3 -dotnet run -- read -u opc.tcp://localhost:4840 -n "ns=2;s=SomeNode" -dotnet run -- subscribe -u opc.tcp://localhost:4840 -n "ns=2;s=SomeNode" -i 500 +dotnet run --project src/ZB.MOM.WW.LmxOpcUa.Client.CLI -- connect -u opc.tcp://localhost:4840 +dotnet run --project src/ZB.MOM.WW.LmxOpcUa.Client.CLI -- browse -u opc.tcp://localhost:4840 -r -d 3 +dotnet run --project src/ZB.MOM.WW.LmxOpcUa.Client.CLI -- read -u opc.tcp://localhost:4840 -n "ns=2;s=SomeNode" +dotnet run --project src/ZB.MOM.WW.LmxOpcUa.Client.CLI -- subscribe -u opc.tcp://localhost:4840 -n "ns=2;s=SomeNode" -i 500 ``` ### OPC PLC Sample Server diff --git a/README.md b/README.md index ac0a726..d09d317 100644 --- a/README.md +++ b/README.md @@ -66,25 +66,25 @@ ZB.MOM.WW.LmxOpcUa.Host.exe start ```bash # Connect -dotnet run --project tools/opcuacli-dotnet -- connect -u opc.tcp://localhost:4840/LmxOpcUa +dotnet run --project src/ZB.MOM.WW.LmxOpcUa.Client.CLI -- connect -u opc.tcp://localhost:4840/LmxOpcUa # Browse Galaxy hierarchy -dotnet run --project tools/opcuacli-dotnet -- browse -u opc.tcp://localhost:4840/LmxOpcUa -n "ns=1;s=ZB" -r -d 5 +dotnet run --project src/ZB.MOM.WW.LmxOpcUa.Client.CLI -- browse -u opc.tcp://localhost:4840/LmxOpcUa -n "ns=1;s=ZB" -r -d 5 # Read a tag -dotnet run --project tools/opcuacli-dotnet -- read -u opc.tcp://localhost:4840/LmxOpcUa -n "ns=1;s=TestMachine_001.MachineID" +dotnet run --project src/ZB.MOM.WW.LmxOpcUa.Client.CLI -- read -u opc.tcp://localhost:4840/LmxOpcUa -n "ns=1;s=TestMachine_001.MachineID" # Write a tag -dotnet run --project tools/opcuacli-dotnet -- write -u opc.tcp://localhost:4840/LmxOpcUa -n "ns=1;s=TestChildObject.TestString" -v "Hello" +dotnet run --project src/ZB.MOM.WW.LmxOpcUa.Client.CLI -- write -u opc.tcp://localhost:4840/LmxOpcUa -n "ns=1;s=TestChildObject.TestString" -v "Hello" # Subscribe to changes -dotnet run --project tools/opcuacli-dotnet -- subscribe -u opc.tcp://localhost:4840/LmxOpcUa -n "ns=1;s=TestChildObject.TestInt" -i 500 +dotnet run --project src/ZB.MOM.WW.LmxOpcUa.Client.CLI -- subscribe -u opc.tcp://localhost:4840/LmxOpcUa -n "ns=1;s=TestChildObject.TestInt" -i 500 # Read historical data -dotnet run --project tools/opcuacli-dotnet -- historyread -u opc.tcp://localhost:4840/LmxOpcUa -n "ns=1;s=TestMachine_001.TestHistoryValue" --start "2026-03-25" --end "2026-03-30" +dotnet run --project src/ZB.MOM.WW.LmxOpcUa.Client.CLI -- historyread -u opc.tcp://localhost:4840/LmxOpcUa -n "ns=1;s=TestMachine_001.TestHistoryValue" --start "2026-03-25" --end "2026-03-30" # Subscribe to alarm events -dotnet run --project tools/opcuacli-dotnet -- alarms -u opc.tcp://localhost:4840/LmxOpcUa -n "ns=1;s=TestMachine_001" --refresh +dotnet run --project src/ZB.MOM.WW.LmxOpcUa.Client.CLI -- alarms -u opc.tcp://localhost:4840/LmxOpcUa -n "ns=1;s=TestMachine_001" --refresh ``` ### Run tests @@ -118,7 +118,9 @@ tests/ZB.MOM.WW.LmxOpcUa.Tests/ OpcUa/ Address space build/rebuild, diff, data conversion tests Wiring/ Component integration tests -tools/opcuacli-dotnet/ OPC UA CLI test tool +src/ZB.MOM.WW.LmxOpcUa.Client.Shared/ Shared OPC UA client library +src/ZB.MOM.WW.LmxOpcUa.Client.CLI/ Command-line client (lmxopcua-cli) +src/ZB.MOM.WW.LmxOpcUa.Client.UI/ Avalonia desktop client gr/ Galaxy repository docs, SQL queries, schema ``` @@ -139,7 +141,7 @@ gr/ Galaxy repository docs, SQL queries, schema | [Configuration](docs/Configuration.md) | appsettings.json binding, feature flags, validation | | [Status Dashboard](docs/StatusDashboard.md) | HTTP server, health checks, metrics reporting | | [Service Hosting](docs/ServiceHosting.md) | TopShelf, startup/shutdown sequence, error handling | -| [CLI Tool](docs/CliTool.md) | Connect, browse, read, write, subscribe, historyread, alarms commands | +| [Client CLI](docs/Client.CLI.md) | Connect, browse, read, write, subscribe, historyread, alarms, redundancy commands | | [Security](docs/security.md) | Transport security profiles, certificate trust, production hardening | | [Redundancy](docs/Redundancy.md) | Non-transparent warm/hot redundancy, ServiceLevel, paired deployment | diff --git a/docs/CliTool.md b/docs/Client.CLI.md similarity index 90% rename from docs/CliTool.md rename to docs/Client.CLI.md index f1aed07..dcc562b 100644 --- a/docs/CliTool.md +++ b/docs/Client.CLI.md @@ -235,18 +235,10 @@ Server URIs: Application URI: urn:localhost:LmxOpcUa:instance1 ``` -## Differences from tools/opcuacli-dotnet +## Testing -The `Client.CLI` replaces the standalone tool at `tools/opcuacli-dotnet/`. Key differences: +The Client CLI has 52 unit tests covering option parsing, service invocation, output formatting, and cleanup behavior: -| Aspect | tools/opcuacli-dotnet | Client.CLI | -|--------|----------------------|------------| -| OPC UA access | Direct `Session` API | `IOpcUaClientService` abstraction | -| Connection/failover | Per-command `OpcUaFailoverHelper` | Shared service with built-in failover and keep-alive | -| Certificate management | Inline in `OpcUaHelper` | Shared `ApplicationConfigurationFactory` | -| Type conversion | `OpcUaHelper.ConvertValue` | `ValueConverter` in `Client.Shared` | -| Executable name | `opcuacli-dotnet` | `lmxopcua-cli` | -| Verbose logging | Not available | `--verbose` flag with Serilog | -| Testability | No test seam | `IOpcUaClientServiceFactory` injection, 52 unit tests | - -The `tools/opcuacli-dotnet/` tool remains available for low-level debugging but is no longer the recommended client CLI. +```bash +dotnet test tests/ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests +``` diff --git a/docs/Redundancy.md b/docs/Redundancy.md index 00ee87d..443d830 100644 --- a/docs/Redundancy.md +++ b/docs/Redundancy.md @@ -135,11 +135,11 @@ When deploying a redundant pair, the following configuration properties must dif ## CLI `redundancy` Command -The CLI tool at `tools/opcuacli-dotnet/` includes a `redundancy` command that reads the redundancy state from a running server. +The Client CLI includes a `redundancy` command that reads the redundancy state from a running server. ```bash -dotnet run -- redundancy -u opc.tcp://localhost:4840/LmxOpcUa -dotnet run -- redundancy -u opc.tcp://localhost:4841/LmxOpcUa +dotnet run --project src/ZB.MOM.WW.LmxOpcUa.Client.CLI -- redundancy -u opc.tcp://localhost:4840/LmxOpcUa +dotnet run --project src/ZB.MOM.WW.LmxOpcUa.Client.CLI -- redundancy -u opc.tcp://localhost:4841/LmxOpcUa ``` The command reads the following standard OPC UA nodes and displays their values: diff --git a/docs/reqs/ClientRequirements.md b/docs/reqs/ClientRequirements.md index f61f950..e35ab95 100644 --- a/docs/reqs/ClientRequirements.md +++ b/docs/reqs/ClientRequirements.md @@ -104,7 +104,7 @@ Serilog with `ILogger` passed via constructor or `Log.ForContext()`. No sinks ### Commands -Port all 8 commands from the existing `tools/opcuacli-dotnet/`: +All 8 commands: | Command | Description | |---------|-------------| diff --git a/docs/security.md b/docs/security.md index 6780511..681fefd 100644 --- a/docs/security.md +++ b/docs/security.md @@ -176,25 +176,24 @@ Periodically inspect the `rejected/` directory. Certificates that appear here we ## CLI Examples -The `tools/opcuacli-dotnet` CLI tool supports the `-S` (or `--security`) flag to select the transport security mode when connecting. Valid values are `none`, `sign`, and `encrypt`. +The Client CLI supports the `-S` (or `--security`) flag to select the transport security mode when connecting. Valid values are `none`, `sign`, `encrypt`, and `signandencrypt`. ### Connect with no security ```bash -cd tools/opcuacli-dotnet -dotnet run -- connect -u opc.tcp://localhost:4840/LmxOpcUa -S none +dotnet run --project src/ZB.MOM.WW.LmxOpcUa.Client.CLI -- connect -u opc.tcp://localhost:4840/LmxOpcUa -S none ``` ### Connect with signing ```bash -dotnet run -- connect -u opc.tcp://localhost:4840/LmxOpcUa -S sign +dotnet run --project src/ZB.MOM.WW.LmxOpcUa.Client.CLI -- connect -u opc.tcp://localhost:4840/LmxOpcUa -S sign ``` ### Connect with signing and encryption ```bash -dotnet run -- connect -u opc.tcp://localhost:4840/LmxOpcUa -S encrypt +dotnet run --project src/ZB.MOM.WW.LmxOpcUa.Client.CLI -- connect -u opc.tcp://localhost:4840/LmxOpcUa -S encrypt ``` ### Browse with encryption and authentication diff --git a/tools/opcuacli-dotnet/Commands/AlarmsCommand.cs b/tools/opcuacli-dotnet/Commands/AlarmsCommand.cs deleted file mode 100644 index cf70058..0000000 --- a/tools/opcuacli-dotnet/Commands/AlarmsCommand.cs +++ /dev/null @@ -1,198 +0,0 @@ -using CliFx; -using CliFx.Attributes; -using CliFx.Infrastructure; -using Opc.Ua; -using Opc.Ua.Client; - -namespace OpcUaCli.Commands; - -[Command("alarms", Description = "Subscribe to alarm events on a node")] -public class AlarmsCommand : ICommand -{ - /// - /// Gets the OPC UA endpoint URL for the server whose alarm stream should be monitored. - /// - [CommandOption("url", 'u', Description = "OPC UA server endpoint URL", IsRequired = true)] - public string Url { get; init; } = default!; - - [CommandOption("username", 'U', Description = "Username for authentication")] - public string? Username { get; init; } - - [CommandOption("password", 'P', Description = "Password for authentication")] - public string? Password { get; init; } - - [CommandOption("security", 'S', Description = "Transport security: none, sign, encrypt (default: none)")] - public string Security { get; init; } = "none"; - - [CommandOption("failover-urls", 'F', Description = "Comma-separated failover endpoint URLs for redundancy")] - public string? FailoverUrls { get; init; } - - /// - /// Gets the node to subscribe to for event notifications, typically a source object or the server node. - /// - [CommandOption("node", 'n', Description = "Node ID to monitor for events (default: Server node)")] - public string? NodeId { get; init; } - - /// - /// Gets the requested publishing and sampling interval for the alarm subscription. - /// - [CommandOption("interval", 'i', Description = "Publishing interval in milliseconds")] - public int Interval { get; init; } = 1000; - - /// - /// Gets a value indicating whether the command should request a retained-condition refresh after subscribing. - /// - [CommandOption("refresh", Description = "Request a ConditionRefresh after subscribing")] - public bool Refresh { get; init; } - - /// - /// Connects to the target server and streams alarm or condition events to the operator console. - /// - /// The CLI console used for cancellation and alarm-event output. - public async ValueTask ExecuteAsync(IConsole console) - { - var urls = FailoverUrlParser.Parse(Url, FailoverUrls); - using var failover = new OpcUaFailoverHelper(urls, Username, Password, Security); - using var session = await failover.ConnectAsync(); - - var nodeId = string.IsNullOrEmpty(NodeId) - ? ObjectIds.Server - : new NodeId(NodeId); - - var subscription = new Subscription(session.DefaultSubscription) - { - PublishingInterval = Interval, - DisplayName = "CLI Alarm Subscription" - }; - - var item = new MonitoredItem(subscription.DefaultItem) - { - StartNodeId = nodeId, - DisplayName = "AlarmMonitor", - SamplingInterval = Interval, - NodeClass = NodeClass.Object, - AttributeId = Attributes.EventNotifier, - Filter = CreateEventFilter() - }; - - item.Notification += (_, e) => - { - if (e.NotificationValue is EventFieldList eventFields) - { - PrintAlarmEvent(eventFields); - } - }; - - subscription.AddItem(item); - session.AddSubscription(subscription); - await subscription.CreateAsync(); - - Console.WriteLine($"Subscribed to alarm events on {nodeId} (interval: {Interval}ms). Press Ctrl+C to stop."); - Console.Out.Flush(); - - if (Refresh) - { - try - { - await subscription.ConditionRefreshAsync(); - Console.WriteLine("Condition refresh requested."); - } - catch (Exception ex) - { - Console.WriteLine($"Condition refresh not supported: {ex.Message}"); - } - Console.Out.Flush(); - } - - var ct = console.RegisterCancellationHandler(); - - while (!ct.IsCancellationRequested) - { - await Task.Delay(2000, ct).ContinueWith(_ => { }); - } - - await console.Output.WriteLineAsync("Unsubscribed."); - } - - private static EventFilter CreateEventFilter() - { - var filter = new EventFilter(); - // 0: EventId - filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.EventId); - // 1: EventType - filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.EventType); - // 2: SourceName - filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.SourceName); - // 3: Time - filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.Time); - // 4: Message - filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.Message); - // 5: Severity - filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.Severity); - // 6: ConditionName - filter.AddSelectClause(ObjectTypeIds.ConditionType, BrowseNames.ConditionName); - // 7: Retain - filter.AddSelectClause(ObjectTypeIds.ConditionType, BrowseNames.Retain); - // 8: AckedState/Id - filter.AddSelectClause(ObjectTypeIds.AcknowledgeableConditionType, "AckedState/Id"); - // 9: ActiveState/Id - filter.AddSelectClause(ObjectTypeIds.AlarmConditionType, "ActiveState/Id"); - // 10: EnabledState/Id - filter.AddSelectClause(ObjectTypeIds.AlarmConditionType, "EnabledState/Id"); - // 11: SuppressedOrShelved - filter.AddSelectClause(ObjectTypeIds.AlarmConditionType, "SuppressedOrShelved"); - return filter; - } - - private static void PrintAlarmEvent(EventFieldList eventFields) - { - var fields = eventFields.EventFields; - if (fields == null || fields.Count < 6) - return; - - var time = fields.Count > 3 ? fields[3].Value as DateTime? : null; - var sourceName = fields.Count > 2 ? fields[2].Value as string : null; - var message = fields.Count > 4 ? (fields[4].Value as LocalizedText)?.Text : null; - var severity = fields.Count > 5 ? fields[5].Value : null; - var conditionName = fields.Count > 6 ? fields[6].Value as string : null; - var retain = fields.Count > 7 ? fields[7].Value as bool? : null; - var ackedState = fields.Count > 8 ? fields[8].Value as bool? : null; - var activeState = fields.Count > 9 ? fields[9].Value as bool? : null; - var enabledState = fields.Count > 10 ? fields[10].Value as bool? : null; - var suppressed = fields.Count > 11 ? fields[11].Value as bool? : null; - - Console.WriteLine($"[{time:O}] ALARM {sourceName}"); - - if (conditionName != null) - Console.WriteLine($" Condition: {conditionName}"); - - if (activeState.HasValue || ackedState.HasValue) - { - var state = FormatAlarmState(activeState, ackedState); - Console.WriteLine($" State: {state}"); - } - - if (enabledState.HasValue) - Console.WriteLine($" Enabled: {enabledState.Value}"); - - Console.WriteLine($" Severity: {severity}"); - - if (!string.IsNullOrEmpty(message)) - Console.WriteLine($" Message: {message}"); - - if (retain.HasValue) - Console.WriteLine($" Retain: {retain.Value}"); - - if (suppressed == true) - Console.WriteLine($" Suppressed/Shelved: True"); - - Console.WriteLine(); - } - - private static string FormatAlarmState(bool? active, bool? acked) - { - var activePart = active == true ? "Active" : "Inactive"; - var ackedPart = acked == true ? "Acknowledged" : "Unacknowledged"; - return $"{activePart}, {ackedPart}"; - } -} diff --git a/tools/opcuacli-dotnet/Commands/BrowseCommand.cs b/tools/opcuacli-dotnet/Commands/BrowseCommand.cs deleted file mode 100644 index 46dc2f9..0000000 --- a/tools/opcuacli-dotnet/Commands/BrowseCommand.cs +++ /dev/null @@ -1,122 +0,0 @@ -using CliFx; -using CliFx.Attributes; -using CliFx.Infrastructure; -using Opc.Ua; -using Opc.Ua.Client; - -namespace OpcUaCli.Commands; - -[Command("browse", Description = "Browse the OPC UA address space")] -public class BrowseCommand : ICommand -{ - /// - /// Gets the OPC UA endpoint URL to connect to before browsing. - /// - [CommandOption("url", 'u', Description = "OPC UA server endpoint URL", IsRequired = true)] - public string Url { get; init; } = default!; - - [CommandOption("username", 'U', Description = "Username for authentication")] - public string? Username { get; init; } - - [CommandOption("password", 'P', Description = "Password for authentication")] - public string? Password { get; init; } - - [CommandOption("security", 'S', Description = "Transport security: none, sign, encrypt (default: none)")] - public string Security { get; init; } = "none"; - - [CommandOption("failover-urls", 'F', Description = "Comma-separated failover endpoint URLs for redundancy")] - public string? FailoverUrls { get; init; } - - /// - /// Gets the optional node identifier to browse from; defaults to the OPC UA Objects folder. - /// - [CommandOption("node", 'n', Description = "Node ID to browse (default: Objects folder)")] - public string? NodeId { get; init; } - - /// - /// Gets the maximum browse depth when recursive traversal is enabled. - /// - [CommandOption("depth", 'd', Description = "Maximum browse depth")] - public int Depth { get; init; } = 1; - - /// - /// Gets a value indicating whether browse recursion should continue into child objects. - /// - [CommandOption("recursive", 'r', Description = "Browse recursively (uses --depth as max depth)")] - public bool Recursive { get; init; } - - /// - /// Connects to the OPC UA endpoint and writes the browse tree to the console. - /// - /// The console used to emit browse output. - public async ValueTask ExecuteAsync(IConsole console) - { - var urls = FailoverUrlParser.Parse(Url, FailoverUrls); - using var failover = new OpcUaFailoverHelper(urls, Username, Password, Security); - using var session = await failover.ConnectAsync(); - - var startNode = string.IsNullOrEmpty(NodeId) - ? ObjectIds.ObjectsFolder - : new NodeId(NodeId); - - var maxDepth = Recursive ? Depth : 1; - await BrowseNodeAsync(session, console, startNode, maxDepth, 0); - } - - private static async Task BrowseNodeAsync( - ISession session, IConsole console, NodeId nodeId, int maxDepth, int currentDepth) - { - var indent = new string(' ', currentDepth * 2); - - var (_, continuationPoint, references) = await session.BrowseAsync( - null, - null, - nodeId, - 0u, - BrowseDirection.Forward, - ReferenceTypeIds.HierarchicalReferences, - true, - (uint)NodeClass.Object | (uint)NodeClass.Variable | (uint)NodeClass.Method); - - if (references == null) return; - - // Handle continuation points for large result sets - while (references.Count > 0) - { - foreach (var reference in references) - { - var nodeClass = reference.NodeClass; - var marker = nodeClass switch - { - NodeClass.Object => "[Object]", - NodeClass.Variable => "[Variable]", - NodeClass.Method => "[Method]", - _ => $"[{nodeClass}]" - }; - - await console.Output.WriteLineAsync( - $"{indent}{marker} {reference.DisplayName} (NodeId: {reference.NodeId})"); - - if (currentDepth + 1 < maxDepth && nodeClass == NodeClass.Object) - { - var childNodeId = ExpandedNodeId.ToNodeId( - reference.NodeId, session.NamespaceUris); - await BrowseNodeAsync(session, console, childNodeId, maxDepth, currentDepth + 1); - } - } - - // Follow continuation point if present - if (continuationPoint != null && continuationPoint.Length > 0) - { - var (_, nextCp, nextRefs) = await session.BrowseNextAsync( - null, false, continuationPoint); - continuationPoint = nextCp; - references = nextRefs; - } - else - { - break; - } - } - } -} diff --git a/tools/opcuacli-dotnet/Commands/ConnectCommand.cs b/tools/opcuacli-dotnet/Commands/ConnectCommand.cs deleted file mode 100644 index fcc73c2..0000000 --- a/tools/opcuacli-dotnet/Commands/ConnectCommand.cs +++ /dev/null @@ -1,43 +0,0 @@ -using CliFx; -using CliFx.Attributes; -using CliFx.Infrastructure; - -namespace OpcUaCli.Commands; - -[Command("connect", Description = "Test connection to an OPC UA server")] -public class ConnectCommand : ICommand -{ - /// - /// Gets the OPC UA endpoint URL to test. - /// - [CommandOption("url", 'u', Description = "OPC UA server endpoint URL", IsRequired = true)] - public string Url { get; init; } = default!; - - [CommandOption("username", 'U', Description = "Username for authentication")] - public string? Username { get; init; } - - [CommandOption("password", 'P', Description = "Password for authentication")] - public string? Password { get; init; } - - [CommandOption("security", 'S', Description = "Transport security: none, sign, encrypt (default: none)")] - public string Security { get; init; } = "none"; - - [CommandOption("failover-urls", 'F', Description = "Comma-separated failover endpoint URLs for redundancy")] - public string? FailoverUrls { get; init; } - - /// - /// Connects to the OPC UA endpoint and prints the resolved server metadata. - /// - /// The console used to report connection results. - public async ValueTask ExecuteAsync(IConsole console) - { - var urls = FailoverUrlParser.Parse(Url, FailoverUrls); - using var failover = new OpcUaFailoverHelper(urls, Username, Password, Security); - using var session = await failover.ConnectAsync(); - await console.Output.WriteLineAsync($"Connected to: {session.Endpoint.EndpointUrl}"); - await console.Output.WriteLineAsync($"Server: {session.Endpoint.Server!.ApplicationName}"); - await console.Output.WriteLineAsync($"Security Mode: {session.Endpoint.SecurityMode}"); - await console.Output.WriteLineAsync($"Security Policy: {session.Endpoint.SecurityPolicyUri}"); - await console.Output.WriteLineAsync("Connection successful."); - } -} diff --git a/tools/opcuacli-dotnet/Commands/HistoryReadCommand.cs b/tools/opcuacli-dotnet/Commands/HistoryReadCommand.cs deleted file mode 100644 index 694c548..0000000 --- a/tools/opcuacli-dotnet/Commands/HistoryReadCommand.cs +++ /dev/null @@ -1,243 +0,0 @@ -using CliFx; -using CliFx.Attributes; -using CliFx.Infrastructure; -using Opc.Ua; -using Opc.Ua.Client; - -namespace OpcUaCli.Commands; - -[Command("historyread", Description = "Read historical data from a node")] -public class HistoryReadCommand : ICommand -{ - /// - /// Gets the OPC UA endpoint URL for the server that exposes the historized node. - /// - [CommandOption("url", 'u', Description = "OPC UA server endpoint URL", IsRequired = true)] - public string Url { get; init; } = default!; - - [CommandOption("username", 'U', Description = "Username for authentication")] - public string? Username { get; init; } - - [CommandOption("password", 'P', Description = "Password for authentication")] - public string? Password { get; init; } - - [CommandOption("security", 'S', Description = "Transport security: none, sign, encrypt (default: none)")] - public string Security { get; init; } = "none"; - - [CommandOption("failover-urls", 'F', Description = "Comma-separated failover endpoint URLs for redundancy")] - public string? FailoverUrls { get; init; } - - /// - /// Gets the node identifier for the historized variable to query. - /// - [CommandOption("node", 'n', Description = "Node ID (e.g. ns=1;s=TestMachine_001.TestHistoryValue)", IsRequired = true)] - public string NodeId { get; init; } = default!; - - /// - /// Gets the requested history start time string supplied by the operator. - /// - [CommandOption("start", Description = "Start time (ISO 8601 or date string, default: 24 hours ago)")] - public string? StartTime { get; init; } - - /// - /// Gets the requested history end time string supplied by the operator. - /// - [CommandOption("end", Description = "End time (ISO 8601 or date string, default: now)")] - public string? EndTime { get; init; } - - /// - /// Gets the maximum number of raw history values that should be returned to the console. - /// - [CommandOption("max", Description = "Maximum number of values to return")] - public int MaxValues { get; init; } = 1000; - - /// - /// Gets the optional aggregate name to request when the operator wants processed history instead of raw values. - /// - [CommandOption("aggregate", Description = "Aggregate function: Average, Minimum, Maximum, Count")] - public string? Aggregate { get; init; } - - /// - /// Gets the aggregate processing interval, in milliseconds, for processed history reads. - /// - [CommandOption("interval", Description = "Processing interval in milliseconds for aggregates")] - public double IntervalMs { get; init; } = 3600000; - - /// - /// Connects to the target server and prints raw or aggregate historical data for the requested node. - /// - /// The CLI console used for output, errors, and cancellation handling. - public async ValueTask ExecuteAsync(IConsole console) - { - var urls = FailoverUrlParser.Parse(Url, FailoverUrls); - using var failover = new OpcUaFailoverHelper(urls, Username, Password, Security); - using var session = await failover.ConnectAsync(); - - var nodeId = new NodeId(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(); - - if (string.IsNullOrEmpty(Aggregate)) - { - await ReadRawAsync(session, console, nodeId, start, end); - } - else - { - await ReadProcessedAsync(session, console, nodeId, start, end); - } - } - - private async Task ReadRawAsync(Session session, IConsole console, NodeId nodeId, DateTime start, DateTime end) - { - var details = new ReadRawModifiedDetails - { - StartTime = start, - EndTime = end, - NumValuesPerNode = (uint)MaxValues, - IsReadModified = false, - ReturnBounds = false - }; - - var nodesToRead = new HistoryReadValueIdCollection - { - new HistoryReadValueId { NodeId = nodeId } - }; - - await console.Output.WriteLineAsync( - $"History for {NodeId} ({start:yyyy-MM-dd HH:mm} → {end:yyyy-MM-dd HH:mm})"); - await console.Output.WriteLineAsync(); - await console.Output.WriteLineAsync($"{"Timestamp",-35} {"Value",-15} {"Status"}"); - - int totalValues = 0; - byte[]? continuationPoint = null; - - do - { - if (continuationPoint != null) - nodesToRead[0].ContinuationPoint = continuationPoint; - - session.HistoryRead( - null, - new ExtensionObject(details), - TimestampsToReturn.Source, - continuationPoint != null, - nodesToRead, - out var results, - out _); - - if (results == null || results.Count == 0) - break; - - var result = results[0]; - if (StatusCode.IsBad(result.StatusCode)) - { - await console.Error.WriteLineAsync($"HistoryRead failed: {result.StatusCode}"); - break; - } - - if (result.HistoryData == null) - { - await console.Error.WriteLineAsync($"No history data returned (status: {result.StatusCode})"); - break; - } - - if (result.HistoryData is ExtensionObject ext && ext.Body is HistoryData historyData) - { - foreach (var dv in historyData.DataValues) - { - 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}"); - totalValues++; - } - } - - continuationPoint = result.ContinuationPoint; - } - while (continuationPoint != null && continuationPoint.Length > 0 && totalValues < MaxValues); - - await console.Output.WriteLineAsync(); - await console.Output.WriteLineAsync($"{totalValues} values returned."); - } - - private async Task ReadProcessedAsync(Session session, IConsole console, NodeId nodeId, DateTime start, DateTime end) - { - var aggregateId = MapAggregateName(Aggregate!); - if (aggregateId == null) - { - await console.Error.WriteLineAsync($"Unknown aggregate: {Aggregate}. Supported: Average, Minimum, Maximum, Count, Start, End"); - return; - } - - var details = new ReadProcessedDetails - { - StartTime = start, - EndTime = end, - ProcessingInterval = IntervalMs, - AggregateType = new NodeIdCollection { aggregateId } - }; - - var nodesToRead = new HistoryReadValueIdCollection - { - new HistoryReadValueId { NodeId = nodeId } - }; - - session.HistoryRead( - null, - new ExtensionObject(details), - TimestampsToReturn.Source, - false, - nodesToRead, - out var results, - out _); - - await console.Output.WriteLineAsync( - $"History for {NodeId} ({Aggregate}, interval={IntervalMs}ms)"); - await console.Output.WriteLineAsync(); - await console.Output.WriteLineAsync($"{"Timestamp",-35} {"Value",-15} {"Status"}"); - - int totalValues = 0; - - if (results != null && results.Count > 0) - { - var result = results[0]; - if (StatusCode.IsBad(result.StatusCode)) - { - await console.Error.WriteLineAsync($"HistoryRead failed: {result.StatusCode}"); - return; - } - - if (result.HistoryData is ExtensionObject ext && ext.Body is HistoryData historyData) - { - foreach (var dv in historyData.DataValues) - { - 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}"); - totalValues++; - } - } - } - - await console.Output.WriteLineAsync(); - await console.Output.WriteLineAsync($"{totalValues} values returned."); - } - - private static NodeId? MapAggregateName(string name) - { - return name.ToLowerInvariant() switch - { - "average" => ObjectIds.AggregateFunction_Average, - "minimum" or "min" => ObjectIds.AggregateFunction_Minimum, - "maximum" or "max" => ObjectIds.AggregateFunction_Maximum, - "count" => ObjectIds.AggregateFunction_Count, - "start" or "first" => ObjectIds.AggregateFunction_Start, - "end" or "last" => ObjectIds.AggregateFunction_End, - _ => null - }; - } -} diff --git a/tools/opcuacli-dotnet/Commands/ReadCommand.cs b/tools/opcuacli-dotnet/Commands/ReadCommand.cs deleted file mode 100644 index 67e078f..0000000 --- a/tools/opcuacli-dotnet/Commands/ReadCommand.cs +++ /dev/null @@ -1,55 +0,0 @@ -using CliFx; -using CliFx.Attributes; -using CliFx.Infrastructure; -using Opc.Ua; -using Opc.Ua.Client; - -namespace OpcUaCli.Commands; - -[Command("read", Description = "Read a value from a node")] -public class ReadCommand : ICommand -{ - /// - /// Gets the OPC UA endpoint URL to connect to before reading. - /// - [CommandOption("url", 'u', Description = "OPC UA server endpoint URL", IsRequired = true)] - public string Url { get; init; } = default!; - - [CommandOption("username", 'U', Description = "Username for authentication")] - public string? Username { get; init; } - - [CommandOption("password", 'P', Description = "Password for authentication")] - public string? Password { get; init; } - - [CommandOption("security", 'S', Description = "Transport security: none, sign, encrypt (default: none)")] - public string Security { get; init; } = "none"; - - [CommandOption("failover-urls", 'F', Description = "Comma-separated failover endpoint URLs for redundancy")] - public string? FailoverUrls { get; init; } - - /// - /// Gets the node identifier whose value should be read. - /// - [CommandOption("node", 'n', Description = "Node ID (e.g. ns=2;s=MyNode)", IsRequired = true)] - public string NodeId { get; init; } = default!; - - /// - /// Connects to the endpoint, reads the target node, and prints the returned value details. - /// - /// The console used to report the read result. - public async ValueTask ExecuteAsync(IConsole console) - { - var urls = FailoverUrlParser.Parse(Url, FailoverUrls); - using var failover = new OpcUaFailoverHelper(urls, Username, Password, Security); - using var session = await failover.ConnectAsync(); - - var node = new NodeId(NodeId); - var value = await session.ReadValueAsync(node); - - 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}"); - } -} diff --git a/tools/opcuacli-dotnet/Commands/RedundancyCommand.cs b/tools/opcuacli-dotnet/Commands/RedundancyCommand.cs deleted file mode 100644 index 5a9646d..0000000 --- a/tools/opcuacli-dotnet/Commands/RedundancyCommand.cs +++ /dev/null @@ -1,75 +0,0 @@ -using CliFx; -using CliFx.Attributes; -using CliFx.Infrastructure; -using Opc.Ua; -using Opc.Ua.Client; - -namespace OpcUaCli.Commands; - -[Command("redundancy", Description = "Read redundancy state from an OPC UA server")] -public class RedundancyCommand : ICommand -{ - [CommandOption("url", 'u', Description = "OPC UA server endpoint URL", IsRequired = true)] - public string Url { get; init; } = default!; - - [CommandOption("username", 'U', Description = "Username for authentication")] - public string? Username { get; init; } - - [CommandOption("password", 'P', Description = "Password for authentication")] - public string? Password { get; init; } - - [CommandOption("security", 'S', Description = "Transport security: none, sign, encrypt (default: none)")] - public string Security { get; init; } = "none"; - - [CommandOption("failover-urls", 'F', Description = "Comma-separated failover endpoint URLs for redundancy")] - public string? FailoverUrls { get; init; } - - public async ValueTask ExecuteAsync(IConsole console) - { - var urls = FailoverUrlParser.Parse(Url, FailoverUrls); - using var failover = new OpcUaFailoverHelper(urls, Username, Password, Security); - using var session = await failover.ConnectAsync(); - - // Read RedundancySupport - var redundancySupportValue = await session.ReadValueAsync(VariableIds.Server_ServerRedundancy_RedundancySupport); - var redundancyMode = (RedundancySupport)(int)redundancySupportValue.Value; - await console.Output.WriteLineAsync($"Redundancy Mode: {redundancyMode}"); - - // Read ServiceLevel - var serviceLevelValue = await session.ReadValueAsync(VariableIds.Server_ServiceLevel); - var serviceLevel = (byte)serviceLevelValue.Value; - await console.Output.WriteLineAsync($"Service Level: {serviceLevel}"); - - // Read ServerUriArray (only present for non-transparent redundancy) - try - { - var serverUriArrayValue = await session.ReadValueAsync(VariableIds.Server_ServerRedundancy_ServerUriArray); - if (serverUriArrayValue.Value is string[] uris && uris.Length > 0) - { - await console.Output.WriteLineAsync("Server URIs:"); - foreach (var uri in uris) - { - await console.Output.WriteLineAsync($" - {uri}"); - } - } - } - catch - { - // ServerUriArray may not be present when RedundancySupport is None - } - - // Read ServerArray for the local server's ApplicationUri - try - { - var serverArrayValue = await session.ReadValueAsync(VariableIds.Server_ServerArray); - if (serverArrayValue.Value is string[] serverArray && serverArray.Length > 0) - { - await console.Output.WriteLineAsync($"Application URI: {serverArray[0]}"); - } - } - catch - { - // Informational only - } - } -} diff --git a/tools/opcuacli-dotnet/Commands/SubscribeCommand.cs b/tools/opcuacli-dotnet/Commands/SubscribeCommand.cs deleted file mode 100644 index 7dedc83..0000000 --- a/tools/opcuacli-dotnet/Commands/SubscribeCommand.cs +++ /dev/null @@ -1,170 +0,0 @@ -using CliFx; -using CliFx.Attributes; -using CliFx.Infrastructure; -using Opc.Ua; -using Opc.Ua.Client; - -namespace OpcUaCli.Commands; - -[Command("subscribe", Description = "Monitor a node for value changes")] -public class SubscribeCommand : ICommand -{ - [CommandOption("url", 'u', Description = "OPC UA server endpoint URL", IsRequired = true)] - public string Url { get; init; } = default!; - - [CommandOption("username", 'U', Description = "Username for authentication")] - public string? Username { get; init; } - - [CommandOption("password", 'P', Description = "Password for authentication")] - public string? Password { get; init; } - - [CommandOption("security", 'S', Description = "Transport security: none, sign, encrypt (default: none)")] - public string Security { get; init; } = "none"; - - [CommandOption("failover-urls", 'F', Description = "Comma-separated failover endpoint URLs for redundancy")] - public string? FailoverUrls { get; init; } - - [CommandOption("node", 'n', Description = "Node ID to monitor", IsRequired = true)] - public string NodeId { get; init; } = default!; - - [CommandOption("interval", 'i', Description = "Polling interval in milliseconds")] - public int Interval { get; init; } = 1000; - - public async ValueTask ExecuteAsync(IConsole console) - { - var urls = FailoverUrlParser.Parse(Url, FailoverUrls); - var hasFailover = urls.Length > 1; - - if (hasFailover) - { - await RunWithFailoverAsync(console, urls); - } - else - { - await RunSimpleAsync(console); - } - } - - private async Task RunSimpleAsync(IConsole console) - { - using var session = await OpcUaHelper.ConnectAsync(Url, Username, Password, Security); - var (subscription, item) = await CreateSubscriptionAsync(session); - await console.Output.WriteLineAsync($"Subscribed to {NodeId} (interval: {Interval}ms). Press Ctrl+C to stop."); - - var ct = console.RegisterCancellationHandler(); - await MonitorLoopAsync(session, subscription, item, ct); - await console.Output.WriteLineAsync("Unsubscribed."); - } - - private async Task RunWithFailoverAsync(IConsole console, string[] urls) - { - using var failover = new OpcUaFailoverHelper(urls, Username, Password, Security); - var session = await failover.ConnectAsync(); - - Subscription? subscription = null; - MonitoredItem? item = null; - var subLock = new object(); - - (subscription, item) = await CreateSubscriptionAsync(session); - await console.Output.WriteLineAsync( - $"Subscribed to {NodeId} (interval: {Interval}ms, failover enabled). Press Ctrl+C to stop."); - - // Install failover handler - failover.OnFailover += (oldUrl, newUrl) => - { - Console.WriteLine($" [failover] Switched from {oldUrl} to {newUrl}"); - }; - - failover.InstallKeepAliveHandler(async newSession => - { - try - { - var (newSub, newItem) = await CreateSubscriptionAsync(newSession); - lock (subLock) - { - subscription = newSub; - item = newItem; - } - Console.WriteLine($" [failover] Re-subscribed to {NodeId} on {failover.CurrentEndpointUrl}"); - } - catch (Exception ex) - { - Console.WriteLine($" [failover] Failed to re-subscribe: {ex.Message}"); - } - }); - - var ct = console.RegisterCancellationHandler(); - - int tick = 0; - while (!ct.IsCancellationRequested) - { - await Task.Delay(2000, ct).ContinueWith(_ => { }); - tick++; - - Session? currentSession; - MonitoredItem? currentItem; - Subscription? currentSub; - lock (subLock) - { - currentSession = failover.Session; - currentSub = subscription; - currentItem = item; - } - - Console.WriteLine( - $" [tick {tick}] Server={failover.CurrentEndpointUrl}, Connected={currentSession?.Connected}, " + - $"Sub.Id={currentSub?.Id}, " + - $"LastValue={((currentItem?.LastValue as MonitoredItemNotification)?.Value?.Value)} " + - $"({((currentItem?.LastValue as MonitoredItemNotification)?.Value?.StatusCode)})"); - } - - await console.Output.WriteLineAsync("Unsubscribed."); - } - - private async Task<(Subscription, MonitoredItem)> CreateSubscriptionAsync(Session session) - { - var subscription = new Subscription(session.DefaultSubscription) - { - PublishingInterval = Interval, - DisplayName = "CLI Subscription" - }; - - var item = new MonitoredItem(subscription.DefaultItem) - { - StartNodeId = new NodeId(NodeId), - DisplayName = NodeId, - SamplingInterval = Interval - }; - - item.Notification += (_, e) => - { - if (e.NotificationValue is MonitoredItemNotification notification) - { - Console.WriteLine( - $"[{notification.Value.SourceTimestamp:O}] {NodeId} = {notification.Value.Value} ({notification.Value.StatusCode})"); - } - }; - - subscription.AddItem(item); - session.AddSubscription(subscription); - await subscription.CreateAsync(); - - return (subscription, item); - } - - private static async Task MonitorLoopAsync(Session session, Subscription subscription, MonitoredItem item, CancellationToken ct) - { - int tick = 0; - while (!ct.IsCancellationRequested) - { - await Task.Delay(2000, ct).ContinueWith(_ => { }); - tick++; - Console.WriteLine( - $" [tick {tick}] Session={session.Connected}, Sub.Id={subscription.Id}, " + - $"PublishingEnabled={subscription.PublishingEnabled}, " + - $"MonitoredItemCount={subscription.MonitoredItemCount}, " + - $"ItemStatus={item.Status?.Id}, " + - $"LastNotification={((item.LastValue as MonitoredItemNotification)?.Value?.Value)} ({((item.LastValue as MonitoredItemNotification)?.Value?.StatusCode)})"); - } - } -} diff --git a/tools/opcuacli-dotnet/Commands/WriteCommand.cs b/tools/opcuacli-dotnet/Commands/WriteCommand.cs deleted file mode 100644 index 11014ae..0000000 --- a/tools/opcuacli-dotnet/Commands/WriteCommand.cs +++ /dev/null @@ -1,71 +0,0 @@ -using CliFx; -using CliFx.Attributes; -using CliFx.Infrastructure; -using Opc.Ua; -using Opc.Ua.Client; - -namespace OpcUaCli.Commands; - -[Command("write", Description = "Write a value to a node")] -public class WriteCommand : ICommand -{ - /// - /// Gets the OPC UA endpoint URL to connect to before issuing the write. - /// - [CommandOption("url", 'u', Description = "OPC UA server endpoint URL", IsRequired = true)] - public string Url { get; init; } = default!; - - [CommandOption("username", 'U', Description = "Username for authentication")] - public string? Username { get; init; } - - [CommandOption("password", 'P', Description = "Password for authentication")] - public string? Password { get; init; } - - [CommandOption("security", 'S', Description = "Transport security: none, sign, encrypt (default: none)")] - public string Security { get; init; } = "none"; - - [CommandOption("failover-urls", 'F', Description = "Comma-separated failover endpoint URLs for redundancy")] - public string? FailoverUrls { get; init; } - - /// - /// Gets the node identifier that should receive the write. - /// - [CommandOption("node", 'n', Description = "Node ID (e.g. ns=2;s=MyNode)", IsRequired = true)] - public string NodeId { get; init; } = default!; - - /// - /// Gets the textual value supplied on the command line before type conversion. - /// - [CommandOption("value", 'v', Description = "Value to write", IsRequired = true)] - public string Value { get; init; } = default!; - - /// - /// Connects to the OPC UA endpoint, converts the supplied value, and writes it to the target node. - /// - /// The console used to report the write result. - public async ValueTask ExecuteAsync(IConsole console) - { - var urls = FailoverUrlParser.Parse(Url, FailoverUrls); - using var failover = new OpcUaFailoverHelper(urls, Username, Password, Security); - using var session = await failover.ConnectAsync(); - - var node = new NodeId(NodeId); - var current = await session.ReadValueAsync(node); - var typedValue = OpcUaHelper.ConvertValue(Value, current.Value); - - var writeValue = new WriteValue - { - NodeId = node, - AttributeId = Attributes.Value, - Value = new DataValue(new Variant(typedValue)) - }; - - var request = new WriteValueCollection { writeValue }; - var response = await session.WriteAsync(null, request, CancellationToken.None); - - if (StatusCode.IsGood(response.Results[0])) - await console.Output.WriteLineAsync($"Write successful: {NodeId} = {typedValue}"); - else - await console.Output.WriteLineAsync($"Write failed: {response.Results[0]}"); - } -} diff --git a/tools/opcuacli-dotnet/OpcUaFailoverHelper.cs b/tools/opcuacli-dotnet/OpcUaFailoverHelper.cs deleted file mode 100644 index e0ac17c..0000000 --- a/tools/opcuacli-dotnet/OpcUaFailoverHelper.cs +++ /dev/null @@ -1,156 +0,0 @@ -using Opc.Ua; -using Opc.Ua.Client; - -namespace OpcUaCli; - -/// -/// Manages OPC UA client sessions with automatic failover across a set of redundant server endpoints. -/// -public sealed class OpcUaFailoverHelper : IDisposable -{ - private readonly string[] _endpointUrls; - private readonly string? _username; - private readonly string? _password; - private readonly string _security; - private Session? _session; - private int _currentIndex; - private bool _disposed; - - /// - /// Gets the active session, or null if not connected. - /// - public Session? Session => _session; - - /// - /// Gets the endpoint URL the session is currently connected to. - /// - public string? CurrentEndpointUrl => _currentIndex < _endpointUrls.Length ? _endpointUrls[_currentIndex] : null; - - /// - /// Raised when a failover occurs, providing the old and new endpoint URLs. - /// - public event Action? OnFailover; - - public OpcUaFailoverHelper(string[] endpointUrls, string? username = null, string? password = null, string security = "none") - { - if (endpointUrls.Length == 0) - throw new ArgumentException("At least one endpoint URL is required."); - _endpointUrls = endpointUrls; - _username = username; - _password = password; - _security = security; - _currentIndex = 0; - } - - /// - /// Connects to the first reachable server in the endpoint list. - /// - public async Task ConnectAsync() - { - for (int attempt = 0; attempt < _endpointUrls.Length; attempt++) - { - var idx = (_currentIndex + attempt) % _endpointUrls.Length; - var url = _endpointUrls[idx]; - try - { - Console.WriteLine($" [failover] Connecting to {url}..."); - _session = await OpcUaHelper.ConnectAsync(url, _username, _password, _security); - _currentIndex = idx; - Console.WriteLine($" [failover] Connected to {url}"); - return _session; - } - catch (Exception ex) - { - Console.WriteLine($" [failover] Failed to connect to {url}: {ex.Message}"); - } - } - - throw new InvalidOperationException("All redundant servers are unreachable."); - } - - /// - /// Attempts to fail over to the next available server in the set. - /// Closes the old session if still open. - /// - /// The new session. - public async Task FailoverAsync() - { - var oldUrl = CurrentEndpointUrl; - - // Close old session - if (_session != null) - { - try { _session.Close(); } catch { } - _session.Dispose(); - _session = null; - } - - // Try the next server first, then cycle through all - _currentIndex = (_currentIndex + 1) % _endpointUrls.Length; - - var newSession = await ConnectAsync(); - OnFailover?.Invoke(oldUrl, CurrentEndpointUrl!); - return newSession; - } - - /// - /// Installs a KeepAlive handler that triggers automatic failover when the session drops. - /// Returns a task that completes when the session is lost and failover is needed. - /// - public void InstallKeepAliveHandler(Action onReconnected) - { - if (_session == null) return; - - _session.KeepAlive += async (session, e) => - { - if (e.Status == null || ServiceResult.IsGood(e.Status)) - return; - - Console.WriteLine($" [failover] Session lost (status={e.Status}). Attempting failover..."); - - try - { - var newSession = await FailoverAsync(); - onReconnected(newSession); - } - catch (Exception ex) - { - Console.WriteLine($" [failover] All servers unreachable: {ex.Message}"); - } - }; - } - - public void Dispose() - { - if (_disposed) return; - _disposed = true; - if (_session != null) - { - try { _session.Close(); } catch { } - _session.Dispose(); - } - } -} - -/// -/// Parses the --failover-urls option into an array of endpoint URLs. -/// If failover URLs are provided, the primary URL is prepended to form the full set. -/// If not provided, returns only the primary URL (no failover). -/// -public static class FailoverUrlParser -{ - public static string[] Parse(string primaryUrl, string? failoverUrls) - { - if (string.IsNullOrWhiteSpace(failoverUrls)) - return new[] { primaryUrl }; - - var urls = new List { primaryUrl }; - foreach (var url in failoverUrls.Split(',', StringSplitOptions.RemoveEmptyEntries)) - { - var trimmed = url.Trim(); - if (!string.IsNullOrEmpty(trimmed) && trimmed != primaryUrl) - urls.Add(trimmed); - } - return urls.ToArray(); - } -} diff --git a/tools/opcuacli-dotnet/OpcUaHelper.cs b/tools/opcuacli-dotnet/OpcUaHelper.cs deleted file mode 100644 index 1c24afb..0000000 --- a/tools/opcuacli-dotnet/OpcUaHelper.cs +++ /dev/null @@ -1,188 +0,0 @@ -using Opc.Ua; -using Opc.Ua.Client; -using Opc.Ua.Configuration; - -namespace OpcUaCli; - -public static class OpcUaHelper -{ - /// - /// Creates an OPC UA client session for the specified endpoint URL. - /// - /// The OPC UA endpoint URL to connect to. - /// Optional username for authentication. - /// Optional password for authentication. - /// The requested transport security mode: "none", "sign", or "encrypt". - /// An active OPC UA client session. - public static async Task ConnectAsync(string endpointUrl, string? username = null, string? password = null, - string security = "none") - { - var config = new ApplicationConfiguration - { - ApplicationName = "OpcUaCli", - ApplicationUri = "urn:localhost:OpcUaCli", - ApplicationType = ApplicationType.Client, - SecurityConfiguration = new SecurityConfiguration - { - ApplicationCertificate = new CertificateIdentifier - { - StoreType = CertificateStoreType.Directory, - StorePath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "OpcUaCli", "pki", "own") - }, - TrustedIssuerCertificates = new CertificateTrustList - { - StoreType = CertificateStoreType.Directory, - StorePath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "OpcUaCli", "pki", "issuer") - }, - TrustedPeerCertificates = new CertificateTrustList - { - StoreType = CertificateStoreType.Directory, - StorePath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "OpcUaCli", "pki", "trusted") - }, - RejectedCertificateStore = new CertificateTrustList - { - StoreType = CertificateStoreType.Directory, - StorePath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "OpcUaCli", "pki", "rejected") - }, - AutoAcceptUntrustedCertificates = true - }, - ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60000 } - }; - -#pragma warning disable CS0618 // Sync/obsolete API is fine for a CLI tool - await config.Validate(ApplicationType.Client); - config.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true; - - var requestedMode = ParseSecurityMode(security); - - EndpointDescription endpoint; - if (requestedMode == MessageSecurityMode.None) - { - endpoint = CoreClientUtils.SelectEndpoint(config, endpointUrl, false); - } - else - { - // For secure connections, ensure the client has a certificate - var app = new ApplicationInstance - { - ApplicationName = "OpcUaCli", - ApplicationType = ApplicationType.Client, - ApplicationConfiguration = config - }; - await app.CheckApplicationInstanceCertificatesAsync(false, 2048); - - // Discover endpoints and pick the one matching the requested security mode - endpoint = SelectSecureEndpoint(config, endpointUrl, requestedMode); - } - - var endpointConfig = EndpointConfiguration.Create(config); - var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfig); - - UserIdentity identity = (username != null) - ? new UserIdentity(username, System.Text.Encoding.UTF8.GetBytes(password ?? "")) - : new UserIdentity(); - - var session = await Session.Create( - config, - configuredEndpoint, - false, - "OpcUaCli", - 60000, - identity, - null); - - return session; -#pragma warning restore CS0618 - } - - /// - /// Parses the security mode string from the CLI option. - /// - private static MessageSecurityMode ParseSecurityMode(string security) - { - return (security ?? "none").Trim().ToLowerInvariant() switch - { - "none" => MessageSecurityMode.None, - "sign" => MessageSecurityMode.Sign, - "encrypt" or "signandencrypt" => MessageSecurityMode.SignAndEncrypt, - _ => throw new ArgumentException( - $"Unknown security mode '{security}'. Valid values: none, sign, encrypt") - }; - } - - /// - /// Discovers server endpoints and selects one matching the requested security mode, - /// preferring Basic256Sha256 when multiple matches exist. - /// - private static EndpointDescription SelectSecureEndpoint(ApplicationConfiguration config, - string endpointUrl, MessageSecurityMode requestedMode) - { - // Use discovery to get all endpoints - using var client = DiscoveryClient.Create(new Uri(endpointUrl)); - var allEndpoints = client.GetEndpoints(null); - - EndpointDescription? best = null; - - foreach (var ep in allEndpoints) - { - if (ep.SecurityMode != requestedMode) - continue; - - if (best == null) - { - best = ep; - continue; - } - - // Prefer Basic256Sha256 - if (ep.SecurityPolicyUri == SecurityPolicies.Basic256Sha256) - best = ep; - } - - if (best == null) - { - var available = string.Join(", ", allEndpoints.Select(e => $"{e.SecurityMode}/{e.SecurityPolicyUri}")); - throw new InvalidOperationException( - $"No endpoint found with security mode '{requestedMode}'. Available endpoints: {available}"); - } - - // Rewrite endpoint URL to use the user-supplied hostname instead of the server's - // internal address (e.g., 0.0.0.0 -> localhost) to handle NAT/hostname differences - var serverUri = new Uri(best.EndpointUrl); - var requestedUri = new Uri(endpointUrl); - if (serverUri.Host != requestedUri.Host) - { - var builder = new UriBuilder(best.EndpointUrl) - { - Host = requestedUri.Host - }; - best.EndpointUrl = builder.ToString(); - } - - return best; - } - - /// - /// Converts a raw command-line string into the runtime type expected by the target node. - /// - /// The raw string supplied by the user. - /// The current node value used to infer the target type. - /// A typed value suitable for an OPC UA write request. - public static object ConvertValue(string rawValue, object? currentValue) - { - return currentValue switch - { - bool => bool.Parse(rawValue), - byte => byte.Parse(rawValue), - short => short.Parse(rawValue), - ushort => ushort.Parse(rawValue), - int => int.Parse(rawValue), - uint => uint.Parse(rawValue), - long => long.Parse(rawValue), - ulong => ulong.Parse(rawValue), - float => float.Parse(rawValue), - double => double.Parse(rawValue), - _ => rawValue - }; - } -} diff --git a/tools/opcuacli-dotnet/Program.cs b/tools/opcuacli-dotnet/Program.cs deleted file mode 100644 index ea3c478..0000000 --- a/tools/opcuacli-dotnet/Program.cs +++ /dev/null @@ -1,8 +0,0 @@ -using CliFx; - -return await new CliApplicationBuilder() - .AddCommandsFromThisAssembly() - .SetExecutableName("opcuacli-dotnet") - .SetDescription("OPC UA CLI - command-line tool for testing OPC UA servers") - .Build() - .RunAsync(args); diff --git a/tools/opcuacli-dotnet/README.md b/tools/opcuacli-dotnet/README.md deleted file mode 100644 index b690c3e..0000000 --- a/tools/opcuacli-dotnet/README.md +++ /dev/null @@ -1,183 +0,0 @@ -# OPC UA CLI Tool (.NET) - -Command-line utility for testing OPC UA server functions. Built with the [OPC Foundation UA .NET Standard](https://github.com/OPCFoundation/UA-.NETStandard) client library and [CliFx](https://github.com/Tyrrrz/CliFx). - -- **Runtime**: .NET 10 -- **OPC UA Client**: OPCFoundation.NetStandard.Opc.Ua.Client - -## Authentication - -All commands accept optional authentication flags: - -| Flag | Description | -|------|-------------| -| `-U` | Username for authentication | -| `-P` | Password for authentication | - -Without credentials, the client connects anonymously. Example: - -``` -dotnet run -- write -u opc.tcp://localhost:4840/LmxOpcUa -n "ns=1;s=TestMachine_001.MachineID" -v "Hello" -U operator -P op123 -``` - -## Build & Run - -```bash -cd tools/opcuacli-dotnet -dotnet build -dotnet run -- [options] -``` - -## Commands - -### connect - -Test connection to an OPC UA server: - -``` -dotnet run -- connect -u opc.tcp://localhost:4840 -``` - -| Flag | Description | -|------|-------------| -| `-u` | OPC UA server endpoint URL (required) | - -### read - -Read a value from a node: - -``` -dotnet run -- read -u opc.tcp://localhost:4840 -n "ns=2;s=MyNode" -``` - -| Flag | Description | -|------|-------------| -| `-u` | OPC UA server endpoint URL (required) | -| `-n` | Node ID to read (required) | - -### write - -Write a value to a node (auto-detects the data type from the current value): - -``` -dotnet run -- write -u opc.tcp://localhost:4840 -n "ns=2;s=MyNode" -v 42 -``` - -| Flag | Description | -|------|-------------| -| `-u` | OPC UA server endpoint URL (required) | -| `-n` | Node ID to write to (required) | -| `-v` | Value to write (required) | - -### subscribe - -Monitor a node for value changes: - -``` -dotnet run -- subscribe -u opc.tcp://localhost:4840 -n "ns=2;s=MyNode" -i 500 -``` - -| Flag | Description | -|------|-------------| -| `-u` | OPC UA server endpoint URL (required) | -| `-n` | Node ID to monitor (required) | -| `-i` | Polling interval in milliseconds (default: 1000) | - -### browse - -Browse the OPC UA address space: - -```bash -# Browse top-level Objects folder -dotnet run -- browse -u opc.tcp://localhost:4840 - -# Browse a specific node -dotnet run -- browse -u opc.tcp://localhost:4840 -n "ns=2;s=MyFolder" - -# Browse recursively (depth 3) -dotnet run -- browse -u opc.tcp://localhost:4840 -r -d 3 -``` - -| Flag | Description | -|------|-------------| -| `-u` | OPC UA server endpoint URL (required) | -| `-n` | Node ID to browse (default: Objects folder) | -| `-d` | Maximum browse depth (default: 1) | -| `-r` | Browse recursively using `--depth` as max depth | - -### historyread - -Read historical data from a node: - -``` -dotnet run -- historyread -u opc.tcp://localhost:4840/LmxOpcUa -n "ns=1;s=TestMachine_001.TestHistoryValue" --start "2026-03-25" --end "2026-03-30" -``` - -Read with aggregate (1-hour average): - -``` -dotnet run -- historyread -u opc.tcp://localhost:4840/LmxOpcUa -n "ns=1;s=TestMachine_001.TestHistoryValue" --start "2026-03-25" --end "2026-03-30" --aggregate Average --interval 3600000 -``` - -| Flag | Description | -|------|-------------| -| `-u` | OPC UA server endpoint URL (required) | -| `-n` | Node ID to read history for (required) | -| `--start` | Start time (default: 24 hours ago) | -| `--end` | End time (default: now) | -| `--max` | Maximum number of values (default: 1000) | -| `--aggregate` | Aggregate function: Average, Minimum, Maximum, Count | -| `--interval` | Processing interval in ms for aggregates (default: 3600000) | - -### alarms - -Subscribe to alarm events on a node: - -``` -dotnet run -- alarms -u opc.tcp://localhost:4840/LmxOpcUa -n "ns=1;s=TestMachine_001" -``` - -With condition refresh to get current alarm states: - -``` -dotnet run -- alarms -u opc.tcp://localhost:4840/LmxOpcUa -n "ns=1;s=TestMachine_001" --refresh -``` - -| Flag | Description | -|------|-------------| -| `-u` | OPC UA server endpoint URL (required) | -| `-n` | Node ID to monitor for events (default: Server node) | -| `-i` | Publishing interval in milliseconds (default: 1000) | -| `--refresh` | Request a ConditionRefresh after subscribing | - -## Example: Testing the LmxOpcUa Server - -```bash -cd tools/opcuacli-dotnet - -# Connect to the local OPC UA server -dotnet run -- connect -u opc.tcp://localhost:4840 - -# Browse the address space -dotnet run -- browse -u opc.tcp://localhost:4840 -r -d 3 - -# Read a tag value -dotnet run -- read -u opc.tcp://localhost:4840 -n "ns=2;s=TestMachine_001.SomeAttribute" - -# Subscribe to live updates -dotnet run -- subscribe -u opc.tcp://localhost:4840 -n "ns=2;s=TestMachine_001.SomeAttribute" -i 500 -``` - -## Example: Testing with the OPC PLC Sample Server - -```bash -# Start the sample server (from another terminal) -cd tools/opcsampleserver/publish -dotnet opcplc.dll --pn=50000 --autoaccept --unsecuretransport - -# Browse the sample server -dotnet run -- browse -u opc.tcp://localhost:50000 -r -d 2 - -# Read a sample node -dotnet run -- read -u opc.tcp://localhost:50000 -n "ns=2;s=SlowUInt1" -``` diff --git a/tools/opcuacli-dotnet/opcuacli-dotnet.csproj b/tools/opcuacli-dotnet/opcuacli-dotnet.csproj deleted file mode 100644 index 3bff643..0000000 --- a/tools/opcuacli-dotnet/opcuacli-dotnet.csproj +++ /dev/null @@ -1,16 +0,0 @@ - - - - Exe - net10.0 - opcuacli_dotnet - enable - enable - - - - - - - -