diff --git a/src/ScadaLink.CentralUI/Components/Pages/Deployment/Instances.razor b/src/ScadaLink.CentralUI/Components/Pages/Deployment/Instances.razor index 7e175af..4e239f5 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Deployment/Instances.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Deployment/Instances.razor @@ -178,10 +178,67 @@ } + + @if (_bindingInstanceId == inst.Id) + { + + +
+ Connection Bindings for @inst.UniqueName + @if (_bindingDataSourceAttrs.Count > 0 && _siteConnections.Count > 0) + { +
+ + +
+ } +
+ @if (_bindingDataSourceAttrs.Count == 0) + { +

No data-sourced attributes in this template.

+ } + else + { + + + + + + @foreach (var attr in _bindingDataSourceAttrs) + { + + + + + + } + +
AttributeTag PathConnection
@attr.Name@attr.DataSourceReference + +
+ + } + + + } } @@ -482,4 +539,100 @@ _createError = $"Create failed: {ex.Message}"; } } + + // Connection binding state + private int _bindingInstanceId; + private List _bindingDataSourceAttrs = new(); + private List _siteConnections = new(); + private Dictionary _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; + } } diff --git a/src/ScadaLink.DataConnectionLayer/Adapters/OpcUaDataConnection.cs b/src/ScadaLink.DataConnectionLayer/Adapters/OpcUaDataConnection.cs index b527213..a216053 100644 --- a/src/ScadaLink.DataConnectionLayer/Adapters/OpcUaDataConnection.cs +++ b/src/ScadaLink.DataConnectionLayer/Adapters/OpcUaDataConnection.cs @@ -37,7 +37,12 @@ public class OpcUaDataConnection : IDataConnection public async Task ConnectAsync(IDictionary 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(); diff --git a/src/ScadaLink.DataConnectionLayer/Adapters/RealOpcUaClient.cs b/src/ScadaLink.DataConnectionLayer/Adapters/RealOpcUaClient.cs new file mode 100644 index 0000000..d891e2a --- /dev/null +++ b/src/ScadaLink.DataConnectionLayer/Adapters/RealOpcUaClient.cs @@ -0,0 +1,185 @@ +using Opc.Ua; +using Opc.Ua.Client; +using Opc.Ua.Configuration; + +namespace ScadaLink.DataConnectionLayer.Adapters; + +/// +/// Real OPC UA client implementation using the OPC Foundation .NET Standard Library. +/// Wraps Session, Subscription, and MonitoredItem for tag subscriptions. +/// +public class RealOpcUaClient : IOpcUaClient +{ + private ISession? _session; + private Subscription? _subscription; + private readonly Dictionary _monitoredItems = new(); + private readonly Dictionary> _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 CreateSubscriptionAsync( + string nodeId, Action 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 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(); + } +} + +/// +/// Factory that creates real OPC UA client instances using the OPC Foundation SDK. +/// +public class RealOpcUaClientFactory : IOpcUaClientFactory +{ + public IOpcUaClient Create() => new RealOpcUaClient(); +} diff --git a/src/ScadaLink.DataConnectionLayer/DataConnectionFactory.cs b/src/ScadaLink.DataConnectionLayer/DataConnectionFactory.cs index 7aa30ef..4f5f1a2 100644 --- a/src/ScadaLink.DataConnectionLayer/DataConnectionFactory.cs +++ b/src/ScadaLink.DataConnectionLayer/DataConnectionFactory.cs @@ -19,7 +19,7 @@ public class DataConnectionFactory : IDataConnectionFactory // Register built-in protocols RegisterAdapter("OpcUa", details => new OpcUaDataConnection( - new DefaultOpcUaClientFactory(), _loggerFactory.CreateLogger())); + new RealOpcUaClientFactory(), _loggerFactory.CreateLogger())); RegisterAdapter("LmxProxy", details => new LmxProxyDataConnection( new DefaultLmxProxyClientFactory(), _loggerFactory.CreateLogger())); } diff --git a/src/ScadaLink.DataConnectionLayer/ScadaLink.DataConnectionLayer.csproj b/src/ScadaLink.DataConnectionLayer/ScadaLink.DataConnectionLayer.csproj index adb43f8..f91800d 100644 --- a/src/ScadaLink.DataConnectionLayer/ScadaLink.DataConnectionLayer.csproj +++ b/src/ScadaLink.DataConnectionLayer/ScadaLink.DataConnectionLayer.csproj @@ -15,6 +15,7 @@ + diff --git a/src/ScadaLink.TemplateEngine/Flattening/FlatteningService.cs b/src/ScadaLink.TemplateEngine/Flattening/FlatteningService.cs index f4b130b..ac4529d 100644 --- a/src/ScadaLink.TemplateEngine/Flattening/FlatteningService.cs +++ b/src/ScadaLink.TemplateEngine/Flattening/FlatteningService.cs @@ -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(); + 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 };