feat(dcl): Layer C runtime wires new OPC UA settings through to OPC SDK

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.
This commit is contained in:
Joseph Doherty
2026-05-12 02:26:15 -04:00
parent b60a8ef409
commit e6a5b558f3
3 changed files with 76 additions and 6 deletions

View File

@@ -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);
/// <summary>
/// WP-7: Abstraction over OPC UA client library for testability.

View File

@@ -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;

View File

@@ -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
};
}
/// <summary>