Compare commits
7 Commits
084da55ad6
...
22d91c858a
| Author | SHA1 | Date | |
|---|---|---|---|
| 22d91c858a | |||
| f89f234558 | |||
| 8faaa8fe2b | |||
| e6a5b558f3 | |||
| b60a8ef409 | |||
| 91450ec390 | |||
| 16f7ab0d0a |
@@ -40,6 +40,61 @@
|
|||||||
</div>
|
</div>
|
||||||
</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="text-muted small mt-2 mb-1">Timing</div>
|
||||||
<div class="row g-2 mb-2">
|
<div class="row g-2 mb-2">
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
@@ -96,6 +151,69 @@
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
<div class="text-muted small mt-2 mb-1">Heartbeat</div>
|
||||||
@if (Config.Heartbeat is null)
|
@if (Config.Heartbeat is null)
|
||||||
{
|
{
|
||||||
@@ -138,6 +256,12 @@
|
|||||||
private void EnableHeartbeat() =>
|
private void EnableHeartbeat() =>
|
||||||
Config.Heartbeat = new OpcUaHeartbeatConfig();
|
Config.Heartbeat = new OpcUaHeartbeatConfig();
|
||||||
|
|
||||||
|
private void EnableAuthentication() =>
|
||||||
|
Config.UserIdentity = new OpcUaUserIdentityConfig();
|
||||||
|
|
||||||
|
private void EnableDeadband() =>
|
||||||
|
Config.Deadband = new OpcUaDeadbandConfig();
|
||||||
|
|
||||||
private RenderFragment? RenderFieldError(string field)
|
private RenderFragment? RenderFieldError(string field)
|
||||||
{
|
{
|
||||||
var match = Errors?.Errors.FirstOrDefault(e =>
|
var match = Errors?.Errors.FirstOrDefault(e =>
|
||||||
|
|||||||
@@ -70,12 +70,29 @@ public static class OpcUaEndpointConfigSerializer
|
|||||||
["KeepAliveCount"] = config.KeepAliveCount.ToString(),
|
["KeepAliveCount"] = config.KeepAliveCount.ToString(),
|
||||||
["LifetimeCount"] = config.LifetimeCount.ToString(),
|
["LifetimeCount"] = config.LifetimeCount.ToString(),
|
||||||
["MaxNotificationsPerPublish"] = config.MaxNotificationsPerPublish.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)
|
if (config.Heartbeat is { } hb)
|
||||||
{
|
{
|
||||||
dict["HeartbeatTagPath"] = hb.TagPath;
|
dict["HeartbeatTagPath"] = hb.TagPath;
|
||||||
dict["HeartbeatMaxSilence"] = hb.MaxSilenceSeconds.ToString();
|
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;
|
return dict;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,6 +122,16 @@ public static class OpcUaEndpointConfigSerializer
|
|||||||
TryAssignInt(dict, "LifetimeCount", v => c.LifetimeCount = v);
|
TryAssignInt(dict, "LifetimeCount", v => c.LifetimeCount = v);
|
||||||
TryAssignInt(dict, "MaxNotificationsPerPublish", v => c.MaxNotificationsPerPublish = 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)
|
if (dict.TryGetValue("HeartbeatTagPath", out var hbPath)
|
||||||
&& !string.IsNullOrWhiteSpace(hbPath))
|
&& !string.IsNullOrWhiteSpace(hbPath))
|
||||||
{
|
{
|
||||||
@@ -113,6 +140,28 @@ public static class OpcUaEndpointConfigSerializer
|
|||||||
c.Heartbeat = hb;
|
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;
|
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 KeepAliveCount { get; set; } = 10;
|
||||||
public int LifetimeCount { get; set; } = 30;
|
public int LifetimeCount { get; set; } = 30;
|
||||||
public int MaxNotificationsPerPublish { get; set; } = 100;
|
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)
|
// Heartbeat (optional)
|
||||||
public OpcUaHeartbeatConfig? Heartbeat { get; set; }
|
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)
|
if (config.MaxNotificationsPerPublish < 1)
|
||||||
errors.Add(Err("MaxNotificationsPerPublish", "Must be ≥ 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 (config.Heartbeat is { } hb)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(hb.TagPath))
|
if (string.IsNullOrWhiteSpace(hb.TagPath))
|
||||||
@@ -49,6 +53,21 @@ public static class OpcUaEndpointConfigValidator
|
|||||||
errors.Add(Err("Heartbeat.MaxSilenceSeconds", "Must be > 0."));
|
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
|
return errors.Count == 0
|
||||||
? ValidationResult.Success()
|
? ValidationResult.Success()
|
||||||
: ValidationResult.FromErrors(errors.ToArray());
|
: ValidationResult.FromErrors(errors.ToArray());
|
||||||
|
|||||||
@@ -14,7 +14,22 @@ public record OpcUaConnectionOptions(
|
|||||||
int SamplingIntervalMs = 1000,
|
int SamplingIntervalMs = 1000,
|
||||||
int QueueSize = 10,
|
int QueueSize = 10,
|
||||||
string SecurityMode = "None",
|
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>
|
/// <summary>
|
||||||
/// WP-7: Abstraction over OPC UA client library for testability.
|
/// WP-7: Abstraction over OPC UA client library for testability.
|
||||||
|
|||||||
@@ -61,7 +61,19 @@ public class OpcUaDataConnection : IDataConnection
|
|||||||
SamplingIntervalMs: config.SamplingIntervalMs,
|
SamplingIntervalMs: config.SamplingIntervalMs,
|
||||||
QueueSize: config.QueueSize,
|
QueueSize: config.QueueSize,
|
||||||
SecurityMode: config.SecurityMode.ToString(),
|
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;
|
_status = ConnectionHealth.Connecting;
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Security.Cryptography.X509Certificates;
|
||||||
using Opc.Ua;
|
using Opc.Ua;
|
||||||
using Opc.Ua.Client;
|
using Opc.Ua.Client;
|
||||||
using Opc.Ua.Configuration;
|
using Opc.Ua.Configuration;
|
||||||
@@ -16,6 +17,12 @@ public class RealOpcUaClient : IOpcUaClient
|
|||||||
private readonly Dictionary<string, Action<string, object?, DateTime, uint>> _callbacks = new();
|
private readonly Dictionary<string, Action<string, object?, DateTime, uint>> _callbacks = new();
|
||||||
private volatile bool _connectionLostFired;
|
private volatile bool _connectionLostFired;
|
||||||
private OpcUaConnectionOptions _options = new();
|
private OpcUaConnectionOptions _options = new();
|
||||||
|
private readonly OpcUaGlobalOptions _globalOptions;
|
||||||
|
|
||||||
|
public RealOpcUaClient(OpcUaGlobalOptions? globalOptions = null)
|
||||||
|
{
|
||||||
|
_globalOptions = globalOptions ?? new OpcUaGlobalOptions();
|
||||||
|
}
|
||||||
|
|
||||||
public bool IsConnected => _session?.Connected ?? false;
|
public bool IsConnected => _session?.Connected ?? false;
|
||||||
public event Action? ConnectionLost;
|
public event Action? ConnectionLost;
|
||||||
@@ -33,15 +40,17 @@ public class RealOpcUaClient : IOpcUaClient
|
|||||||
|
|
||||||
var appConfig = new ApplicationConfiguration
|
var appConfig = new ApplicationConfiguration
|
||||||
{
|
{
|
||||||
ApplicationName = "ScadaLink-DCL",
|
ApplicationName = string.IsNullOrWhiteSpace(_globalOptions.ApplicationName)
|
||||||
|
? "ScadaLink-DCL"
|
||||||
|
: _globalOptions.ApplicationName,
|
||||||
ApplicationType = ApplicationType.Client,
|
ApplicationType = ApplicationType.Client,
|
||||||
SecurityConfiguration = new SecurityConfiguration
|
SecurityConfiguration = new SecurityConfiguration
|
||||||
{
|
{
|
||||||
AutoAcceptUntrustedCertificates = opts.AutoAcceptUntrustedCerts,
|
AutoAcceptUntrustedCertificates = opts.AutoAcceptUntrustedCerts,
|
||||||
ApplicationCertificate = new CertificateIdentifier(),
|
ApplicationCertificate = new CertificateIdentifier(),
|
||||||
TrustedIssuerCertificates = new CertificateTrustList { StorePath = Path.Combine(Path.GetTempPath(), "ScadaLink", "pki", "issuers") },
|
TrustedIssuerCertificates = new CertificateTrustList { StorePath = ResolveStorePath(_globalOptions.TrustedIssuerStorePath, "issuers") },
|
||||||
TrustedPeerCertificates = new CertificateTrustList { StorePath = Path.Combine(Path.GetTempPath(), "ScadaLink", "pki", "trusted") },
|
TrustedPeerCertificates = new CertificateTrustList { StorePath = ResolveStorePath(_globalOptions.TrustedPeerStorePath, "trusted") },
|
||||||
RejectedCertificateStore = new CertificateTrustList { StorePath = Path.Combine(Path.GetTempPath(), "ScadaLink", "pki", "rejected") }
|
RejectedCertificateStore = new CertificateTrustList { StorePath = ResolveStorePath(_globalOptions.RejectedCertificateStorePath, "rejected") }
|
||||||
},
|
},
|
||||||
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = opts.SessionTimeoutMs },
|
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = opts.SessionTimeoutMs },
|
||||||
TransportQuotas = new TransportQuotas { OperationTimeout = opts.OperationTimeoutMs }
|
TransportQuotas = new TransportQuotas { OperationTimeout = opts.OperationTimeoutMs }
|
||||||
@@ -77,9 +86,10 @@ public class RealOpcUaClient : IOpcUaClient
|
|||||||
#pragma warning disable CS0618 // Allow obsolete DefaultSessionFactory constructor for compatibility
|
#pragma warning disable CS0618 // Allow obsolete DefaultSessionFactory constructor for compatibility
|
||||||
var sessionFactory = new DefaultSessionFactory();
|
var sessionFactory = new DefaultSessionFactory();
|
||||||
#pragma warning restore CS0618
|
#pragma warning restore CS0618
|
||||||
|
var userIdentity = BuildUserIdentity(opts.UserIdentity);
|
||||||
_session = await sessionFactory.CreateAsync(
|
_session = await sessionFactory.CreateAsync(
|
||||||
appConfig, configuredEndpoint, false,
|
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
|
// Detect server going offline via keep-alive failures
|
||||||
_connectionLostFired = false;
|
_connectionLostFired = false;
|
||||||
@@ -91,7 +101,8 @@ public class RealOpcUaClient : IOpcUaClient
|
|||||||
// Create a default subscription for all monitored items
|
// Create a default subscription for all monitored items
|
||||||
_subscription = new Subscription(_session.DefaultSubscription)
|
_subscription = new Subscription(_session.DefaultSubscription)
|
||||||
{
|
{
|
||||||
DisplayName = "ScadaLink",
|
DisplayName = opts.SubscriptionDisplayName,
|
||||||
|
Priority = opts.SubscriptionPriority,
|
||||||
PublishingEnabled = true,
|
PublishingEnabled = true,
|
||||||
PublishingInterval = opts.PublishingIntervalMs,
|
PublishingInterval = opts.PublishingIntervalMs,
|
||||||
KeepAliveCount = (uint)opts.KeepAliveCount,
|
KeepAliveCount = (uint)opts.KeepAliveCount,
|
||||||
@@ -135,7 +146,8 @@ public class RealOpcUaClient : IOpcUaClient
|
|||||||
AttributeId = Attributes.Value,
|
AttributeId = Attributes.Value,
|
||||||
SamplingInterval = _options.SamplingIntervalMs,
|
SamplingInterval = _options.SamplingIntervalMs,
|
||||||
QueueSize = (uint)_options.QueueSize,
|
QueueSize = (uint)_options.QueueSize,
|
||||||
DiscardOldest = true
|
DiscardOldest = _options.DiscardOldest,
|
||||||
|
Filter = BuildDataChangeFilter(_options.Deadband)
|
||||||
};
|
};
|
||||||
|
|
||||||
_callbacks[handle] = onValueChanged;
|
_callbacks[handle] = onValueChanged;
|
||||||
@@ -185,7 +197,7 @@ public class RealOpcUaClient : IOpcUaClient
|
|||||||
};
|
};
|
||||||
|
|
||||||
var response = await _session.ReadAsync(
|
var response = await _session.ReadAsync(
|
||||||
null, 0, TimestampsToReturn.Source,
|
null, 0, MapTimestampsToReturn(_options.TimestampsToReturn),
|
||||||
new ReadValueIdCollection { readValue }, cancellationToken);
|
new ReadValueIdCollection { readValue }, cancellationToken);
|
||||||
|
|
||||||
var result = response.Results[0];
|
var result = response.Results[0];
|
||||||
@@ -227,6 +239,50 @@ public class RealOpcUaClient : IOpcUaClient
|
|||||||
{
|
{
|
||||||
await DisconnectAsync();
|
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>
|
/// <summary>
|
||||||
@@ -234,5 +290,13 @@ public class RealOpcUaClient : IOpcUaClient
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class RealOpcUaClientFactory : IOpcUaClientFactory
|
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.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
using ScadaLink.Commons.Interfaces.Protocol;
|
using ScadaLink.Commons.Interfaces.Protocol;
|
||||||
using ScadaLink.DataConnectionLayer.Adapters;
|
using ScadaLink.DataConnectionLayer.Adapters;
|
||||||
|
|
||||||
@@ -14,12 +15,17 @@ public class DataConnectionFactory : IDataConnectionFactory
|
|||||||
private readonly ILoggerFactory _loggerFactory;
|
private readonly ILoggerFactory _loggerFactory;
|
||||||
|
|
||||||
public DataConnectionFactory(ILoggerFactory loggerFactory)
|
public DataConnectionFactory(ILoggerFactory loggerFactory)
|
||||||
|
: this(loggerFactory, Options.Create(new OpcUaGlobalOptions())) { }
|
||||||
|
|
||||||
|
public DataConnectionFactory(ILoggerFactory loggerFactory, IOptions<OpcUaGlobalOptions> opcUaGlobalOptions)
|
||||||
{
|
{
|
||||||
_loggerFactory = loggerFactory;
|
_loggerFactory = loggerFactory;
|
||||||
|
var globalOptions = opcUaGlobalOptions.Value;
|
||||||
|
|
||||||
// Register built-in protocols
|
// Register built-in protocols
|
||||||
RegisterAdapter("OpcUa", details => new OpcUaDataConnection(
|
RegisterAdapter("OpcUa", details => new OpcUaDataConnection(
|
||||||
new RealOpcUaClientFactory(), _loggerFactory.CreateLogger<OpcUaDataConnection>()));
|
new RealOpcUaClientFactory(globalOptions),
|
||||||
|
_loggerFactory.CreateLogger<OpcUaDataConnection>()));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <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>()
|
services.AddOptions<DataConnectionOptions>()
|
||||||
.BindConfiguration("DataConnectionLayer");
|
.BindConfiguration("DataConnectionLayer");
|
||||||
|
|
||||||
|
services.AddOptions<OpcUaGlobalOptions>()
|
||||||
|
.BindConfiguration("OpcUa");
|
||||||
|
|
||||||
// WP-34: Register the factory for protocol extensibility
|
// WP-34: Register the factory for protocol extensibility
|
||||||
services.AddSingleton<IDataConnectionFactory, DataConnectionFactory>();
|
services.AddSingleton<IDataConnectionFactory, DataConnectionFactory>();
|
||||||
|
|
||||||
|
|||||||
@@ -82,4 +82,150 @@ public class OpcUaEndpointEditorTests : BunitContext
|
|||||||
Assert.Contains("alert-warning", cut.Markup);
|
Assert.Contains("alert-warning", cut.Markup);
|
||||||
Assert.Contains("migrated from a legacy format", 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+203
@@ -197,4 +197,207 @@ public class OpcUaEndpointConfigSerializerTests
|
|||||||
Assert.NotNull(config.Heartbeat);
|
Assert.NotNull(config.Heartbeat);
|
||||||
Assert.Equal("Hb", config.Heartbeat!.TagPath);
|
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.EndpointUrl");
|
||||||
Assert.Contains(r.Errors, e => e.EntityName == "Primary.QueueSize");
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user