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);
+ }
+ }
+}