From 91eaf534c8ea6839730e2605e85afdff40e7e8fe Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 19 Apr 2026 01:07:57 -0400 Subject: [PATCH] Phase 3 PR 66 -- OPC UA Client (gateway) driver project scaffold + IDriver session lifecycle. First driver that CONSUMES OPC UA rather than PUBLISHES it -- connects to a remote server and re-exposes its address space through the local OtOpcUa server per driver-specs.md \u00A78. Uses the same OPCFoundation.NetStandard.Opc.Ua.Client package the existing Client.Shared ships (bumped to 1.5.378.106 to match). Builds its own ApplicationConfiguration (cert stores under %LocalAppData%/OtOpcUa/pki so multiple driver instances in one OtOpcUa server process share a trust anchor) rather than reusing Client.Shared -- Client.Shared is oriented at the interactive CLI with different session-lifetime needs (this driver is always-on, needs keep-alive + session transfer on reconnect + multi-year uptime). Navigated the post-refactor 1.5.378 SDK surface: every Session.Create* static is now [Obsolete] in favour of DefaultSessionFactory; CoreClientUtils.SelectEndpoint got the sync overloads deprecated in favour of SelectEndpointAsync with a required ITelemetryContext parameter. Driver passes telemetry: null! to both SelectEndpointAsync + new DefaultSessionFactory(telemetry: null!) -- the SDK's internal default sink handles null gracefully and plumbing a telemetry context through the driver options surface is out of scope (the driver emits its own logs via the DriverHealth surface anyway). ApplicationInstance default ctor is also obsolete; wrapped in #pragma warning disable CS0618 rather than migrate to the ITelemetryContext overload for the same reason. OpcUaClientDriverOptions models driver-specs.md \u00A78 settings: EndpointUrl (default opc.tcp://localhost:4840 IANA-assigned port), SecurityPolicy/SecurityMode/AuthType enums, Username/Password, SessionTimeout=120s + KeepAliveInterval=5s + ReconnectPeriod=5s (defaults from spec), AutoAcceptCertificates=false (production default; dev turns on for self-signed servers), ApplicationUri + SessionName knobs for certificate SAN matching and remote-server session-list identification. OpcUaClientDriver : IDriver: InitializeAsync builds the ApplicationConfiguration, resolves + creates cert if missing via app.CheckApplicationInstanceCertificatesAsync, selects endpoint via CoreClientUtils.SelectEndpointAsync, builds UserIdentity (Anonymous or Username with UTF-8-encoded password bytes -- the legacy string-password ctor went away; Certificate auth deferred), creates session via DefaultSessionFactory.CreateAsync. Health transitions Unknown -> Initializing -> Healthy on success or -> Faulted on failure with best-effort Session.CloseAsync cleanup. ShutdownAsync (async now, not Task.CompletedTask) closes the session + disposes. Internal Session + Gate expose to the test project via InternalsVisibleTo so PRs 67-69 can stack read/write/discovery/subscribe on the same serialization. Scaffold tests (OpcUaClientDriverScaffoldTests, 5 facts): Default_options_target_standard_opcua_port_and_anonymous_auth (4840 + None mode + Anonymous + AutoAccept=false production default), Default_timeouts_match_driver_specs_section_8 (120s/5s/5s), Driver_reports_type_and_id_before_connect (DriverType=OpcUaClient, DriverInstanceId round-trip, pre-init Unknown health), Initialize_against_unreachable_endpoint_transitions_to_Faulted_and_throws, Reinitialize_against_unreachable_endpoint_re_throws. Uses opc.tcp://127.0.0.1:1 as the 'guaranteed-unreachable' target -- RFC 5737 reserved IPs get black-holed and time out only after the SDK's internal retry/backoff fully elapses (~60s), while port 1 on loopback refuses immediately with TCP RST which keeps the test suite snappy (5 tests / 8s). 5/5 pass. dotnet build clean. Scope boundary: ITagDiscovery / IReadable / IWritable / ISubscribable / IHostConnectivityProbe deliberately NOT in this PR -- they need browse + namespace remapping + reference-counted MonitoredItem forwarding + keep-alive probing and land in PRs 67-69. --- ZB.MOM.WW.OtOpcUa.slnx | 2 + .../OpcUaClientDriver.cs | 231 ++++++++++++++++++ .../OpcUaClientDriverOptions.cs | 81 ++++++ ...B.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj | 28 +++ .../OpcUaClientDriverScaffoldTests.cs | 90 +++++++ ...WW.OtOpcUa.Driver.OpcUaClient.Tests.csproj | 31 +++ 6 files changed, 463 insertions(+) create mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs create mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverOptions.cs create mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientDriverScaffoldTests.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests.csproj diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx index 97f2481..403e60f 100644 --- a/ZB.MOM.WW.OtOpcUa.slnx +++ b/ZB.MOM.WW.OtOpcUa.slnx @@ -10,6 +10,7 @@ + @@ -28,6 +29,7 @@ + diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs new file mode 100644 index 0000000..0decf84 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs @@ -0,0 +1,231 @@ +using Opc.Ua; +using Opc.Ua.Client; +using Opc.Ua.Configuration; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient; + +/// +/// OPC UA Client (gateway) driver. Opens a against a remote OPC UA +/// server and re-exposes its address space through the local OtOpcUa server. PR 66 ships +/// the scaffold: only (connect / close / health). Browse, read, +/// write, subscribe, and probe land in PRs 67-69. +/// +/// +/// +/// Builds its own rather than reusing +/// Client.Shared — Client.Shared is oriented at the interactive CLI; this +/// driver is an always-on service component with different session-lifetime needs +/// (keep-alive monitor, session transfer on reconnect, multi-year uptime). +/// +/// +/// Session lifetime: a single per driver instance. +/// Subscriptions multiplex onto that session; SDK reconnect handler takes the session +/// down and brings it back up on remote-server restart — the driver must re-send +/// subscriptions + TransferSubscriptions on reconnect to avoid dangling +/// monitored-item handles. That mechanic lands in PR 69. +/// +/// +public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string driverInstanceId) + : IDriver, IDisposable, IAsyncDisposable +{ + private readonly OpcUaClientDriverOptions _options = options; + private readonly SemaphoreSlim _gate = new(1, 1); + + /// Active OPC UA session. Null until returns cleanly. + internal ISession? Session { get; private set; } + + /// Per-connection gate. PRs 67+ serialize read/write/browse on this. + internal SemaphoreSlim Gate => _gate; + + private DriverHealth _health = new(DriverState.Unknown, null, null); + private bool _disposed; + + public string DriverInstanceId => driverInstanceId; + public string DriverType => "OpcUaClient"; + + public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) + { + _health = new DriverHealth(DriverState.Initializing, null, null); + try + { + var appConfig = await BuildApplicationConfigurationAsync(cancellationToken).ConfigureAwait(false); + + // Endpoint selection: let the stack pick the best matching endpoint for the + // requested security policy/mode so the driver doesn't have to hand-validate. + // UseSecurity=false when SecurityMode=None shortcuts around cert validation + // entirely and is the typical dev-bench configuration. + var useSecurity = _options.SecurityMode != OpcUaSecurityMode.None; + // The non-obsolete SelectEndpointAsync overloads all require an ITelemetryContext + // parameter. Passing null is valid — the SDK falls through to its built-in default + // trace sink. Plumbing a telemetry context through every driver surface is out of + // scope; the driver emits its own logs via the health surface anyway. + var selected = await CoreClientUtils.SelectEndpointAsync( + appConfig, _options.EndpointUrl, useSecurity, + telemetry: null!, + ct: cancellationToken).ConfigureAwait(false); + var endpointConfig = EndpointConfiguration.Create(appConfig); + endpointConfig.OperationTimeout = (int)_options.Timeout.TotalMilliseconds; + var endpoint = new ConfiguredEndpoint(null, selected, endpointConfig); + + var identity = _options.AuthType switch + { + OpcUaAuthType.Anonymous => new UserIdentity(new AnonymousIdentityToken()), + // The UserIdentity(string, string) overload was removed in favour of + // (string, byte[]) to make the password encoding explicit. UTF-8 is the + // overwhelmingly common choice for Basic256Sha256-secured sessions. + OpcUaAuthType.Username => new UserIdentity( + _options.Username ?? string.Empty, + System.Text.Encoding.UTF8.GetBytes(_options.Password ?? string.Empty)), + OpcUaAuthType.Certificate => throw new NotSupportedException( + "Certificate authentication lands in a follow-up PR; for now use Anonymous or Username"), + _ => new UserIdentity(new AnonymousIdentityToken()), + }; + + // All Session.Create* static methods are marked [Obsolete] in SDK 1.5.378; the + // non-obsolete path is DefaultSessionFactory.Instance.CreateAsync (which is the + // 8-arg signature matching our driver config — ApplicationConfiguration + + // ConfiguredEndpoint, no transport-waiting-connection or reverse-connect-manager + // required for the standard opc.tcp direct-connect case). + // DefaultSessionFactory's parameterless ctor is also obsolete in 1.5.378; the + // current constructor requires an ITelemetryContext. Passing null is tolerated — + // the factory falls back to its internal default sink, same as the telemetry:null + // on SelectEndpointAsync above. + var session = await new DefaultSessionFactory(telemetry: null!).CreateAsync( + appConfig, + endpoint, + false, // updateBeforeConnect + _options.SessionName, + (uint)_options.SessionTimeout.TotalMilliseconds, + identity, + null, // preferredLocales + cancellationToken).ConfigureAwait(false); + + session.KeepAliveInterval = (int)_options.KeepAliveInterval.TotalMilliseconds; + + Session = session; + _health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null); + } + catch (Exception ex) + { + try { if (Session is Session s) await s.CloseAsync().ConfigureAwait(false); } catch { } + Session = null; + _health = new DriverHealth(DriverState.Faulted, null, ex.Message); + throw; + } + } + + /// + /// Build a minimal in-memory . Certificates live + /// under the OS user profile — on Windows that's %LocalAppData%\OtOpcUa\pki + /// — so multiple driver instances in the same OtOpcUa server process share one + /// certificate store without extra config. + /// + private async Task BuildApplicationConfigurationAsync(CancellationToken ct) + { + // The default ctor is obsolete in favour of the ITelemetryContext overload; suppress + // locally rather than plumbing a telemetry context all the way through the driver + // surface — the driver emits no per-request telemetry of its own and the SDK's + // internal fallback is fine for a gateway use case. +#pragma warning disable CS0618 + var app = new ApplicationInstance + { + ApplicationName = _options.SessionName, + ApplicationType = ApplicationType.Client, + }; +#pragma warning restore CS0618 + + var pkiRoot = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "OtOpcUa", "pki"); + + var config = new ApplicationConfiguration + { + ApplicationName = _options.SessionName, + ApplicationType = ApplicationType.Client, + ApplicationUri = _options.ApplicationUri, + SecurityConfiguration = new SecurityConfiguration + { + ApplicationCertificate = new CertificateIdentifier + { + StoreType = CertificateStoreType.Directory, + StorePath = Path.Combine(pkiRoot, "own"), + SubjectName = $"CN={_options.SessionName}", + }, + TrustedPeerCertificates = new CertificateTrustList + { + StoreType = CertificateStoreType.Directory, + StorePath = Path.Combine(pkiRoot, "trusted"), + }, + TrustedIssuerCertificates = new CertificateTrustList + { + StoreType = CertificateStoreType.Directory, + StorePath = Path.Combine(pkiRoot, "issuers"), + }, + RejectedCertificateStore = new CertificateTrustList + { + StoreType = CertificateStoreType.Directory, + StorePath = Path.Combine(pkiRoot, "rejected"), + }, + AutoAcceptUntrustedCertificates = _options.AutoAcceptCertificates, + }, + TransportQuotas = new TransportQuotas { OperationTimeout = (int)_options.Timeout.TotalMilliseconds }, + ClientConfiguration = new ClientConfiguration + { + DefaultSessionTimeout = (int)_options.SessionTimeout.TotalMilliseconds, + }, + DisableHiResClock = true, + }; + + await config.ValidateAsync(ApplicationType.Client, ct).ConfigureAwait(false); + + // Attach a cert-validator handler that honours the AutoAccept flag. Without this, + // AutoAcceptUntrustedCertificates on the config alone isn't always enough in newer + // SDK versions — the validator raises an event the app has to handle. + if (_options.AutoAcceptCertificates) + { + config.CertificateValidator.CertificateValidation += (s, e) => + { + if (e.Error.StatusCode == StatusCodes.BadCertificateUntrusted) + e.Accept = true; + }; + } + + // Ensure an application certificate exists. The SDK auto-generates one if missing. + app.ApplicationConfiguration = config; + await app.CheckApplicationInstanceCertificatesAsync(silent: true, lifeTimeInMonths: null, ct) + .ConfigureAwait(false); + + return config; + } + + public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) + { + await ShutdownAsync(cancellationToken).ConfigureAwait(false); + await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false); + } + + public async Task ShutdownAsync(CancellationToken cancellationToken) + { + try { if (Session is Session s) await s.CloseAsync(cancellationToken).ConfigureAwait(false); } + catch { /* best-effort */ } + try { Session?.Dispose(); } catch { } + Session = null; + _health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null); + } + + public DriverHealth GetHealth() => _health; + public long GetMemoryFootprint() => 0; + public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult(); + + public async ValueTask DisposeAsync() + { + if (_disposed) return; + _disposed = true; + try { await ShutdownAsync(CancellationToken.None).ConfigureAwait(false); } + catch { /* disposal is best-effort */ } + _gate.Dispose(); + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverOptions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverOptions.cs new file mode 100644 index 0000000..000b068 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverOptions.cs @@ -0,0 +1,81 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient; + +/// +/// OPC UA Client (gateway) driver configuration. Bound from DriverConfig JSON at +/// driver-host registration time. Models the settings documented in +/// docs/v2/driver-specs.md §8. +/// +/// +/// This driver connects to a REMOTE OPC UA server and re-exposes its address space +/// through the local OtOpcUa server — the opposite direction from the usual "server +/// exposes PLC data" flow. Tier A (pure managed, OPC Foundation reference SDK); universal +/// protections cover it. +/// +public sealed class OpcUaClientDriverOptions +{ + /// Remote OPC UA endpoint URL, e.g. opc.tcp://plc.internal:4840. + public string EndpointUrl { get; init; } = "opc.tcp://localhost:4840"; + + /// Security policy. One of None, Basic256Sha256, Aes128_Sha256_RsaOaep. + public string SecurityPolicy { get; init; } = "None"; + + /// Security mode. + public OpcUaSecurityMode SecurityMode { get; init; } = OpcUaSecurityMode.None; + + /// Authentication type. + public OpcUaAuthType AuthType { get; init; } = OpcUaAuthType.Anonymous; + + /// User name (required only for ). + public string? Username { get; init; } + + /// Password (required only for ). + public string? Password { get; init; } + + /// Server-negotiated session timeout. Default 120s per driver-specs.md §8. + public TimeSpan SessionTimeout { get; init; } = TimeSpan.FromSeconds(120); + + /// Client-side keep-alive interval. + public TimeSpan KeepAliveInterval { get; init; } = TimeSpan.FromSeconds(5); + + /// Initial reconnect delay after a session drop. + public TimeSpan ReconnectPeriod { get; init; } = TimeSpan.FromSeconds(5); + + /// + /// When true, the driver accepts any self-signed / untrusted server certificate. + /// Dev-only — must be false in production so MITM attacks against the opc.tcp + /// channel fail closed. + /// + public bool AutoAcceptCertificates { get; init; } = false; + + /// + /// Application URI the driver reports during session creation. Must match the + /// subject-alt-name on the client certificate if one is used, which is why it's a + /// config knob rather than hard-coded. + /// + public string ApplicationUri { get; init; } = "urn:localhost:OtOpcUa:GatewayClient"; + + /// + /// Friendly name sent to the remote server for diagnostics. Shows up in the remote + /// server's session-list so operators can identify which gateway instance is calling. + /// + public string SessionName { get; init; } = "OtOpcUa-Gateway"; + + /// Connect + per-operation timeout. + public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(10); +} + +/// OPC UA message security mode. +public enum OpcUaSecurityMode +{ + None, + Sign, + SignAndEncrypt, +} + +/// User authentication type sent to the remote server. +public enum OpcUaAuthType +{ + Anonymous, + Username, + Certificate, +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj b/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj new file mode 100644 index 0000000..c9f8bcc --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + enable + enable + latest + true + true + $(NoWarn);CS1591 + ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient + ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient + + + + + + + + + + + + + + + + diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientDriverScaffoldTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientDriverScaffoldTests.cs new file mode 100644 index 0000000..c145cd5 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientDriverScaffoldTests.cs @@ -0,0 +1,90 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests; + +/// +/// Scaffold-level tests for that don't require a live +/// remote OPC UA server. PR 67+ adds IReadable/IWritable/ITagDiscovery/ISubscribable +/// tests against a local in-process OPC UA server fixture. +/// +[Trait("Category", "Unit")] +public sealed class OpcUaClientDriverScaffoldTests +{ + [Fact] + public void Default_options_target_standard_opcua_port_and_anonymous_auth() + { + var opts = new OpcUaClientDriverOptions(); + opts.EndpointUrl.ShouldBe("opc.tcp://localhost:4840", "4840 is the IANA-assigned OPC UA port"); + opts.SecurityMode.ShouldBe(OpcUaSecurityMode.None); + opts.AuthType.ShouldBe(OpcUaAuthType.Anonymous); + opts.AutoAcceptCertificates.ShouldBeFalse("production default must reject untrusted server certs"); + } + + [Fact] + public void Default_timeouts_match_driver_specs_section_8() + { + var opts = new OpcUaClientDriverOptions(); + opts.SessionTimeout.ShouldBe(TimeSpan.FromSeconds(120)); + opts.KeepAliveInterval.ShouldBe(TimeSpan.FromSeconds(5)); + opts.ReconnectPeriod.ShouldBe(TimeSpan.FromSeconds(5)); + } + + [Fact] + public void Driver_reports_type_and_id_before_connect() + { + using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-test"); + drv.DriverType.ShouldBe("OpcUaClient"); + drv.DriverInstanceId.ShouldBe("opcua-test"); + drv.GetHealth().State.ShouldBe(DriverState.Unknown); + } + + [Fact] + public async Task Initialize_against_unreachable_endpoint_transitions_to_Faulted_and_throws() + { + // RFC 5737 reserved-for-documentation IP; won't route anywhere. Pick opc.tcp:// so + // endpoint selection hits the transport-layer connection rather than a DNS lookup. + var opts = new OpcUaClientDriverOptions + { + // Port 1 on loopback is effectively guaranteed to be closed — the OS responds + // with TCP RST immediately instead of hanging on connect, which keeps the + // unreachable-host tests snappy. Don't use an RFC 5737 reserved IP; those get + // routed to a black-hole + time out only after the SDK's internal retry/backoff + // fully elapses (~60s even with Options.Timeout=500ms). + EndpointUrl = "opc.tcp://127.0.0.1:1", + Timeout = TimeSpan.FromMilliseconds(500), + AutoAcceptCertificates = true, // dev-mode to bypass cert validation in the test + }; + using var drv = new OpcUaClientDriver(opts, "opcua-unreach"); + + await Should.ThrowAsync(async () => + await drv.InitializeAsync("{}", TestContext.Current.CancellationToken)); + + var health = drv.GetHealth(); + health.State.ShouldBe(DriverState.Faulted); + health.LastError.ShouldNotBeNull(); + } + + [Fact] + public async Task Reinitialize_against_unreachable_endpoint_re_throws() + { + var opts = new OpcUaClientDriverOptions + { + // Port 1 on loopback is effectively guaranteed to be closed — the OS responds + // with TCP RST immediately instead of hanging on connect, which keeps the + // unreachable-host tests snappy. Don't use an RFC 5737 reserved IP; those get + // routed to a black-hole + time out only after the SDK's internal retry/backoff + // fully elapses (~60s even with Options.Timeout=500ms). + EndpointUrl = "opc.tcp://127.0.0.1:1", + Timeout = TimeSpan.FromMilliseconds(500), + AutoAcceptCertificates = true, + }; + using var drv = new OpcUaClientDriver(opts, "opcua-reinit"); + + await Should.ThrowAsync(async () => + await drv.InitializeAsync("{}", TestContext.Current.CancellationToken)); + await Should.ThrowAsync(async () => + await drv.ReinitializeAsync("{}", TestContext.Current.CancellationToken)); + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests.csproj b/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests.csproj new file mode 100644 index 0000000..1b938fd --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests.csproj @@ -0,0 +1,31 @@ + + + + net10.0 + enable + enable + false + true + ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + -- 2.49.1