using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Junction.Domain; using Junction.Domain.Models; using Junction.Domain.Protocols; namespace Junction.Protocols.OpcUa { /// /// Per-machine OPC UA protocol driver. Reads the configured node values and browses the address /// space through an injected (the transport seam, mirroring the HttpClient /// injection in MtconnectDriver). SDK types stay behind so this /// driver is unit-tested against a mock with no live server. /// /// Never throws for expected transport failures: maps them to /// and honours cancellation via . /// /// public sealed class OpcuaDriver : IProtocolDriver { private const string Source = "OpcuaDriver"; private readonly IUaClient _client; private readonly Guid _machineId; private readonly IReadOnlyList _nodeIds; private readonly string _browseRoot; private readonly TimeSpan _timeout; // Selected data item ids to keep in ReadCurrentAsync snapshots. Empty => opt-in "monitor nothing". private readonly HashSet _monitoredItemIds; /// Protocol identifier this driver serves. public string ProtocolId => "opcua"; /// Transport seam; injected for testability, owned by the factory. /// Machine this driver reads for; stamped onto the snapshot. /// Configured node ids to read in . /// Address-space browse start node for . /// Per-operation timeout enforced via a linked . /// /// DataItem ids the user selected to monitor. keeps only these /// (opt-in); empty/null => "monitor nothing". /// public OpcuaDriver( IUaClient client, Guid machineId, IReadOnlyList? nodeIds, string browseRoot, TimeSpan timeout, IReadOnlyCollection? monitoredItemIds = null) { _client = client ?? throw new ArgumentNullException(nameof(client)); _machineId = machineId; _nodeIds = nodeIds ?? Array.Empty(); _browseRoot = browseRoot ?? ""; if (timeout <= TimeSpan.Zero && timeout != Timeout.InfiniteTimeSpan) { throw new ArgumentOutOfRangeException(nameof(timeout), timeout, "Timeout must be positive or Timeout.InfiniteTimeSpan."); } _timeout = timeout; _monitoredItemIds = new HashSet(StringComparer.Ordinal); if (monitoredItemIds != null) { foreach (var id in monitoredItemIds) { if (!string.IsNullOrEmpty(id)) { _monitoredItemIds.Add(id); } } } } /// public async Task> ReadCurrentAsync(CancellationToken cancellationToken) { if (cancellationToken.IsCancellationRequested) { return Result.Cancelled(); } using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); cts.CancelAfter(_timeout); IReadOnlyList reads; try { await _client.ConnectAsync(cts.Token).ConfigureAwait(false); reads = await _client.ReadAsync(_nodeIds, cts.Token).ConfigureAwait(false); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { return Result.Cancelled(); } catch (OperationCanceledException ex) { return Fail("CURRENT_TIMEOUT", "OPC UA read timed out: " + ex.Message); } catch (Exception ex) { return Fail("CURRENT_READ_ERROR", "OPC UA read failed: " + ex.Message); } var items = new List(reads.Count); foreach (UaReadResult r in reads) { items.Add(new DataItem(r.NodeId, r.NodeId, r.Value, "VARIABLE", r.SourceTimestamp)); } var snapshot = new MachineSnapshot(_machineId, DateTimeOffset.UtcNow, ConnectionState.Connected, items); return Result.Ok(FilterToMonitored(snapshot)); } /// public async Task>> ProbeAsync(CancellationToken cancellationToken) { if (cancellationToken.IsCancellationRequested) { return Result>.Cancelled(); } using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); cts.CancelAfter(_timeout); IReadOnlyList descriptors; try { await _client.ConnectAsync(cts.Token).ConfigureAwait(false); descriptors = await _client.BrowseAsync(_browseRoot, cts.Token).ConfigureAwait(false); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { return Result>.Cancelled(); } catch (OperationCanceledException ex) { return ProbeFail("PROBE_TIMEOUT", "OPC UA browse timed out: " + ex.Message); } catch (Exception ex) { return ProbeFail("PROBE_BROWSE_ERROR", "OPC UA browse failed: " + ex.Message); } return Result>.Ok(descriptors); } /// /// Returns a copy of keeping only items whose id is in the monitored /// selection. Empty selection yields an empty item set (opt-in). Connection state and capture /// instant are preserved. Exact semantics as MtconnectDriver. /// private MachineSnapshot FilterToMonitored(MachineSnapshot snapshot) { if (_monitoredItemIds.Count == 0) { return new MachineSnapshot( snapshot.MachineId, snapshot.CapturedAt, snapshot.ConnectionState, Array.Empty()); } var kept = new List(); for (int i = 0; i < snapshot.Items.Count; i++) { DataItem item = snapshot.Items[i]; if (_monitoredItemIds.Contains(item.Id)) { kept.Add(item); } } return new MachineSnapshot(snapshot.MachineId, snapshot.CapturedAt, snapshot.ConnectionState, kept); } private static Result Fail(string code, string message) => Result.Fail(new OperationError(code, Source, message)); private static Result> ProbeFail(string code, string message) => Result>.Fail(new OperationError(code, Source, message)); } }