From e6a5b558f3422b8cdd39d18f15b8ccdca0e20811 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 12 May 2026 02:26:15 -0400 Subject: [PATCH] feat(dcl): Layer C runtime wires new OPC UA settings through to OPC SDK MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpcUaConnectionOptions record gains DiscardOldest, SubscriptionPriority, SubscriptionDisplayName, TimestampsToReturn, plus OpcUaDeadbandOptions and OpcUaUserIdentityOptions nullable sub-records. OpcUaDataConnection.ConnectAsync copies all new fields from the typed OpcUaEndpointConfig (including the Deadband and UserIdentity sub-objects) into the OpcUaConnectionOptions record. RealOpcUaClient: - BuildUserIdentity translates TokenType into Opc.Ua.UserIdentity: Anonymous → null, UsernamePassword → new UserIdentity(name, utf8(pass)), X509Certificate → new UserIdentity(X509CertificateLoader.LoadPkcs12FromFile(...)). - Subscription uses opts.SubscriptionDisplayName and opts.SubscriptionPriority. - MonitoredItem.DiscardOldest is opts.DiscardOldest (was hardcoded true). - BuildDataChangeFilter materializes a DataChangeFilter when Deadband is set. - ReadAsync uses MapTimestampsToReturn for opts.TimestampsToReturn (was hardcoded Source). X509CertificateLoader replaces obsolete X509Certificate2(string,string) ctor (SYSLIB0057 on .NET 10). UserIdentity(string,byte[]) ctor used because the (string,string) overload was removed in OPC Foundation 1.5.378.106. --- .../Adapters/IOpcUaClient.cs | 17 ++++++- .../Adapters/OpcUaDataConnection.cs | 14 ++++- .../Adapters/RealOpcUaClient.cs | 51 +++++++++++++++++-- 3 files changed, 76 insertions(+), 6 deletions(-) diff --git a/src/ScadaLink.DataConnectionLayer/Adapters/IOpcUaClient.cs b/src/ScadaLink.DataConnectionLayer/Adapters/IOpcUaClient.cs index 1eac814..3eb88b3 100644 --- a/src/ScadaLink.DataConnectionLayer/Adapters/IOpcUaClient.cs +++ b/src/ScadaLink.DataConnectionLayer/Adapters/IOpcUaClient.cs @@ -14,7 +14,22 @@ public record OpcUaConnectionOptions( int SamplingIntervalMs = 1000, int QueueSize = 10, string SecurityMode = "None", - bool AutoAcceptUntrustedCerts = true); + bool AutoAcceptUntrustedCerts = true, + bool DiscardOldest = true, + byte SubscriptionPriority = 0, + string SubscriptionDisplayName = "ScadaLink", + string TimestampsToReturn = "Source", + OpcUaDeadbandOptions? Deadband = null, + OpcUaUserIdentityOptions? UserIdentity = null); + +public record OpcUaDeadbandOptions(string Type, double Value); + +public record OpcUaUserIdentityOptions( + string TokenType, + string Username, + string Password, + string CertificatePath, + string CertificatePassword); /// /// WP-7: Abstraction over OPC UA client library for testability. diff --git a/src/ScadaLink.DataConnectionLayer/Adapters/OpcUaDataConnection.cs b/src/ScadaLink.DataConnectionLayer/Adapters/OpcUaDataConnection.cs index 4dd6d74..4eb1fed 100644 --- a/src/ScadaLink.DataConnectionLayer/Adapters/OpcUaDataConnection.cs +++ b/src/ScadaLink.DataConnectionLayer/Adapters/OpcUaDataConnection.cs @@ -61,7 +61,19 @@ public class OpcUaDataConnection : IDataConnection SamplingIntervalMs: config.SamplingIntervalMs, QueueSize: config.QueueSize, SecurityMode: config.SecurityMode.ToString(), - AutoAcceptUntrustedCerts: config.AutoAcceptUntrustedCerts); + AutoAcceptUntrustedCerts: config.AutoAcceptUntrustedCerts, + DiscardOldest: config.DiscardOldest, + SubscriptionPriority: config.SubscriptionPriority, + SubscriptionDisplayName: config.SubscriptionDisplayName, + TimestampsToReturn: config.TimestampsToReturn.ToString(), + Deadband: config.Deadband is { } db + ? new OpcUaDeadbandOptions(db.Type.ToString(), db.Value) + : null, + UserIdentity: config.UserIdentity is { } ui + ? new OpcUaUserIdentityOptions( + ui.TokenType.ToString(), ui.Username, ui.Password, + ui.CertificatePath, ui.CertificatePassword) + : null); _status = ConnectionHealth.Connecting; diff --git a/src/ScadaLink.DataConnectionLayer/Adapters/RealOpcUaClient.cs b/src/ScadaLink.DataConnectionLayer/Adapters/RealOpcUaClient.cs index de00a8a..5202031 100644 --- a/src/ScadaLink.DataConnectionLayer/Adapters/RealOpcUaClient.cs +++ b/src/ScadaLink.DataConnectionLayer/Adapters/RealOpcUaClient.cs @@ -1,3 +1,4 @@ +using System.Security.Cryptography.X509Certificates; using Opc.Ua; using Opc.Ua.Client; using Opc.Ua.Configuration; @@ -77,9 +78,10 @@ public class RealOpcUaClient : IOpcUaClient #pragma warning disable CS0618 // Allow obsolete DefaultSessionFactory constructor for compatibility var sessionFactory = new DefaultSessionFactory(); #pragma warning restore CS0618 + var userIdentity = BuildUserIdentity(opts.UserIdentity); _session = await sessionFactory.CreateAsync( appConfig, configuredEndpoint, false, - "ScadaLink-DCL-Session", (uint)opts.SessionTimeoutMs, null, null, cancellationToken); + "ScadaLink-DCL-Session", (uint)opts.SessionTimeoutMs, userIdentity, null, cancellationToken); // Detect server going offline via keep-alive failures _connectionLostFired = false; @@ -91,7 +93,8 @@ public class RealOpcUaClient : IOpcUaClient // Create a default subscription for all monitored items _subscription = new Subscription(_session.DefaultSubscription) { - DisplayName = "ScadaLink", + DisplayName = opts.SubscriptionDisplayName, + Priority = opts.SubscriptionPriority, PublishingEnabled = true, PublishingInterval = opts.PublishingIntervalMs, KeepAliveCount = (uint)opts.KeepAliveCount, @@ -135,7 +138,8 @@ public class RealOpcUaClient : IOpcUaClient AttributeId = Attributes.Value, SamplingInterval = _options.SamplingIntervalMs, QueueSize = (uint)_options.QueueSize, - DiscardOldest = true + DiscardOldest = _options.DiscardOldest, + Filter = BuildDataChangeFilter(_options.Deadband) }; _callbacks[handle] = onValueChanged; @@ -185,7 +189,7 @@ public class RealOpcUaClient : IOpcUaClient }; var response = await _session.ReadAsync( - null, 0, TimestampsToReturn.Source, + null, 0, MapTimestampsToReturn(_options.TimestampsToReturn), new ReadValueIdCollection { readValue }, cancellationToken); var result = response.Results[0]; @@ -227,6 +231,45 @@ public class RealOpcUaClient : IOpcUaClient { await DisconnectAsync(); } + + private static UserIdentity? BuildUserIdentity(OpcUaUserIdentityOptions? options) + { + if (options is null) return null; + return options.TokenType.ToUpperInvariant() switch + { + "USERNAMEPASSWORD" => new UserIdentity( + options.Username, + System.Text.Encoding.UTF8.GetBytes(options.Password ?? "")), + "X509CERTIFICATE" => new UserIdentity( + X509CertificateLoader.LoadPkcs12FromFile( + options.CertificatePath, options.CertificatePassword)), + _ => null + }; + } + + private static MonitoringFilter? BuildDataChangeFilter(OpcUaDeadbandOptions? deadband) + { + if (deadband is null) return null; + var deadbandType = deadband.Type.ToUpperInvariant() switch + { + "PERCENT" => DeadbandType.Percent, + _ => DeadbandType.Absolute + }; + return new DataChangeFilter + { + Trigger = DataChangeTrigger.StatusValue, + DeadbandType = (uint)deadbandType, + DeadbandValue = deadband.Value + }; + } + + private static TimestampsToReturn MapTimestampsToReturn(string mode) => + mode.ToUpperInvariant() switch + { + "SERVER" => TimestampsToReturn.Server, + "BOTH" => TimestampsToReturn.Both, + _ => TimestampsToReturn.Source + }; } ///