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:
@@ -178,10 +178,67 @@
|
||||
<button class="btn btn-outline-success btn-sm py-0 px-1 me-1"
|
||||
@onclick="() => EnableInstance(inst)" disabled="@_actionInProgress">Enable</button>
|
||||
}
|
||||
<button class="btn btn-outline-info btn-sm py-0 px-1 me-1"
|
||||
@onclick="() => ToggleBindings(inst)">Bindings</button>
|
||||
<button class="btn btn-outline-danger btn-sm py-0 px-1"
|
||||
@onclick="() => DeleteInstance(inst)" disabled="@_actionInProgress">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
@if (_bindingInstanceId == inst.Id)
|
||||
{
|
||||
<tr>
|
||||
<td colspan="7" class="bg-light p-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<strong>Connection Bindings for @inst.UniqueName</strong>
|
||||
@if (_bindingDataSourceAttrs.Count > 0 && _siteConnections.Count > 0)
|
||||
{
|
||||
<div>
|
||||
<select class="form-select form-select-sm d-inline-block me-1" style="width:auto;" @bind="_bulkConnectionId">
|
||||
<option value="0">Select connection...</option>
|
||||
@foreach (var c in _siteConnections)
|
||||
{
|
||||
<option value="@c.Id">@c.Name (@c.Protocol)</option>
|
||||
}
|
||||
</select>
|
||||
<button class="btn btn-sm btn-outline-primary" @onclick="ApplyBulkBinding" disabled="@(_bulkConnectionId == 0)">Assign All</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (_bindingDataSourceAttrs.Count == 0)
|
||||
{
|
||||
<p class="text-muted small mb-0">No data-sourced attributes in this template.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table table-sm table-bordered mb-2">
|
||||
<thead class="table-light">
|
||||
<tr><th>Attribute</th><th>Tag Path</th><th>Connection</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var attr in _bindingDataSourceAttrs)
|
||||
{
|
||||
<tr>
|
||||
<td class="small">@attr.Name</td>
|
||||
<td class="small text-muted font-monospace">@attr.DataSourceReference</td>
|
||||
<td>
|
||||
<select class="form-select form-select-sm" value="@GetBindingConnectionId(attr.Name)"
|
||||
@onchange="(e) => OnBindingChanged(attr.Name, e)">
|
||||
<option value="0">— none —</option>
|
||||
@foreach (var c in _siteConnections)
|
||||
{
|
||||
<option value="@c.Id">@c.Name</option>
|
||||
}
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
<button class="btn btn-success btn-sm" @onclick="SaveBindings" disabled="@_actionInProgress">Save Bindings</button>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -482,4 +539,100 @@
|
||||
_createError = $"Create failed: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
// Connection binding state
|
||||
private int _bindingInstanceId;
|
||||
private List<TemplateAttribute> _bindingDataSourceAttrs = new();
|
||||
private List<DataConnection> _siteConnections = new();
|
||||
private Dictionary<string, int> _bindingSelections = new();
|
||||
private int _bulkConnectionId;
|
||||
|
||||
private async Task ToggleBindings(Instance inst)
|
||||
{
|
||||
if (_bindingInstanceId == inst.Id)
|
||||
{
|
||||
_bindingInstanceId = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
_bindingInstanceId = inst.Id;
|
||||
_bindingSelections.Clear();
|
||||
_bulkConnectionId = 0;
|
||||
|
||||
// Load template attributes with DataSourceReference
|
||||
var attrs = await TemplateEngineRepository.GetAttributesByTemplateIdAsync(inst.TemplateId);
|
||||
_bindingDataSourceAttrs = attrs.Where(a => !string.IsNullOrEmpty(a.DataSourceReference)).ToList();
|
||||
|
||||
// Load data connections for this site
|
||||
_siteConnections = (await SiteRepository.GetDataConnectionsBySiteIdAsync(inst.SiteId)).ToList();
|
||||
if (_siteConnections.Count == 0)
|
||||
{
|
||||
// Also show unassigned connections (they may not be assigned to a site yet)
|
||||
_siteConnections = (await SiteRepository.GetAllDataConnectionsAsync()).ToList();
|
||||
}
|
||||
|
||||
// Load existing bindings
|
||||
var existingBindings = await TemplateEngineRepository.GetBindingsByInstanceIdAsync(inst.Id);
|
||||
foreach (var b in existingBindings)
|
||||
{
|
||||
_bindingSelections[b.AttributeName] = b.DataConnectionId;
|
||||
}
|
||||
}
|
||||
|
||||
private int GetBindingConnectionId(string attrName)
|
||||
{
|
||||
return _bindingSelections.GetValueOrDefault(attrName, 0);
|
||||
}
|
||||
|
||||
private void OnBindingChanged(string attrName, ChangeEventArgs e)
|
||||
{
|
||||
var val = int.TryParse(e.Value?.ToString(), out var id) ? id : 0;
|
||||
SetBinding(attrName, val);
|
||||
}
|
||||
|
||||
private void SetBinding(string attrName, int connectionId)
|
||||
{
|
||||
if (connectionId == 0)
|
||||
_bindingSelections.Remove(attrName);
|
||||
else
|
||||
_bindingSelections[attrName] = connectionId;
|
||||
}
|
||||
|
||||
private void ApplyBulkBinding()
|
||||
{
|
||||
if (_bulkConnectionId == 0) return;
|
||||
foreach (var attr in _bindingDataSourceAttrs)
|
||||
{
|
||||
_bindingSelections[attr.Name] = _bulkConnectionId;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SaveBindings()
|
||||
{
|
||||
_actionInProgress = true;
|
||||
try
|
||||
{
|
||||
var bindings = _bindingSelections
|
||||
.Select(kv => (kv.Key, kv.Value))
|
||||
.ToList();
|
||||
|
||||
var result = await InstanceService.SetConnectionBindingsAsync(
|
||||
_bindingInstanceId, bindings, "system");
|
||||
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
_toast.ShowSuccess($"Saved {bindings.Count} connection bindings.");
|
||||
_bindingInstanceId = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
_toast.ShowError($"Save failed: {result.Error}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_toast.ShowError($"Save failed: {ex.Message}");
|
||||
}
|
||||
_actionInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -77,6 +77,25 @@ public class FlatteningService
|
||||
// Step 7: Resolve alarm on-trigger script references to canonical names
|
||||
ResolveAlarmScriptReferences(alarms, scripts);
|
||||
|
||||
// Step 8: Collect connection configurations for deployment packaging
|
||||
var connections = new Dictionary<string, ConnectionConfig>();
|
||||
foreach (var attr in attributes.Values)
|
||||
{
|
||||
if (attr.BoundDataConnectionId.HasValue &&
|
||||
!string.IsNullOrEmpty(attr.BoundDataConnectionName) &&
|
||||
!connections.ContainsKey(attr.BoundDataConnectionName))
|
||||
{
|
||||
if (dataConnections.TryGetValue(attr.BoundDataConnectionId.Value, out var conn))
|
||||
{
|
||||
connections[attr.BoundDataConnectionName] = new ConnectionConfig
|
||||
{
|
||||
Protocol = conn.Protocol,
|
||||
ConfigurationJson = conn.Configuration
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = instance.UniqueName,
|
||||
@@ -86,6 +105,7 @@ public class FlatteningService
|
||||
Attributes = attributes.Values.OrderBy(a => a.CanonicalName, StringComparer.Ordinal).ToList(),
|
||||
Alarms = alarms.Values.OrderBy(a => a.CanonicalName, StringComparer.Ordinal).ToList(),
|
||||
Scripts = scripts.Values.OrderBy(s => s.CanonicalName, StringComparer.Ordinal).ToList(),
|
||||
Connections = connections.Count > 0 ? connections : null,
|
||||
GeneratedAtUtc = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user