mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-19 07:02:16 +08:00
2966f693b2
Introduces `OPCFoundation.NetStandard.Opc.Ua.MigrationAnalyzer` — a single NuGet package that ships **three Roslyn components + a runtime shim** to make the 1.5.378 → 2.0 upgrade as close to "install package, run `dotnet format`, remove package" as possible: | DLL | Where | Purpose | |-----|-------|---------| | `Opc.Ua.MigrationAnalyzer.dll` | `analyzers/dotnet/cs/` | 19 Roslyn analyzers (`UA0001`–`UA0022`) covering every breaking-change pattern in [`Docs/MigrationGuide.md`](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/Docs/MigrationGuide.md). Built against the stable Roslyn 4.14 analyzer API — loads cleanly in csc.exe (no `Microsoft.CodeAnalysis.Workspaces` reference). | | `Opc.Ua.MigrationAnalyzer.CodeFixer.dll` | `analyzers/dotnet/cs/` | 14 companion `CodeFixProvider`s; loaded only by Workspaces-aware hosts (Visual Studio, `dotnet format analyzers`). | | `Opc.Ua.MigrationAnalyzer.Generator.dll` | `analyzers/dotnet/cs/` | Roslyn `IIncrementalGenerator` that emits per-consumer `internal sealed [Obsolete] class <Name>Collection : List<TElement>` shims for every `<Type>Collection` wrapper the consumer references — including **arbitrary model-compiled `<UserType>Collection` patterns**, not just the 30 well-known built-in ones. | | `Opc.Ua.MigrationAnalyzer.Core.dll` | `lib/<tfm>/` for `net472, net48, netstandard2.1, net8.0, net9.0, net10.0` | 1.5.378 → 2.0 runtime compatibility shim. Re-exposes the obsolete extension surface so 1.5.378-style call sites keep compiling against 2.0 with warnings instead of errors. | ### Migration workflow the package enables 1. **Bump every `OPCFoundation.NetStandard.Opc.Ua.*` PackageReference to `2.0.*-*`.** 2. **Add the migration package** as a build-only dependency: ```xml <PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.MigrationAnalyzer" Version="2.0.*-*" PrivateAssets="all" /> ``` 3. **Run `dotnet build`.** The Core shim DLL re-supplies obsolete extension members; the source generator covers `<Type>Collection` references; what remains are `[Obsolete]` warnings + `UA00xx` analyzer diagnostics + (occasionally) `MIG01` for unresolvable model-compiled element types. 4. **Apply auto-fixes** in the IDE or from the command line: ```bash dotnet format analyzers <solution>.sln \ --diagnostics UA0002 UA0003 UA0004 UA0005 UA0006 UA0007 UA0008 \ UA0009 UA0010 UA0012 UA0014 UA0019 UA0020 UA0022 \ --severity warn ``` 5. **Walk the residual** `UA0001` / `UA0011` / `UA0015` / `UA0018` / `UA0021` diagnostics manually — they cover patterns that require human judgement. 6. **Remove the migration package.** You are now on clean 2.0 with no shim dependency. ### Source generator (`Opc.Ua.MigrationAnalyzer.Generator.dll`) Per-consumer Roslyn `IIncrementalGenerator` that emits `.g.cs` files for every `<Name>Collection` reference in the consumer's compilation that fails to bind: - **Catalog override (30 entries)** — pins element types that renamed across the 1.5.378 → 2.0 boundary: `DateTime`→`DateTimeUtc`, `Guid`→`Uuid`, `byte[]`→`ByteString`, `XmlElement`→`Opc.Ua.XmlElement`, plus the primitives and built-in opc types whose element name didn't rename. - **Semantic lookup** — for model-compiled `<UserType>Collection` patterns the model compiler historically emitted for any user-defined complex type. Strips the `Collection` suffix, runs `Compilation.GetSymbolsWithName` against the unique element short name, emits the shim with the fully-qualified type reference. - **`MIG01` diagnostic** — fires when the element type can't be uniquely resolved (zero or multiple candidates). User adds the missing `using` or migrates the site manually. Each generated file emits a class shaped exactly like the 1.5.378 wrapper: ```csharp [global::System.Obsolete("'Int32Collection' was removed in 2.0. Use 'List<int>' or 'ArrayOf<int>' instead. (UA0002)")] internal sealed class Int32Collection : global::System.Collections.Generic.List<int> { public Int32Collection() { } public Int32Collection(int capacity) : base(capacity) { } public Int32Collection(global::System.Collections.Generic.IEnumerable<int> collection) : base(collection) { } public static implicit operator global::Opc.Ua.ArrayOf<int>(Int32Collection? value) => value is null ? default : value.ToArrayOf(); } ``` **Design choice — `internal` (not `public`):** the generated shim never leaks through the consumer's public API surface. Consumers with *public* methods or properties returning a `<Type>Collection` will hit `CS0050: Inconsistent accessibility` — the intended signal that the public surface must migrate to `List<T>` / `ArrayOf<T>` first; internal call sites keep compiling under the shim so iteration is incremental. ### 19-rule analyzer reference | Rule | Replaces | Default | Auto-fix | |------|----------|---------|----------| | `UA0001` | `Utils.Trace` / `Utils.LogX` | Info | No (telemetry plumbing requires manual review) | | `UA0002` | Removed `<Type>Collection` wrappers | Warning | Yes (→ `List<T>` / `ArrayOf<T>`); the source generator keeps the call site compiling in the meantime | | `UA0003` | `x == null` on now-struct built-ins | Warning | Yes (→ `x.IsNull`) | | `UA0004` | `?.` on now-struct built-ins | Warning | Yes (drop the `?`) | | `UA0005` | `byte[]` where `ByteString` is expected | Warning | Yes (append `.ToByteString()`) | | `UA0006` | `new Variant(object\|DateTime\|Guid\|byte[])` | Warning | Yes (→ `Variant.From(...)`) | | `UA0007` | `new NodeId(string)` / `new ExpandedNodeId(string)` | Warning | Yes (→ `Parse`) | | `UA0008` | `Session.Call(..., params object[])` | Warning | Yes (wrap with `Variant.From`) | | `UA0009` | `[DataContract]` / `[DataMember]` on config extensions | Warning | Yes (→ `[DataType]` / `[DataTypeField]`) | | `UA0010` | `using` / `Dispose` on `CertificateIdentifier` / `UserIdentity` / `IUserIdentityTokenHandler` | Warning | Yes (drop the disposable) | | `UA0011` | Sync `IUserIdentityTokenHandler.{Encrypt,Decrypt,Sign,Verify}` | Info | No (async promotion required) | | `UA0012` | `CertificateFactory.*` static helpers | Warning | Yes (→ instance methods) | | `UA0014` | `DataValue.IsGood(dv)` static helper | Warning | Yes (→ `dv.IsGood`) | | `UA0015` | Sync / APM members on GDS / LDS clients | Info | No (async promotion required) | | `UA0018` | `CertificateIdentifier.Certificate` getter | Info | No (→ `LoadCertificate2Async`) | | `UA0019` | `new DataValue(StatusCode[, ts])` | Warning | Yes (→ object-initializer form) | | `UA0020` | `EncodeableFactory.GlobalFactory` / `Create()` | Warning | Yes (→ `ServiceMessageContext.Factory` / `Fork()`) | | `UA0021` | `CertificateValidator` / `CertificateValidationEventArgs` (structural rename) | Info | No (see migration guide §Certificate validation) | | `UA0022` | `ApplicationConfiguration.CertificateValidator` / `ServerBase.CertificateValidator` property rename | Warning | Yes (→ `.CertificateManager`) | Plus one generator-only diagnostic: | Rule | Source | Default | Triggers | |------|--------|---------|----------| | `MIG01` | Generator | Warning | Generator can't uniquely resolve the element type for a `<Foo>Collection` reference (zero or multiple candidates). The user adds a `using` for the namespace defining `Foo`, or migrates the call site manually. | ### Runtime shim coverage (`Opc.Ua.MigrationAnalyzer.Core.dll`) C# 14 `extension` members so 1.5.378 call sites continue to compile: - **Moved obsolete extensions** the 2.0 libraries no longer carry inline: `NodeId` / `Variant` / `DataValue` null-check helpers, `Session` sync helpers, `Subscription` sync helpers, `ApplicationInstance` helpers, `ServerBase.Start` / `Stop`, `TransportChannel` APM, `ChannelBase` static factory methods, etc. - **New shims for genuinely-removed members**: `EncodeableFactory.GlobalFactory`, `CertificateIdentifier.Certificate` (throws), sync wrappers for `IUserIdentityTokenHandler.{Encrypt,Decrypt,Sign,Verify}`, sync + APM wrappers for the GDS / LDS client APIs, `GlobalDiscoverySampleServer` 1.5.378-shape ctor (in-tree `Libraries/Opc.Ua.Gds.Server.Common/`). > ⚠ The sync shims wrap their `*Async` counterparts via `Task.Run(…).GetAwaiter().GetResult()`. Intended as a **migration aid only** — port to `await` before shipping. ### Repo layout ``` Tools/Opc.Ua.MigrationAnalyzer/ # analyzer DLL Analyzers/UA00xxAnalyzer.cs # 19 DiagnosticAnalyzer impls Diagnostics/{DiagnosticIds,Descriptors,WellKnownProperties}.cs Helpers/{SymbolExtensions,UaSymbols}.cs Opc.Ua.MigrationAnalyzer.nuspec # ships all 3 analyzer DLLs + 6-TFM Core OPCFoundation.NetStandard.Opc.Ua.MigrationAnalyzer.props Tools/Opc.Ua.MigrationAnalyzer.CodeFixer/ # code-fix DLL CodeFixes/UA00xxCodeFix.cs # 14 CodeFixProvider impls Tools/Opc.Ua.MigrationAnalyzer.Generator/ # source generator DLL (NEW) MigrationGenerator.cs # IIncrementalGenerator CollectionShimCatalog.cs # 30-entry element-type overrides Diagnostics/GeneratorDiagnostics.cs # MIG01 Tools/Opc.Ua.MigrationAnalyzer.Core/ # runtime shim (multi-TFM) Client/, Configuration/, Core/, Gds.Client.Common/, Types/, Marker/ Tools/Roslyn.slnx # source-gen + migration projects Tests/Opc.Ua.MigrationAnalyzer.Tests/ # 142 tests Analyzers/UA00xxTests.cs # 107 analyzer + code-fix tests Generators/MigrationGeneratorTests.cs # 35 generator tests (NEW) Tests/Opc.Ua.MigrationAnalyzer.Core.Tests/ # 13 runtime-shim tests ``` ### Documentation - [`Docs/MigrationGuide.md`](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/Docs/MigrationGuide.md) — §3 Collection Type Migration gains a tip pointing at the generator so CS0246 is replaced with `[Obsolete]` warnings + UA0002. - [`.github/agents/opcua-v20-migration.agent.md`](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/.github/agents/opcua-v20-migration.agent.md) — Strategy section mentions the three Roslyn components + runtime shim explicitly, plus the MIG01 diagnostic and the "internal-by-design" CS0050 hint. ### Central package pinning `Directory.Packages.props` pins `Microsoft.CodeAnalysis.*` (Common, CSharp, CSharp.Workspaces, Workspaces.Common) to **4.14.0** — the stable analyzer API surface. The earlier 5.3 pin was the *csc-internal* version, which is not safe for analyzer hosts. ### Dogfood story The full 42-sample dogfood against `OPCFoundation/UA-.NETStandard-Samples` surfaced a **critical analyzer-loader bug** in an earlier iteration: the analyzer DLL initially co-shipped its code-fix providers in one assembly, which transitively referenced `Microsoft.CodeAnalysis.Workspaces.dll`. csc.exe's analyzer host ships only `Microsoft.CodeAnalysis.dll` + `CSharp.dll` in its bincore and **silently swallows** load failures — net result: zero diagnostics across all samples. Fix landed in commit `861fa6ee1`: 1. **Split** the assembly: analyzer in `Opc.Ua.MigrationAnalyzer.dll` (Workspaces-free, csc-safe), code-fixes in `Opc.Ua.MigrationAnalyzer.CodeFixer.dll` (Workspaces-aware, IDE-only). 2. **Downgrade** to Roslyn 4.14 — the stable analyzer API. 3. `/p:ReportAnalyzer=true` confirms all 19 analyzers initialise and execute on real consumer code. This PR's new generator was smoke-tested against a minimal consumer that exercises all 30 catalog entries + a model-compiled element type + the unresolvable case: - 30 catalog patterns + model-compiled `MyThingCollection` → all emit shims, fire CS0618 with `(UA0002)` marker - Element-type renames verified: `DateTimeCollection` → `DateTimeUtc`, `ByteStringCollection` → `ByteString`, `GuidCollection` → `Uuid`, etc. - `NeverHeardOfItCollection` → `MIG01` fires with help link to the migration guide Known compatibility gaps (documented in the migration agent): - Legacy WinForms `.Net4` sample projects use pre-SDK MSBuild XML and **ignore** `Directory.Build.targets` `<PackageReference>` injection — consumers in this format must add the migration package to their csproj inline. ### Packaging design notes - Hand-authored nuspec ships 3 DLLs in `analyzers/dotnet/cs/` + 6 TFMs of Core (shim) DLLs under `lib/`. All 6 OPC UA runtime package dependencies are declared per-TFM-group. - `build/OPCFoundation.NetStandard.Opc.Ua.MigrationAnalyzer.props` is auto-imported by NuGet (file name must match package id exactly) and injects all three analyzer DLLs as `<Analyzer>` items. - `DiagnosticIds.cs` / `DiagnosticDescriptors.cs` / `WellKnownProperties.cs` / `Helpers/*.cs` are **shared via `<Compile Link=…>`** between the analyzer and code-fixer projects to break what would otherwise be a NuGet restore cycle. The generator has no compile dependency on the analyzer; the 30-entry override table is duplicated in `CollectionShimCatalog.cs` (the analyzer table also drives diagnostic messages, so the duplication is small and intentional). - `Opc.Ua.MigrationAnalyzer.Core` is multi-TFM (`net472, net48, netstandard2.1, net8.0, net9.0, net10.0`) and **non-packable on its own**; the parent migration package's nuspec consumes each TFM's DLL by path.
867 lines
42 KiB
C#
867 lines
42 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 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,
|
|
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);
|
|
// 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;
|
|
// TODO: remove this in 2.0 migration; use telemetry.ConfigureLogging below.
|
|
#pragma warning disable CS0618 // Type or member is obsolete
|
|
config.TraceConfiguration.ApplySettings();
|
|
#pragma warning restore CS0618 // Type or member is obsolete
|
|
}
|
|
|
|
// 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));
|
|
reverseConnectManager.StartService(config);
|
|
}
|
|
|
|
// 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 = new ComplexTypeSystem(uaClient.Session, telemetry);
|
|
await samples.LoadTypeSystemAsync(complexTypeSystem, ct).ConfigureAwait(false);
|
|
}
|
|
|
|
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.WriteLine($"{ex.Message}");
|
|
}
|
|
finally
|
|
{
|
|
reverseConnectManager?.Dispose();
|
|
}
|
|
});
|
|
|
|
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;
|
|
|
|
public ApplicationMessageDlg(TextWriter? output = null)
|
|
{
|
|
m_output = output ?? Console.Out;
|
|
}
|
|
|
|
public override void Message(string text, bool ask)
|
|
{
|
|
m_message = text;
|
|
m_ask = ask;
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
}
|