diff --git a/Junction.sln b/Junction.sln index 0bf0d83..81d27fb 100644 --- a/Junction.sln +++ b/Junction.sln @@ -19,6 +19,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{362A84DF EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Junction.Tests", "tests\Junction.Tests\Junction.Tests.csproj", "{618E6634-A16C-4806-836A-185ECDEE7312}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Junction.Protocols.OpcUa", "src\Junction.Protocols.OpcUa\Junction.Protocols.OpcUa.csproj", "{93FC27CB-C147-4BE6-8E86-F46192B72099}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -52,6 +54,10 @@ Global {618E6634-A16C-4806-836A-185ECDEE7312}.Debug|Any CPU.Build.0 = Debug|Any CPU {618E6634-A16C-4806-836A-185ECDEE7312}.Release|Any CPU.ActiveCfg = Release|Any CPU {618E6634-A16C-4806-836A-185ECDEE7312}.Release|Any CPU.Build.0 = Release|Any CPU + {93FC27CB-C147-4BE6-8E86-F46192B72099}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {93FC27CB-C147-4BE6-8E86-F46192B72099}.Debug|Any CPU.Build.0 = Debug|Any CPU + {93FC27CB-C147-4BE6-8E86-F46192B72099}.Release|Any CPU.ActiveCfg = Release|Any CPU + {93FC27CB-C147-4BE6-8E86-F46192B72099}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {EE2D0A1D-3135-4065-A9EF-AB99F4FCC062} = {6DDDE464-201D-4B4F-A23E-566B5FC111BC} @@ -60,5 +66,6 @@ Global {BBAD50B2-18B5-4F4B-B8E3-CE749DB46C83} = {6DDDE464-201D-4B4F-A23E-566B5FC111BC} {965918A3-4C29-4F6A-B53D-0A72BDF4376C} = {6DDDE464-201D-4B4F-A23E-566B5FC111BC} {618E6634-A16C-4806-836A-185ECDEE7312} = {362A84DF-21D2-4DA7-B8B5-8C5E3C6E8500} + {93FC27CB-C147-4BE6-8E86-F46192B72099} = {6DDDE464-201D-4B4F-A23E-566B5FC111BC} EndGlobalSection EndGlobal diff --git a/src/Junction.App/Junction.App.csproj b/src/Junction.App/Junction.App.csproj index e14997f..c75e49f 100644 --- a/src/Junction.App/Junction.App.csproj +++ b/src/Junction.App/Junction.App.csproj @@ -7,6 +7,8 @@ WinExe Junction.App true + + true @@ -42,6 +44,8 @@ + @@ -53,4 +57,15 @@ + + + + + + + + + diff --git a/src/Junction.Protocols.OpcUa/IUaClient.cs b/src/Junction.Protocols.OpcUa/IUaClient.cs new file mode 100644 index 0000000..0d2e229 --- /dev/null +++ b/src/Junction.Protocols.OpcUa/IUaClient.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Junction.Domain.Protocols; + +namespace Junction.Protocols.OpcUa +{ + /// + /// Transport seam over an OPC UA session, mirroring the HttpClient injection the MTConnect + /// driver uses. Deliberately keeps all Opc.Ua.* SDK types OUT of its signatures so + /// can be unit-tested against a mock with no live server. + /// + public interface IUaClient + { + /// Establish the session (connect + authenticate). Idempotent: safe to call before each op. + Task ConnectAsync(CancellationToken ct); + + /// Read the Value attribute of each given node id. Order matches the input order. + Task> ReadAsync(IReadOnlyList nodeIds, CancellationToken ct); + + /// + /// Recursively browse the address space from , returning one + /// descriptor per Variable node found. + /// + Task> BrowseAsync(string browseRoot, CancellationToken ct); + } +} diff --git a/src/Junction.Protocols.OpcUa/Junction.Protocols.OpcUa.csproj b/src/Junction.Protocols.OpcUa/Junction.Protocols.OpcUa.csproj new file mode 100644 index 0000000..e6aa591 --- /dev/null +++ b/src/Junction.Protocols.OpcUa/Junction.Protocols.OpcUa.csproj @@ -0,0 +1,27 @@ + + + + + net48;net8.0 + Junction.Protocols.OpcUa + + true + + + + + + + + + + + + + + + + diff --git a/src/Junction.Protocols.OpcUa/OpcuaDriver.cs b/src/Junction.Protocols.OpcUa/OpcuaDriver.cs new file mode 100644 index 0000000..4c112aa --- /dev/null +++ b/src/Junction.Protocols.OpcUa/OpcuaDriver.cs @@ -0,0 +1,182 @@ +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)); + } +} diff --git a/src/Junction.Protocols.OpcUa/OpcuaDriverFactory.cs b/src/Junction.Protocols.OpcUa/OpcuaDriverFactory.cs new file mode 100644 index 0000000..1f6a40c --- /dev/null +++ b/src/Junction.Protocols.OpcUa/OpcuaDriverFactory.cs @@ -0,0 +1,220 @@ +using System; +using System.Collections.Generic; +using Junction.Domain; +using Junction.Domain.Models; +using Junction.Domain.Protocols; + +namespace Junction.Protocols.OpcUa +{ + /// + /// Plugin entrypoint for the OPC UA protocol. Resolved by the Core plugin loader from + /// plugin.manifest.json via , so it MUST + /// keep a public parameterless constructor. + /// + /// Owns OPC UA config validation, mirroring MtconnectDriverFactory (case-insensitive + /// lookup, CONFIG_* fail codes). Builds a real and injects it into the + /// per-machine . + /// + /// Expected keys (case-insensitive): + /// + /// EndpointUrl (required) — absolute opc.tcp:// URL of the server. + /// SecurityMode (optional) — None|Sign|SignAndEncrypt, default None. + /// SecurityPolicy (optional) — default None. + /// AuthMode (optional) — Anonymous|UsernamePassword, default Anonymous. + /// Username/Password — required iff AuthMode=UsernamePassword. + /// NodeIds (optional CSV) — node ids read by ReadCurrentAsync. + /// BrowseRoot (optional) — browse start node, default "i=85". + /// TimeoutSeconds (optional) — positive int, default 10. + /// + /// + public sealed class OpcuaDriverFactory : IProtocolDriverFactory + { + private const string Source = "OpcuaDriverFactory"; + private const string EndpointUrlKey = "EndpointUrl"; + private const string SecurityModeKey = "SecurityMode"; + private const string SecurityPolicyKey = "SecurityPolicy"; + private const string AuthModeKey = "AuthMode"; + private const string UsernameKey = "Username"; + private const string PasswordKey = "Password"; + private const string NodeIdsKey = "NodeIds"; + private const string BrowseRootKey = "BrowseRoot"; + private const string TimeoutKey = "TimeoutSeconds"; + + private const string DefaultBrowseRoot = "i=85"; + private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(10); + + /// Protocol identifier this factory produces drivers for. + public string ProtocolId => "opcua"; + + /// Required by the plugin loader (Activator.CreateInstance). + public OpcuaDriverFactory() + { + } + + /// + public Result Create(Machine machine) + { + if (machine is null) + { + return Fail("MACHINE_NULL", "Machine was null."); + } + + var config = machine.ConnectionConfig; + + // EndpointUrl: required, absolute, opc.tcp scheme. + var endpointUrl = GetValue(config, EndpointUrlKey); + if (string.IsNullOrWhiteSpace(endpointUrl)) + { + return Fail("CONFIG_ENDPOINTURL_MISSING", + "Required connection config key '" + EndpointUrlKey + "' is missing or empty."); + } + endpointUrl = endpointUrl!.Trim(); + if (!Uri.TryCreate(endpointUrl, UriKind.Absolute, out var uri)) + { + return Fail("CONFIG_ENDPOINTURL_INVALID", + "Connection config key '" + EndpointUrlKey + "' is not an absolute URI: '" + endpointUrl + "'."); + } + if (!string.Equals(uri.Scheme, "opc.tcp", StringComparison.OrdinalIgnoreCase)) + { + return Fail("CONFIG_ENDPOINTURL_SCHEME", + "Connection config key '" + EndpointUrlKey + "' must use the 'opc.tcp' scheme: '" + endpointUrl + "'."); + } + + // SecurityMode: optional, one of None|Sign|SignAndEncrypt (default None). + var securityMode = GetValue(config, SecurityModeKey); + if (string.IsNullOrWhiteSpace(securityMode)) + { + securityMode = "None"; + } + else + { + securityMode = securityMode!.Trim(); + if (!IsOneOf(securityMode, "None", "Sign", "SignAndEncrypt")) + { + return Fail("CONFIG_SECURITYMODE_INVALID", + "Connection config key '" + SecurityModeKey + "' must be None, Sign or SignAndEncrypt: '" + securityMode + "'."); + } + } + + // SecurityPolicy: optional, default None. Free-form (validated by the server on connect). + var securityPolicy = GetValue(config, SecurityPolicyKey); + if (string.IsNullOrWhiteSpace(securityPolicy)) + { + securityPolicy = "None"; + } + + // AuthMode: optional, one of Anonymous|UsernamePassword (default Anonymous). + var authMode = GetValue(config, AuthModeKey); + if (string.IsNullOrWhiteSpace(authMode)) + { + authMode = "Anonymous"; + } + else + { + authMode = authMode!.Trim(); + if (!IsOneOf(authMode, "Anonymous", "UsernamePassword")) + { + return Fail("CONFIG_AUTHMODE_INVALID", + "Connection config key '" + AuthModeKey + "' must be Anonymous or UsernamePassword: '" + authMode + "'."); + } + } + + string? username = GetValue(config, UsernameKey); + string? password = GetValue(config, PasswordKey); + if (string.Equals(authMode, "UsernamePassword", StringComparison.OrdinalIgnoreCase)) + { + if (string.IsNullOrWhiteSpace(username)) + { + return Fail("CONFIG_USERNAME_MISSING", + "Connection config key '" + UsernameKey + "' is required when AuthMode=UsernamePassword."); + } + if (string.IsNullOrEmpty(password)) + { + return Fail("CONFIG_PASSWORD_MISSING", + "Connection config key '" + PasswordKey + "' is required when AuthMode=UsernamePassword."); + } + } + + // BrowseRoot: optional, default i=85. + var browseRoot = GetValue(config, BrowseRootKey); + if (string.IsNullOrWhiteSpace(browseRoot)) + { + browseRoot = DefaultBrowseRoot; + } + browseRoot = browseRoot!.Trim(); + + // NodeIds: optional CSV of node ids to read. + var nodeIds = ParseCsv(GetValue(config, NodeIdsKey)); + + // TimeoutSeconds: optional positive int (default 10). + var timeout = DefaultTimeout; + var timeoutRaw = GetValue(config, TimeoutKey); + if (!string.IsNullOrWhiteSpace(timeoutRaw)) + { + if (!int.TryParse(timeoutRaw, out var seconds) || seconds <= 0) + { + return Fail("CONFIG_TIMEOUT_INVALID", + "Connection config key '" + TimeoutKey + "' must be a positive integer (seconds): '" + timeoutRaw + "'."); + } + timeout = TimeSpan.FromSeconds(seconds); + } + + var client = new UaClient(endpointUrl, securityMode!, authMode!, username, password, timeout); + var driver = new OpcuaDriver(client, machine.Id, nodeIds, browseRoot, timeout, machine.MonitoredItemIds); + return Result.Ok(driver); + } + + private static IReadOnlyList ParseCsv(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + { + return Array.Empty(); + } + + var parts = raw!.Split(','); + var list = new List(parts.Length); + foreach (var p in parts) + { + var trimmed = p.Trim(); + if (trimmed.Length > 0) + { + list.Add(trimmed); + } + } + return list; + } + + private static bool IsOneOf(string value, params string[] allowed) + { + foreach (var a in allowed) + { + if (string.Equals(value, a, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + return false; + } + + private static string? GetValue(IReadOnlyDictionary config, string key) + { + if (config is null) + { + return null; + } + + foreach (var pair in config) + { + if (string.Equals(pair.Key, key, StringComparison.OrdinalIgnoreCase)) + { + return pair.Value; + } + } + + return null; + } + + private static Result Fail(string code, string message) => + Result.Fail(new OperationError(code, Source, message)); + } +} diff --git a/src/Junction.Protocols.OpcUa/UaClient.cs b/src/Junction.Protocols.OpcUa/UaClient.cs new file mode 100644 index 0000000..415dd47 --- /dev/null +++ b/src/Junction.Protocols.OpcUa/UaClient.cs @@ -0,0 +1,332 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Junction.Domain.Protocols; +using Opc.Ua; +using Opc.Ua.Client; +using Opc.Ua.Configuration; + +namespace Junction.Protocols.OpcUa +{ + /// + /// Real over the OPC Foundation .NET Standard SDK. Owns an + /// with a Directory PKI store under LocalApplicationData, + /// auto-generates the app-instance certificate, opens a and translates SDK + /// calls into the SDK-free DTOs the driver consumes. + /// + /// This type is NOT unit-tested (it needs a live server); the driver is tested via a mocked + /// . Exceptions raised here are caught by the driver and mapped to + /// failures. + /// + /// + /// Uses the SDK's non-obsolete telemetry-threaded async surface throughout. The pinned SDK + /// (1.5.378.156) is mid-migration to an ITelemetryContext/DI-first API; a null telemetry + /// context is accepted and the SDK substitutes its default. + /// + /// + public sealed class UaClient : IUaClient, IDisposable + { + private const string ApplicationName = "Junction"; + + private readonly string _endpointUrl; + private readonly string _securityMode; // None | Sign | SignAndEncrypt + private readonly string _authMode; // Anonymous | UsernamePassword + private readonly string? _username; + private readonly string? _password; + private readonly TimeSpan _timeout; + + // Intentionally null: the SDK substitutes its default telemetry context. Typed non-nullable so + // the non-obsolete telemetry-threaded async overloads bind without nullable-analysis noise. + private readonly ITelemetryContext _telemetry = null!; + + private ApplicationConfiguration? _appConfig; + private ISession? _session; + + public UaClient( + string endpointUrl, + string securityMode, + string authMode, + string? username, + string? password, + TimeSpan timeout) + { + _endpointUrl = endpointUrl ?? throw new ArgumentNullException(nameof(endpointUrl)); + _securityMode = string.IsNullOrWhiteSpace(securityMode) ? "None" : securityMode; + _authMode = string.IsNullOrWhiteSpace(authMode) ? "Anonymous" : authMode; + _username = username; + _password = password; + _timeout = timeout; + } + + public async Task ConnectAsync(CancellationToken ct) + { + if (_session != null && _session.Connected) + { + return; + } + + ApplicationConfiguration config = await BuildConfigurationAsync(ct).ConfigureAwait(false); + + bool useSecurity = !string.Equals(_securityMode, "None", StringComparison.OrdinalIgnoreCase); + + // Discover + pick the server endpoint matching the requested security. + EndpointDescription selected = + (await CoreClientUtils.SelectEndpointAsync(config, _endpointUrl, useSecurity, _telemetry, ct).ConfigureAwait(false))!; + var endpointConfiguration = EndpointConfiguration.Create(config); + var endpoint = new ConfiguredEndpoint(null, selected, endpointConfiguration); + + IUserIdentity identity = BuildIdentity(); + + var factory = new DefaultSessionFactory(_telemetry); + _session = await factory.CreateAsync( + config, + endpoint, + updateBeforeConnect: false, + checkDomain: false, + sessionName: ApplicationName, + sessionTimeout: (uint)Math.Max(1000, _timeout.TotalMilliseconds), + identity: identity, + preferredLocales: null, + ct).ConfigureAwait(false); + } + + public async Task> ReadAsync(IReadOnlyList nodeIds, CancellationToken ct) + { + await ConnectAsync(ct).ConfigureAwait(false); + ISession session = _session ?? throw new InvalidOperationException("OPC UA session not established."); + + var results = new List(nodeIds.Count); + if (nodeIds.Count == 0) + { + return results; + } + + var toRead = new ReadValueIdCollection(); + foreach (var id in nodeIds) + { + toRead.Add(new ReadValueId { NodeId = new NodeId(id), AttributeId = Attributes.Value }); + } + + ReadResponse response = + await session.ReadAsync(null, 0, TimestampsToReturn.Both, toRead, ct).ConfigureAwait(false); + DataValueCollection values = response.Results; + + for (int i = 0; i < nodeIds.Count; i++) + { + DataValue dv = (values != null && i < values.Count) ? values[i] : new DataValue(StatusCodes.BadUnexpectedError); + bool ok = StatusCode.IsGood(dv.StatusCode); + string value = dv.Value != null ? dv.Value.ToString() ?? "" : ""; + DateTimeOffset ts = dv.SourceTimestamp == DateTime.MinValue + ? DateTimeOffset.UtcNow + : new DateTimeOffset(DateTime.SpecifyKind(dv.SourceTimestamp, DateTimeKind.Utc)); + results.Add(new UaReadResult(nodeIds[i], value, ok, ts)); + } + + return results; + } + + public async Task> BrowseAsync(string browseRoot, CancellationToken ct) + { + await ConnectAsync(ct).ConfigureAwait(false); + ISession session = _session ?? throw new InvalidOperationException("OPC UA session not established."); + + NodeId root = string.IsNullOrWhiteSpace(browseRoot) ? ObjectIds.ObjectsFolder : new NodeId(browseRoot); + var descriptors = new List(); + var visited = new HashSet(StringComparer.Ordinal); + + await BrowseRecursiveAsync(session, root, descriptors, visited, ct).ConfigureAwait(false); + return descriptors; + } + + private static async Task BrowseRecursiveAsync( + ISession session, + NodeId node, + List descriptors, + HashSet visited, + CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + if (!visited.Add(node.ToString())) + { + return; // guard against cycles + } + + var nodeToBrowse = new BrowseDescription + { + NodeId = node, + BrowseDirection = BrowseDirection.Forward, + ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences, + IncludeSubtypes = true, + NodeClassMask = (uint)(NodeClass.Object | NodeClass.Variable), + ResultMask = (uint)BrowseResultMask.All, + }; + + BrowseResponse response = await session.BrowseAsync( + null, null, 0u, new BrowseDescriptionCollection { nodeToBrowse }, ct).ConfigureAwait(false); + + if (response.Results == null || response.Results.Count == 0) + { + return; + } + + BrowseResult result = response.Results[0]; + var references = new ReferenceDescriptionCollection(); + if (result.References != null) + { + references.AddRange(result.References); + } + + // Follow continuation points to enumerate the full child set. + byte[] continuation = result.ContinuationPoint; + while (continuation != null && continuation.Length > 0) + { + ct.ThrowIfCancellationRequested(); + BrowseNextResponse next = await session.BrowseNextAsync( + null, false, new ByteStringCollection { continuation }, ct).ConfigureAwait(false); + if (next.Results == null || next.Results.Count == 0) + { + break; + } + BrowseResult nr = next.Results[0]; + if (nr.References != null) + { + references.AddRange(nr.References); + } + continuation = nr.ContinuationPoint; + } + + foreach (ReferenceDescription r in references) + { + NodeId? childId = ExpandedNodeId.ToNodeId(r.NodeId, session.NamespaceUris); + if (childId == null) + { + continue; + } + + if (r.NodeClass == NodeClass.Variable) + { + string displayName = r.DisplayName != null ? r.DisplayName.Text : ""; + string dataType = await ReadDataTypeNameAsync(session, childId, ct).ConfigureAwait(false); + descriptors.Add(new DataItemDescriptor(childId.ToString(), displayName, dataType, "VARIABLE", "")); + } + + // Recurse into both objects and variables (variables can have variable children). + await BrowseRecursiveAsync(session, childId, descriptors, visited, ct).ConfigureAwait(false); + } + } + + /// Reads a variable's DataType attribute and maps it to a built-in type name; "" on failure. + private static async Task ReadDataTypeNameAsync(ISession session, NodeId variable, CancellationToken ct) + { + try + { + var toRead = new ReadValueIdCollection + { + new ReadValueId { NodeId = variable, AttributeId = Attributes.DataType }, + }; + ReadResponse response = + await session.ReadAsync(null, 0, TimestampsToReturn.Neither, toRead, ct).ConfigureAwait(false); + DataValueCollection values = response.Results; + if (values != null && values.Count > 0 && StatusCode.IsGood(values[0].StatusCode) && values[0].Value is NodeId dtId) + { + BuiltInType bt = Opc.Ua.TypeInfo.GetBuiltInType(dtId); + return bt != BuiltInType.Null ? bt.ToString() : dtId.ToString(); + } + } + catch + { + // Best-effort enrichment; the descriptor is still valid without a data type. + } + return ""; + } + + private IUserIdentity BuildIdentity() + { + if (string.Equals(_authMode, "UsernamePassword", StringComparison.OrdinalIgnoreCase)) + { + return new UserIdentity(_username ?? "", System.Text.Encoding.UTF8.GetBytes(_password ?? "")); + } + return new UserIdentity(); + } + + private async Task BuildConfigurationAsync(CancellationToken ct) + { + if (_appConfig != null) + { + return _appConfig; + } + + string pkiRoot = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + ApplicationName, + "pki"); + string own = Path.Combine(pkiRoot, "own"); + string trusted = Path.Combine(pkiRoot, "trusted"); + string issuer = Path.Combine(pkiRoot, "issuer"); + string rejected = Path.Combine(pkiRoot, "rejected"); + Directory.CreateDirectory(own); + Directory.CreateDirectory(trusted); + Directory.CreateDirectory(issuer); + Directory.CreateDirectory(rejected); + + bool secure = !string.Equals(_securityMode, "None", StringComparison.OrdinalIgnoreCase); + int operationTimeoutMs = (int)Math.Max(1000, _timeout.TotalMilliseconds); + + var config = new ApplicationConfiguration + { + ApplicationName = ApplicationName, + ApplicationUri = "urn:localhost:" + ApplicationName, + ApplicationType = ApplicationType.Client, + SecurityConfiguration = new SecurityConfiguration + { + ApplicationCertificate = new CertificateIdentifier + { + StoreType = CertificateStoreType.Directory, + StorePath = own, + SubjectName = "CN=" + ApplicationName + ", O=Junction", + }, + TrustedPeerCertificates = new CertificateTrustList + { + StoreType = CertificateStoreType.Directory, + StorePath = trusted, + }, + TrustedIssuerCertificates = new CertificateTrustList + { + StoreType = CertificateStoreType.Directory, + StorePath = issuer, + }, + RejectedCertificateStore = new CertificateStoreIdentifier + { + StoreType = CertificateStoreType.Directory, + StorePath = rejected, + }, + // SecurityMode=None has no peer trust to establish, so accept untrusted server certs. + AutoAcceptUntrustedCertificates = !secure, + AddAppCertToTrustedStore = true, + }, + TransportConfigurations = new TransportConfigurationCollection(), + TransportQuotas = new TransportQuotas { OperationTimeout = operationTimeoutMs }, + ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60000 }, + TraceConfiguration = new TraceConfiguration(), + }; + + await config.ValidateAsync(ApplicationType.Client, ct).ConfigureAwait(false); + + var appInstance = new ApplicationInstance(config, _telemetry); + // Auto-generate the app-instance certificate if absent (silent, default lifetime). + await appInstance.CheckApplicationInstanceCertificatesAsync(true, null, ct).ConfigureAwait(false); + + _appConfig = config; + return config; + } + + public void Dispose() + { + // Session : IDisposable — disposing closes the channel/session. + _session?.Dispose(); + _session = null; + } + } +} diff --git a/src/Junction.Protocols.OpcUa/UaReadResult.cs b/src/Junction.Protocols.OpcUa/UaReadResult.cs new file mode 100644 index 0000000..ecbc1a8 --- /dev/null +++ b/src/Junction.Protocols.OpcUa/UaReadResult.cs @@ -0,0 +1,31 @@ +using System; + +namespace Junction.Protocols.OpcUa +{ + /// + /// Protocol-agnostic result of reading a single OPC UA node's Value attribute. Keeps SDK types + /// out of so the driver is testable without a live server. + /// + public sealed class UaReadResult + { + /// The node id that was read (as configured, e.g. "ns=2;s=Temperature"). + public string NodeId { get; } + + /// Value rendered as string to stay type-agnostic; empty when null/unreadable. + public string Value { get; } + + /// True when the read StatusCode was Good. + public bool StatusOk { get; } + + /// Source timestamp reported by the server for the value. + public DateTimeOffset SourceTimestamp { get; } + + public UaReadResult(string nodeId, string value, bool statusOk, DateTimeOffset sourceTimestamp) + { + NodeId = nodeId ?? ""; + Value = value ?? ""; + StatusOk = statusOk; + SourceTimestamp = sourceTimestamp; + } + } +} diff --git a/src/Junction.Protocols.OpcUa/plugin.manifest.json b/src/Junction.Protocols.OpcUa/plugin.manifest.json new file mode 100644 index 0000000..8e71a03 --- /dev/null +++ b/src/Junction.Protocols.OpcUa/plugin.manifest.json @@ -0,0 +1,7 @@ +{ + "protocolId": "opcua", + "displayName": "OPC UA", + "assemblyFile": "Junction.Protocols.OpcUa.dll", + "entryTypeName": "Junction.Protocols.OpcUa.OpcuaDriverFactory", + "apiVersion": "1.0" +} diff --git a/tests/Junction.Tests/Junction.Tests.csproj b/tests/Junction.Tests/Junction.Tests.csproj index 43b5e21..89d9372 100644 --- a/tests/Junction.Tests/Junction.Tests.csproj +++ b/tests/Junction.Tests/Junction.Tests.csproj @@ -26,6 +26,7 @@ + diff --git a/tests/Junction.Tests/Unit/OpcuaDriverFactoryTests.cs b/tests/Junction.Tests/Unit/OpcuaDriverFactoryTests.cs new file mode 100644 index 0000000..2077ed5 --- /dev/null +++ b/tests/Junction.Tests/Unit/OpcuaDriverFactoryTests.cs @@ -0,0 +1,176 @@ +using System; +using System.Collections.Generic; +using Junction.Domain.Models; +using Junction.Protocols.OpcUa; +using Xunit; + +namespace Junction.Tests.Unit +{ + /// + /// Config-validation tests for , mirroring + /// MtconnectDriverFactory tests: required/absolute/scheme checks, enum checks, the + /// UsernamePassword credential rule and case-insensitive key lookup. + /// + public sealed class OpcuaDriverFactoryTests + { + private static readonly Guid MachineId = Guid.Parse("99999999-8888-7777-6666-555555555555"); + private const string EndpointUrl = "opc.tcp://opcua-server.test:4840"; + + private static Machine MachineWithConfig(IReadOnlyDictionary? config) => + new Machine(MachineId, "PLC-01", "opcua", config, TimeSpan.FromSeconds(5)); + + [Fact] + public void ProtocolId_IsOpcua() + { + Assert.Equal("opcua", new OpcuaDriverFactory().ProtocolId); + } + + [Fact] + public void MissingEndpointUrl_ReturnsFail() + { + var result = new OpcuaDriverFactory().Create(MachineWithConfig(new Dictionary())); + + Assert.False(result.IsSuccess); + Assert.Contains(result.Errors, e => e.Code == "CONFIG_ENDPOINTURL_MISSING"); + } + + [Fact] + public void NonAbsoluteEndpointUrl_ReturnsFail() + { + var result = new OpcuaDriverFactory().Create(MachineWithConfig( + new Dictionary { ["EndpointUrl"] = "not a uri" })); + + Assert.False(result.IsSuccess); + Assert.Contains(result.Errors, e => e.Code == "CONFIG_ENDPOINTURL_INVALID"); + } + + [Fact] + public void WrongScheme_ReturnsFail() + { + var result = new OpcuaDriverFactory().Create(MachineWithConfig( + new Dictionary { ["EndpointUrl"] = "http://opcua-server.test:4840" })); + + Assert.False(result.IsSuccess); + Assert.Contains(result.Errors, e => e.Code == "CONFIG_ENDPOINTURL_SCHEME"); + } + + [Fact] + public void ValidEndpointUrl_ReturnsOkDriverWithOpcuaProtocolId() + { + var result = new OpcuaDriverFactory().Create(MachineWithConfig( + new Dictionary { ["EndpointUrl"] = EndpointUrl })); + + Assert.True(result.IsSuccess); + Assert.NotNull(result.Value); + Assert.Equal("opcua", result.Value.ProtocolId); + } + + [Fact] + public void EndpointUrlKeyIsCaseInsensitive() + { + var result = new OpcuaDriverFactory().Create(MachineWithConfig( + new Dictionary { ["endpointurl"] = EndpointUrl })); + + Assert.True(result.IsSuccess); + } + + [Fact] + public void InvalidSecurityMode_ReturnsFail() + { + var result = new OpcuaDriverFactory().Create(MachineWithConfig(new Dictionary + { + ["EndpointUrl"] = EndpointUrl, + ["SecurityMode"] = "Bogus", + })); + + Assert.False(result.IsSuccess); + Assert.Contains(result.Errors, e => e.Code == "CONFIG_SECURITYMODE_INVALID"); + } + + [Fact] + public void InvalidAuthMode_ReturnsFail() + { + var result = new OpcuaDriverFactory().Create(MachineWithConfig(new Dictionary + { + ["EndpointUrl"] = EndpointUrl, + ["AuthMode"] = "Kerberos", + })); + + Assert.False(result.IsSuccess); + Assert.Contains(result.Errors, e => e.Code == "CONFIG_AUTHMODE_INVALID"); + } + + [Fact] + public void UsernamePasswordWithoutUsername_ReturnsFail() + { + var result = new OpcuaDriverFactory().Create(MachineWithConfig(new Dictionary + { + ["EndpointUrl"] = EndpointUrl, + ["AuthMode"] = "UsernamePassword", + ["Password"] = "secret", + })); + + Assert.False(result.IsSuccess); + Assert.Contains(result.Errors, e => e.Code == "CONFIG_USERNAME_MISSING"); + } + + [Fact] + public void UsernamePasswordWithoutPassword_ReturnsFail() + { + var result = new OpcuaDriverFactory().Create(MachineWithConfig(new Dictionary + { + ["EndpointUrl"] = EndpointUrl, + ["AuthMode"] = "UsernamePassword", + ["Username"] = "admin", + })); + + Assert.False(result.IsSuccess); + Assert.Contains(result.Errors, e => e.Code == "CONFIG_PASSWORD_MISSING"); + } + + [Fact] + public void UsernamePasswordWithCredentials_ReturnsOk() + { + var result = new OpcuaDriverFactory().Create(MachineWithConfig(new Dictionary + { + ["EndpointUrl"] = EndpointUrl, + ["AuthMode"] = "UsernamePassword", + ["Username"] = "admin", + ["Password"] = "secret", + })); + + Assert.True(result.IsSuccess); + } + + [Fact] + public void InvalidTimeout_ReturnsFail() + { + var result = new OpcuaDriverFactory().Create(MachineWithConfig(new Dictionary + { + ["EndpointUrl"] = EndpointUrl, + ["TimeoutSeconds"] = "-5", + })); + + Assert.False(result.IsSuccess); + Assert.Contains(result.Errors, e => e.Code == "CONFIG_TIMEOUT_INVALID"); + } + + [Fact] + public void FullValidConfig_ReturnsOk() + { + var result = new OpcuaDriverFactory().Create(MachineWithConfig(new Dictionary + { + ["EndpointUrl"] = EndpointUrl, + ["SecurityMode"] = "SignAndEncrypt", + ["SecurityPolicy"] = "Basic256Sha256", + ["AuthMode"] = "Anonymous", + ["NodeIds"] = "ns=2;s=Temp, ns=2;s=Speed", + ["BrowseRoot"] = "i=85", + ["TimeoutSeconds"] = "15", + })); + + Assert.True(result.IsSuccess); + Assert.Equal("opcua", result.Value.ProtocolId); + } + } +} diff --git a/tests/Junction.Tests/Unit/OpcuaDriverTests.cs b/tests/Junction.Tests/Unit/OpcuaDriverTests.cs new file mode 100644 index 0000000..f80f806 --- /dev/null +++ b/tests/Junction.Tests/Unit/OpcuaDriverTests.cs @@ -0,0 +1,218 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Junction.Domain.Models; +using Junction.Domain.Protocols; +using Junction.Protocols.OpcUa; +using Moq; +using Xunit; + +namespace Junction.Tests.Unit +{ + /// + /// Drives against a mocked (no live server), + /// mirroring MtconnectDriverTests. Exercises the read/browse happy paths, the opt-in selection + /// filter, transport-failure mapping and cancellation. + /// + public sealed class OpcuaDriverTests + { + private static readonly Guid MachineId = Guid.Parse("11111111-2222-3333-4444-555555555555"); + private const string BrowseRoot = "i=85"; + private static readonly TimeSpan GenerousTimeout = TimeSpan.FromSeconds(30); + + // Node ids configured for ReadCurrentAsync. + private static readonly string[] ConfiguredNodes = { "ns=2;s=Temp", "ns=2;s=Speed", "ns=2;s=State" }; + + private static IReadOnlyList FakeReads() => new List + { + new UaReadResult("ns=2;s=Temp", "21.5", true, DateTimeOffset.UtcNow), + new UaReadResult("ns=2;s=Speed", "1500", true, DateTimeOffset.UtcNow), + new UaReadResult("ns=2;s=State", "RUNNING", true, DateTimeOffset.UtcNow), + }; + + private static OpcuaDriver DriverWith( + IUaClient client, + IReadOnlyList? nodeIds = null, + IReadOnlyCollection? monitoredItemIds = null, + TimeSpan? timeout = null) => + new OpcuaDriver( + client, + MachineId, + nodeIds ?? ConfiguredNodes, + BrowseRoot, + timeout ?? GenerousTimeout, + monitoredItemIds ?? ConfiguredNodes); + + [Fact] + public void ProtocolId_IsOpcua() + { + var driver = DriverWith(new Mock().Object); + Assert.Equal("opcua", driver.ProtocolId); + } + + // ---- ReadCurrentAsync: happy path ---- + + [Fact] + public async Task ReadCurrentAsync_ReadsConfiguredNodes_ReturnsConnectedSnapshotStampedWithMachineId() + { + var mock = new Mock(); + mock.Setup(c => c.ConnectAsync(It.IsAny())).Returns(Task.CompletedTask); + mock.Setup(c => c.ReadAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(FakeReads()); + + var driver = DriverWith(mock.Object); + var result = await driver.ReadCurrentAsync(CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.False(result.WasCancelled); + Assert.Equal(MachineId, result.Value.MachineId); + Assert.Equal(ConnectionState.Connected, result.Value.ConnectionState); + Assert.Equal(3, result.Value.Items.Count); + Assert.Contains(result.Value.Items, i => i.Id == "ns=2;s=Temp" && i.Value == "21.5"); + mock.Verify(c => c.ReadAsync(It.IsAny>(), It.IsAny()), Times.Once); + } + + // ---- ReadCurrentAsync: opt-in selection filter ---- + + [Fact] + public async Task ReadCurrentAsync_SelectionSubset_KeepsOnlySelectedItems_PreservesConnectionState() + { + var mock = new Mock(); + mock.Setup(c => c.ReadAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(FakeReads()); + + var selection = new[] { "ns=2;s=Temp", "ns=2;s=State" }; + var driver = DriverWith(mock.Object, monitoredItemIds: selection); + var result = await driver.ReadCurrentAsync(CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal(2, result.Value.Items.Count); + Assert.Contains(result.Value.Items, i => i.Id == "ns=2;s=Temp"); + Assert.Contains(result.Value.Items, i => i.Id == "ns=2;s=State"); + Assert.DoesNotContain(result.Value.Items, i => i.Id == "ns=2;s=Speed"); + Assert.Equal(ConnectionState.Connected, result.Value.ConnectionState); + } + + [Fact] + public async Task ReadCurrentAsync_EmptySelection_ReturnsEmptyItems_PreservesConnectionState() + { + var mock = new Mock(); + mock.Setup(c => c.ReadAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(FakeReads()); + + var driver = DriverWith(mock.Object, monitoredItemIds: Array.Empty()); + var result = await driver.ReadCurrentAsync(CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Empty(result.Value.Items); + Assert.Equal(ConnectionState.Connected, result.Value.ConnectionState); + } + + // ---- ReadCurrentAsync: failure + cancellation ---- + + [Fact] + public async Task ReadCurrentAsync_ClientThrows_ReturnsFailNoThrow() + { + var mock = new Mock(); + mock.Setup(c => c.ReadAsync(It.IsAny>(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("server unreachable")); + + var driver = DriverWith(mock.Object); + var result = await driver.ReadCurrentAsync(CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.False(result.WasCancelled); + Assert.Contains(result.Errors, e => e.Code == "CURRENT_READ_ERROR"); + } + + [Fact] + public async Task ReadCurrentAsync_CancelledBeforeCall_ReturnsCancelled() + { + var mock = new Mock(); + var driver = DriverWith(mock.Object); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var result = await driver.ReadCurrentAsync(cts.Token); + + Assert.False(result.IsSuccess); + Assert.True(result.WasCancelled); + } + + [Fact] + public async Task ReadCurrentAsync_CancelledDuringCall_ReturnsCancelledNoThrow() + { + var mock = new Mock(); + using var cts = new CancellationTokenSource(); + mock.Setup(c => c.ReadAsync(It.IsAny>(), It.IsAny())) + .Returns, CancellationToken>(async (_, ct) => + { + cts.Cancel(); + await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false); + return FakeReads(); + }); + + var driver = DriverWith(mock.Object); + var result = await driver.ReadCurrentAsync(cts.Token); + + Assert.False(result.IsSuccess); + Assert.True(result.WasCancelled); + } + + // ---- ProbeAsync: catalog ---- + + [Fact] + public async Task ProbeAsync_BrowsesFromRoot_ReturnsDescriptors() + { + var descriptors = new List + { + new DataItemDescriptor("ns=2;s=Temp", "Temperature", "Double", "VARIABLE", ""), + new DataItemDescriptor("ns=2;s=Speed", "Speed", "Int32", "VARIABLE", ""), + }; + var mock = new Mock(); + mock.Setup(c => c.BrowseAsync(BrowseRoot, It.IsAny())) + .ReturnsAsync(descriptors); + + var driver = DriverWith(mock.Object); + var result = await driver.ProbeAsync(CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal(2, result.Value.Count); + var temp = result.Value.Single(d => d.Id == "ns=2;s=Temp"); + Assert.Equal("Temperature", temp.Name); + Assert.Equal("Double", temp.Type); + Assert.Equal("VARIABLE", temp.Category); + mock.Verify(c => c.BrowseAsync(BrowseRoot, It.IsAny()), Times.Once); + } + + [Fact] + public async Task ProbeAsync_ClientThrows_ReturnsFailNoThrow() + { + var mock = new Mock(); + mock.Setup(c => c.BrowseAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("browse failed")); + + var driver = DriverWith(mock.Object); + var result = await driver.ProbeAsync(CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Contains(result.Errors, e => e.Code == "PROBE_BROWSE_ERROR"); + } + + [Fact] + public async Task ProbeAsync_CancelledBeforeCall_ReturnsCancelled() + { + var mock = new Mock(); + var driver = DriverWith(mock.Object); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var result = await driver.ProbeAsync(cts.Token); + + Assert.False(result.IsSuccess); + Assert.True(result.WasCancelled); + } + } +}