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:
@@ -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();
|
||||
|
||||
185
src/ScadaLink.DataConnectionLayer/Adapters/RealOpcUaClient.cs
Normal file
185
src/ScadaLink.DataConnectionLayer/Adapters/RealOpcUaClient.cs
Normal 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();
|
||||
}
|
||||
@@ -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>()));
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user