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, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IDisposable, IAsyncDisposable { // ---- ISubscribable + IHostConnectivityProbe state ---- private readonly System.Collections.Concurrent.ConcurrentDictionary _subscriptions = new(); private long _nextSubscriptionId; private readonly object _probeLock = new(); private HostState _hostState = HostState.Unknown; private DateTime _hostStateChangedUtc = DateTime.UtcNow; private KeepAliveEventHandler? _keepAliveHandler; public event EventHandler? OnDataChange; public event EventHandler? OnHostStatusChanged; // OPC UA StatusCode constants the driver surfaces for local-side faults. Upstream-server // StatusCodes are passed through verbatim per driver-specs.md §8 "cascading quality" — // downstream clients need to distinguish 'remote source down' from 'local driver failure'. private const uint StatusBadNodeIdInvalid = 0x80330000u; private const uint StatusBadInternalError = 0x80020000u; private const uint StatusBadCommunicationError = 0x80050000u; 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; // Wire the session's keep-alive channel into HostState. OPC UA keep-alives are // authoritative for session liveness: the SDK pings on KeepAliveInterval and sets // KeepAliveStopped when N intervals elapse without a response. That's strictly // better than a driver-side polling probe — no extra round-trip, no duplicate // semantic. _keepAliveHandler = (_, e) => { var healthy = !ServiceResult.IsBad(e.Status); TransitionTo(healthy ? HostState.Running : HostState.Stopped); }; session.KeepAlive += _keepAliveHandler; Session = session; _health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null); TransitionTo(HostState.Running); } 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) { // Tear down remote subscriptions first — otherwise Session.Close will try and may fail // with BadSubscriptionIdInvalid noise in the upstream log. _subscriptions is cleared // whether or not the wire-side delete succeeds since the local handles are useless // after close anyway. foreach (var rs in _subscriptions.Values) { try { await rs.Subscription.DeleteAsync(silent: true, cancellationToken).ConfigureAwait(false); } catch { /* best-effort */ } } _subscriptions.Clear(); if (_keepAliveHandler is not null && Session is not null) { try { Session.KeepAlive -= _keepAliveHandler; } catch { } } _keepAliveHandler = null; try { if (Session is Session s) await s.CloseAsync(cancellationToken).ConfigureAwait(false); } catch { /* best-effort */ } try { Session?.Dispose(); } catch { } Session = null; TransitionTo(HostState.Unknown); _health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null); } public DriverHealth GetHealth() => _health; public long GetMemoryFootprint() => 0; public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask; // ---- IReadable ---- public async Task> ReadAsync( IReadOnlyList fullReferences, CancellationToken cancellationToken) { var session = RequireSession(); var results = new DataValueSnapshot[fullReferences.Count]; var now = DateTime.UtcNow; // Parse NodeIds up-front. Tags whose reference doesn't parse get BadNodeIdInvalid // and are omitted from the wire request — saves a round-trip against the upstream // server for a fault the driver can detect locally. var toSend = new ReadValueIdCollection(); var indexMap = new List(fullReferences.Count); // maps wire-index -> results-index for (var i = 0; i < fullReferences.Count; i++) { if (!TryParseNodeId(session, fullReferences[i], out var nodeId)) { results[i] = new DataValueSnapshot(null, StatusBadNodeIdInvalid, null, now); continue; } toSend.Add(new ReadValueId { NodeId = nodeId, AttributeId = Attributes.Value }); indexMap.Add(i); } if (toSend.Count == 0) return results; await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); try { try { var resp = await session.ReadAsync( requestHeader: null, maxAge: 0, timestampsToReturn: TimestampsToReturn.Both, nodesToRead: toSend, ct: cancellationToken).ConfigureAwait(false); var values = resp.Results; for (var w = 0; w < values.Count; w++) { var r = indexMap[w]; var dv = values[w]; // Preserve the upstream StatusCode verbatim — including Bad codes per // §8's cascading-quality rule. Also preserve SourceTimestamp so downstream // clients can detect stale upstream data. results[r] = new DataValueSnapshot( Value: dv.Value, StatusCode: dv.StatusCode.Code, SourceTimestampUtc: dv.SourceTimestamp == DateTime.MinValue ? null : dv.SourceTimestamp, ServerTimestampUtc: dv.ServerTimestamp == DateTime.MinValue ? now : dv.ServerTimestamp); } _health = new DriverHealth(DriverState.Healthy, now, null); } catch (Exception ex) { // Transport / timeout / session-dropped — fan out the same fault across every // tag in this batch. Per-tag StatusCode stays BadCommunicationError (not // BadInternalError) so operators distinguish "upstream unreachable" from // "driver bug". for (var w = 0; w < indexMap.Count; w++) { var r = indexMap[w]; results[r] = new DataValueSnapshot(null, StatusBadCommunicationError, null, now); } _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message); } } finally { _gate.Release(); } return results; } // ---- IWritable ---- public async Task> WriteAsync( IReadOnlyList writes, CancellationToken cancellationToken) { var session = RequireSession(); var results = new WriteResult[writes.Count]; var toSend = new WriteValueCollection(); var indexMap = new List(writes.Count); for (var i = 0; i < writes.Count; i++) { if (!TryParseNodeId(session, writes[i].FullReference, out var nodeId)) { results[i] = new WriteResult(StatusBadNodeIdInvalid); continue; } toSend.Add(new WriteValue { NodeId = nodeId, AttributeId = Attributes.Value, Value = new DataValue(new Variant(writes[i].Value)), }); indexMap.Add(i); } if (toSend.Count == 0) return results; await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); try { try { var resp = await session.WriteAsync( requestHeader: null, nodesToWrite: toSend, ct: cancellationToken).ConfigureAwait(false); var codes = resp.Results; for (var w = 0; w < codes.Count; w++) { var r = indexMap[w]; // Pass upstream WriteResult StatusCode through verbatim. Success codes // include Good (0) and any warning-level Good* variants; anything with // the severity bits set is a Bad. results[r] = new WriteResult(codes[w].Code); } } catch (Exception) { for (var w = 0; w < indexMap.Count; w++) results[indexMap[w]] = new WriteResult(StatusBadCommunicationError); } } finally { _gate.Release(); } return results; } /// /// Parse a tag's full-reference string as a NodeId. Accepts the standard OPC UA /// serialized forms (ns=2;s=…, i=2253, ns=4;g=…, ns=3;b=…). /// Empty + malformed strings return false; the driver surfaces that as /// without a wire round-trip. /// internal static bool TryParseNodeId(ISession session, string fullReference, out NodeId nodeId) { nodeId = NodeId.Null; if (string.IsNullOrWhiteSpace(fullReference)) return false; try { nodeId = NodeId.Parse(session.MessageContext, fullReference); return !NodeId.IsNull(nodeId); } catch { return false; } } private ISession RequireSession() => Session ?? throw new InvalidOperationException("OpcUaClientDriver not initialized"); // ---- ITagDiscovery ---- public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(builder); var session = RequireSession(); var root = !string.IsNullOrEmpty(_options.BrowseRoot) ? NodeId.Parse(session.MessageContext, _options.BrowseRoot) : ObjectIds.ObjectsFolder; var rootFolder = builder.Folder("Remote", "Remote"); var visited = new HashSet(); var discovered = 0; await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); try { await BrowseRecursiveAsync(session, root, rootFolder, visited, depth: 0, discovered: () => discovered, increment: () => discovered++, ct: cancellationToken).ConfigureAwait(false); } finally { _gate.Release(); } } private async Task BrowseRecursiveAsync( ISession session, NodeId node, IAddressSpaceBuilder folder, HashSet visited, int depth, Func discovered, Action increment, CancellationToken ct) { if (depth >= _options.MaxBrowseDepth) return; if (discovered() >= _options.MaxDiscoveredNodes) return; if (!visited.Add(node)) return; var browseDescriptions = new BrowseDescriptionCollection { new() { NodeId = node, BrowseDirection = BrowseDirection.Forward, ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences, IncludeSubtypes = true, NodeClassMask = (uint)(NodeClass.Object | NodeClass.Variable), ResultMask = (uint)(BrowseResultMask.BrowseName | BrowseResultMask.DisplayName | BrowseResultMask.NodeClass | BrowseResultMask.TypeDefinition), } }; BrowseResponse resp; try { resp = await session.BrowseAsync( requestHeader: null, view: null, requestedMaxReferencesPerNode: 0, nodesToBrowse: browseDescriptions, ct: ct).ConfigureAwait(false); } catch { // Transient browse failure on a sub-tree — don't kill the whole discovery, just // skip this branch. The driver's health surface will reflect the cascade via the // probe loop (PR 69). return; } if (resp.Results.Count == 0) return; var refs = resp.Results[0].References; foreach (var rf in refs) { if (discovered() >= _options.MaxDiscoveredNodes) break; var childId = ExpandedNodeId.ToNodeId(rf.NodeId, session.NamespaceUris); if (NodeId.IsNull(childId)) continue; var browseName = rf.BrowseName?.Name ?? childId.ToString(); var displayName = rf.DisplayName?.Text ?? browseName; if (rf.NodeClass == NodeClass.Object) { var subFolder = folder.Folder(browseName, displayName); increment(); await BrowseRecursiveAsync(session, childId, subFolder, visited, depth + 1, discovered, increment, ct).ConfigureAwait(false); } else if (rf.NodeClass == NodeClass.Variable) { // Serialize the NodeId so the IReadable/IWritable surface receives a // round-trippable string. Deferring the DataType + AccessLevel fetch to a // follow-up PR — initial browse uses a conservative ViewOnly + Int32 default. var nodeIdString = childId.ToString() ?? string.Empty; folder.Variable(browseName, displayName, new DriverAttributeInfo( FullName: nodeIdString, DriverDataType: DriverDataType.Int32, IsArray: false, ArrayDim: null, SecurityClass: SecurityClassification.ViewOnly, IsHistorized: false, IsAlarm: false)); increment(); } } } // ---- ISubscribable ---- public async Task SubscribeAsync( IReadOnlyList fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken) { var session = RequireSession(); var id = Interlocked.Increment(ref _nextSubscriptionId); var handle = new OpcUaSubscriptionHandle(id); // Floor the publishing interval at 50ms — OPC UA servers routinely negotiate // minimum-supported intervals up anyway, but sending sub-50ms wastes negotiation // bandwidth on every subscription create. var intervalMs = publishingInterval < TimeSpan.FromMilliseconds(50) ? 50 : (int)publishingInterval.TotalMilliseconds; var subscription = new Subscription(telemetry: null!, new SubscriptionOptions { DisplayName = $"opcua-sub-{id}", PublishingInterval = intervalMs, KeepAliveCount = 10, LifetimeCount = 1000, MaxNotificationsPerPublish = 0, PublishingEnabled = true, Priority = 0, TimestampsToReturn = TimestampsToReturn.Both, }); await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); try { session.AddSubscription(subscription); await subscription.CreateAsync(cancellationToken).ConfigureAwait(false); foreach (var fullRef in fullReferences) { if (!TryParseNodeId(session, fullRef, out var nodeId)) continue; // The tag string is routed through MonitoredItem.Handle so the Notification // handler can identify which tag changed without an extra lookup. var item = new MonitoredItem(telemetry: null!, new MonitoredItemOptions { DisplayName = fullRef, StartNodeId = nodeId, AttributeId = Attributes.Value, MonitoringMode = MonitoringMode.Reporting, SamplingInterval = intervalMs, QueueSize = 1, DiscardOldest = true, }) { Handle = fullRef, }; item.Notification += (mi, args) => OnMonitoredItemNotification(handle, mi, args); subscription.AddItem(item); } await subscription.CreateItemsAsync(cancellationToken).ConfigureAwait(false); _subscriptions[id] = new RemoteSubscription(subscription, handle); } finally { _gate.Release(); } return handle; } public async Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken) { if (handle is not OpcUaSubscriptionHandle h) return; if (!_subscriptions.TryRemove(h.Id, out var rs)) return; await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); try { try { await rs.Subscription.DeleteAsync(silent: true, cancellationToken).ConfigureAwait(false); } catch { /* best-effort — the subscription may already be gone on reconnect */ } } finally { _gate.Release(); } } private void OnMonitoredItemNotification(OpcUaSubscriptionHandle handle, MonitoredItem item, MonitoredItemNotificationEventArgs args) { // args.NotificationValue arrives as a MonitoredItemNotification for value-change // subscriptions; extract its DataValue. The Handle property carries our tag string. if (args.NotificationValue is not MonitoredItemNotification mn) return; var dv = mn.Value; if (dv is null) return; var fullRef = (item.Handle as string) ?? item.DisplayName ?? string.Empty; var snapshot = new DataValueSnapshot( Value: dv.Value, StatusCode: dv.StatusCode.Code, SourceTimestampUtc: dv.SourceTimestamp == DateTime.MinValue ? null : dv.SourceTimestamp, ServerTimestampUtc: dv.ServerTimestamp == DateTime.MinValue ? DateTime.UtcNow : dv.ServerTimestamp); OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, fullRef, snapshot)); } private sealed record RemoteSubscription(Subscription Subscription, OpcUaSubscriptionHandle Handle); private sealed record OpcUaSubscriptionHandle(long Id) : ISubscriptionHandle { public string DiagnosticId => $"opcua-sub-{Id}"; } // ---- IHostConnectivityProbe ---- /// Endpoint-URL-keyed host identity for the Admin /hosts dashboard. public string HostName => _options.EndpointUrl; public IReadOnlyList GetHostStatuses() { lock (_probeLock) return [new HostConnectivityStatus(HostName, _hostState, _hostStateChangedUtc)]; } private void TransitionTo(HostState newState) { HostState old; lock (_probeLock) { old = _hostState; if (old == newState) return; _hostState = newState; _hostStateChangedUtc = DateTime.UtcNow; } OnHostStatusChanged?.Invoke(this, new HostStatusChangedEventArgs(HostName, old, newState)); } 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(); } }