mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-18 14:43:08 +08:00
7411654c8d
# Description Completes the OPC UA Part 11 Historical Access work requested by #4387 and the historian framework integration requested by #4400. The conformance/evidence catalog covers all 37 released UACore 1.05 Historical Access profiles: 15 Server facets and 22 Client facets, together with the standard aggregate functions and their conformance units. Server facets are advertised only when the resolved providers expose their required interfaces and capabilities. ## Server and provider support - Completes raw, modified, at-time, processed, annotation, structured, and event history. - Dispatches best-effort, bulk, and transactional updates with aligned statuses, old values, diagnostics, and rollback state. - Supports mixed history-update detail types and batched structured/annotation operations. - Uses composite identities and exclusive cursors for same-timestamp structured, modified, and event values. - Captures live data and events, installs historical configuration objects, and emits typed audit payloads. - Derives aggregate monitoring filters from per-node historian capabilities and rejects unsupported processed aggregates before provider dispatch. - Supports tri-state `Historizing`: set, clear, or preserve provider-owned state. ## Client support - Exposes modified values together with `ModificationInfo`. - Adds event-history reads and event insert/replace/update/delete operations. - Adds structured and annotation batching with strict response validation. - Shares continuation handling and release-on-abandon across read families. - Accepts inherited standard event fields rooted at event subtypes. ## Review hardening - Registers DI-selected historians before node-manager reconciliation and capability publication, while preserving provider ownership. - Uses committed Raft read barriers for linearizable `TryGetAsync` and `ScanAsync`. - Keeps claimed continuation state immutable until a fresh successor is saved, restoring the original after transient failures. - Uses exact-incarnation compare-and-delete so delayed cleanup cannot remove a restored same-ID continuation. - Cleans up indeterminate initial and replacement saves, and propagates shutdown cancellation through cleanup resolution. - Preserves legacy continuation envelopes/cursors and distinct annotation parent/property identities. - Deletes every structured entry at a requested timestamp and uses the complete modified-history ordering tuple. - Shares native/fallback at-time and AnnotationCount calculations, including reverse endpoints and cross-framework fractional intervals. - Primes modified aggregate items before buffered live delivery and deduplicates history/live overlap using provider-specific identities. - Keeps committed Modify results successful while reporting priming failures through the monitored item's notification queue. - Keeps protected-notification state live-only for queues larger than one. Queue size one retains the newest notification without pinning errors or transferring priority during resizing. Durable restore retains ordinary definitions, last values/errors, and raw queues without recreating priority. - Isolates aggregate calculation, modification preparation, and historical/live handoff in `AggregationFilterHandler`, retaining existing provider/factory wiring and supported past-start behavior. The larger-queue protection policy is unchanged pending the open review discussion. - Uses one `IMonitoringFilter` owner for protocol filters or the aggregation handler, eliminating parallel effective-filter storage and retaining the current definition during preparation. The original client filter stays distinct. - Integrates current master, retaining custom monitored-item creation decisions and per-node history callbacks. ## Redundant-server alignment and samples - Adds a protected shared historian with immutable segments, manifest CAS publication, writer fencing, and restart recovery. - Persists portable continuation state before exposing it to clients and transfers ownership during mirrored-session takeover. - Rejects eventual-consistency and active/active multi-writer historian configurations rather than silently degrading. - ReferenceServer and ConsoleReferenceClient demonstrate discovery, paging, raw/modified/at-time/processed reads, annotation/structured CRUD, event capture, and event CRUD. - RedundantServer and RedundantClient demonstrate active/passive history, continuation recovery after replica termination, and post-promotion writes. - Updates Historical Access, High Availability, profile, migration, and sample documentation. ## Compatibility - New 2.0 collection boundaries use `ArrayOf<T>` and public byte payloads use `ByteString`. - Removes the legacy public virtual `SamplingGroupManager.CreateMonitoredItem` / `ModifyMonitoredItem` entry points and protected creation factory, as agreed in review. Migration guidance points to the monitored-item manager and construction-decision APIs. - Retains established persisted formats and current write layouts. Retired interim notification-state formats (sample v2 / shared v4) are rejected, as agreed in review. - Keeps the historian integration compatible with .NET Framework and NativeAOT. ## Validation ### Latest CI source repair `73c619435` forwards the existing cancellation token at both predefined-node creation calls in the PR-added History event fixture. The merged lifecycle overload exposed these two CA2016 errors, which blocked History jobs, the solution build matrix, and CodeQL on GitHub and Azure. The exact compiler failure was reproduced locally; the repaired fixture passes 4/4 integration cases on net10.0 and 4/4 on net48 with clean scoped diagnostics. No assertions, tests, coverage thresholds, or CI configuration were weakened. The repaired History jobs on Linux/macOS and all four Azure solution builds now pass. The Windows all-TFM GitHub job instead lost its hosted runner's connection; no build log was recoverable, and GitHub recorded a runner-loss annotation rather than a compiler/test diagnostic. One unchanged retry was requested. Remaining checks are monitored with `gh pr checks --watch --fail-fast --interval 30`; CI is not yet reported as green. ### Latest base integration `bd52e5fea` merges upstream `master` at `da7412c42`. The lifecycle conflict retains upstream's shared cancellation-aware registration helper and the historian behavior. The hosted-historian fixture forwards its existing token to the new lifecycle overload. GDS project renames and all three AOT test hosts are retained, with their project references resolving after the merge. | Integrated scope | net10.0 | net48 | | --- | --- | --- | | Server lifecycle, fluent authoring/import, historian, monitored-item and hosting regressions | 732 passed | 732 passed | | NodeState lifecycle regressions | 216 passed | 216 passed | | Automatically merged Raft client fixtures | 28 passed | 28 passed | No selected tests failed or were skipped. The manually resolved files pass scoped formatting/analyzer checks. One CA1861 warning remains in the incoming `NodeSetImportIntegrationTests.cs`; that file is byte-identical to upstream master and was not rewritten or suppressed during integration. ### Latest feedback follow-up `102954eaa` replaces split filter references with the shared `IMonitoringFilter` contract implemented by protocol filters and `AggregationFilterHandler`. Targeted server regressions pass **103/103 on net10.0 and 103/103 on net48**; scoped formatting/analyzers are clean, and the updated XML-documentation inventory has zero gaps across 188 non-generated PR C# files. `93c5b5db8` renames `AggregationFilterHandler`, makes it the owner of the server-revised aggregate filter while preserving the original client filter, and removes the retired interim notification-state readers. Server regressions pass **101/101** and shared-store regressions **64/64**, on both net10.0 and net48, with no failures or skips. Runtime-file formatting/analyzers are clean. `5ccf45630` completes the public/internal XML-documentation sweep. The Roslyn inventory finds zero undocumented eligible declarations across all 186 non-generated PR C# files; the 110-file documentation slice changes only XML comments and spacing. `f17765475` applies the three requested Markdown edits. ### Earlier feedback follow-ups `a53950737` removes single-slot notification pinning and extracts aggregate implementation from MonitoredItem. Targeted sampling, aggregate modification, history/live handoff, queue/lifecycle, restore, and serialization cases pass **101/101 on net10.0 and 101/101 on net48**, without failures or skips. Scoped formatting/analyzers are clean; the focused review found no significant issues. The final formatted monitored-item fixtures also pass 45/45 on net48. The preceding `b21555568` removes the legacy sampling API bridge and durable notification-priority state. Its targeted Server cases passed 97/97 and shared subscription-store cases passed 64/64 on each framework. The broader evidence below remains pinned to its original tested commit, not attributed to these follow-ups. ### Earlier integrated-implementation validation Validated implementation commit: **`3efae597f`**. The following commit, `f6dbefb1c`, only adds two explanatory comment lines for the final standards review; it changes no executable code. | Scope | net10.0 | net48 | | --- | --- | --- | | Server | 5,187 passed; 56 skipped | Affected surface: 2,160 passed; 1 reproduced baseline failure; 56 skipped | | Client | 2,202 passed; 2 skipped | 2,204 passed; 5 skipped | | History integration | 519 passed; 21 skipped | 519 passed; 21 skipped | | Redundancy server | 641 passed | 641 passed | | Redundancy client | 124 passed | 124 passed | | Core shared-store regression suite | 23 passed | 23 passed | All six affected samples passed non-incremental Release/net10.0 builds with zero warnings and errors: ConsoleReferenceClient, ConsoleReferenceServer, RedundantServer, RedundantClient, MinimalBoilerServer, and PumpDeviceIntegrationServer. The published self-contained win-x64 NativeAOT executable is 116,924,928 bytes and has no CLR header or managed runtime dependency. Direct execution passed **HistoryAotTests 3/3** and **RaftAotTests 2/2**. Final standards and specification reviews found no confirmed outstanding high/medium functional gaps or hard standards violations. The remaining constructor-purpose clarification is addressed in `f6dbefb1c`. ### Reproduced baseline exceptions The net48 hosted-startup timeout also reproduces in a pristine worktree at the previous PR head, `24cf1416d`, in `RegisterPostStartRegistriesWiresHistorianAndAliasStoresOnPlainServerAsync`, with the same subsequent certificate-leak assertion. The corresponding renamed case fails before startup in the reviewed branch as well. This is reported separately from the net48 affected-surface results, not counted as a passing full Server suite. The net48 matrix selected the historian, aggregate, monitored-item, fluent, and continuation surfaces and excluded the Hosting namespace. `DurableDataValueQueueVerifyReferenceBatchingAsync` failed at item 1000 in the net48 affected-surface run and passed its single isolated rerun. The same failure at the same assertion reproduces on pristine `849e16acc`; the test, sample queue, queue factory, and batch persistor are unchanged. The original failure remains in the table rather than being erased by the rerun. ### CI source repairs - `4be7eb8f7` keeps diagnostics/configuration managers out of distributed address-space replication. Those managers inherit the source interface, but their live server state and redundancy metadata must remain replica-local. The ownership regression passes on net10.0 and net48, and all nine redundancy sample CI tests pass locally. - `d17f467b3` completes the sample startup repair: core managers are also replica-local; the startup banner no longer advertises readiness before initialization; the producer starts after distributed services; and native Raft startup no longer forces disruptive extra elections. The full sample suite passed 9/9, with initialized `RedundancySupport=Hot`, and focused Raft/address-space regressions passed 8/8 and 3/3. - `81b0b72be` moves the unchanged distributed-historian NativeAOT scenario into a smaller, always-run companion executable to bound Apple's linker input. All 137 AOT tests remain present; the moved test and helpers are unchanged. GitHub and Azure publish/run both native executables, with separate result directories where they share a job. The companion passed actual Windows NativeAOT execution. Linux and Intel macOS CI passed both executables, but this split alone did not fix ARM64 macOS linking. The remaining historian graph is rooted by ReferenceServer startup, not just the relocated test. No test exclusion or speculative linker setting was added to conceal it. - `8a5842913` isolates the independent MCP test graph into an always-run native host. The existing MCP source and assembly leak hooks are linked unchanged, and evaluated compiler inputs prove exactly one owner for every test source: 133 main + 3 MCP + 1 historian = 137 declarations. All three hosts run on every AOT platform. The main Windows native object is 9.35% smaller; both changed publishes had zero diagnostics. MCP passed 3/3; the identical main executable passed 133/133 on one unfiltered followup after two initial endpoint-discovery connection failures, which remain recorded in the local evidence. - The previous Intel macOS job successfully published and executed its native tests, then failed on artifact-upload DNS (`ENOTFOUND`). No upload gate, shared environment setting, or cache configuration was disabled. The first repair round also encountered the unrelated `MetricsAreEmittedForChannelLifetimeAsync` timing failure on macOS Core. Neither that test nor channel-manager code is changed by this PR, and its assertions remain intact. It passed unchanged on the final repair head. On `d17f467b3`, the Linux and macOS redundancy sample jobs pass; the remaining failure was ARM64 Apple's `too many large addends` assertion. The verified upstream relocation-anchor fix (dotnet/runtime#124721, backport dotnet/runtime#132171) is not present in the shipping ILCompiler 10.0.11. An experimental multi-module probe failed and was rejected, not added to CI. On `8a5842913`, [`aot-macos-latest`](https://github.com/OPCFoundation/UA-.NETStandard/actions/runs/34102158680/job/101678934057) successfully publishes and executes all three native hosts on ARM64: 136 main runtime cases, 1 historian case, and 3 MCP cases, with no skips. The previous Apple linker blocker is resolved. Linux NativeAOT also passes. No assertion, test, shared environment setting, cache, or required gate was weakened. The same CI run reports a separate macOS Sessions teardown failure: all 662 executed tests pass, but the global leak assertion detects one undisposed certificate. The MCP partition changes no runtime, Sessions, or test-fixture code. A single unchanged diagnostic rerun is queued once GitHub permits rerunning that job; the failure is not suppressed or counted as green. Other CI checks are still pending. ## Related issues - Fixes #4387 - Fixes #4400 ## Checklist - [ ] I have signed the CLA and read the contributing guide. - [x] I have added regression tests and directly related documentation. - [x] I have retained working reference and redundant-server sample wiring. - [x] Final validation and review evidence is complete, including the baseline exceptions above. - [ ] All required CI checks pass; the fail-fast rerun for `73c619435` is pending. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9dbd9527-d818-47a0-984c-fbb1472d3d6b
890 lines
43 KiB
C#
890 lines
43 KiB
C#
/* ========================================================================
|
|
* Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
|
|
*
|
|
* OPC Foundation MIT License 1.00
|
|
*
|
|
* Permission is hereby granted, free of charge, to any person
|
|
* obtaining a copy of this software and associated documentation
|
|
* files (the "Software"), to deal in the Software without
|
|
* restriction, including without limitation the rights to use,
|
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
* copies of the Software, and to permit persons to whom the
|
|
* Software is furnished to do so, subject to the following
|
|
* conditions:
|
|
*
|
|
* The above copyright notice and this permission notice shall be
|
|
* included in all copies or substantial portions of the Software.
|
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
|
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
|
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
|
* OTHER DEALINGS IN THE SOFTWARE.
|
|
*
|
|
* The complete license agreement can be found here:
|
|
* http://opcfoundation.org/License/MIT/1.00/
|
|
* ======================================================================*/
|
|
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.CommandLine;
|
|
using System.Globalization;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Security.Cryptography.X509Certificates;
|
|
using System.Text;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.Extensions.Logging;
|
|
using Opc.Ua;
|
|
using Opc.Ua.Client;
|
|
using Opc.Ua.Client.ComplexTypes;
|
|
using Opc.Ua.Configuration;
|
|
using Opc.Ua.Identity;
|
|
using Opc.Ua.Security.Certificates;
|
|
|
|
namespace Quickstarts.ConsoleReferenceClient
|
|
{
|
|
/// <summary>
|
|
/// The program.
|
|
/// </summary>
|
|
public static class Program
|
|
{
|
|
/// <summary>
|
|
/// Main entry point.
|
|
/// </summary>
|
|
/// <exception cref="ErrorExitException"></exception>
|
|
public static Task<int> Main(string[] args)
|
|
{
|
|
Console.WriteLine("OPC UA Console Reference Client");
|
|
|
|
Console.WriteLine(
|
|
"OPC UA library: {0} @ {1} -- {2}",
|
|
Utils.GetAssemblyBuildNumber(),
|
|
Utils.GetAssemblyTimestamp().ToString("G", CultureInfo.InvariantCulture),
|
|
Utils.GetAssemblySoftwareVersion()
|
|
);
|
|
|
|
// The application name and config file names
|
|
const string applicationName = "ConsoleReferenceClient";
|
|
const string configSectionName = "Quickstarts.ReferenceClient";
|
|
|
|
// command line options
|
|
var autoAcceptOption = new Option<bool>("--autoaccept", "-a")
|
|
{
|
|
Description = "auto accept certificates (for testing only)"
|
|
};
|
|
var noSecurityOption = new Option<bool>("--nosecurity", "--nsec")
|
|
{
|
|
Description = "select endpoint with security NONE, least secure if unavailable"
|
|
};
|
|
var usernameOption = new Option<string>("--username", "--un")
|
|
{
|
|
Description = "the name of the user identity for the connection"
|
|
};
|
|
var userPasswordOption = new Option<string>("--userpassword", "--up")
|
|
{
|
|
Description = "the password of the user identity for the connection"
|
|
};
|
|
var userCertificateOption = new Option<string>("--usercertificate", "--uc")
|
|
{
|
|
Description = "the thumbprint of the user certificate for the user identity"
|
|
};
|
|
var userCertificatePasswordOption = new Option<string>("--usercertificatepassword", "--ucp")
|
|
{
|
|
Description = "the password of the user certificate for the user identity"
|
|
};
|
|
var consoleOption = new Option<bool>("--console", "-c") { Description = "log to console" };
|
|
var logOption = new Option<bool>("--log", "-l") { Description = "log app output" };
|
|
var fileOption = new Option<bool>("--file", "-f") { Description = "log to file" };
|
|
var passwordOption = new Option<string>("--password", "-p")
|
|
{
|
|
Description = "optional password for private key"
|
|
};
|
|
var renewOption = new Option<bool>("--renew", "-r")
|
|
{
|
|
Description = "renew application certificate"
|
|
};
|
|
var timeoutOption = new Option<int>("--timeout", "-t")
|
|
{
|
|
Description = "timeout in seconds to exit application",
|
|
DefaultValueFactory = _ => Timeout.Infinite
|
|
};
|
|
var logFileOption = new Option<string>("--logfile")
|
|
{
|
|
Description = "custom file name for log output"
|
|
};
|
|
var loadTypesOption = new Option<bool>("--loadtypes", "--lt")
|
|
{
|
|
Description = "Load custom types"
|
|
};
|
|
var managedBrowseAllOption = new Option<bool>("--managedbrowseall", "-m")
|
|
{
|
|
Description = "Browse all references using the MangedBrowseAsync method"
|
|
};
|
|
var browseAllOption = new Option<bool>("--browseall", "-b")
|
|
{
|
|
Description = "Browse all references"
|
|
};
|
|
var exportOption = new Option<bool>("--export", "-e")
|
|
{
|
|
Description = "Export all fetched nodes into Nodeset2 xml per default"
|
|
};
|
|
var exportNamespaceOption = new Option<string[]>("--namespace", "--ns")
|
|
{
|
|
Description =
|
|
"Restrict --export to nodes whose namespace URI is in the supplied list. " +
|
|
"Repeat the option to add more, e.g. --namespace http://opcfoundation.org/UA/Boiler/ " +
|
|
"--namespace urn:other. When omitted, all non-OPC-UA-base namespaces are exported."
|
|
};
|
|
var fetchAllOption = new Option<bool>("--fetchall", "--fa")
|
|
{
|
|
Description = "Fetch all nodes"
|
|
};
|
|
var jsonOption = new Option<bool>("--json", "-j")
|
|
{
|
|
Description = "Output all Values as JSON"
|
|
};
|
|
var verboseOption = new Option<bool>("--verbose", "-v") { Description = "Verbose output" };
|
|
var subscribeOption = new Option<bool>("--subscribe", "-s") { Description = "Subscribe" };
|
|
var testallEndpointsOption = new Option<bool>("--testall", "--ea") { Description = "Test All Endpoints" };
|
|
var reverseConnectOption = new Option<string>("--reverseconnect", "--rc")
|
|
{
|
|
Description = "Connect using the reverse connect endpoint. (e.g. --rc opc.tcp://localhost:65300)"
|
|
};
|
|
var foreverOption = new Option<bool>("--forever")
|
|
{
|
|
Description = "run inner connect/disconnect loop forever"
|
|
};
|
|
var leakChannelsOption = new Option<bool>("--leakchannels")
|
|
{
|
|
Description = "Leave a channel leak open when disconnecting a session."
|
|
};
|
|
var durableSubscriptionOption = new Option<bool>("--durablesubscription", "--ds")
|
|
{
|
|
Description = "SetDurableSubscription example"
|
|
};
|
|
var historianOption = new Option<bool>("--historian")
|
|
{
|
|
Description = "Run the complete OPC UA Historical Access client workflow"
|
|
};
|
|
var serverUrlArgument = new Argument<string>("serverUrl")
|
|
{
|
|
Description = "The OPC UA server URL to connect to",
|
|
DefaultValueFactory = _ => "opc.tcp://localhost:62541/Quickstarts/ReferenceServer"
|
|
};
|
|
|
|
var rootCommand = new RootCommand($"Usage: dotnet {applicationName}.dll [OPTIONS] [serverUrl]")
|
|
{
|
|
autoAcceptOption,
|
|
noSecurityOption,
|
|
usernameOption,
|
|
userPasswordOption,
|
|
userCertificateOption,
|
|
userCertificatePasswordOption,
|
|
consoleOption,
|
|
logOption,
|
|
fileOption,
|
|
passwordOption,
|
|
renewOption,
|
|
timeoutOption,
|
|
logFileOption,
|
|
loadTypesOption,
|
|
managedBrowseAllOption,
|
|
browseAllOption,
|
|
exportOption,
|
|
exportNamespaceOption,
|
|
fetchAllOption,
|
|
jsonOption,
|
|
verboseOption,
|
|
subscribeOption,
|
|
testallEndpointsOption,
|
|
reverseConnectOption,
|
|
foreverOption,
|
|
leakChannelsOption,
|
|
durableSubscriptionOption,
|
|
historianOption,
|
|
serverUrlArgument
|
|
};
|
|
|
|
rootCommand.SetAction(async (parseResult, cancellationToken) =>
|
|
{
|
|
bool autoAccept = parseResult.GetValue(autoAcceptOption);
|
|
bool noSecurity = parseResult.GetValue(noSecurityOption);
|
|
string? username = parseResult.GetValue(usernameOption);
|
|
byte[]? userpassword =
|
|
parseResult.GetValue(userPasswordOption) is string upStr ?
|
|
Encoding.UTF8.GetBytes(upStr) : null;
|
|
string? userCertificateThumbprint =
|
|
parseResult.GetValue(userCertificateOption);
|
|
byte[]? userCertificatePassword =
|
|
parseResult.GetValue(userCertificatePasswordOption) is string ucpStr ?
|
|
Encoding.UTF8.GetBytes(ucpStr) : null;
|
|
bool logConsole = parseResult.GetValue(consoleOption);
|
|
bool appLog = parseResult.GetValue(logOption);
|
|
bool fileLog = parseResult.GetValue(fileOption);
|
|
byte[]? pfxPassword =
|
|
parseResult.GetValue(passwordOption) is string pfxStr ?
|
|
Encoding.UTF8.GetBytes(pfxStr) : null;
|
|
bool renewCertificate = parseResult.GetValue(renewOption);
|
|
int timeout = parseResult.GetValue(timeoutOption);
|
|
if (timeout > 0)
|
|
{
|
|
timeout *= 1000;
|
|
}
|
|
string? logFile = parseResult.GetValue(logFileOption);
|
|
bool loadTypes = parseResult.GetValue(loadTypesOption);
|
|
bool managedbrowseall =
|
|
parseResult.GetValue(managedBrowseAllOption);
|
|
bool browseall = parseResult.GetValue(browseAllOption);
|
|
bool exportNodes = parseResult.GetValue(exportOption);
|
|
string[]? exportNamespacesRaw = parseResult.GetValue(exportNamespaceOption);
|
|
HashSet<string>? exportNamespaces = exportNamespacesRaw is { Length: > 0 }
|
|
? new HashSet<string>(exportNamespacesRaw, StringComparer.OrdinalIgnoreCase)
|
|
: null;
|
|
bool fetchall = parseResult.GetValue(fetchAllOption);
|
|
bool jsonvalues = parseResult.GetValue(jsonOption);
|
|
bool verbose = parseResult.GetValue(verboseOption);
|
|
bool subscribe = parseResult.GetValue(subscribeOption);
|
|
string? reverseConnectUrlString =
|
|
parseResult.GetValue(reverseConnectOption);
|
|
bool forever = parseResult.GetValue(foreverOption);
|
|
bool leakChannels = parseResult.GetValue(leakChannelsOption);
|
|
bool enableDurableSubscriptions =
|
|
parseResult.GetValue(durableSubscriptionOption);
|
|
bool runHistorian = parseResult.GetValue(historianOption);
|
|
// serverUrlArgument has DefaultValueFactory so GetValue returns non-null at runtime.
|
|
var serverUrl = new Uri(parseResult.GetValue(serverUrlArgument)!);
|
|
bool testallEndpoints = parseResult.GetValue(testallEndpointsOption);
|
|
|
|
ReverseConnectManager? reverseConnectManager = null;
|
|
using var telemetry = new ConsoleTelemetry();
|
|
ILogger logger = LoggerUtils.Null.Logger;
|
|
try
|
|
{
|
|
// log console output to logger
|
|
if (logConsole && appLog)
|
|
{
|
|
logger = telemetry.CreateLogger("Main");
|
|
}
|
|
|
|
// Define the UA Client application
|
|
ApplicationInstance.MessageDlg = new ApplicationMessageDlg();
|
|
// Lib param is annotated non-null but the impl tolerates null at runtime.
|
|
var passwordProvider = new CertificatePasswordProvider(pfxPassword!);
|
|
var application = new ApplicationInstance(telemetry)
|
|
{
|
|
ApplicationName = applicationName,
|
|
ApplicationType = ApplicationType.Client,
|
|
ConfigSectionName = configSectionName,
|
|
CertificatePasswordProvider = passwordProvider
|
|
};
|
|
|
|
// load the application configuration.
|
|
ApplicationConfiguration config = await application
|
|
.LoadApplicationConfigurationAsync(silent: false, ct: cancellationToken)
|
|
.ConfigureAwait(false);
|
|
// After LoadApplicationConfigurationAsync the property is set; cache the
|
|
// non-null reference so we don't repeat the null-forgiving operator.
|
|
ApplicationConfiguration appConfig = application.ApplicationConfiguration!;
|
|
|
|
// override logfile
|
|
if (logFile != null)
|
|
{
|
|
string? logFilePath = config.TraceConfiguration!.OutputFilePath;
|
|
string filename = Path.GetFileNameWithoutExtension(logFilePath)!;
|
|
config.TraceConfiguration.OutputFilePath = logFilePath!.Replace(
|
|
filename,
|
|
logFile,
|
|
StringComparison.Ordinal
|
|
);
|
|
config.TraceConfiguration.DeleteOnLoad = true;
|
|
}
|
|
|
|
// setup the logging
|
|
telemetry.ConfigureLogging(
|
|
config,
|
|
applicationName,
|
|
logConsole,
|
|
fileLog,
|
|
appLog,
|
|
LogLevel.Information);
|
|
|
|
// delete old certificate
|
|
if (renewCertificate)
|
|
{
|
|
await application.DeleteApplicationInstanceCertificateAsync(ct: cancellationToken)
|
|
.ConfigureAwait(false);
|
|
}
|
|
|
|
// check the application certificate.
|
|
bool haveAppCertificate = await application
|
|
.CheckApplicationInstanceCertificatesAsync(false, ct: cancellationToken)
|
|
.ConfigureAwait(false);
|
|
if (!haveAppCertificate)
|
|
{
|
|
throw new ErrorExitException(
|
|
"Application instance certificate invalid!",
|
|
ExitCode.ErrorCertificate
|
|
);
|
|
}
|
|
|
|
if (reverseConnectUrlString != null)
|
|
{
|
|
// start the reverse connection manager
|
|
Console.WriteLine($"Create reverse connection endpoint at {reverseConnectUrlString}.");
|
|
reverseConnectManager = new ReverseConnectManager(telemetry);
|
|
reverseConnectManager.AddEndpoint(new Uri(reverseConnectUrlString));
|
|
await reverseConnectManager.StartServiceAsync(config, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
}
|
|
|
|
// wait for timeout or Ctrl-C
|
|
var quitCTS = new CancellationTokenSource();
|
|
CancellationToken ct = quitCTS.Token;
|
|
ManualResetEvent quitEvent = ConsoleUtils.CtrlCHandler(quitCTS);
|
|
|
|
// handle connect all endpoints test.
|
|
if (testallEndpoints)
|
|
{
|
|
var tester = new ConnectTester(telemetry, quitEvent);
|
|
await using (tester.ConfigureAwait(false))
|
|
{
|
|
await tester.RunAsync(ct).ConfigureAwait(false);
|
|
}
|
|
return;
|
|
}
|
|
|
|
var identityProviders = new List<IClientIdentityProvider>();
|
|
|
|
// set user identity of type username/pw
|
|
if (!string.IsNullOrEmpty(username))
|
|
{
|
|
if (userpassword == null)
|
|
{
|
|
Console.WriteLine($"No password provided for user {username}, using empty password.");
|
|
}
|
|
|
|
var passwordStore = new InMemorySecretStore();
|
|
var passwordId = new SecretIdentifier(
|
|
"console-reference-client-password",
|
|
passwordStore.StoreType);
|
|
await passwordStore
|
|
.SetAsync(passwordId, userpassword ?? [], ct)
|
|
.ConfigureAwait(false);
|
|
identityProviders.Add(new UserNamePasswordIdentityProvider(
|
|
username!,
|
|
new SecretRegistry(passwordStore),
|
|
passwordId));
|
|
Console.WriteLine($"Connect with user identity for user {username}");
|
|
}
|
|
|
|
// set user identity of type certificate
|
|
if (!string.IsNullOrEmpty(userCertificateThumbprint))
|
|
{
|
|
CertificateIdentifier? userCertificateIdentifier =
|
|
await FindUserCertificateIdentifierAsync(
|
|
userCertificateThumbprint!,
|
|
appConfig.SecurityConfiguration
|
|
.TrustedUserCertificates!,
|
|
telemetry,
|
|
ct).ConfigureAwait(true);
|
|
|
|
if (userCertificateIdentifier != null)
|
|
{
|
|
identityProviders.Insert(0, new X509ClientIdentityProvider(
|
|
userCertificateIdentifier,
|
|
new CertificatePasswordProvider(userCertificatePassword!),
|
|
application.ApplicationConfiguration!.CertificateManager!.CertificateProvider));
|
|
|
|
Console.WriteLine($"Connect with user certificate with Thumbprint {userCertificateThumbprint}");
|
|
}
|
|
else
|
|
{
|
|
Console.WriteLine($"Failed to load user certificate with Thumbprint {userCertificateThumbprint}");
|
|
}
|
|
}
|
|
|
|
identityProviders.Add(new AnonymousIdentityProvider());
|
|
var identityProvider = new CompositeClientIdentityProvider(identityProviders);
|
|
|
|
// connect to a server until application stops
|
|
bool quit = false;
|
|
DateTime start = DateTime.UtcNow;
|
|
int waitTime = int.MaxValue;
|
|
do
|
|
{
|
|
if (timeout > 0)
|
|
{
|
|
waitTime = timeout - (int)DateTime.UtcNow.Subtract(start).TotalMilliseconds;
|
|
if (waitTime <= 0)
|
|
{
|
|
if (!forever)
|
|
{
|
|
break;
|
|
}
|
|
|
|
waitTime = 0;
|
|
}
|
|
|
|
if (forever)
|
|
{
|
|
start = DateTime.UtcNow;
|
|
}
|
|
}
|
|
|
|
// create the UA Client object and connect to configured server.
|
|
using var uaClient = new UAClient(
|
|
appConfig,
|
|
reverseConnectManager,
|
|
telemetry,
|
|
null
|
|
)
|
|
{
|
|
AutoAccept = autoAccept,
|
|
SessionLifeTime = 60_000,
|
|
IdentityProvider = identityProvider
|
|
};
|
|
|
|
if (enableDurableSubscriptions)
|
|
{
|
|
uaClient.ReconnectPeriodExponentialBackoff = 60000;
|
|
}
|
|
|
|
bool connected = await uaClient
|
|
.ConnectAsync(serverUrl.ToString(), !noSecurity, ct)
|
|
.ConfigureAwait(false);
|
|
if (connected)
|
|
{
|
|
Console.WriteLine("Connected! Ctrl-C to quit.");
|
|
|
|
// enable subscription transfer
|
|
uaClient.ReconnectPeriod = 1000;
|
|
uaClient.ReconnectPeriodExponentialBackoff = 10000;
|
|
uaClient.Session.MinPublishRequestCount = 3;
|
|
uaClient.Session.TransferSubscriptionsOnReconnect = true;
|
|
var samples = new ClientSamples(
|
|
telemetry,
|
|
null,
|
|
quitEvent,
|
|
verbose);
|
|
if (loadTypes)
|
|
{
|
|
var complexTypeSystem = ComplexTypeSystem.Create(uaClient.Session, telemetry);
|
|
await samples.LoadTypeSystemAsync(complexTypeSystem, ct).ConfigureAwait(false);
|
|
}
|
|
|
|
if (runHistorian)
|
|
{
|
|
await HistorianClientSample.RunAsync(
|
|
uaClient.Session,
|
|
ct).ConfigureAwait(false);
|
|
quit = true;
|
|
}
|
|
else if (browseall || fetchall || jsonvalues || managedbrowseall)
|
|
{
|
|
List<NodeId>? variableIds = null;
|
|
List<NodeId>? variableIdsManagedBrowse = null;
|
|
ArrayOf<ReferenceDescription> referenceDescriptions = default;
|
|
ArrayOf<ReferenceDescription> referenceDescriptionsFromManagedBrowse = default;
|
|
|
|
if (browseall)
|
|
{
|
|
Console.WriteLine("Browse the full address space.");
|
|
referenceDescriptions = await samples
|
|
.BrowseFullAddressSpaceAsync(uaClient, ObjectIds.RootFolder, ct: ct)
|
|
.ConfigureAwait(false);
|
|
variableIds =
|
|
[
|
|
.. referenceDescriptions
|
|
.Filter(r =>
|
|
r.NodeClass == NodeClass.Variable &&
|
|
r.TypeDefinition.NamespaceIndex != 0
|
|
)
|
|
.ConvertAll(r => ExpandedNodeId.ToNodeId(
|
|
r.NodeId,
|
|
uaClient.Session.NamespaceUris))
|
|
];
|
|
}
|
|
|
|
if (managedbrowseall)
|
|
{
|
|
Console.WriteLine("ManagedBrowse the full address space.");
|
|
referenceDescriptionsFromManagedBrowse = await samples
|
|
.ManagedBrowseFullAddressSpaceAsync(
|
|
uaClient,
|
|
ObjectIds.RootFolder,
|
|
ct: ct)
|
|
.ConfigureAwait(false);
|
|
variableIdsManagedBrowse =
|
|
[
|
|
.. referenceDescriptionsFromManagedBrowse
|
|
.Filter(r =>
|
|
r.NodeClass == NodeClass.Variable &&
|
|
r.TypeDefinition.NamespaceIndex != 0
|
|
)
|
|
.ConvertAll(r => ExpandedNodeId.ToNodeId(
|
|
r.NodeId,
|
|
uaClient.Session.NamespaceUris))
|
|
];
|
|
}
|
|
|
|
// treat managedBrowseall result like browseall results if the latter is missing
|
|
if (!browseall && managedbrowseall)
|
|
{
|
|
referenceDescriptions = referenceDescriptionsFromManagedBrowse;
|
|
browseall = managedbrowseall;
|
|
}
|
|
|
|
IList<INode>? allNodes = null;
|
|
if (fetchall)
|
|
{
|
|
allNodes = await samples
|
|
.FetchAllNodesNodeCacheAsync(
|
|
uaClient,
|
|
ObjectIds.RootFolder,
|
|
true,
|
|
true,
|
|
false,
|
|
ct: ct)
|
|
.ConfigureAwait(false);
|
|
variableIds =
|
|
[
|
|
.. allNodes
|
|
.Where(r =>
|
|
r.NodeClass == NodeClass.Variable &&
|
|
r is VariableNode v &&
|
|
v.DataType.NamespaceIndex != 0
|
|
)
|
|
.Select(r => ExpandedNodeId.ToNodeId(
|
|
r.NodeId,
|
|
uaClient.Session.NamespaceUris))
|
|
];
|
|
|
|
if (exportNodes)
|
|
{
|
|
await samples.ExportNodesToNodeSet2PerNamespaceAsync(
|
|
uaClient.Session,
|
|
allNodes,
|
|
Environment.CurrentDirectory,
|
|
exportNamespaces,
|
|
cancellationToken)
|
|
.ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
if (jsonvalues && variableIds != null)
|
|
{
|
|
(
|
|
ArrayOf<DataValue> allValues,
|
|
ArrayOf<ServiceResult> results
|
|
) = await samples
|
|
.ReadAllValuesAsync(uaClient, variableIds.ToArrayOf(), ct)
|
|
.ConfigureAwait(false);
|
|
}
|
|
|
|
if (subscribe && (browseall || fetchall))
|
|
{
|
|
// subscribe to 1000 random variables
|
|
const int maxVariables = 1000;
|
|
var variables = new List<Node>();
|
|
|
|
if (fetchall)
|
|
{
|
|
variables.AddRange(
|
|
allNodes!
|
|
.Where(r =>
|
|
r.NodeClass == NodeClass.Variable &&
|
|
r.NodeId.NamespaceIndex > 1
|
|
)
|
|
.Cast<VariableNode>()
|
|
.OrderBy(o => UnsecureRandom.Shared.Next())
|
|
.Take(maxVariables)
|
|
);
|
|
}
|
|
else if (browseall)
|
|
{
|
|
var variableReferences = referenceDescriptions
|
|
.ToList()
|
|
.Where(r => r.NodeClass == NodeClass.Variable &&
|
|
r.NodeId.NamespaceIndex > 1)
|
|
.Select(r => r.NodeId)
|
|
.OrderBy(o => UnsecureRandom.Shared.Next())
|
|
.Take(maxVariables)
|
|
.ToList();
|
|
variables.AddRange(
|
|
(await uaClient.Session.NodeCache.FindAsync(variableReferences, ct)
|
|
.ConfigureAwait(false))
|
|
.ToList()
|
|
.Cast<Node>()
|
|
);
|
|
}
|
|
|
|
await samples
|
|
.SubscribeAllValuesAsync(
|
|
uaClient,
|
|
variableIds: [.. variables],
|
|
samplingInterval: 100,
|
|
publishingInterval: 1000,
|
|
queueSize: 10,
|
|
lifetimeCount: 60,
|
|
keepAliveCount: 2,
|
|
ct: ct
|
|
)
|
|
.ConfigureAwait(false);
|
|
|
|
// Wait for DataChange notifications from MonitoredItems
|
|
Console.WriteLine($"Subscribed to {maxVariables} variables. Press Ctrl-C to exit.");
|
|
|
|
// free unused memory
|
|
uaClient.Session.NodeCache.Clear();
|
|
|
|
waitTime = timeout -
|
|
(int)DateTime.UtcNow.Subtract(start).TotalMilliseconds;
|
|
DateTime endTime =
|
|
waitTime > 0
|
|
? DateTime.UtcNow.Add(TimeSpan.FromMilliseconds(waitTime))
|
|
: DateTime.MaxValue;
|
|
List<Node>.Enumerator variableIterator = variables.GetEnumerator();
|
|
while (!quit && endTime > DateTime.UtcNow)
|
|
{
|
|
if (variableIterator.MoveNext())
|
|
{
|
|
try
|
|
{
|
|
DataValue value = await uaClient
|
|
.Session.ReadValueAsync(
|
|
variableIterator.Current.NodeId, ct)
|
|
.ConfigureAwait(false);
|
|
Console.WriteLine($"Value of {variableIterator.Current.NodeId} is {value}");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Console.WriteLine($"Error reading value of {variableIterator.Current.NodeId}: {ex.Message}");
|
|
}
|
|
}
|
|
else
|
|
{
|
|
variableIterator = variables.GetEnumerator();
|
|
}
|
|
quit = quitEvent.WaitOne(500);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
quit = true;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
int quitTimeout = 65_000;
|
|
if (enableDurableSubscriptions)
|
|
{
|
|
quitTimeout = 150_000;
|
|
uaClient.ReconnectPeriod = 500_000;
|
|
}
|
|
|
|
NodeId sessionNodeId = uaClient.Session.SessionId;
|
|
// Run tests for available methods on reference server.
|
|
await samples.ReadNodesAsync(
|
|
uaClient.Session,
|
|
ct).ConfigureAwait(false);
|
|
await samples.WriteNodesAsync(
|
|
uaClient.Session,
|
|
ct).ConfigureAwait(false);
|
|
await samples.BrowseAsync(
|
|
uaClient.Session,
|
|
ct).ConfigureAwait(false);
|
|
await samples.CallMethodAsync(
|
|
uaClient.Session,
|
|
ct).ConfigureAwait(false);
|
|
await samples.EnableEventsAsync(
|
|
uaClient.Session,
|
|
(uint)quitTimeout,
|
|
ct).ConfigureAwait(false);
|
|
await samples.SubscribeToDataChangesAsync(
|
|
uaClient.Session,
|
|
60_000,
|
|
enableDurableSubscriptions,
|
|
ct).ConfigureAwait(false);
|
|
|
|
Console.WriteLine("Waiting...");
|
|
|
|
// Wait for some DataChange notifications from MonitoredItems
|
|
int waitCounters = 0;
|
|
const int checkForWaitTime = 1000;
|
|
const int closeSessionTime = checkForWaitTime * 15;
|
|
const int restartSessionTime = checkForWaitTime * 45;
|
|
const bool stopNotQuit = false;
|
|
int stopCount = 0;
|
|
while (!quit && !stopNotQuit && waitCounters < quitTimeout)
|
|
{
|
|
quit = quitEvent.WaitOne(checkForWaitTime);
|
|
waitCounters += checkForWaitTime;
|
|
if (enableDurableSubscriptions)
|
|
{
|
|
if (waitCounters == closeSessionTime &&
|
|
uaClient.Session.SubscriptionCount == 1)
|
|
{
|
|
Console.WriteLine($"Closing Session (CurrentTime: {DateTime.Now:T})");
|
|
await uaClient.Session.CloseAsync(closeChannel: false, ct: ct)
|
|
.ConfigureAwait(false);
|
|
}
|
|
|
|
if (waitCounters == restartSessionTime)
|
|
{
|
|
Console.WriteLine($"Restarting Session (CurrentTime: {DateTime.Now:T})");
|
|
await uaClient
|
|
.DurableSubscriptionTransferAsync(
|
|
serverUrl.ToString(),
|
|
useSecurity: !noSecurity,
|
|
ct
|
|
)
|
|
.ConfigureAwait(true);
|
|
}
|
|
|
|
if (waitCounters is > closeSessionTime and < restartSessionTime)
|
|
{
|
|
Console.WriteLine(
|
|
"No Communication Interval " +
|
|
stopCount.ToString(CultureInfo.InvariantCulture)
|
|
);
|
|
stopCount++;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Console.WriteLine("Client disconnected.");
|
|
|
|
await uaClient.DisconnectAsync(leakChannels, ct).ConfigureAwait(false);
|
|
}
|
|
else
|
|
{
|
|
Console.WriteLine("Could not connect to server! Retry in 10 seconds or Ctrl-C to quit.");
|
|
quit = quitEvent.WaitOne(Math.Min(10_000, waitTime));
|
|
}
|
|
} while (!quit);
|
|
|
|
Console.WriteLine("Client stopped.");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Console.Error.WriteLine(ex.Message);
|
|
throw;
|
|
}
|
|
finally
|
|
{
|
|
if (reverseConnectManager != null)
|
|
{
|
|
await reverseConnectManager.DisposeAsync().ConfigureAwait(false);
|
|
}
|
|
}
|
|
});
|
|
|
|
args = ConsoleUtils.MergeEnvironmentArgs(args, "REFCLIENT", rootCommand);
|
|
ParseResult cmdParseResult = rootCommand.Parse(args);
|
|
return cmdParseResult
|
|
.InvokeAsync(new InvocationConfiguration(), CancellationToken.None);
|
|
}
|
|
|
|
/// <summary>
|
|
/// returns a CertificateIdentifier of the Certificate with the specified thumbprint
|
|
/// if it is found in the trustedUserCertificates TrustList
|
|
/// </summary>
|
|
/// <param name="thumbprint">the thumbprint of the certificate to select</param>
|
|
/// <param name="trustedUserCertificates">the trustlist of the user certificates</param>
|
|
/// <param name="ct">the cancellation token</param>
|
|
/// <returns>Certificate Identifier</returns>
|
|
private static async Task<CertificateIdentifier?> FindUserCertificateIdentifierAsync(
|
|
string thumbprint,
|
|
CertificateTrustList trustedUserCertificates,
|
|
ITelemetryContext telemetry,
|
|
CancellationToken ct = default)
|
|
{
|
|
CertificateIdentifier? userCertificateIdentifier = null;
|
|
|
|
using CertificateCollection certificates =
|
|
await trustedUserCertificates.GetCertificatesAsync(telemetry, ct).ConfigureAwait(false);
|
|
// get user certificate with matching thumbprint
|
|
using CertificateCollection userCertificatesWithMatchingThumbprint =
|
|
certificates.Find(X509FindType.FindByThumbprint, thumbprint, false);
|
|
|
|
// create Certificate Identifier
|
|
if (userCertificatesWithMatchingThumbprint.Count == 1)
|
|
{
|
|
Certificate userCert = userCertificatesWithMatchingThumbprint[0];
|
|
userCertificateIdentifier = new CertificateIdentifier
|
|
{
|
|
Thumbprint = userCert.Thumbprint,
|
|
SubjectName = userCert.Subject,
|
|
StorePath = trustedUserCertificates.StorePath,
|
|
StoreType = trustedUserCertificates.StoreType
|
|
};
|
|
}
|
|
|
|
return userCertificateIdentifier;
|
|
}
|
|
|
|
/// <summary>
|
|
/// A dialog which asks for user input.
|
|
/// </summary>
|
|
public class ApplicationMessageDlg : IApplicationMessageDlg
|
|
{
|
|
private readonly TextWriter m_output;
|
|
private string m_message = string.Empty;
|
|
private bool m_ask;
|
|
|
|
/// <summary>
|
|
/// Initializes a message dialog that writes to the supplied writer or standard output.
|
|
/// </summary>
|
|
public ApplicationMessageDlg(TextWriter? output = null)
|
|
{
|
|
m_output = output ?? Console.Out;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sets the message and whether displaying it should request confirmation.
|
|
/// </summary>
|
|
public override void Message(string text, bool ask)
|
|
{
|
|
m_message = text;
|
|
m_ask = ask;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Displays the message and returns the confirmation response, defaulting to acceptance.
|
|
/// </summary>
|
|
public override async Task<bool> ShowAsync()
|
|
{
|
|
if (m_ask)
|
|
{
|
|
var message = new StringBuilder(m_message);
|
|
message.Append(" (y/n, default y): ");
|
|
m_output.Write(message.ToString());
|
|
|
|
try
|
|
{
|
|
ConsoleKeyInfo result = Console.ReadKey();
|
|
m_output.WriteLine();
|
|
return await Task.FromResult(result.KeyChar is 'y' or 'Y' or '\r')
|
|
.ConfigureAwait(false);
|
|
}
|
|
catch
|
|
{
|
|
// intentionally fall through
|
|
}
|
|
}
|
|
else
|
|
{
|
|
m_output.WriteLine(m_message);
|
|
}
|
|
|
|
return await Task.FromResult(true).ConfigureAwait(false);
|
|
}
|
|
}
|
|
}
|
|
}
|