Complete OPC UA data flow: binding UI, flattening connections, real OPC UA client

- Add connection binding UI to Instances page (per-attribute and bulk assign)
- FlatteningService populates Connections dict from bound data connections
- Real OPC UA client using OPC Foundation SDK for live tag subscriptions
- DataConnectionFactory uses RealOpcUaClientFactory by default
- OpcUaDataConnection supports both "endpoint" and "EndpointUrl" config keys
This commit is contained in:
Joseph Doherty
2026-03-17 11:40:39 -04:00
parent dfb809a909
commit 8e1d0816b3
6 changed files with 366 additions and 2 deletions

View File

@@ -37,7 +37,12 @@ public class OpcUaDataConnection : IDataConnection
public async Task ConnectAsync(IDictionary<string, string> connectionDetails, CancellationToken cancellationToken = default)
{
_endpointUrl = connectionDetails.TryGetValue("EndpointUrl", out var url) ? url : "opc.tcp://localhost:4840";
// Support both "endpoint" (from JSON config) and "EndpointUrl" (programmatic)
_endpointUrl = connectionDetails.TryGetValue("endpoint", out var url)
? url
: connectionDetails.TryGetValue("EndpointUrl", out var url2)
? url2
: "opc.tcp://localhost:4840";
_status = ConnectionHealth.Connecting;
_client = _clientFactory.Create();

View File

@@ -0,0 +1,185 @@
using Opc.Ua;
using Opc.Ua.Client;
using Opc.Ua.Configuration;
namespace ScadaLink.DataConnectionLayer.Adapters;
/// <summary>
/// Real OPC UA client implementation using the OPC Foundation .NET Standard Library.
/// Wraps Session, Subscription, and MonitoredItem for tag subscriptions.
/// </summary>
public class RealOpcUaClient : IOpcUaClient
{
private ISession? _session;
private Subscription? _subscription;
private readonly Dictionary<string, MonitoredItem> _monitoredItems = new();
private readonly Dictionary<string, Action<string, object?, DateTime, uint>> _callbacks = new();
public bool IsConnected => _session?.Connected ?? false;
public async Task ConnectAsync(string endpointUrl, CancellationToken cancellationToken = default)
{
var appConfig = new ApplicationConfiguration
{
ApplicationName = "ScadaLink-DCL",
ApplicationType = ApplicationType.Client,
SecurityConfiguration = new SecurityConfiguration
{
AutoAcceptUntrustedCertificates = true,
ApplicationCertificate = new CertificateIdentifier()
},
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60000 },
TransportQuotas = new TransportQuotas { OperationTimeout = 15000 }
};
await appConfig.ValidateAsync(ApplicationType.Client);
appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
var endpoint = new EndpointDescription(endpointUrl)
{
SecurityMode = MessageSecurityMode.None,
SecurityPolicyUri = SecurityPolicies.None
};
var endpointConfig = EndpointConfiguration.Create(appConfig);
var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfig);
#pragma warning disable CS0618 // Allow obsolete DefaultSessionFactory constructor for compatibility
var sessionFactory = new DefaultSessionFactory();
#pragma warning restore CS0618
_session = await sessionFactory.CreateAsync(
appConfig, configuredEndpoint, false,
"ScadaLink-DCL-Session", 60000, null, null, cancellationToken);
// Create a default subscription for all monitored items
_subscription = new Subscription(_session.DefaultSubscription)
{
DisplayName = "ScadaLink",
PublishingEnabled = true,
PublishingInterval = 1000,
KeepAliveCount = 10,
LifetimeCount = 30,
MaxNotificationsPerPublish = 100
};
_session.AddSubscription(_subscription);
await _subscription.CreateAsync(cancellationToken);
}
public async Task DisconnectAsync(CancellationToken cancellationToken = default)
{
if (_subscription != null)
{
await _subscription.DeleteAsync(true);
_subscription = null;
}
if (_session != null)
{
await _session.CloseAsync(cancellationToken);
_session = null;
}
_monitoredItems.Clear();
_callbacks.Clear();
}
public async Task<string> CreateSubscriptionAsync(
string nodeId, Action<string, object?, DateTime, uint> onValueChanged,
CancellationToken cancellationToken = default)
{
if (_subscription == null || _session == null)
throw new InvalidOperationException("Not connected.");
var handle = Guid.NewGuid().ToString();
var monitoredItem = new MonitoredItem(_subscription.DefaultItem)
{
DisplayName = nodeId,
StartNodeId = nodeId,
AttributeId = Attributes.Value,
SamplingInterval = 1000,
QueueSize = 10,
DiscardOldest = true
};
_callbacks[handle] = onValueChanged;
monitoredItem.Notification += (item, e) =>
{
if (e.NotificationValue is MonitoredItemNotification notification)
{
var value = notification.Value?.Value;
var timestamp = notification.Value?.SourceTimestamp ?? DateTime.UtcNow;
var statusCode = notification.Value?.StatusCode.Code ?? 0;
if (_callbacks.TryGetValue(handle, out var cb))
{
cb(nodeId, value, timestamp, statusCode);
}
}
};
_subscription.AddItem(monitoredItem);
await _subscription.ApplyChangesAsync(cancellationToken);
_monitoredItems[handle] = monitoredItem;
return handle;
}
public async Task RemoveSubscriptionAsync(string subscriptionHandle, CancellationToken cancellationToken = default)
{
if (_subscription != null && _monitoredItems.TryGetValue(subscriptionHandle, out var item))
{
_subscription.RemoveItem(item);
await _subscription.ApplyChangesAsync(cancellationToken);
_monitoredItems.Remove(subscriptionHandle);
_callbacks.Remove(subscriptionHandle);
}
}
public async Task<(object? Value, DateTime SourceTimestamp, uint StatusCode)> ReadValueAsync(
string nodeId, CancellationToken cancellationToken = default)
{
if (_session == null) throw new InvalidOperationException("Not connected.");
var readValue = new ReadValueId
{
NodeId = nodeId,
AttributeId = Attributes.Value
};
var response = await _session.ReadAsync(
null, 0, TimestampsToReturn.Source,
new ReadValueIdCollection { readValue }, cancellationToken);
var result = response.Results[0];
return (result.Value, result.SourceTimestamp, result.StatusCode.Code);
}
public async Task<uint> WriteValueAsync(string nodeId, object? value, CancellationToken cancellationToken = default)
{
if (_session == null) throw new InvalidOperationException("Not connected.");
var writeValue = new WriteValue
{
NodeId = nodeId,
AttributeId = Attributes.Value,
Value = new DataValue(new Variant(value))
};
var response = await _session.WriteAsync(
null, new WriteValueCollection { writeValue }, cancellationToken);
return response.Results[0].Code;
}
public async ValueTask DisposeAsync()
{
await DisconnectAsync();
}
}
/// <summary>
/// Factory that creates real OPC UA client instances using the OPC Foundation SDK.
/// </summary>
public class RealOpcUaClientFactory : IOpcUaClientFactory
{
public IOpcUaClient Create() => new RealOpcUaClient();
}

View File

@@ -19,7 +19,7 @@ public class DataConnectionFactory : IDataConnectionFactory
// Register built-in protocols
RegisterAdapter("OpcUa", details => new OpcUaDataConnection(
new DefaultOpcUaClientFactory(), _loggerFactory.CreateLogger<OpcUaDataConnection>()));
new RealOpcUaClientFactory(), _loggerFactory.CreateLogger<OpcUaDataConnection>()));
RegisterAdapter("LmxProxy", details => new LmxProxyDataConnection(
new DefaultLmxProxyClientFactory(), _loggerFactory.CreateLogger<LmxProxyDataConnection>()));
}

View File

@@ -15,6 +15,7 @@
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.5" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.5" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.5" />
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client" Version="1.5.378.106" />
</ItemGroup>
<ItemGroup>