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
+
+
+
+
+
+
+
+
+
+
+
+
+