Compare commits
25 Commits
phase-3-pr
...
phase-3-pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9e856178a | ||
| 63eb569fd6 | |||
|
|
fad04bbdf7 | ||
| 17f901bb65 | |||
|
|
ba3a5598e1 | ||
| 8cd932e7c9 | |||
|
|
28328def5d | ||
| d3bf544abc | |||
|
|
24435712c4 | ||
| 3f7b4d05e6 | |||
|
|
a79c5f3008 | ||
| a5299a2fee | |||
|
|
a65215684c | ||
| 82f2dfcfa3 | |||
|
|
0433d3a35e | ||
| 141673fc80 | |||
|
|
db56a95819 | ||
| 89bd726fa8 | |||
|
|
238748bc98 | ||
| b21d550836 | |||
|
|
91eaf534c8 | ||
| d33e38e059 | |||
|
|
d8ef35d5bd | ||
| 5e318a1ab6 | |||
| 0eab1271be |
@@ -10,6 +10,7 @@
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ZB.MOM.WW.OtOpcUa.Driver.Modbus.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.S7/ZB.MOM.WW.OtOpcUa.Driver.S7.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.Shared/ZB.MOM.WW.OtOpcUa.Client.Shared.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.CLI/ZB.MOM.WW.OtOpcUa.Client.CLI.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.UI/ZB.MOM.WW.OtOpcUa.Client.UI.csproj"/>
|
||||
@@ -28,6 +29,7 @@
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Client.UI.Tests/ZB.MOM.WW.OtOpcUa.Client.UI.Tests.csproj"/>
|
||||
|
||||
1384
src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs
Normal file
1384
src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,180 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||
|
||||
/// <summary>
|
||||
/// OPC UA Client (gateway) driver configuration. Bound from <c>DriverConfig</c> JSON at
|
||||
/// driver-host registration time. Models the settings documented in
|
||||
/// <c>docs/v2/driver-specs.md</c> §8.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This driver connects to a REMOTE OPC UA server and re-exposes its address space
|
||||
/// through the local OtOpcUa server — the opposite direction from the usual "server
|
||||
/// exposes PLC data" flow. Tier A (pure managed, OPC Foundation reference SDK); universal
|
||||
/// protections cover it.
|
||||
/// </remarks>
|
||||
public sealed class OpcUaClientDriverOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Remote OPC UA endpoint URL, e.g. <c>opc.tcp://plc.internal:4840</c>. Convenience
|
||||
/// shortcut for a single-endpoint deployment — equivalent to setting
|
||||
/// <see cref="EndpointUrls"/> to a list with this one URL. When both are provided,
|
||||
/// the list wins and <see cref="EndpointUrl"/> is ignored.
|
||||
/// </summary>
|
||||
public string EndpointUrl { get; init; } = "opc.tcp://localhost:4840";
|
||||
|
||||
/// <summary>
|
||||
/// Ordered list of candidate endpoint URLs for failover. The driver tries each in
|
||||
/// order at <see cref="OpcUaClientDriver.InitializeAsync"/> and on session drop;
|
||||
/// the first URL that successfully connects wins. Typical use-case: an OPC UA server
|
||||
/// pair running in hot-standby (primary 4840 + backup 4841) where either can serve
|
||||
/// the same address space. Leave unset (or empty) to use <see cref="EndpointUrl"/>
|
||||
/// as a single-URL shortcut.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> EndpointUrls { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Per-endpoint connect-attempt timeout during the failover sweep. Short enough that
|
||||
/// cycling through several dead servers doesn't blow the overall init budget, long
|
||||
/// enough to tolerate a slow TLS handshake on a healthy server. Applied independently
|
||||
/// of <see cref="Timeout"/> which governs steady-state operations.
|
||||
/// </summary>
|
||||
public TimeSpan PerEndpointConnectTimeout { get; init; } = TimeSpan.FromSeconds(3);
|
||||
|
||||
/// <summary>
|
||||
/// Security policy to require when selecting an endpoint. Either a
|
||||
/// <see cref="OpcUaSecurityPolicy"/> enum constant or a free-form string (for
|
||||
/// forward-compatibility with future OPC UA policies not yet in the enum).
|
||||
/// Matched against <c>EndpointDescription.SecurityPolicyUri</c> suffix — the driver
|
||||
/// connects to the first endpoint whose policy name matches AND whose mode matches
|
||||
/// <see cref="SecurityMode"/>. When set to <see cref="OpcUaSecurityPolicy.None"/>
|
||||
/// the driver picks any unsecured endpoint regardless of policy string.
|
||||
/// </summary>
|
||||
public OpcUaSecurityPolicy SecurityPolicy { get; init; } = OpcUaSecurityPolicy.None;
|
||||
|
||||
/// <summary>Security mode.</summary>
|
||||
public OpcUaSecurityMode SecurityMode { get; init; } = OpcUaSecurityMode.None;
|
||||
|
||||
/// <summary>Authentication type.</summary>
|
||||
public OpcUaAuthType AuthType { get; init; } = OpcUaAuthType.Anonymous;
|
||||
|
||||
/// <summary>User name (required only for <see cref="OpcUaAuthType.Username"/>).</summary>
|
||||
public string? Username { get; init; }
|
||||
|
||||
/// <summary>Password (required only for <see cref="OpcUaAuthType.Username"/>).</summary>
|
||||
public string? Password { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Filesystem path to the user-identity certificate (PFX/PEM). Required when
|
||||
/// <see cref="AuthType"/> is <see cref="OpcUaAuthType.Certificate"/>. The driver
|
||||
/// loads the cert + private key, which the remote server validates against its
|
||||
/// <c>TrustedUserCertificates</c> store to authenticate the session's user token.
|
||||
/// Leave unset to use the driver's application-instance certificate as the user
|
||||
/// token (not typical — most deployments have a separate user cert).
|
||||
/// </summary>
|
||||
public string? UserCertificatePath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional password that unlocks <see cref="UserCertificatePath"/> when the PFX is
|
||||
/// protected. PEM files generally have their password on the adjacent key file; this
|
||||
/// knob only applies to password-locked PFX.
|
||||
/// </summary>
|
||||
public string? UserCertificatePassword { get; init; }
|
||||
|
||||
/// <summary>Server-negotiated session timeout. Default 120s per driver-specs.md §8.</summary>
|
||||
public TimeSpan SessionTimeout { get; init; } = TimeSpan.FromSeconds(120);
|
||||
|
||||
/// <summary>Client-side keep-alive interval.</summary>
|
||||
public TimeSpan KeepAliveInterval { get; init; } = TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <summary>Initial reconnect delay after a session drop.</summary>
|
||||
public TimeSpan ReconnectPeriod { get; init; } = TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <summary>
|
||||
/// When <c>true</c>, the driver accepts any self-signed / untrusted server certificate.
|
||||
/// Dev-only — must be <c>false</c> in production so MITM attacks against the opc.tcp
|
||||
/// channel fail closed.
|
||||
/// </summary>
|
||||
public bool AutoAcceptCertificates { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Application URI the driver reports during session creation. Must match the
|
||||
/// subject-alt-name on the client certificate if one is used, which is why it's a
|
||||
/// config knob rather than hard-coded.
|
||||
/// </summary>
|
||||
public string ApplicationUri { get; init; } = "urn:localhost:OtOpcUa:GatewayClient";
|
||||
|
||||
/// <summary>
|
||||
/// Friendly name sent to the remote server for diagnostics. Shows up in the remote
|
||||
/// server's session-list so operators can identify which gateway instance is calling.
|
||||
/// </summary>
|
||||
public string SessionName { get; init; } = "OtOpcUa-Gateway";
|
||||
|
||||
/// <summary>Connect + per-operation timeout.</summary>
|
||||
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(10);
|
||||
|
||||
/// <summary>
|
||||
/// Root NodeId to mirror. Default <c>null</c> = <c>ObjectsFolder</c> (i=85). Set to
|
||||
/// a scoped root to restrict the address space the driver exposes locally — useful
|
||||
/// when the remote server has tens of thousands of nodes and only a subset is
|
||||
/// needed downstream.
|
||||
/// </summary>
|
||||
public string? BrowseRoot { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Cap on total nodes discovered during <c>DiscoverAsync</c>. Default 10_000 —
|
||||
/// bounds memory on runaway remote servers without being so low that normal
|
||||
/// deployments hit it. When the cap is reached discovery stops and a warning is
|
||||
/// written to the driver health surface; the partially-discovered tree is still
|
||||
/// projected into the local address space.
|
||||
/// </summary>
|
||||
public int MaxDiscoveredNodes { get; init; } = 10_000;
|
||||
|
||||
/// <summary>
|
||||
/// Max hierarchical depth of the browse. Default 10 — deep enough for realistic
|
||||
/// OPC UA information models, shallow enough that cyclic graphs can't spin the
|
||||
/// browse forever.
|
||||
/// </summary>
|
||||
public int MaxBrowseDepth { get; init; } = 10;
|
||||
}
|
||||
|
||||
/// <summary>OPC UA message security mode.</summary>
|
||||
public enum OpcUaSecurityMode
|
||||
{
|
||||
None,
|
||||
Sign,
|
||||
SignAndEncrypt,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OPC UA security policies recognized by the driver. Maps to the standard
|
||||
/// <c>http://opcfoundation.org/UA/SecurityPolicy#</c> URI suffixes the SDK uses for
|
||||
/// endpoint matching.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <see cref="Basic128Rsa15"/> and <see cref="Basic256"/> are <b>deprecated</b> per OPC UA
|
||||
/// spec v1.04 — they remain in the enum only for brownfield interop with older servers.
|
||||
/// Prefer <see cref="Basic256Sha256"/>, <see cref="Aes128_Sha256_RsaOaep"/>, or
|
||||
/// <see cref="Aes256_Sha256_RsaPss"/> for new deployments.
|
||||
/// </remarks>
|
||||
public enum OpcUaSecurityPolicy
|
||||
{
|
||||
/// <summary>No security. Unsigned, unencrypted wire.</summary>
|
||||
None,
|
||||
/// <summary>Deprecated (OPC UA 1.04). Retained for legacy server interop.</summary>
|
||||
Basic128Rsa15,
|
||||
/// <summary>Deprecated (OPC UA 1.04). Retained for legacy server interop.</summary>
|
||||
Basic256,
|
||||
/// <summary>Recommended baseline for current deployments.</summary>
|
||||
Basic256Sha256,
|
||||
/// <summary>Current OPC UA policy; AES-128 + SHA-256 + RSA-OAEP.</summary>
|
||||
Aes128_Sha256_RsaOaep,
|
||||
/// <summary>Current OPC UA policy; AES-256 + SHA-256 + RSA-PSS.</summary>
|
||||
Aes256_Sha256_RsaPss,
|
||||
}
|
||||
|
||||
/// <summary>User authentication type sent to the remote server.</summary>
|
||||
public enum OpcUaAuthType
|
||||
{
|
||||
Anonymous,
|
||||
Username,
|
||||
Certificate,
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient</RootNamespace>
|
||||
<AssemblyName>ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client" Version="1.5.378.106"/>
|
||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Configuration" Version="1.5.378.106"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -26,8 +26,20 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
||||
: IDriver, IReadable, IWritable, IDisposable, IAsyncDisposable
|
||||
: IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IDisposable, IAsyncDisposable
|
||||
{
|
||||
// ---- ISubscribable + IHostConnectivityProbe state ----
|
||||
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<long, SubscriptionState> _subscriptions = new();
|
||||
private long _nextSubscriptionId;
|
||||
private readonly object _probeLock = new();
|
||||
private HostState _hostState = HostState.Unknown;
|
||||
private DateTime _hostStateChangedUtc = DateTime.UtcNow;
|
||||
private CancellationTokenSource? _probeCts;
|
||||
|
||||
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
|
||||
|
||||
/// <summary>OPC UA StatusCode used when the tag name isn't in the driver's tag map.</summary>
|
||||
private const uint StatusBadNodeIdUnknown = 0x80340000u;
|
||||
/// <summary>OPC UA StatusCode used when the tag's data type isn't implemented yet.</summary>
|
||||
@@ -98,6 +110,15 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
||||
}
|
||||
|
||||
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
|
||||
|
||||
// Kick off the probe loop once the connection is up. Initial HostState stays
|
||||
// Unknown until the first probe tick succeeds — avoids broadcasting a premature
|
||||
// Running transition before any PDU round-trip has happened.
|
||||
if (_options.Probe.Enabled)
|
||||
{
|
||||
_probeCts = new CancellationTokenSource();
|
||||
_ = Task.Run(() => ProbeLoopAsync(_probeCts.Token), _probeCts.Token);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -118,6 +139,17 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
||||
|
||||
public Task ShutdownAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try { _probeCts?.Cancel(); } catch { }
|
||||
_probeCts?.Dispose();
|
||||
_probeCts = null;
|
||||
|
||||
foreach (var state in _subscriptions.Values)
|
||||
{
|
||||
try { state.Cts.Cancel(); } catch { }
|
||||
state.Cts.Dispose();
|
||||
}
|
||||
_subscriptions.Clear();
|
||||
|
||||
try { Plc?.Close(); } catch { /* best-effort — tearing down anyway */ }
|
||||
Plc = null;
|
||||
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
||||
@@ -294,6 +326,180 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
||||
private global::S7.Net.Plc RequirePlc() =>
|
||||
Plc ?? throw new InvalidOperationException("S7Driver not initialized");
|
||||
|
||||
// ---- ITagDiscovery ----
|
||||
|
||||
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
var folder = builder.Folder("S7", "S7");
|
||||
foreach (var t in _options.Tags)
|
||||
{
|
||||
folder.Variable(t.Name, t.Name, new DriverAttributeInfo(
|
||||
FullName: t.Name,
|
||||
DriverDataType: MapDataType(t.DataType),
|
||||
IsArray: false,
|
||||
ArrayDim: null,
|
||||
SecurityClass: t.Writable ? SecurityClassification.Operate : SecurityClassification.ViewOnly,
|
||||
IsHistorized: false,
|
||||
IsAlarm: false));
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static DriverDataType MapDataType(S7DataType t) => t switch
|
||||
{
|
||||
S7DataType.Bool => DriverDataType.Boolean,
|
||||
S7DataType.Byte => DriverDataType.Int32, // no 8-bit in DriverDataType yet
|
||||
S7DataType.Int16 or S7DataType.UInt16 or S7DataType.Int32 or S7DataType.UInt32 => DriverDataType.Int32,
|
||||
S7DataType.Int64 or S7DataType.UInt64 => DriverDataType.Int32, // widens; lossy for >2^31-1
|
||||
S7DataType.Float32 => DriverDataType.Float32,
|
||||
S7DataType.Float64 => DriverDataType.Float64,
|
||||
S7DataType.String => DriverDataType.String,
|
||||
S7DataType.DateTime => DriverDataType.DateTime,
|
||||
_ => DriverDataType.Int32,
|
||||
};
|
||||
|
||||
// ---- ISubscribable (polling overlay) ----
|
||||
|
||||
public Task<ISubscriptionHandle> SubscribeAsync(
|
||||
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
|
||||
{
|
||||
var id = Interlocked.Increment(ref _nextSubscriptionId);
|
||||
var cts = new CancellationTokenSource();
|
||||
// Floor at 100 ms — S7 CPUs scan 2-10 ms but the comms mailbox is processed at most
|
||||
// once per scan; sub-100 ms polling just queues wire-side with worse latency.
|
||||
var interval = publishingInterval < TimeSpan.FromMilliseconds(100)
|
||||
? TimeSpan.FromMilliseconds(100)
|
||||
: publishingInterval;
|
||||
var handle = new S7SubscriptionHandle(id);
|
||||
var state = new SubscriptionState(handle, [.. fullReferences], interval, cts);
|
||||
_subscriptions[id] = state;
|
||||
_ = Task.Run(() => PollLoopAsync(state, cts.Token), cts.Token);
|
||||
return Task.FromResult<ISubscriptionHandle>(handle);
|
||||
}
|
||||
|
||||
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
|
||||
{
|
||||
if (handle is S7SubscriptionHandle h && _subscriptions.TryRemove(h.Id, out var state))
|
||||
{
|
||||
state.Cts.Cancel();
|
||||
state.Cts.Dispose();
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task PollLoopAsync(SubscriptionState state, CancellationToken ct)
|
||||
{
|
||||
// Initial-data push per OPC UA Part 4 convention.
|
||||
try { await PollOnceAsync(state, forceRaise: true, ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { return; }
|
||||
catch { /* first-read error — polling continues */ }
|
||||
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
try { await Task.Delay(state.Interval, ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { return; }
|
||||
|
||||
try { await PollOnceAsync(state, forceRaise: false, ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { return; }
|
||||
catch { /* transient polling error — loop continues, health surface reflects it */ }
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PollOnceAsync(SubscriptionState state, bool forceRaise, CancellationToken ct)
|
||||
{
|
||||
var snapshots = await ReadAsync(state.TagReferences, ct).ConfigureAwait(false);
|
||||
for (var i = 0; i < state.TagReferences.Count; i++)
|
||||
{
|
||||
var tagRef = state.TagReferences[i];
|
||||
var current = snapshots[i];
|
||||
var lastSeen = state.LastValues.TryGetValue(tagRef, out var prev) ? prev : default;
|
||||
|
||||
if (forceRaise || !Equals(lastSeen?.Value, current.Value) || lastSeen?.StatusCode != current.StatusCode)
|
||||
{
|
||||
state.LastValues[tagRef] = current;
|
||||
OnDataChange?.Invoke(this, new DataChangeEventArgs(state.Handle, tagRef, current));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record SubscriptionState(
|
||||
S7SubscriptionHandle Handle,
|
||||
IReadOnlyList<string> TagReferences,
|
||||
TimeSpan Interval,
|
||||
CancellationTokenSource Cts)
|
||||
{
|
||||
public System.Collections.Concurrent.ConcurrentDictionary<string, DataValueSnapshot> LastValues { get; }
|
||||
= new(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private sealed record S7SubscriptionHandle(long Id) : ISubscriptionHandle
|
||||
{
|
||||
public string DiagnosticId => $"s7-sub-{Id}";
|
||||
}
|
||||
|
||||
// ---- IHostConnectivityProbe ----
|
||||
|
||||
/// <summary>
|
||||
/// Host identifier surfaced in <see cref="GetHostStatuses"/>. <c>host:port</c> format
|
||||
/// matches the Modbus driver's convention so the Admin UI dashboard renders both
|
||||
/// family's rows uniformly.
|
||||
/// </summary>
|
||||
public string HostName => $"{_options.Host}:{_options.Port}";
|
||||
|
||||
public IReadOnlyList<HostConnectivityStatus> GetHostStatuses()
|
||||
{
|
||||
lock (_probeLock)
|
||||
return [new HostConnectivityStatus(HostName, _hostState, _hostStateChangedUtc)];
|
||||
}
|
||||
|
||||
private async Task ProbeLoopAsync(CancellationToken ct)
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
var success = false;
|
||||
try
|
||||
{
|
||||
// Probe via S7.Net's low-cost GetCpuStatus — returns the CPU state (Run/Stop)
|
||||
// and is intentionally light on the comms mailbox. Single-word Plc.ReadAsync
|
||||
// would also work but GetCpuStatus doubles as a "PLC actually up" check.
|
||||
using var probeCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
probeCts.CancelAfter(_options.Probe.Timeout);
|
||||
|
||||
var plc = Plc;
|
||||
if (plc is null) throw new InvalidOperationException("Plc dropped during probe");
|
||||
|
||||
await _gate.WaitAsync(probeCts.Token).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
_ = await plc.ReadStatusAsync(probeCts.Token).ConfigureAwait(false);
|
||||
success = true;
|
||||
}
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { return; }
|
||||
catch { /* transport/timeout/exception — treated as Stopped below */ }
|
||||
|
||||
TransitionTo(success ? HostState.Running : HostState.Stopped);
|
||||
|
||||
try { await Task.Delay(_options.Probe.Interval, ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { return; }
|
||||
}
|
||||
}
|
||||
|
||||
private void TransitionTo(HostState newState)
|
||||
{
|
||||
HostState old;
|
||||
lock (_probeLock)
|
||||
{
|
||||
old = _hostState;
|
||||
if (old == newState) return;
|
||||
_hostState = newState;
|
||||
_hostStateChangedUtc = DateTime.UtcNow;
|
||||
}
|
||||
OnHostStatusChanged?.Invoke(this, new HostStatusChangedEventArgs(HostName, old, newState));
|
||||
}
|
||||
|
||||
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpcUaClientAlarmTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData((ushort)1, AlarmSeverity.Low)]
|
||||
[InlineData((ushort)200, AlarmSeverity.Low)]
|
||||
[InlineData((ushort)201, AlarmSeverity.Medium)]
|
||||
[InlineData((ushort)500, AlarmSeverity.Medium)]
|
||||
[InlineData((ushort)501, AlarmSeverity.High)]
|
||||
[InlineData((ushort)800, AlarmSeverity.High)]
|
||||
[InlineData((ushort)801, AlarmSeverity.Critical)]
|
||||
[InlineData((ushort)1000, AlarmSeverity.Critical)]
|
||||
public void MapSeverity_buckets_per_OPC_UA_Part_9_guidance(ushort opcSev, AlarmSeverity expected)
|
||||
{
|
||||
OpcUaClientDriver.MapSeverity(opcSev).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapSeverity_zero_maps_to_Low()
|
||||
{
|
||||
// 0 isn't in OPC UA's 1-1000 range but we handle it gracefully as Low.
|
||||
OpcUaClientDriver.MapSeverity(0).ShouldBe(AlarmSeverity.Low);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubscribeAlarmsAsync_without_initialize_throws_InvalidOperationException()
|
||||
{
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-alarm-uninit");
|
||||
await Should.ThrowAsync<InvalidOperationException>(async () =>
|
||||
await drv.SubscribeAlarmsAsync([], TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnsubscribeAlarmsAsync_with_unknown_handle_is_noop()
|
||||
{
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-alarm-unknown");
|
||||
// Parallels the subscribe handle path — session-drop races shouldn't crash the caller.
|
||||
await drv.UnsubscribeAlarmsAsync(new FakeAlarmHandle(), TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcknowledgeAsync_without_initialize_throws_InvalidOperationException()
|
||||
{
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-ack-uninit");
|
||||
await Should.ThrowAsync<InvalidOperationException>(async () =>
|
||||
await drv.AcknowledgeAsync(
|
||||
[new AlarmAcknowledgeRequest("ns=2;s=Src", "ns=2;s=Cond", "operator ack")],
|
||||
TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcknowledgeAsync_with_empty_batch_is_noop_even_without_init()
|
||||
{
|
||||
// Empty batch short-circuits before touching the session, so it's safe pre-init. This
|
||||
// keeps batch-ack callers from needing to guard the list size themselves.
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-ack-empty");
|
||||
await drv.AcknowledgeAsync([], TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
private sealed class FakeAlarmHandle : IAlarmSubscriptionHandle
|
||||
{
|
||||
public string DiagnosticId => "fake-alarm";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpcUaClientAttributeMappingTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData((uint)DataTypes.Boolean, DriverDataType.Boolean)]
|
||||
[InlineData((uint)DataTypes.Int16, DriverDataType.Int16)]
|
||||
[InlineData((uint)DataTypes.UInt16, DriverDataType.UInt16)]
|
||||
[InlineData((uint)DataTypes.Int32, DriverDataType.Int32)]
|
||||
[InlineData((uint)DataTypes.UInt32, DriverDataType.UInt32)]
|
||||
[InlineData((uint)DataTypes.Int64, DriverDataType.Int64)]
|
||||
[InlineData((uint)DataTypes.UInt64, DriverDataType.UInt64)]
|
||||
[InlineData((uint)DataTypes.Float, DriverDataType.Float32)]
|
||||
[InlineData((uint)DataTypes.Double, DriverDataType.Float64)]
|
||||
[InlineData((uint)DataTypes.String, DriverDataType.String)]
|
||||
[InlineData((uint)DataTypes.DateTime, DriverDataType.DateTime)]
|
||||
public void MapUpstreamDataType_recognizes_standard_builtin_types(uint typeId, DriverDataType expected)
|
||||
{
|
||||
var nodeId = new NodeId(typeId);
|
||||
OpcUaClientDriver.MapUpstreamDataType(nodeId).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapUpstreamDataType_maps_SByte_and_Byte_to_Int16_since_DriverDataType_lacks_8bit()
|
||||
{
|
||||
// DriverDataType has no 8-bit type; conservative widen to Int16. Documented so a
|
||||
// future Core.Abstractions PR that adds Int8/Byte can find this call site.
|
||||
OpcUaClientDriver.MapUpstreamDataType(new NodeId((uint)DataTypes.SByte)).ShouldBe(DriverDataType.Int16);
|
||||
OpcUaClientDriver.MapUpstreamDataType(new NodeId((uint)DataTypes.Byte)).ShouldBe(DriverDataType.Int16);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapUpstreamDataType_falls_back_to_String_for_unknown_custom_types()
|
||||
{
|
||||
// Custom vendor extension object — NodeId in namespace 2 that isn't a standard type.
|
||||
OpcUaClientDriver.MapUpstreamDataType(new NodeId("CustomStruct", 2)).ShouldBe(DriverDataType.String);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapUpstreamDataType_handles_UtcTime_as_DateTime()
|
||||
{
|
||||
OpcUaClientDriver.MapUpstreamDataType(new NodeId((uint)DataTypes.UtcTime)).ShouldBe(DriverDataType.DateTime);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData((byte)0, SecurityClassification.ViewOnly)] // no access flags set
|
||||
[InlineData((byte)1, SecurityClassification.ViewOnly)] // CurrentRead only
|
||||
[InlineData((byte)2, SecurityClassification.Operate)] // CurrentWrite only
|
||||
[InlineData((byte)3, SecurityClassification.Operate)] // CurrentRead + CurrentWrite
|
||||
[InlineData((byte)0x0F, SecurityClassification.Operate)] // read+write+historyRead+historyWrite
|
||||
[InlineData((byte)0x04, SecurityClassification.ViewOnly)] // HistoryRead only — no Write bit
|
||||
public void MapAccessLevelToSecurityClass_respects_CurrentWrite_bit(byte accessLevel, SecurityClassification expected)
|
||||
{
|
||||
OpcUaClientDriver.MapAccessLevelToSecurityClass(accessLevel).ShouldBe(expected);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpcUaClientCertAuthTests
|
||||
{
|
||||
[Fact]
|
||||
public void BuildCertificateIdentity_rejects_missing_path()
|
||||
{
|
||||
var opts = new OpcUaClientDriverOptions { AuthType = OpcUaAuthType.Certificate };
|
||||
Should.Throw<InvalidOperationException>(() => OpcUaClientDriver.BuildCertificateIdentity(opts))
|
||||
.Message.ShouldContain("UserCertificatePath");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildCertificateIdentity_rejects_nonexistent_file()
|
||||
{
|
||||
var opts = new OpcUaClientDriverOptions
|
||||
{
|
||||
AuthType = OpcUaAuthType.Certificate,
|
||||
UserCertificatePath = Path.Combine(Path.GetTempPath(), $"does-not-exist-{Guid.NewGuid():N}.pfx"),
|
||||
};
|
||||
Should.Throw<FileNotFoundException>(() => OpcUaClientDriver.BuildCertificateIdentity(opts));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildCertificateIdentity_loads_a_valid_PFX_with_private_key()
|
||||
{
|
||||
// Generate a self-signed cert on the fly so the test doesn't ship a static PFX.
|
||||
// The driver doesn't care about the issuer — just needs a cert with a private key.
|
||||
using var rsa = RSA.Create(2048);
|
||||
var req = new CertificateRequest("CN=OpcUaClientCertAuthTests", rsa,
|
||||
HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddHours(1));
|
||||
|
||||
var tmpPath = Path.Combine(Path.GetTempPath(), $"opcua-cert-test-{Guid.NewGuid():N}.pfx");
|
||||
File.WriteAllBytes(tmpPath, cert.Export(X509ContentType.Pfx, "testpw"));
|
||||
try
|
||||
{
|
||||
var opts = new OpcUaClientDriverOptions
|
||||
{
|
||||
AuthType = OpcUaAuthType.Certificate,
|
||||
UserCertificatePath = tmpPath,
|
||||
UserCertificatePassword = "testpw",
|
||||
};
|
||||
var identity = OpcUaClientDriver.BuildCertificateIdentity(opts);
|
||||
identity.ShouldNotBeNull();
|
||||
identity.TokenType.ShouldBe(Opc.Ua.UserTokenType.Certificate);
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { File.Delete(tmpPath); } catch { /* best-effort */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Scaffold tests for <see cref="OpcUaClientDriver"/>'s <see cref="ITagDiscovery"/>
|
||||
/// surface that don't require a live remote server. Live-browse coverage lands in a
|
||||
/// follow-up PR once the in-process OPC UA server fixture is scaffolded.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpcUaClientDiscoveryTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_without_initialize_throws_InvalidOperationException()
|
||||
{
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-disco");
|
||||
var builder = new NullAddressSpaceBuilder();
|
||||
await Should.ThrowAsync<InvalidOperationException>(async () =>
|
||||
await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiscoverAsync_rejects_null_builder()
|
||||
{
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-disco");
|
||||
Should.ThrowAsync<ArgumentNullException>(async () =>
|
||||
await drv.DiscoverAsync(null!, TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Discovery_caps_are_sensible_defaults()
|
||||
{
|
||||
var opts = new OpcUaClientDriverOptions();
|
||||
opts.MaxDiscoveredNodes.ShouldBe(10_000, "bounds memory on runaway servers without clipping normal models");
|
||||
opts.MaxBrowseDepth.ShouldBe(10, "deep enough for realistic info models; shallow enough for cycle safety");
|
||||
opts.BrowseRoot.ShouldBeNull("null = default to ObjectsFolder i=85");
|
||||
}
|
||||
|
||||
private sealed class NullAddressSpaceBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName) => this;
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
|
||||
=> new StubHandle();
|
||||
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
|
||||
public void AttachAlarmCondition(IVariableHandle sourceVariable, string alarmName, DriverAttributeInfo alarmInfo) { }
|
||||
|
||||
private sealed class StubHandle : IVariableHandle
|
||||
{
|
||||
public string FullReference => "stub";
|
||||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Scaffold-level tests for <see cref="OpcUaClientDriver"/> that don't require a live
|
||||
/// remote OPC UA server. PR 67+ adds IReadable/IWritable/ITagDiscovery/ISubscribable
|
||||
/// tests against a local in-process OPC UA server fixture.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpcUaClientDriverScaffoldTests
|
||||
{
|
||||
[Fact]
|
||||
public void Default_options_target_standard_opcua_port_and_anonymous_auth()
|
||||
{
|
||||
var opts = new OpcUaClientDriverOptions();
|
||||
opts.EndpointUrl.ShouldBe("opc.tcp://localhost:4840", "4840 is the IANA-assigned OPC UA port");
|
||||
opts.SecurityMode.ShouldBe(OpcUaSecurityMode.None);
|
||||
opts.SecurityPolicy.ShouldBe(OpcUaSecurityPolicy.None);
|
||||
opts.AuthType.ShouldBe(OpcUaAuthType.Anonymous);
|
||||
opts.AutoAcceptCertificates.ShouldBeFalse("production default must reject untrusted server certs");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Default_timeouts_match_driver_specs_section_8()
|
||||
{
|
||||
var opts = new OpcUaClientDriverOptions();
|
||||
opts.SessionTimeout.ShouldBe(TimeSpan.FromSeconds(120));
|
||||
opts.KeepAliveInterval.ShouldBe(TimeSpan.FromSeconds(5));
|
||||
opts.ReconnectPeriod.ShouldBe(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Driver_reports_type_and_id_before_connect()
|
||||
{
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-test");
|
||||
drv.DriverType.ShouldBe("OpcUaClient");
|
||||
drv.DriverInstanceId.ShouldBe("opcua-test");
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Initialize_against_unreachable_endpoint_transitions_to_Faulted_and_throws()
|
||||
{
|
||||
// RFC 5737 reserved-for-documentation IP; won't route anywhere. Pick opc.tcp:// so
|
||||
// endpoint selection hits the transport-layer connection rather than a DNS lookup.
|
||||
var opts = new OpcUaClientDriverOptions
|
||||
{
|
||||
// Port 1 on loopback is effectively guaranteed to be closed — the OS responds
|
||||
// with TCP RST immediately instead of hanging on connect, which keeps the
|
||||
// unreachable-host tests snappy. Don't use an RFC 5737 reserved IP; those get
|
||||
// routed to a black-hole + time out only after the SDK's internal retry/backoff
|
||||
// fully elapses (~60s even with Options.Timeout=500ms).
|
||||
EndpointUrl = "opc.tcp://127.0.0.1:1",
|
||||
Timeout = TimeSpan.FromMilliseconds(500),
|
||||
AutoAcceptCertificates = true, // dev-mode to bypass cert validation in the test
|
||||
};
|
||||
using var drv = new OpcUaClientDriver(opts, "opcua-unreach");
|
||||
|
||||
await Should.ThrowAsync<Exception>(async () =>
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
|
||||
|
||||
var health = drv.GetHealth();
|
||||
health.State.ShouldBe(DriverState.Faulted);
|
||||
health.LastError.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reinitialize_against_unreachable_endpoint_re_throws()
|
||||
{
|
||||
var opts = new OpcUaClientDriverOptions
|
||||
{
|
||||
// Port 1 on loopback is effectively guaranteed to be closed — the OS responds
|
||||
// with TCP RST immediately instead of hanging on connect, which keeps the
|
||||
// unreachable-host tests snappy. Don't use an RFC 5737 reserved IP; those get
|
||||
// routed to a black-hole + time out only after the SDK's internal retry/backoff
|
||||
// fully elapses (~60s even with Options.Timeout=500ms).
|
||||
EndpointUrl = "opc.tcp://127.0.0.1:1",
|
||||
Timeout = TimeSpan.FromMilliseconds(500),
|
||||
AutoAcceptCertificates = true,
|
||||
};
|
||||
using var drv = new OpcUaClientDriver(opts, "opcua-reinit");
|
||||
|
||||
await Should.ThrowAsync<Exception>(async () =>
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
|
||||
await Should.ThrowAsync<Exception>(async () =>
|
||||
await drv.ReinitializeAsync("{}", TestContext.Current.CancellationToken));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpcUaClientFailoverTests
|
||||
{
|
||||
[Fact]
|
||||
public void ResolveEndpointCandidates_prefers_EndpointUrls_when_provided()
|
||||
{
|
||||
var opts = new OpcUaClientDriverOptions
|
||||
{
|
||||
EndpointUrl = "opc.tcp://fallback:4840",
|
||||
EndpointUrls = ["opc.tcp://primary:4840", "opc.tcp://backup:4841"],
|
||||
};
|
||||
var list = OpcUaClientDriver.ResolveEndpointCandidates(opts);
|
||||
list.Count.ShouldBe(2);
|
||||
list[0].ShouldBe("opc.tcp://primary:4840");
|
||||
list[1].ShouldBe("opc.tcp://backup:4841");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveEndpointCandidates_falls_back_to_single_EndpointUrl_when_list_empty()
|
||||
{
|
||||
var opts = new OpcUaClientDriverOptions { EndpointUrl = "opc.tcp://only:4840" };
|
||||
var list = OpcUaClientDriver.ResolveEndpointCandidates(opts);
|
||||
list.Count.ShouldBe(1);
|
||||
list[0].ShouldBe("opc.tcp://only:4840");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveEndpointCandidates_empty_list_treated_as_fallback_to_EndpointUrl()
|
||||
{
|
||||
// Explicit empty list should still fall back to the single-URL shortcut rather than
|
||||
// producing a zero-candidate sweep that would immediately throw with no URLs tried.
|
||||
var opts = new OpcUaClientDriverOptions
|
||||
{
|
||||
EndpointUrl = "opc.tcp://single:4840",
|
||||
EndpointUrls = [],
|
||||
};
|
||||
OpcUaClientDriver.ResolveEndpointCandidates(opts).Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HostName_uses_first_candidate_before_connect()
|
||||
{
|
||||
var opts = new OpcUaClientDriverOptions
|
||||
{
|
||||
EndpointUrls = ["opc.tcp://primary:4840", "opc.tcp://backup:4841"],
|
||||
};
|
||||
using var drv = new OpcUaClientDriver(opts, "opcua-host");
|
||||
drv.HostName.ShouldBe("opc.tcp://primary:4840",
|
||||
"pre-connect the dashboard should show the first candidate URL so operators can link back");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Initialize_against_all_unreachable_endpoints_throws_AggregateException_listing_each()
|
||||
{
|
||||
// Port 1 + port 2 + port 3 on loopback are all guaranteed closed (TCP RST immediate).
|
||||
// Failover sweep should attempt all three and throw AggregateException naming each URL
|
||||
// so operators see exactly which candidates were tried.
|
||||
var opts = new OpcUaClientDriverOptions
|
||||
{
|
||||
EndpointUrls = ["opc.tcp://127.0.0.1:1", "opc.tcp://127.0.0.1:2", "opc.tcp://127.0.0.1:3"],
|
||||
PerEndpointConnectTimeout = TimeSpan.FromMilliseconds(500),
|
||||
Timeout = TimeSpan.FromMilliseconds(500),
|
||||
AutoAcceptCertificates = true,
|
||||
};
|
||||
using var drv = new OpcUaClientDriver(opts, "opcua-failover");
|
||||
|
||||
var ex = await Should.ThrowAsync<AggregateException>(async () =>
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
|
||||
|
||||
ex.Message.ShouldContain("127.0.0.1:1");
|
||||
ex.Message.ShouldContain("127.0.0.1:2");
|
||||
ex.Message.ShouldContain("127.0.0.1:3");
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpcUaClientHistoryTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(HistoryAggregateType.Average)]
|
||||
[InlineData(HistoryAggregateType.Minimum)]
|
||||
[InlineData(HistoryAggregateType.Maximum)]
|
||||
[InlineData(HistoryAggregateType.Total)]
|
||||
[InlineData(HistoryAggregateType.Count)]
|
||||
public void MapAggregateToNodeId_returns_standard_Part13_aggregate_for_every_enum(HistoryAggregateType agg)
|
||||
{
|
||||
var nodeId = OpcUaClientDriver.MapAggregateToNodeId(agg);
|
||||
NodeId.IsNull(nodeId).ShouldBeFalse();
|
||||
// Every mapping should resolve to an AggregateFunction_* NodeId (namespace 0, numeric id).
|
||||
nodeId.NamespaceIndex.ShouldBe((ushort)0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapAggregateToNodeId_rejects_invalid_enum_value()
|
||||
{
|
||||
// Defense-in-depth: a future HistoryAggregateType addition mustn't silently fall through.
|
||||
Should.Throw<ArgumentOutOfRangeException>(() =>
|
||||
OpcUaClientDriver.MapAggregateToNodeId((HistoryAggregateType)99));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadRawAsync_without_initialize_throws_InvalidOperationException()
|
||||
{
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-hist-uninit");
|
||||
await Should.ThrowAsync<InvalidOperationException>(async () =>
|
||||
await drv.ReadRawAsync("ns=2;s=Counter",
|
||||
DateTime.UtcNow.AddMinutes(-5), DateTime.UtcNow, 1000,
|
||||
TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadRawAsync_with_malformed_NodeId_returns_empty_result_not_throw()
|
||||
{
|
||||
// Same defensive pattern as ReadAsync / WriteAsync — malformed NodeId short-circuits
|
||||
// to an empty result rather than crashing a batch history call. Needs init via the
|
||||
// throw path first, then we pass "" to trigger the parse-fail branch inside
|
||||
// ExecuteHistoryReadAsync. The init itself fails against 127.0.0.1:1 so we stop there.
|
||||
// Not runnable without init — keep as placeholder for when the in-process fixture
|
||||
// PR lands.
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadProcessedAsync_without_initialize_throws_InvalidOperationException()
|
||||
{
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-hist-uninit");
|
||||
await Should.ThrowAsync<InvalidOperationException>(async () =>
|
||||
await drv.ReadProcessedAsync("ns=2;s=Counter",
|
||||
DateTime.UtcNow.AddMinutes(-5), DateTime.UtcNow,
|
||||
TimeSpan.FromSeconds(10), HistoryAggregateType.Average,
|
||||
TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAtTimeAsync_without_initialize_throws_InvalidOperationException()
|
||||
{
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-hist-uninit");
|
||||
await Should.ThrowAsync<InvalidOperationException>(async () =>
|
||||
await drv.ReadAtTimeAsync("ns=2;s=Counter",
|
||||
[DateTime.UtcNow.AddMinutes(-5), DateTime.UtcNow],
|
||||
TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadEventsAsync_throws_NotSupportedException_as_documented()
|
||||
{
|
||||
// The IHistoryProvider default implementation throws; the OPC UA Client driver
|
||||
// deliberately inherits that default (see PR 76 commit body) because the OPC UA
|
||||
// client call path needs an EventFilter SelectClauses spec the interface doesn't carry.
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-events-default");
|
||||
await Should.ThrowAsync<NotSupportedException>(async () =>
|
||||
await ((IHistoryProvider)drv).ReadEventsAsync(
|
||||
sourceName: null,
|
||||
startUtc: DateTime.UtcNow.AddMinutes(-5),
|
||||
endUtc: DateTime.UtcNow,
|
||||
maxEvents: 100,
|
||||
cancellationToken: TestContext.Current.CancellationToken));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the IReadable/IWritable surface that don't need a live remote OPC UA
|
||||
/// server. Wire-level round-trips against a local in-process server fixture land in a
|
||||
/// follow-up PR once we have one scaffolded.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpcUaClientReadWriteTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ReadAsync_without_initialize_throws_InvalidOperationException()
|
||||
{
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-uninit");
|
||||
await Should.ThrowAsync<InvalidOperationException>(async () =>
|
||||
await drv.ReadAsync(["ns=2;s=Demo"], TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteAsync_without_initialize_throws_InvalidOperationException()
|
||||
{
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-uninit");
|
||||
await Should.ThrowAsync<InvalidOperationException>(async () =>
|
||||
await drv.WriteAsync(
|
||||
[new WriteRequest("ns=2;s=Demo", 42)],
|
||||
TestContext.Current.CancellationToken));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Scaffold tests for <see cref="SessionReconnectHandler"/> wiring. Wire-level
|
||||
/// disconnect-reconnect-resume coverage against a live upstream server lands with the
|
||||
/// in-process fixture — too much machinery for a unit-test-only lane.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpcUaClientReconnectTests
|
||||
{
|
||||
[Fact]
|
||||
public void Default_ReconnectPeriod_matches_driver_specs_5_seconds()
|
||||
{
|
||||
new OpcUaClientDriverOptions().ReconnectPeriod.ShouldBe(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Options_ReconnectPeriod_is_configurable_for_aggressive_or_relaxed_retry()
|
||||
{
|
||||
var opts = new OpcUaClientDriverOptions { ReconnectPeriod = TimeSpan.FromMilliseconds(500) };
|
||||
opts.ReconnectPeriod.ShouldBe(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Driver_starts_with_no_reconnect_handler_active_pre_init()
|
||||
{
|
||||
// The reconnect handler is lazy — spun up only when a bad keep-alive fires. Pre-init
|
||||
// there's no session to reconnect, so the field must be null (indirectly verified by
|
||||
// the lifecycle-shape test suite catching any accidental construction).
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-reconnect");
|
||||
drv.GetHealth().State.ShouldBe(Core.Abstractions.DriverState.Unknown);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpcUaClientSecurityPolicyTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(OpcUaSecurityPolicy.None)]
|
||||
[InlineData(OpcUaSecurityPolicy.Basic128Rsa15)]
|
||||
[InlineData(OpcUaSecurityPolicy.Basic256)]
|
||||
[InlineData(OpcUaSecurityPolicy.Basic256Sha256)]
|
||||
[InlineData(OpcUaSecurityPolicy.Aes128_Sha256_RsaOaep)]
|
||||
[InlineData(OpcUaSecurityPolicy.Aes256_Sha256_RsaPss)]
|
||||
public void MapSecurityPolicy_returns_known_non_empty_uri_for_every_enum_value(OpcUaSecurityPolicy policy)
|
||||
{
|
||||
var uri = OpcUaClientDriver.MapSecurityPolicy(policy);
|
||||
uri.ShouldNotBeNullOrEmpty();
|
||||
// Each URI should end in the enum name (for the non-None policies) so a driver
|
||||
// operator reading logs can correlate the URI back to the config value.
|
||||
if (policy != OpcUaSecurityPolicy.None)
|
||||
uri.ShouldContain(policy.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapSecurityPolicy_None_matches_SDK_None_URI()
|
||||
{
|
||||
OpcUaClientDriver.MapSecurityPolicy(OpcUaSecurityPolicy.None)
|
||||
.ShouldBe(SecurityPolicies.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapSecurityPolicy_Basic256Sha256_matches_SDK_URI()
|
||||
{
|
||||
OpcUaClientDriver.MapSecurityPolicy(OpcUaSecurityPolicy.Basic256Sha256)
|
||||
.ShouldBe(SecurityPolicies.Basic256Sha256);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapSecurityPolicy_Aes256_Sha256_RsaPss_matches_SDK_URI()
|
||||
{
|
||||
OpcUaClientDriver.MapSecurityPolicy(OpcUaSecurityPolicy.Aes256_Sha256_RsaPss)
|
||||
.ShouldBe(SecurityPolicies.Aes256_Sha256_RsaPss);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Every_enum_value_has_a_mapping()
|
||||
{
|
||||
foreach (OpcUaSecurityPolicy p in Enum.GetValues<OpcUaSecurityPolicy>())
|
||||
Should.NotThrow(() => OpcUaClientDriver.MapSecurityPolicy(p));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Scaffold tests for <c>ISubscribable</c> + <c>IHostConnectivityProbe</c> that don't
|
||||
/// need a live remote server. Live-session tests (subscribe/unsubscribe round-trip,
|
||||
/// keep-alive transitions) land in a follow-up PR once the in-process OPC UA server
|
||||
/// fixture is scaffolded.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpcUaClientSubscribeAndProbeTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SubscribeAsync_without_initialize_throws_InvalidOperationException()
|
||||
{
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-sub-uninit");
|
||||
await Should.ThrowAsync<InvalidOperationException>(async () =>
|
||||
await drv.SubscribeAsync(["ns=2;s=Demo"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnsubscribeAsync_with_unknown_handle_is_noop()
|
||||
{
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-sub-unknown");
|
||||
// UnsubscribeAsync returns cleanly for handles it doesn't recognise — protects against
|
||||
// the caller's race with server-side cleanup after a session drop.
|
||||
await drv.UnsubscribeAsync(new FakeHandle(), TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetHostStatuses_returns_endpoint_url_row_pre_init()
|
||||
{
|
||||
using var drv = new OpcUaClientDriver(
|
||||
new OpcUaClientDriverOptions { EndpointUrl = "opc.tcp://plc.example:4840" },
|
||||
"opcua-hosts");
|
||||
var rows = drv.GetHostStatuses();
|
||||
rows.Count.ShouldBe(1);
|
||||
rows[0].HostName.ShouldBe("opc.tcp://plc.example:4840",
|
||||
"host identity mirrors the endpoint URL so the Admin /hosts dashboard can link back to the remote server");
|
||||
rows[0].State.ShouldBe(HostState.Unknown);
|
||||
}
|
||||
|
||||
private sealed class FakeHandle : ISubscriptionHandle
|
||||
{
|
||||
public string DiagnosticId => "fake";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3" Version="1.1.0"/>
|
||||
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,117 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Shape tests for <see cref="S7Driver"/>'s <see cref="ITagDiscovery"/>,
|
||||
/// <see cref="ISubscribable"/>, and <see cref="IHostConnectivityProbe"/> surfaces that
|
||||
/// don't need a live PLC. Wire-level polling round-trips and probe transitions land in a
|
||||
/// follow-up PR once we have a mock S7 server.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class S7DiscoveryAndSubscribeTests
|
||||
{
|
||||
private sealed class RecordingAddressSpaceBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
public readonly List<string> Folders = new();
|
||||
public readonly List<(string Name, DriverAttributeInfo Attr)> Variables = new();
|
||||
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName)
|
||||
{
|
||||
Folders.Add(browseName);
|
||||
return this;
|
||||
}
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
|
||||
{
|
||||
Variables.Add((browseName, attributeInfo));
|
||||
return new StubHandle();
|
||||
}
|
||||
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
|
||||
public void AttachAlarmCondition(IVariableHandle sourceVariable, string alarmName, DriverAttributeInfo alarmInfo) { }
|
||||
|
||||
private sealed class StubHandle : IVariableHandle
|
||||
{
|
||||
public string FullReference => "stub";
|
||||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info)
|
||||
=> throw new NotImplementedException("S7 driver never calls this — no alarm surfacing");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_projects_every_tag_into_the_address_space()
|
||||
{
|
||||
var opts = new S7DriverOptions
|
||||
{
|
||||
Host = "192.0.2.1",
|
||||
Tags =
|
||||
[
|
||||
new("TempSetpoint", "DB1.DBW0", S7DataType.Int16, Writable: true),
|
||||
new("FaultBit", "M0.0", S7DataType.Bool, Writable: false),
|
||||
new("PIDOutput", "DB5.DBD12", S7DataType.Float32, Writable: true),
|
||||
],
|
||||
};
|
||||
using var drv = new S7Driver(opts, "s7-disco");
|
||||
|
||||
var builder = new RecordingAddressSpaceBuilder();
|
||||
await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken);
|
||||
|
||||
builder.Folders.ShouldContain("S7");
|
||||
builder.Variables.Count.ShouldBe(3);
|
||||
builder.Variables[0].Name.ShouldBe("TempSetpoint");
|
||||
builder.Variables[0].Attr.SecurityClass.ShouldBe(SecurityClassification.Operate, "writable tags get Operate security class");
|
||||
builder.Variables[1].Attr.SecurityClass.ShouldBe(SecurityClassification.ViewOnly, "read-only tags get ViewOnly");
|
||||
builder.Variables[2].Attr.DriverDataType.ShouldBe(DriverDataType.Float32);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetHostStatuses_returns_one_row_with_host_port_identity_pre_init()
|
||||
{
|
||||
var opts = new S7DriverOptions { Host = "plc1.internal", Port = 102 };
|
||||
using var drv = new S7Driver(opts, "s7-host");
|
||||
|
||||
var rows = drv.GetHostStatuses();
|
||||
rows.Count.ShouldBe(1);
|
||||
rows[0].HostName.ShouldBe("plc1.internal:102");
|
||||
rows[0].State.ShouldBe(HostState.Unknown, "pre-init / pre-probe state is Unknown");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubscribeAsync_returns_unique_handles_and_UnsubscribeAsync_accepts_them()
|
||||
{
|
||||
var opts = new S7DriverOptions { Host = "192.0.2.1" };
|
||||
using var drv = new S7Driver(opts, "s7-sub");
|
||||
|
||||
// SubscribeAsync does not itself call ReadAsync (the poll task does), so this works
|
||||
// even though the driver isn't initialized. The poll task catches the resulting
|
||||
// InvalidOperationException and the loop quietly continues — same pattern as the
|
||||
// Modbus driver's poll loop tolerating transient transport failures.
|
||||
var h1 = await drv.SubscribeAsync(["T1"], TimeSpan.FromMilliseconds(200), TestContext.Current.CancellationToken);
|
||||
var h2 = await drv.SubscribeAsync(["T2"], TimeSpan.FromMilliseconds(200), TestContext.Current.CancellationToken);
|
||||
|
||||
h1.DiagnosticId.ShouldStartWith("s7-sub-");
|
||||
h2.DiagnosticId.ShouldStartWith("s7-sub-");
|
||||
h1.DiagnosticId.ShouldNotBe(h2.DiagnosticId);
|
||||
|
||||
await drv.UnsubscribeAsync(h1, TestContext.Current.CancellationToken);
|
||||
await drv.UnsubscribeAsync(h2, TestContext.Current.CancellationToken);
|
||||
// UnsubscribeAsync with an unknown handle must be a no-op, not throw.
|
||||
await drv.UnsubscribeAsync(h1, TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Subscribe_publishing_interval_is_floored_at_100ms()
|
||||
{
|
||||
var opts = new S7DriverOptions { Host = "192.0.2.1", Probe = new S7ProbeOptions { Enabled = false } };
|
||||
using var drv = new S7Driver(opts, "s7-floor");
|
||||
|
||||
// 50 ms requested — the floor protects the S7 CPU from sub-scan polling that would
|
||||
// just queue wire-side. Test that the subscription is accepted (the floor is applied
|
||||
// internally; the floor value isn't exposed, so we're really just asserting that the
|
||||
// driver doesn't reject small intervals).
|
||||
var h = await drv.SubscribeAsync(["T"], TimeSpan.FromMilliseconds(50), TestContext.Current.CancellationToken);
|
||||
h.ShouldNotBeNull();
|
||||
await drv.UnsubscribeAsync(h, TestContext.Current.CancellationToken);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user