7 Commits

Author SHA1 Message Date
Joseph Doherty 22d91c858a feat(ui): Layer E2 OpcUaEndpointEditor gains Authentication / Advanced / Deadband sections
Three new sections inserted into <OpcUaEndpointEditor>:

1. Authentication (between the existing Connection row and Timing)
   - 'Enable Authentication' button when Config.UserIdentity is null
   - TokenType select (Anonymous / UsernamePassword / X509Certificate)
   - Conditional Username + Password inputs for UsernamePassword
   - Conditional Certificate path + Certificate password for X509Certificate
   - 'Remove Authentication' button

2. Advanced subscription (after the existing Subscription row)
   - Subscription display name (text)
   - Subscription priority (number 0-255)
   - Timestamps to return (Source / Server / Both select)
   - Discard oldest (checkbox)

3. Deadband filter (after Advanced subscription)
   - 'Enable Deadband' button when Config.Deadband is null
   - Type select (Absolute / Percent), Value number input
   - 'Remove Deadband' button

EnableAuthentication and EnableDeadband helpers complement EnableHeartbeat.
All new fields use the existing RenderFieldError helper for validator errors.

82/82 CentralUI tests pass (the 10 new editor tests drove the design).
2026-05-12 02:30:06 -04:00
Joseph Doherty f89f234558 test(ui): failing bUnit tests for OpcUaEndpointEditor new sections
Adds 10 new tests covering:
- Authentication section label + Enable/Remove toggle (creates/nulls UserIdentity)
- TokenType conditional rendering: UsernamePassword shows Username/Password,
  X509Certificate shows Certificate path/password, Anonymous shows no extras
- Deadband Enable/Remove toggle
- Advanced Subscription section labels (Discard oldest, Subscription display
  name, Subscription priority, Timestamps to return)
- UserIdentity per-field error rendering under Username

9 new tests fail because the editor component hasn't been extended yet
(TDD red phase). Layer E2 implements the sections.
2026-05-12 02:28:47 -04:00
Joseph Doherty 8faaa8fe2b feat(dcl): Layer D OpcUaGlobalOptions for app-wide identity + cert paths
New deployment-wide options bound from the "OpcUa" section of appsettings.json:
- ApplicationName (default "ScadaLink-DCL")
- TrustedIssuerStorePath / TrustedPeerStorePath / RejectedCertificateStorePath

Empty paths fall back to Path.GetTempPath()/ScadaLink/pki/* so dev runs work
without explicit config — same defaults the hardcoded values previously used.

Wiring:
- ServiceCollectionExtensions binds OpcUaGlobalOptions to the OpcUa section.
- DataConnectionFactory takes IOptions<OpcUaGlobalOptions> and constructs
  RealOpcUaClientFactory with the snapshot.
- RealOpcUaClient(globalOptions) replaces the hardcoded ApplicationName and
  the three CertificateTrustList store paths in ApplicationConfiguration.
- Parameterless ctors on factory and client preserved for the existing test
  suite (32/32 DCL tests still green).
2026-05-12 02:27:58 -04:00
Joseph Doherty e6a5b558f3 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.
2026-05-12 02:26:15 -04:00
Joseph Doherty b60a8ef409 feat(commons): Layer B serializer + validator handle new OPC UA settings
OpcUaEndpointConfigSerializer:
- ToFlatDict emits new scalar keys (DiscardOldest, SubscriptionPriority,
  SubscriptionDisplayName, TimestampsToReturn).
- ToFlatDict emits dotted sub-object keys (UserIdentity.TokenType / Username /
  Password / CertificatePath / CertificatePassword, Deadband.Type / Value)
  when those sub-objects are non-null.
- FromFlatDict reads the same keys back; missing keys preserve POCO defaults.
- Deadband.Value uses InvariantCulture for double parsing/formatting.

OpcUaEndpointConfigValidator:
- SubscriptionDisplayName required (non-empty).
- UserIdentity.UsernamePassword requires Username.
- UserIdentity.X509Certificate requires CertificatePath.
- Deadband.Value must be > 0 when Deadband is set.
- fieldPrefix propagates through sub-object error EntityNames.

Drives the 11 previously-failing tests green; 51/51 in the suite now pass.
2026-05-12 02:22:51 -04:00
Joseph Doherty 91450ec390 test(commons): failing tests for Layer B serializer + validator extensions
Adds 11 new tests covering:
- Roundtrip of DiscardOldest/SubscriptionPriority/SubscriptionDisplayName/TimestampsToReturn
- Roundtrip of UserIdentity sub-object across all three TokenTypes
- Roundtrip of Deadband sub-object
- ToFlatDict/FromFlatDict for UserIdentity.* and Deadband.* dotted keys
- Validator rules: empty SubscriptionDisplayName, UsernamePassword w/o Username,
  X509 w/o CertificatePath, Deadband Value <= 0, prefix propagation

Build passes; tests fail because serializer/validator have not been extended yet
(TDD red phase). Task B2 will implement the changes to drive them green.
2026-05-12 02:21:33 -04:00
Joseph Doherty 16f7ab0d0a feat(commons): extend OpcUaEndpointConfig with auth, subscription tuning, read/filter knobs
Adds POCOs and enums for upcoming OPC UA editor expansion:
- OpcUaUserTokenType (Anonymous | UsernamePassword | X509Certificate)
- OpcUaUserIdentityConfig (TokenType + Username/Password + CertificatePath/Password)
- OpcUaDeadbandType (Absolute | Percent) + OpcUaDeadbandConfig
- OpcUaTimestampsToReturn (Source | Server | Both)

OpcUaEndpointConfig grows three new scalars (DiscardOldest, SubscriptionPriority,
SubscriptionDisplayName) plus optional UserIdentity and Deadband sub-objects.
Defaults preserve current runtime behavior (anonymous, no deadband, DiscardOldest=true).
2026-05-12 02:20:12 -04:00
18 changed files with 810 additions and 12 deletions
@@ -40,6 +40,61 @@
</div>
</div>
<div class="text-muted small mt-2 mb-1">Authentication</div>
@if (Config.UserIdentity is null)
{
<button type="button" class="btn btn-outline-secondary btn-sm mb-2"
@onclick="EnableAuthentication">Enable Authentication</button>
}
else
{
<div class="row g-2 mb-2">
<div class="col-md-3">
<label class="form-label small">Token type</label>
<select class="form-select form-select-sm" @bind="Config.UserIdentity.TokenType">
<option value="@OpcUaUserTokenType.Anonymous">Anonymous</option>
<option value="@OpcUaUserTokenType.UsernamePassword">Username / Password</option>
<option value="@OpcUaUserTokenType.X509Certificate">X.509 Certificate</option>
</select>
</div>
@if (Config.UserIdentity.TokenType == OpcUaUserTokenType.UsernamePassword)
{
<div class="col-md-3">
<label class="form-label small">Username</label>
<input type="text" class="form-control form-control-sm"
@bind="Config.UserIdentity.Username" />
@RenderFieldError("UserIdentity.Username")
</div>
<div class="col-md-3">
<label class="form-label small">Password</label>
<input type="password" class="form-control form-control-sm"
@bind="Config.UserIdentity.Password" />
</div>
}
else if (Config.UserIdentity.TokenType == OpcUaUserTokenType.X509Certificate)
{
<div class="col-md-4">
<label class="form-label small">Certificate path</label>
<input type="text" class="form-control form-control-sm"
@bind="Config.UserIdentity.CertificatePath"
placeholder="/etc/scadalink/pki/client.pfx" />
@RenderFieldError("UserIdentity.CertificatePath")
</div>
<div class="col-md-3">
<label class="form-label small">Certificate password</label>
<input type="password" class="form-control form-control-sm"
@bind="Config.UserIdentity.CertificatePassword" />
</div>
}
<div class="col-md-3 d-flex align-items-end">
<button type="button" class="btn btn-outline-danger btn-sm"
@onclick="() => Config.UserIdentity = null">
Remove Authentication
</button>
</div>
</div>
}
<div class="text-muted small mt-2 mb-1">Timing</div>
<div class="row g-2 mb-2">
<div class="col-md-3">
@@ -96,6 +151,69 @@
</div>
</div>
<div class="text-muted small mt-2 mb-1">Advanced subscription</div>
<div class="row g-2 mb-2">
<div class="col-md-3">
<label class="form-label small">Subscription display name</label>
<input type="text" class="form-control form-control-sm"
@bind="Config.SubscriptionDisplayName" />
@RenderFieldError("SubscriptionDisplayName")
</div>
<div class="col-md-2">
<label class="form-label small">Subscription priority</label>
<input type="number" class="form-control form-control-sm"
@bind="Config.SubscriptionPriority" min="0" max="255" />
</div>
<div class="col-md-3">
<label class="form-label small">Timestamps to return</label>
<select class="form-select form-select-sm" @bind="Config.TimestampsToReturn">
<option value="@OpcUaTimestampsToReturn.Source">Source</option>
<option value="@OpcUaTimestampsToReturn.Server">Server</option>
<option value="@OpcUaTimestampsToReturn.Both">Both</option>
</select>
</div>
<div class="col-md-2 d-flex align-items-end">
<div class="form-check">
<input class="form-check-input" type="checkbox"
id="@($"{IdPrefix}-discardoldest")"
@bind="Config.DiscardOldest" />
<label class="form-check-label small"
for="@($"{IdPrefix}-discardoldest")">Discard oldest</label>
</div>
</div>
</div>
<div class="text-muted small mt-2 mb-1">Deadband filter</div>
@if (Config.Deadband is null)
{
<button type="button" class="btn btn-outline-secondary btn-sm mb-2"
@onclick="EnableDeadband">Enable Deadband</button>
}
else
{
<div class="row g-2 mb-2">
<div class="col-md-3">
<label class="form-label small">Type</label>
<select class="form-select form-select-sm" @bind="Config.Deadband.Type">
<option value="@OpcUaDeadbandType.Absolute">Absolute</option>
<option value="@OpcUaDeadbandType.Percent">Percent</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label small">Value</label>
<input type="number" step="0.01" class="form-control form-control-sm"
@bind="Config.Deadband.Value" min="0" />
@RenderFieldError("Deadband.Value")
</div>
<div class="col-md-3 d-flex align-items-end">
<button type="button" class="btn btn-outline-danger btn-sm"
@onclick="() => Config.Deadband = null">
Remove Deadband
</button>
</div>
</div>
}
<div class="text-muted small mt-2 mb-1">Heartbeat</div>
@if (Config.Heartbeat is null)
{
@@ -138,6 +256,12 @@
private void EnableHeartbeat() =>
Config.Heartbeat = new OpcUaHeartbeatConfig();
private void EnableAuthentication() =>
Config.UserIdentity = new OpcUaUserIdentityConfig();
private void EnableDeadband() =>
Config.Deadband = new OpcUaDeadbandConfig();
private RenderFragment? RenderFieldError(string field)
{
var match = Errors?.Errors.FirstOrDefault(e =>
@@ -70,12 +70,29 @@ public static class OpcUaEndpointConfigSerializer
["KeepAliveCount"] = config.KeepAliveCount.ToString(),
["LifetimeCount"] = config.LifetimeCount.ToString(),
["MaxNotificationsPerPublish"] = config.MaxNotificationsPerPublish.ToString(),
["DiscardOldest"] = config.DiscardOldest.ToString(),
["SubscriptionPriority"] = config.SubscriptionPriority.ToString(),
["SubscriptionDisplayName"] = config.SubscriptionDisplayName,
["TimestampsToReturn"] = config.TimestampsToReturn.ToString(),
};
if (config.Heartbeat is { } hb)
{
dict["HeartbeatTagPath"] = hb.TagPath;
dict["HeartbeatMaxSilence"] = hb.MaxSilenceSeconds.ToString();
}
if (config.UserIdentity is { } ui)
{
dict["UserIdentity.TokenType"] = ui.TokenType.ToString();
dict["UserIdentity.Username"] = ui.Username;
dict["UserIdentity.Password"] = ui.Password;
dict["UserIdentity.CertificatePath"] = ui.CertificatePath;
dict["UserIdentity.CertificatePassword"] = ui.CertificatePassword;
}
if (config.Deadband is { } db)
{
dict["Deadband.Type"] = db.Type.ToString();
dict["Deadband.Value"] = db.Value.ToString(System.Globalization.CultureInfo.InvariantCulture);
}
return dict;
}
@@ -105,6 +122,16 @@ public static class OpcUaEndpointConfigSerializer
TryAssignInt(dict, "LifetimeCount", v => c.LifetimeCount = v);
TryAssignInt(dict, "MaxNotificationsPerPublish", v => c.MaxNotificationsPerPublish = v);
if (dict.TryGetValue("DiscardOldest", out var doStr) && bool.TryParse(doStr, out var doVal))
c.DiscardOldest = doVal;
if (dict.TryGetValue("SubscriptionPriority", out var spStr) && byte.TryParse(spStr, out var spVal))
c.SubscriptionPriority = spVal;
if (dict.TryGetValue("SubscriptionDisplayName", out var sdnStr) && !string.IsNullOrWhiteSpace(sdnStr))
c.SubscriptionDisplayName = sdnStr;
if (dict.TryGetValue("TimestampsToReturn", out var ttrStr)
&& Enum.TryParse<OpcUaTimestampsToReturn>(ttrStr, ignoreCase: true, out var ttr))
c.TimestampsToReturn = ttr;
if (dict.TryGetValue("HeartbeatTagPath", out var hbPath)
&& !string.IsNullOrWhiteSpace(hbPath))
{
@@ -113,6 +140,28 @@ public static class OpcUaEndpointConfigSerializer
c.Heartbeat = hb;
}
if (dict.TryGetValue("UserIdentity.TokenType", out var uiTt)
&& Enum.TryParse<OpcUaUserTokenType>(uiTt, ignoreCase: true, out var tokenType))
{
var ui = new OpcUaUserIdentityConfig { TokenType = tokenType };
if (dict.TryGetValue("UserIdentity.Username", out var u)) ui.Username = u;
if (dict.TryGetValue("UserIdentity.Password", out var p)) ui.Password = p;
if (dict.TryGetValue("UserIdentity.CertificatePath", out var cp)) ui.CertificatePath = cp;
if (dict.TryGetValue("UserIdentity.CertificatePassword", out var cpw)) ui.CertificatePassword = cpw;
c.UserIdentity = ui;
}
if (dict.TryGetValue("Deadband.Type", out var dbT)
&& Enum.TryParse<OpcUaDeadbandType>(dbT, ignoreCase: true, out var dbType))
{
var db = new OpcUaDeadbandConfig { Type = dbType };
if (dict.TryGetValue("Deadband.Value", out var dbV)
&& double.TryParse(dbV, System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out var dbVal))
db.Value = dbVal;
c.Deadband = db;
}
return c;
}
@@ -0,0 +1,7 @@
namespace ScadaLink.Commons.Types.DataConnections;
public sealed class OpcUaDeadbandConfig
{
public OpcUaDeadbandType Type { get; set; } = OpcUaDeadbandType.Absolute;
public double Value { get; set; } = 0.0;
}
@@ -0,0 +1,7 @@
namespace ScadaLink.Commons.Types.DataConnections;
public enum OpcUaDeadbandType
{
Absolute,
Percent
}
@@ -18,6 +18,16 @@ public sealed class OpcUaEndpointConfig
public int KeepAliveCount { get; set; } = 10;
public int LifetimeCount { get; set; } = 30;
public int MaxNotificationsPerPublish { get; set; } = 100;
public bool DiscardOldest { get; set; } = true;
public byte SubscriptionPriority { get; set; } = 0;
public string SubscriptionDisplayName { get; set; } = "ScadaLink";
// Read / filter
public OpcUaTimestampsToReturn TimestampsToReturn { get; set; } = OpcUaTimestampsToReturn.Source;
public OpcUaDeadbandConfig? Deadband { get; set; }
// Authentication (optional; null = anonymous)
public OpcUaUserIdentityConfig? UserIdentity { get; set; }
// Heartbeat (optional)
public OpcUaHeartbeatConfig? Heartbeat { get; set; }
@@ -0,0 +1,8 @@
namespace ScadaLink.Commons.Types.DataConnections;
public enum OpcUaTimestampsToReturn
{
Source,
Server,
Both
}
@@ -0,0 +1,10 @@
namespace ScadaLink.Commons.Types.DataConnections;
public sealed class OpcUaUserIdentityConfig
{
public OpcUaUserTokenType TokenType { get; set; } = OpcUaUserTokenType.Anonymous;
public string Username { get; set; } = "";
public string Password { get; set; } = "";
public string CertificatePath { get; set; } = "";
public string CertificatePassword { get; set; } = "";
}
@@ -0,0 +1,8 @@
namespace ScadaLink.Commons.Types.DataConnections;
public enum OpcUaUserTokenType
{
Anonymous,
UsernamePassword,
X509Certificate
}
@@ -40,6 +40,10 @@ public static class OpcUaEndpointConfigValidator
if (config.MaxNotificationsPerPublish < 1)
errors.Add(Err("MaxNotificationsPerPublish", "Must be ≥ 1."));
if (string.IsNullOrWhiteSpace(config.SubscriptionDisplayName))
errors.Add(Err("SubscriptionDisplayName",
"Subscription display name is required."));
if (config.Heartbeat is { } hb)
{
if (string.IsNullOrWhiteSpace(hb.TagPath))
@@ -49,6 +53,21 @@ public static class OpcUaEndpointConfigValidator
errors.Add(Err("Heartbeat.MaxSilenceSeconds", "Must be > 0."));
}
if (config.UserIdentity is { } ui)
{
if (ui.TokenType == OpcUaUserTokenType.UsernamePassword
&& string.IsNullOrWhiteSpace(ui.Username))
errors.Add(Err("UserIdentity.Username",
"Username is required when token type is UsernamePassword."));
if (ui.TokenType == OpcUaUserTokenType.X509Certificate
&& string.IsNullOrWhiteSpace(ui.CertificatePath))
errors.Add(Err("UserIdentity.CertificatePath",
"Certificate path is required when token type is X509Certificate."));
}
if (config.Deadband is { } db && db.Value <= 0)
errors.Add(Err("Deadband.Value", "Must be > 0."));
return errors.Count == 0
? ValidationResult.Success()
: ValidationResult.FromErrors(errors.ToArray());
@@ -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.
@@ -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;
@@ -1,3 +1,4 @@
using System.Security.Cryptography.X509Certificates;
using Opc.Ua;
using Opc.Ua.Client;
using Opc.Ua.Configuration;
@@ -16,6 +17,12 @@ public class RealOpcUaClient : IOpcUaClient
private readonly Dictionary<string, Action<string, object?, DateTime, uint>> _callbacks = new();
private volatile bool _connectionLostFired;
private OpcUaConnectionOptions _options = new();
private readonly OpcUaGlobalOptions _globalOptions;
public RealOpcUaClient(OpcUaGlobalOptions? globalOptions = null)
{
_globalOptions = globalOptions ?? new OpcUaGlobalOptions();
}
public bool IsConnected => _session?.Connected ?? false;
public event Action? ConnectionLost;
@@ -33,15 +40,17 @@ public class RealOpcUaClient : IOpcUaClient
var appConfig = new ApplicationConfiguration
{
ApplicationName = "ScadaLink-DCL",
ApplicationName = string.IsNullOrWhiteSpace(_globalOptions.ApplicationName)
? "ScadaLink-DCL"
: _globalOptions.ApplicationName,
ApplicationType = ApplicationType.Client,
SecurityConfiguration = new SecurityConfiguration
{
AutoAcceptUntrustedCertificates = opts.AutoAcceptUntrustedCerts,
ApplicationCertificate = new CertificateIdentifier(),
TrustedIssuerCertificates = new CertificateTrustList { StorePath = Path.Combine(Path.GetTempPath(), "ScadaLink", "pki", "issuers") },
TrustedPeerCertificates = new CertificateTrustList { StorePath = Path.Combine(Path.GetTempPath(), "ScadaLink", "pki", "trusted") },
RejectedCertificateStore = new CertificateTrustList { StorePath = Path.Combine(Path.GetTempPath(), "ScadaLink", "pki", "rejected") }
TrustedIssuerCertificates = new CertificateTrustList { StorePath = ResolveStorePath(_globalOptions.TrustedIssuerStorePath, "issuers") },
TrustedPeerCertificates = new CertificateTrustList { StorePath = ResolveStorePath(_globalOptions.TrustedPeerStorePath, "trusted") },
RejectedCertificateStore = new CertificateTrustList { StorePath = ResolveStorePath(_globalOptions.RejectedCertificateStorePath, "rejected") }
},
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = opts.SessionTimeoutMs },
TransportQuotas = new TransportQuotas { OperationTimeout = opts.OperationTimeoutMs }
@@ -77,9 +86,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 +101,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 +146,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 +197,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 +239,50 @@ 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
};
private static string ResolveStorePath(string configured, string fallbackLeaf) =>
string.IsNullOrWhiteSpace(configured)
? Path.Combine(Path.GetTempPath(), "ScadaLink", "pki", fallbackLeaf)
: configured;
}
/// <summary>
@@ -234,5 +290,13 @@ public class RealOpcUaClient : IOpcUaClient
/// </summary>
public class RealOpcUaClientFactory : IOpcUaClientFactory
{
public IOpcUaClient Create() => new RealOpcUaClient();
private readonly OpcUaGlobalOptions _globalOptions;
public RealOpcUaClientFactory() : this(new OpcUaGlobalOptions()) { }
public RealOpcUaClientFactory(OpcUaGlobalOptions globalOptions)
{
_globalOptions = globalOptions;
}
public IOpcUaClient Create() => new RealOpcUaClient(_globalOptions);
}
@@ -1,4 +1,5 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ScadaLink.Commons.Interfaces.Protocol;
using ScadaLink.DataConnectionLayer.Adapters;
@@ -14,12 +15,17 @@ public class DataConnectionFactory : IDataConnectionFactory
private readonly ILoggerFactory _loggerFactory;
public DataConnectionFactory(ILoggerFactory loggerFactory)
: this(loggerFactory, Options.Create(new OpcUaGlobalOptions())) { }
public DataConnectionFactory(ILoggerFactory loggerFactory, IOptions<OpcUaGlobalOptions> opcUaGlobalOptions)
{
_loggerFactory = loggerFactory;
var globalOptions = opcUaGlobalOptions.Value;
// Register built-in protocols
RegisterAdapter("OpcUa", details => new OpcUaDataConnection(
new RealOpcUaClientFactory(), _loggerFactory.CreateLogger<OpcUaDataConnection>()));
new RealOpcUaClientFactory(globalOptions),
_loggerFactory.CreateLogger<OpcUaDataConnection>()));
}
/// <summary>
@@ -0,0 +1,15 @@
namespace ScadaLink.DataConnectionLayer;
/// <summary>
/// Deployment-wide OPC UA application identity. Bound from the "OpcUa" section
/// of appsettings.json. Per-endpoint behavior lives on OpcUaEndpointConfig.
/// Empty paths fall back to a default under Path.GetTempPath() so dev runs
/// work without explicit configuration.
/// </summary>
public class OpcUaGlobalOptions
{
public string ApplicationName { get; set; } = "ScadaLink-DCL";
public string TrustedIssuerStorePath { get; set; } = "";
public string TrustedPeerStorePath { get; set; } = "";
public string RejectedCertificateStorePath { get; set; } = "";
}
@@ -9,6 +9,9 @@ public static class ServiceCollectionExtensions
services.AddOptions<DataConnectionOptions>()
.BindConfiguration("DataConnectionLayer");
services.AddOptions<OpcUaGlobalOptions>()
.BindConfiguration("OpcUa");
// WP-34: Register the factory for protocol extensibility
services.AddSingleton<IDataConnectionFactory, DataConnectionFactory>();
@@ -82,4 +82,150 @@ public class OpcUaEndpointEditorTests : BunitContext
Assert.Contains("alert-warning", cut.Markup);
Assert.Contains("migrated from a legacy format", cut.Markup);
}
// ── Layer E: new editor sections ──
[Fact]
public void Renders_Authentication_Section_Label()
{
var cut = Render<OpcUaEndpointEditor>(p => p.Add(c => c.Config, new OpcUaEndpointConfig()));
Assert.Contains("Authentication", cut.Markup);
}
[Fact]
public void EnableAuthentication_CreatesUserIdentitySubObject()
{
var config = new OpcUaEndpointConfig();
var cut = Render<OpcUaEndpointEditor>(p => p.Add(c => c.Config, config));
Assert.Null(config.UserIdentity);
cut.FindAll("button").First(b => b.TextContent.Contains("Enable Authentication")).Click();
Assert.NotNull(config.UserIdentity);
Assert.Equal(OpcUaUserTokenType.Anonymous, config.UserIdentity!.TokenType);
}
[Fact]
public void RemoveAuthentication_NullsUserIdentity()
{
var config = new OpcUaEndpointConfig
{
UserIdentity = new OpcUaUserIdentityConfig
{
TokenType = OpcUaUserTokenType.UsernamePassword,
Username = "alice",
Password = "secret"
}
};
var cut = Render<OpcUaEndpointEditor>(p => p.Add(c => c.Config, config));
cut.FindAll("button").First(b => b.TextContent.Contains("Remove Authentication")).Click();
Assert.Null(config.UserIdentity);
}
[Fact]
public void UsernamePassword_RendersUsernameAndPasswordInputs()
{
var config = new OpcUaEndpointConfig
{
UserIdentity = new OpcUaUserIdentityConfig
{
TokenType = OpcUaUserTokenType.UsernamePassword
}
};
var cut = Render<OpcUaEndpointEditor>(p => p.Add(c => c.Config, config));
// <label>Username</label> only renders for the UsernamePassword branch
Assert.Contains(">Username<", cut.Markup);
Assert.Contains(">Password<", cut.Markup);
Assert.DoesNotContain(">Certificate path<", cut.Markup);
}
[Fact]
public void X509Certificate_RendersCertificateFields()
{
var config = new OpcUaEndpointConfig
{
UserIdentity = new OpcUaUserIdentityConfig
{
TokenType = OpcUaUserTokenType.X509Certificate
}
};
var cut = Render<OpcUaEndpointEditor>(p => p.Add(c => c.Config, config));
Assert.Contains(">Certificate path<", cut.Markup);
Assert.Contains(">Certificate password<", cut.Markup);
Assert.DoesNotContain(">Username<", cut.Markup);
}
[Fact]
public void AnonymousTokenType_ShowsNoExtraFields()
{
var config = new OpcUaEndpointConfig
{
UserIdentity = new OpcUaUserIdentityConfig { TokenType = OpcUaUserTokenType.Anonymous }
};
var cut = Render<OpcUaEndpointEditor>(p => p.Add(c => c.Config, config));
Assert.DoesNotContain(">Username<", cut.Markup);
Assert.DoesNotContain(">Certificate path<", cut.Markup);
}
[Fact]
public void EnableDeadband_CreatesDeadbandSubObject()
{
var config = new OpcUaEndpointConfig();
var cut = Render<OpcUaEndpointEditor>(p => p.Add(c => c.Config, config));
Assert.Null(config.Deadband);
cut.FindAll("button").First(b => b.TextContent.Contains("Enable Deadband")).Click();
Assert.NotNull(config.Deadband);
}
[Fact]
public void RemoveDeadband_NullsDeadband()
{
var config = new OpcUaEndpointConfig
{
Deadband = new OpcUaDeadbandConfig { Type = OpcUaDeadbandType.Percent, Value = 1.5 }
};
var cut = Render<OpcUaEndpointEditor>(p => p.Add(c => c.Config, config));
cut.FindAll("button").First(b => b.TextContent.Contains("Remove Deadband")).Click();
Assert.Null(config.Deadband);
}
[Fact]
public void AdvancedSubscription_Section_Renders()
{
var config = new OpcUaEndpointConfig();
var cut = Render<OpcUaEndpointEditor>(p => p.Add(c => c.Config, config));
Assert.Contains("Discard oldest", cut.Markup);
Assert.Contains("Subscription display name", cut.Markup);
Assert.Contains("Subscription priority", cut.Markup);
Assert.Contains("Timestamps to return", cut.Markup);
}
[Fact]
public void UserIdentityError_RendersPerFieldUnderUsername()
{
var config = new OpcUaEndpointConfig
{
UserIdentity = new OpcUaUserIdentityConfig
{
TokenType = OpcUaUserTokenType.UsernamePassword,
Username = ""
}
};
var errors = OpcUaEndpointConfigValidator.Validate(config, "Primary.");
var cut = Render<OpcUaEndpointEditor>(p => p
.Add(c => c.Config, config)
.Add(c => c.Errors, errors));
Assert.Contains("Username is required", cut.Markup);
}
}
@@ -197,4 +197,207 @@ public class OpcUaEndpointConfigSerializerTests
Assert.NotNull(config.Heartbeat);
Assert.Equal("Hb", config.Heartbeat!.TagPath);
}
// ── Layer A/B extensions: subscription tuning, auth, deadband ──
[Fact]
public void Serialize_RoundtripsNewSubscriptionScalars()
{
var original = new OpcUaEndpointConfig
{
EndpointUrl = "opc.tcp://x:4840",
DiscardOldest = false,
SubscriptionPriority = 200,
SubscriptionDisplayName = "ScadaLink-Primary",
TimestampsToReturn = OpcUaTimestampsToReturn.Both
};
var json = OpcUaEndpointConfigSerializer.Serialize(original);
var (round, isLegacy) = OpcUaEndpointConfigSerializer.Deserialize(json);
Assert.False(isLegacy);
Assert.False(round.DiscardOldest);
Assert.Equal((byte)200, round.SubscriptionPriority);
Assert.Equal("ScadaLink-Primary", round.SubscriptionDisplayName);
Assert.Equal(OpcUaTimestampsToReturn.Both, round.TimestampsToReturn);
}
[Fact]
public void Serialize_RoundtripsDeadband()
{
var original = new OpcUaEndpointConfig
{
EndpointUrl = "opc.tcp://x:4840",
Deadband = new OpcUaDeadbandConfig
{
Type = OpcUaDeadbandType.Percent,
Value = 2.5
}
};
var json = OpcUaEndpointConfigSerializer.Serialize(original);
var (round, _) = OpcUaEndpointConfigSerializer.Deserialize(json);
Assert.NotNull(round.Deadband);
Assert.Equal(OpcUaDeadbandType.Percent, round.Deadband!.Type);
Assert.Equal(2.5, round.Deadband.Value);
}
[Theory]
[InlineData(OpcUaUserTokenType.UsernamePassword, "user1", "pass1", "", "")]
[InlineData(OpcUaUserTokenType.X509Certificate, "", "", "/etc/pki/client.pfx", "pfxpass")]
[InlineData(OpcUaUserTokenType.Anonymous, "", "", "", "")]
public void Serialize_RoundtripsUserIdentity(
OpcUaUserTokenType tokenType, string user, string pass, string certPath, string certPass)
{
var original = new OpcUaEndpointConfig
{
EndpointUrl = "opc.tcp://x:4840",
UserIdentity = new OpcUaUserIdentityConfig
{
TokenType = tokenType,
Username = user,
Password = pass,
CertificatePath = certPath,
CertificatePassword = certPass
}
};
var json = OpcUaEndpointConfigSerializer.Serialize(original);
var (round, _) = OpcUaEndpointConfigSerializer.Deserialize(json);
Assert.NotNull(round.UserIdentity);
Assert.Equal(tokenType, round.UserIdentity!.TokenType);
Assert.Equal(user, round.UserIdentity.Username);
Assert.Equal(pass, round.UserIdentity.Password);
Assert.Equal(certPath, round.UserIdentity.CertificatePath);
Assert.Equal(certPass, round.UserIdentity.CertificatePassword);
}
[Fact]
public void Serialize_NullUserIdentityAndDeadband_OmittedFromTypedJson()
{
// Default config: UserIdentity and Deadband are null. Roundtrip should
// preserve nulls (anonymous = no auth needed in flattened JSON either).
var original = new OpcUaEndpointConfig { EndpointUrl = "opc.tcp://x:4840" };
var json = OpcUaEndpointConfigSerializer.Serialize(original);
var (round, _) = OpcUaEndpointConfigSerializer.Deserialize(json);
Assert.Null(round.UserIdentity);
Assert.Null(round.Deadband);
}
[Fact]
public void ToFlatDict_IncludesNewScalars()
{
var config = new OpcUaEndpointConfig
{
EndpointUrl = "opc.tcp://x:4840",
DiscardOldest = false,
SubscriptionPriority = 50,
SubscriptionDisplayName = "ScadaLink-Edge",
TimestampsToReturn = OpcUaTimestampsToReturn.Server
};
var dict = OpcUaEndpointConfigSerializer.ToFlatDict(config);
Assert.Equal("False", dict["DiscardOldest"]);
Assert.Equal("50", dict["SubscriptionPriority"]);
Assert.Equal("ScadaLink-Edge", dict["SubscriptionDisplayName"]);
Assert.Equal("Server", dict["TimestampsToReturn"]);
}
[Fact]
public void ToFlatDict_OmitsNullUserIdentityAndDeadband()
{
var config = new OpcUaEndpointConfig { EndpointUrl = "opc.tcp://x:4840" };
var dict = OpcUaEndpointConfigSerializer.ToFlatDict(config);
Assert.False(dict.ContainsKey("UserIdentity.TokenType"));
Assert.False(dict.ContainsKey("UserIdentity.Username"));
Assert.False(dict.ContainsKey("Deadband.Type"));
Assert.False(dict.ContainsKey("Deadband.Value"));
}
[Fact]
public void ToFlatDict_IncludesUserIdentity_WhenSet()
{
var config = new OpcUaEndpointConfig
{
EndpointUrl = "opc.tcp://x:4840",
UserIdentity = new OpcUaUserIdentityConfig
{
TokenType = OpcUaUserTokenType.UsernamePassword,
Username = "alice",
Password = "secret"
}
};
var dict = OpcUaEndpointConfigSerializer.ToFlatDict(config);
Assert.Equal("UsernamePassword", dict["UserIdentity.TokenType"]);
Assert.Equal("alice", dict["UserIdentity.Username"]);
Assert.Equal("secret", dict["UserIdentity.Password"]);
}
[Fact]
public void ToFlatDict_IncludesDeadband_WhenSet()
{
var config = new OpcUaEndpointConfig
{
EndpointUrl = "opc.tcp://x:4840",
Deadband = new OpcUaDeadbandConfig { Type = OpcUaDeadbandType.Percent, Value = 1.5 }
};
var dict = OpcUaEndpointConfigSerializer.ToFlatDict(config);
Assert.Equal("Percent", dict["Deadband.Type"]);
Assert.Equal("1.5", dict["Deadband.Value"]);
}
[Fact]
public void FromFlatDict_MaterializesUserIdentity()
{
var dict = new Dictionary<string, string>
{
["endpoint"] = "opc.tcp://x:4840",
["UserIdentity.TokenType"] = "UsernamePassword",
["UserIdentity.Username"] = "bob",
["UserIdentity.Password"] = "hunter2"
};
var config = OpcUaEndpointConfigSerializer.FromFlatDict(dict);
Assert.NotNull(config.UserIdentity);
Assert.Equal(OpcUaUserTokenType.UsernamePassword, config.UserIdentity!.TokenType);
Assert.Equal("bob", config.UserIdentity.Username);
Assert.Equal("hunter2", config.UserIdentity.Password);
}
[Fact]
public void FromFlatDict_MaterializesDeadband()
{
var dict = new Dictionary<string, string>
{
["endpoint"] = "opc.tcp://x:4840",
["Deadband.Type"] = "Absolute",
["Deadband.Value"] = "0.25"
};
var config = OpcUaEndpointConfigSerializer.FromFlatDict(dict);
Assert.NotNull(config.Deadband);
Assert.Equal(OpcUaDeadbandType.Absolute, config.Deadband!.Type);
Assert.Equal(0.25, config.Deadband.Value);
}
[Fact]
public void FromFlatDict_AnonymousTokenTypeStillMaterializesUserIdentity()
{
// Explicit Anonymous TokenType (different from "missing") materializes the sub-object.
var dict = new Dictionary<string, string>
{
["endpoint"] = "opc.tcp://x:4840",
["UserIdentity.TokenType"] = "Anonymous"
};
var config = OpcUaEndpointConfigSerializer.FromFlatDict(dict);
Assert.NotNull(config.UserIdentity);
Assert.Equal(OpcUaUserTokenType.Anonymous, config.UserIdentity!.TokenType);
}
}
@@ -109,4 +109,96 @@ public class OpcUaEndpointConfigValidatorTests
Assert.Contains(r.Errors, e => e.EntityName == "Primary.EndpointUrl");
Assert.Contains(r.Errors, e => e.EntityName == "Primary.QueueSize");
}
// ── Layer B extensions: auth, deadband, subscription display name ──
[Fact]
public void Validate_EmptySubscriptionDisplayName_Fails()
{
var c = Valid();
c.SubscriptionDisplayName = "";
var r = OpcUaEndpointConfigValidator.Validate(c);
Assert.Contains(r.Errors, e => e.EntityName == "SubscriptionDisplayName");
}
[Fact]
public void Validate_UserIdentityAnonymous_NoExtraFieldsRequired()
{
var c = Valid();
c.UserIdentity = new OpcUaUserIdentityConfig { TokenType = OpcUaUserTokenType.Anonymous };
var r = OpcUaEndpointConfigValidator.Validate(c);
Assert.True(r.IsValid);
}
[Fact]
public void Validate_UsernamePasswordWithoutUsername_Fails()
{
var c = Valid();
c.UserIdentity = new OpcUaUserIdentityConfig
{
TokenType = OpcUaUserTokenType.UsernamePassword,
Username = "",
Password = "secret"
};
var r = OpcUaEndpointConfigValidator.Validate(c);
Assert.Contains(r.Errors, e => e.EntityName == "UserIdentity.Username");
}
[Fact]
public void Validate_X509WithoutCertificatePath_Fails()
{
var c = Valid();
c.UserIdentity = new OpcUaUserIdentityConfig
{
TokenType = OpcUaUserTokenType.X509Certificate,
CertificatePath = ""
};
var r = OpcUaEndpointConfigValidator.Validate(c);
Assert.Contains(r.Errors, e => e.EntityName == "UserIdentity.CertificatePath");
}
[Fact]
public void Validate_UsernamePasswordWithUsername_Passes()
{
var c = Valid();
c.UserIdentity = new OpcUaUserIdentityConfig
{
TokenType = OpcUaUserTokenType.UsernamePassword,
Username = "alice",
Password = ""
};
var r = OpcUaEndpointConfigValidator.Validate(c);
Assert.True(r.IsValid);
}
[Fact]
public void Validate_DeadbandWithNonPositiveValue_Fails()
{
var c = Valid();
c.Deadband = new OpcUaDeadbandConfig { Type = OpcUaDeadbandType.Absolute, Value = 0 };
var r = OpcUaEndpointConfigValidator.Validate(c);
Assert.Contains(r.Errors, e => e.EntityName == "Deadband.Value");
}
[Fact]
public void Validate_DeadbandWithPositiveValue_Passes()
{
var c = Valid();
c.Deadband = new OpcUaDeadbandConfig { Type = OpcUaDeadbandType.Percent, Value = 1.5 };
var r = OpcUaEndpointConfigValidator.Validate(c);
Assert.True(r.IsValid);
}
[Fact]
public void Validate_UserIdentityErrorsPrefixed_WithFieldPrefix()
{
var c = Valid();
c.UserIdentity = new OpcUaUserIdentityConfig
{
TokenType = OpcUaUserTokenType.UsernamePassword,
Username = ""
};
var r = OpcUaEndpointConfigValidator.Validate(c, fieldPrefix: "Primary.");
Assert.Contains(r.Errors, e => e.EntityName == "Primary.UserIdentity.Username");
}
}