junction/src/Junction.Protocols.OpcUa/OpcuaDriver.cs
dtrentin 88dfa74ebe feat: OPC UA protocol plugin (v0.4)
Second protocol plugin proving multi-protocol architecture. New
Junction.Protocols.OpcUa (multi-target net48;net8.0 — SDK has no ns2.0):
- OpcuaDriverFactory: config validation (EndpointUrl/SecurityMode/Policy/
  AuthMode/Username/Password/NodeIds/BrowseRoot/TimeoutSeconds), CONFIG_* codes.
- OpcuaDriver: ReadCurrentAsync (read configured nodes) + ProbeAsync (browse
  address space), linked-CTS timeout, Result mapping, opt-in FilterToMonitored
  — mirrors MtconnectDriver.
- IUaClient seam (SDK-free) injected for testability; UaClient wraps OPC UA
  Session (non-obsolete async API), Directory PKI store, anon/user-pass auth.

SDK: OPCFoundation.NetStandard.Opc.Ua.Client 1.5.378.156 (MIT), pinned exact,
transitive closure copied per-TFM into plugins/opcua/ (CopyLocalLockFileAssemblies
+ CopyOpcuaPlugin target). Host: AutoGenerateBindingRedirects. Core untouched —
protocol auto-discovered by plugin loader.

Projects: +Junction.Protocols.OpcUa, Junction.App (wiring), Junction.sln,
Junction.Tests (+23 tests: 13 factory + 10 driver via Mock<IUaClient>). 171 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 07:44:23 +02:00

182 lines
7.7 KiB
C#

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
{
/// <summary>
/// Per-machine OPC UA protocol driver. Reads the configured node values and browses the address
/// space through an injected <see cref="IUaClient"/> (the transport seam, mirroring the HttpClient
/// injection in <c>MtconnectDriver</c>). SDK types stay behind <see cref="IUaClient"/> so this
/// driver is unit-tested against a mock with no live server.
/// <para>
/// Never throws for expected transport failures: maps them to <see cref="Result{T}.Fail(OperationError)"/>
/// and honours cancellation via <see cref="Result{T}.Cancelled"/>.
/// </para>
/// </summary>
public sealed class OpcuaDriver : IProtocolDriver
{
private const string Source = "OpcuaDriver";
private readonly IUaClient _client;
private readonly Guid _machineId;
private readonly IReadOnlyList<string> _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<string> _monitoredItemIds;
/// <summary>Protocol identifier this driver serves.</summary>
public string ProtocolId => "opcua";
/// <param name="client">Transport seam; injected for testability, owned by the factory.</param>
/// <param name="machineId">Machine this driver reads for; stamped onto the snapshot.</param>
/// <param name="nodeIds">Configured node ids to read in <see cref="ReadCurrentAsync"/>.</param>
/// <param name="browseRoot">Address-space browse start node for <see cref="ProbeAsync"/>.</param>
/// <param name="timeout">Per-operation timeout enforced via a linked <see cref="CancellationTokenSource"/>.</param>
/// <param name="monitoredItemIds">
/// DataItem ids the user selected to monitor. <see cref="ReadCurrentAsync"/> keeps only these
/// (opt-in); empty/null => "monitor nothing".
/// </param>
public OpcuaDriver(
IUaClient client,
Guid machineId,
IReadOnlyList<string>? nodeIds,
string browseRoot,
TimeSpan timeout,
IReadOnlyCollection<string>? monitoredItemIds = null)
{
_client = client ?? throw new ArgumentNullException(nameof(client));
_machineId = machineId;
_nodeIds = nodeIds ?? Array.Empty<string>();
_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<string>(StringComparer.Ordinal);
if (monitoredItemIds != null)
{
foreach (var id in monitoredItemIds)
{
if (!string.IsNullOrEmpty(id))
{
_monitoredItemIds.Add(id);
}
}
}
}
/// <inheritdoc />
public async Task<Result<MachineSnapshot>> ReadCurrentAsync(CancellationToken cancellationToken)
{
if (cancellationToken.IsCancellationRequested)
{
return Result<MachineSnapshot>.Cancelled();
}
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(_timeout);
IReadOnlyList<UaReadResult> 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<MachineSnapshot>.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<DataItem>(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<MachineSnapshot>.Ok(FilterToMonitored(snapshot));
}
/// <inheritdoc />
public async Task<Result<IReadOnlyList<DataItemDescriptor>>> ProbeAsync(CancellationToken cancellationToken)
{
if (cancellationToken.IsCancellationRequested)
{
return Result<IReadOnlyList<DataItemDescriptor>>.Cancelled();
}
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(_timeout);
IReadOnlyList<DataItemDescriptor> 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<IReadOnlyList<DataItemDescriptor>>.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<IReadOnlyList<DataItemDescriptor>>.Ok(descriptors);
}
/// <summary>
/// Returns a copy of <paramref name="snapshot"/> 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.
/// </summary>
private MachineSnapshot FilterToMonitored(MachineSnapshot snapshot)
{
if (_monitoredItemIds.Count == 0)
{
return new MachineSnapshot(
snapshot.MachineId, snapshot.CapturedAt, snapshot.ConnectionState, Array.Empty<DataItem>());
}
var kept = new List<DataItem>();
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<MachineSnapshot> Fail(string code, string message) =>
Result<MachineSnapshot>.Fail(new OperationError(code, Source, message));
private static Result<IReadOnlyList<DataItemDescriptor>> ProbeFail(string code, string message) =>
Result<IReadOnlyList<DataItemDescriptor>>.Fail(new OperationError(code, Source, message));
}
}