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>
This commit is contained in:
parent
15fd1306c3
commit
88dfa74ebe
12 changed files with 1243 additions and 0 deletions
|
|
@ -19,6 +19,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{362A84DF
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Junction.Tests", "tests\Junction.Tests\Junction.Tests.csproj", "{618E6634-A16C-4806-836A-185ECDEE7312}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Junction.Tests", "tests\Junction.Tests\Junction.Tests.csproj", "{618E6634-A16C-4806-836A-185ECDEE7312}"
|
||||||
EndProject
|
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
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
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}.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.ActiveCfg = Release|Any CPU
|
||||||
{618E6634-A16C-4806-836A-185ECDEE7312}.Release|Any CPU.Build.0 = 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
|
EndGlobalSection
|
||||||
GlobalSection(NestedProjects) = preSolution
|
GlobalSection(NestedProjects) = preSolution
|
||||||
{EE2D0A1D-3135-4065-A9EF-AB99F4FCC062} = {6DDDE464-201D-4B4F-A23E-566B5FC111BC}
|
{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}
|
{BBAD50B2-18B5-4F4B-B8E3-CE749DB46C83} = {6DDDE464-201D-4B4F-A23E-566B5FC111BC}
|
||||||
{965918A3-4C29-4F6A-B53D-0A72BDF4376C} = {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}
|
{618E6634-A16C-4806-836A-185ECDEE7312} = {362A84DF-21D2-4DA7-B8B5-8C5E3C6E8500}
|
||||||
|
{93FC27CB-C147-4BE6-8E86-F46192B72099} = {6DDDE464-201D-4B4F-A23E-566B5FC111BC}
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
EndGlobal
|
EndGlobal
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@
|
||||||
<OutputType>WinExe</OutputType>
|
<OutputType>WinExe</OutputType>
|
||||||
<RootNamespace>Junction.App</RootNamespace>
|
<RootNamespace>Junction.App</RootNamespace>
|
||||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||||
|
<!-- net48 needs binding redirects for the M.E.Logging/System.* shims the OPC UA SDK drags in. -->
|
||||||
|
<AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<!-- Avalonia 11.3.x supports net48; Avalonia 12 DROPS net48. PIN 11.3.10 exact everywhere. -->
|
<!-- Avalonia 11.3.x supports net48; Avalonia 12 DROPS net48. PIN 11.3.10 exact everywhere. -->
|
||||||
|
|
@ -42,6 +44,8 @@
|
||||||
<!-- Plugin = RUNTIME artifact, NOT a compile reference. Build-order-only so we can copy its output. -->
|
<!-- Plugin = RUNTIME artifact, NOT a compile reference. Build-order-only so we can copy its output. -->
|
||||||
<ProjectReference Include="..\Junction.Protocols.MTConnect\Junction.Protocols.MTConnect.csproj"
|
<ProjectReference Include="..\Junction.Protocols.MTConnect\Junction.Protocols.MTConnect.csproj"
|
||||||
ReferenceOutputAssembly="false" Private="false" />
|
ReferenceOutputAssembly="false" Private="false" />
|
||||||
|
<ProjectReference Include="..\Junction.Protocols.OpcUa\Junction.Protocols.OpcUa.csproj"
|
||||||
|
ReferenceOutputAssembly="false" Private="false" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<!-- Copy MTConnect plugin (dll + manifest) into OutDir/plugins/mtconnect/ after build. -->
|
<!-- Copy MTConnect plugin (dll + manifest) into OutDir/plugins/mtconnect/ after build. -->
|
||||||
|
|
@ -53,4 +57,15 @@
|
||||||
<Copy SourceFiles="@(MtconnectPluginFiles)" DestinationFolder="$(OutDir)plugins\mtconnect\" SkipUnchangedFiles="true" />
|
<Copy SourceFiles="@(MtconnectPluginFiles)" DestinationFolder="$(OutDir)plugins\mtconnect\" SkipUnchangedFiles="true" />
|
||||||
</Target>
|
</Target>
|
||||||
|
|
||||||
|
<!-- Copy OPC UA plugin (its dll + FULL transitive SDK closure + manifest) into OutDir/plugins/opcua/
|
||||||
|
after build. Host $(TargetFramework) is net48 or net8.0; the plugin multi-targets the same TFMs,
|
||||||
|
so copy the matching per-TFM output. -->
|
||||||
|
<Target Name="CopyOpcuaPlugin" AfterTargets="Build" Condition="'$(TargetFramework)' != ''">
|
||||||
|
<ItemGroup>
|
||||||
|
<OpcuaPluginFiles Include="$(MSBuildThisFileDirectory)..\Junction.Protocols.OpcUa\bin\$(Configuration)\$(TargetFramework)\*.dll" />
|
||||||
|
<OpcuaPluginFiles Include="$(MSBuildThisFileDirectory)..\Junction.Protocols.OpcUa\bin\$(Configuration)\$(TargetFramework)\plugin.manifest.json" />
|
||||||
|
</ItemGroup>
|
||||||
|
<Copy SourceFiles="@(OpcuaPluginFiles)" DestinationFolder="$(OutDir)plugins\opcua\" SkipUnchangedFiles="true" />
|
||||||
|
</Target>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
||||||
27
src/Junction.Protocols.OpcUa/IUaClient.cs
Normal file
27
src/Junction.Protocols.OpcUa/IUaClient.cs
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Junction.Domain.Protocols;
|
||||||
|
|
||||||
|
namespace Junction.Protocols.OpcUa
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Transport seam over an OPC UA session, mirroring the HttpClient injection the MTConnect
|
||||||
|
/// driver uses. Deliberately keeps all <c>Opc.Ua.*</c> SDK types OUT of its signatures so
|
||||||
|
/// <see cref="OpcuaDriver"/> can be unit-tested against a mock with no live server.
|
||||||
|
/// </summary>
|
||||||
|
public interface IUaClient
|
||||||
|
{
|
||||||
|
/// <summary>Establish the session (connect + authenticate). Idempotent: safe to call before each op.</summary>
|
||||||
|
Task ConnectAsync(CancellationToken ct);
|
||||||
|
|
||||||
|
/// <summary>Read the Value attribute of each given node id. Order matches the input order.</summary>
|
||||||
|
Task<IReadOnlyList<UaReadResult>> ReadAsync(IReadOnlyList<string> nodeIds, CancellationToken ct);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Recursively browse the address space from <paramref name="browseRoot"/>, returning one
|
||||||
|
/// descriptor per Variable node found.
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<DataItemDescriptor>> BrowseAsync(string browseRoot, CancellationToken ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/Junction.Protocols.OpcUa/Junction.Protocols.OpcUa.csproj
Normal file
27
src/Junction.Protocols.OpcUa/Junction.Protocols.OpcUa.csproj
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<!-- Multi-target: net48 = shipped fleet (Win7/8); net8.0 = Linux dev host must load plugin too.
|
||||||
|
MTConnect is ns2.0 (loads on both), but the OPC UA SDK ships no ns2.0 asset, so target the
|
||||||
|
two host TFMs explicitly. -->
|
||||||
|
<TargetFrameworks>net48;net8.0</TargetFrameworks>
|
||||||
|
<RootNamespace>Junction.Protocols.OpcUa</RootNamespace>
|
||||||
|
<!-- Guarantees the full transitive closure (SDK + its deps) lands in bin per TFM so the App copy
|
||||||
|
target ships everything the plugin needs at runtime. -->
|
||||||
|
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client" Version="1.5.378.156" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Junction.Domain\Junction.Domain.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!-- Plugin manifest: ships beside built assembly. Condition keeps build green if file is absent. -->
|
||||||
|
<ItemGroup>
|
||||||
|
<None Include="plugin.manifest.json" Condition="Exists('plugin.manifest.json')" CopyToOutputDirectory="PreserveNewest" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
182
src/Junction.Protocols.OpcUa/OpcuaDriver.cs
Normal file
182
src/Junction.Protocols.OpcUa/OpcuaDriver.cs
Normal file
|
|
@ -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
|
||||||
|
{
|
||||||
|
/// <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));
|
||||||
|
}
|
||||||
|
}
|
||||||
220
src/Junction.Protocols.OpcUa/OpcuaDriverFactory.cs
Normal file
220
src/Junction.Protocols.OpcUa/OpcuaDriverFactory.cs
Normal file
|
|
@ -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
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Plugin entrypoint for the OPC UA protocol. Resolved by the Core plugin loader from
|
||||||
|
/// <c>plugin.manifest.json</c> via <see cref="Activator.CreateInstance(Type)"/>, so it MUST
|
||||||
|
/// keep a public parameterless constructor.
|
||||||
|
/// <para>
|
||||||
|
/// Owns OPC UA config validation, mirroring <c>MtconnectDriverFactory</c> (case-insensitive
|
||||||
|
/// lookup, CONFIG_* fail codes). Builds a real <see cref="UaClient"/> and injects it into the
|
||||||
|
/// per-machine <see cref="OpcuaDriver"/>.
|
||||||
|
/// </para>
|
||||||
|
/// <para>Expected <see cref="Machine.ConnectionConfig"/> keys (case-insensitive):</para>
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><description><c>EndpointUrl</c> (required) — absolute opc.tcp:// URL of the server.</description></item>
|
||||||
|
/// <item><description><c>SecurityMode</c> (optional) — None|Sign|SignAndEncrypt, default None.</description></item>
|
||||||
|
/// <item><description><c>SecurityPolicy</c> (optional) — default None.</description></item>
|
||||||
|
/// <item><description><c>AuthMode</c> (optional) — Anonymous|UsernamePassword, default Anonymous.</description></item>
|
||||||
|
/// <item><description><c>Username</c>/<c>Password</c> — required iff AuthMode=UsernamePassword.</description></item>
|
||||||
|
/// <item><description><c>NodeIds</c> (optional CSV) — node ids read by ReadCurrentAsync.</description></item>
|
||||||
|
/// <item><description><c>BrowseRoot</c> (optional) — browse start node, default "i=85".</description></item>
|
||||||
|
/// <item><description><c>TimeoutSeconds</c> (optional) — positive int, default 10.</description></item>
|
||||||
|
/// </list>
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
|
||||||
|
/// <summary>Protocol identifier this factory produces drivers for.</summary>
|
||||||
|
public string ProtocolId => "opcua";
|
||||||
|
|
||||||
|
/// <summary>Required by the plugin loader (Activator.CreateInstance).</summary>
|
||||||
|
public OpcuaDriverFactory()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Result<IProtocolDriver> 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<IProtocolDriver>.Ok(driver);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<string> ParseCsv(string? raw)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(raw))
|
||||||
|
{
|
||||||
|
return Array.Empty<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var parts = raw!.Split(',');
|
||||||
|
var list = new List<string>(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<string, string> 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<IProtocolDriver> Fail(string code, string message) =>
|
||||||
|
Result<IProtocolDriver>.Fail(new OperationError(code, Source, message));
|
||||||
|
}
|
||||||
|
}
|
||||||
332
src/Junction.Protocols.OpcUa/UaClient.cs
Normal file
332
src/Junction.Protocols.OpcUa/UaClient.cs
Normal file
|
|
@ -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
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Real <see cref="IUaClient"/> over the OPC Foundation .NET Standard SDK. Owns an
|
||||||
|
/// <see cref="ApplicationConfiguration"/> with a Directory PKI store under LocalApplicationData,
|
||||||
|
/// auto-generates the app-instance certificate, opens a <see cref="Session"/> and translates SDK
|
||||||
|
/// calls into the SDK-free DTOs the driver consumes.
|
||||||
|
/// <para>
|
||||||
|
/// This type is NOT unit-tested (it needs a live server); the driver is tested via a mocked
|
||||||
|
/// <see cref="IUaClient"/>. Exceptions raised here are caught by the driver and mapped to
|
||||||
|
/// <see cref="Junction.Domain.Result{T}"/> failures.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Uses the SDK's non-obsolete telemetry-threaded async surface throughout. The pinned SDK
|
||||||
|
/// (1.5.378.156) is mid-migration to an <c>ITelemetryContext</c>/DI-first API; a null telemetry
|
||||||
|
/// context is accepted and the SDK substitutes its default.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
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<IReadOnlyList<UaReadResult>> ReadAsync(IReadOnlyList<string> nodeIds, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await ConnectAsync(ct).ConfigureAwait(false);
|
||||||
|
ISession session = _session ?? throw new InvalidOperationException("OPC UA session not established.");
|
||||||
|
|
||||||
|
var results = new List<UaReadResult>(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<IReadOnlyList<DataItemDescriptor>> 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<DataItemDescriptor>();
|
||||||
|
var visited = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
await BrowseRecursiveAsync(session, root, descriptors, visited, ct).ConfigureAwait(false);
|
||||||
|
return descriptors;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task BrowseRecursiveAsync(
|
||||||
|
ISession session,
|
||||||
|
NodeId node,
|
||||||
|
List<DataItemDescriptor> descriptors,
|
||||||
|
HashSet<string> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reads a variable's DataType attribute and maps it to a built-in type name; "" on failure.</summary>
|
||||||
|
private static async Task<string> 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<ApplicationConfiguration> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/Junction.Protocols.OpcUa/UaReadResult.cs
Normal file
31
src/Junction.Protocols.OpcUa/UaReadResult.cs
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace Junction.Protocols.OpcUa
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Protocol-agnostic result of reading a single OPC UA node's Value attribute. Keeps SDK types
|
||||||
|
/// out of <see cref="IUaClient"/> so the driver is testable without a live server.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class UaReadResult
|
||||||
|
{
|
||||||
|
/// <summary>The node id that was read (as configured, e.g. "ns=2;s=Temperature").</summary>
|
||||||
|
public string NodeId { get; }
|
||||||
|
|
||||||
|
/// <summary>Value rendered as string to stay type-agnostic; empty when null/unreadable.</summary>
|
||||||
|
public string Value { get; }
|
||||||
|
|
||||||
|
/// <summary>True when the read StatusCode was Good.</summary>
|
||||||
|
public bool StatusOk { get; }
|
||||||
|
|
||||||
|
/// <summary>Source timestamp reported by the server for the value.</summary>
|
||||||
|
public DateTimeOffset SourceTimestamp { get; }
|
||||||
|
|
||||||
|
public UaReadResult(string nodeId, string value, bool statusOk, DateTimeOffset sourceTimestamp)
|
||||||
|
{
|
||||||
|
NodeId = nodeId ?? "";
|
||||||
|
Value = value ?? "";
|
||||||
|
StatusOk = statusOk;
|
||||||
|
SourceTimestamp = sourceTimestamp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
7
src/Junction.Protocols.OpcUa/plugin.manifest.json
Normal file
7
src/Junction.Protocols.OpcUa/plugin.manifest.json
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"protocolId": "opcua",
|
||||||
|
"displayName": "OPC UA",
|
||||||
|
"assemblyFile": "Junction.Protocols.OpcUa.dll",
|
||||||
|
"entryTypeName": "Junction.Protocols.OpcUa.OpcuaDriverFactory",
|
||||||
|
"apiVersion": "1.0"
|
||||||
|
}
|
||||||
|
|
@ -26,6 +26,7 @@
|
||||||
<ProjectReference Include="..\..\src\Junction.Core\Junction.Core.csproj" />
|
<ProjectReference Include="..\..\src\Junction.Core\Junction.Core.csproj" />
|
||||||
<ProjectReference Include="..\..\src\Junction.Persistence\Junction.Persistence.csproj" />
|
<ProjectReference Include="..\..\src\Junction.Persistence\Junction.Persistence.csproj" />
|
||||||
<ProjectReference Include="..\..\src\Junction.Protocols.MTConnect\Junction.Protocols.MTConnect.csproj" />
|
<ProjectReference Include="..\..\src\Junction.Protocols.MTConnect\Junction.Protocols.MTConnect.csproj" />
|
||||||
|
<ProjectReference Include="..\..\src\Junction.Protocols.OpcUa\Junction.Protocols.OpcUa.csproj" />
|
||||||
<!-- App reference resolves its net8.0 target (tests are net8.0-only); covers view-layer converters. -->
|
<!-- App reference resolves its net8.0 target (tests are net8.0-only); covers view-layer converters. -->
|
||||||
<ProjectReference Include="..\..\src\Junction.App\Junction.App.csproj" />
|
<ProjectReference Include="..\..\src\Junction.App\Junction.App.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
|
||||||
176
tests/Junction.Tests/Unit/OpcuaDriverFactoryTests.cs
Normal file
176
tests/Junction.Tests/Unit/OpcuaDriverFactoryTests.cs
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Junction.Domain.Models;
|
||||||
|
using Junction.Protocols.OpcUa;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Junction.Tests.Unit
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Config-validation tests for <see cref="OpcuaDriverFactory"/>, mirroring
|
||||||
|
/// MtconnectDriverFactory tests: required/absolute/scheme checks, enum checks, the
|
||||||
|
/// UsernamePassword credential rule and case-insensitive key lookup.
|
||||||
|
/// </summary>
|
||||||
|
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<string, string>? 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<string, string>()));
|
||||||
|
|
||||||
|
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<string, string> { ["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<string, string> { ["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<string, string> { ["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<string, string> { ["endpointurl"] = EndpointUrl }));
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InvalidSecurityMode_ReturnsFail()
|
||||||
|
{
|
||||||
|
var result = new OpcuaDriverFactory().Create(MachineWithConfig(new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["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<string, string>
|
||||||
|
{
|
||||||
|
["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<string, string>
|
||||||
|
{
|
||||||
|
["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<string, string>
|
||||||
|
{
|
||||||
|
["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<string, string>
|
||||||
|
{
|
||||||
|
["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<string, string>
|
||||||
|
{
|
||||||
|
["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<string, string>
|
||||||
|
{
|
||||||
|
["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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
218
tests/Junction.Tests/Unit/OpcuaDriverTests.cs
Normal file
218
tests/Junction.Tests/Unit/OpcuaDriverTests.cs
Normal file
|
|
@ -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
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Drives <see cref="OpcuaDriver"/> against a mocked <see cref="IUaClient"/> (no live server),
|
||||||
|
/// mirroring MtconnectDriverTests. Exercises the read/browse happy paths, the opt-in selection
|
||||||
|
/// filter, transport-failure mapping and cancellation.
|
||||||
|
/// </summary>
|
||||||
|
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<UaReadResult> FakeReads() => new List<UaReadResult>
|
||||||
|
{
|
||||||
|
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<string>? nodeIds = null,
|
||||||
|
IReadOnlyCollection<string>? 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<IUaClient>().Object);
|
||||||
|
Assert.Equal("opcua", driver.ProtocolId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- ReadCurrentAsync: happy path ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReadCurrentAsync_ReadsConfiguredNodes_ReturnsConnectedSnapshotStampedWithMachineId()
|
||||||
|
{
|
||||||
|
var mock = new Mock<IUaClient>();
|
||||||
|
mock.Setup(c => c.ConnectAsync(It.IsAny<CancellationToken>())).Returns(Task.CompletedTask);
|
||||||
|
mock.Setup(c => c.ReadAsync(It.IsAny<IReadOnlyList<string>>(), It.IsAny<CancellationToken>()))
|
||||||
|
.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<IReadOnlyList<string>>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- ReadCurrentAsync: opt-in selection filter ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReadCurrentAsync_SelectionSubset_KeepsOnlySelectedItems_PreservesConnectionState()
|
||||||
|
{
|
||||||
|
var mock = new Mock<IUaClient>();
|
||||||
|
mock.Setup(c => c.ReadAsync(It.IsAny<IReadOnlyList<string>>(), It.IsAny<CancellationToken>()))
|
||||||
|
.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<IUaClient>();
|
||||||
|
mock.Setup(c => c.ReadAsync(It.IsAny<IReadOnlyList<string>>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(FakeReads());
|
||||||
|
|
||||||
|
var driver = DriverWith(mock.Object, monitoredItemIds: Array.Empty<string>());
|
||||||
|
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<IUaClient>();
|
||||||
|
mock.Setup(c => c.ReadAsync(It.IsAny<IReadOnlyList<string>>(), It.IsAny<CancellationToken>()))
|
||||||
|
.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<IUaClient>();
|
||||||
|
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<IUaClient>();
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
mock.Setup(c => c.ReadAsync(It.IsAny<IReadOnlyList<string>>(), It.IsAny<CancellationToken>()))
|
||||||
|
.Returns<IReadOnlyList<string>, 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<DataItemDescriptor>
|
||||||
|
{
|
||||||
|
new DataItemDescriptor("ns=2;s=Temp", "Temperature", "Double", "VARIABLE", ""),
|
||||||
|
new DataItemDescriptor("ns=2;s=Speed", "Speed", "Int32", "VARIABLE", ""),
|
||||||
|
};
|
||||||
|
var mock = new Mock<IUaClient>();
|
||||||
|
mock.Setup(c => c.BrowseAsync(BrowseRoot, It.IsAny<CancellationToken>()))
|
||||||
|
.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<CancellationToken>()), Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ProbeAsync_ClientThrows_ReturnsFailNoThrow()
|
||||||
|
{
|
||||||
|
var mock = new Mock<IUaClient>();
|
||||||
|
mock.Setup(c => c.BrowseAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||||
|
.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<IUaClient>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue