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

@@ -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;
}
}

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>

View File

@@ -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
};