Compare commits
22 Commits
phase-3-pr
...
phase-3-pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba3a5598e1 | ||
| 8cd932e7c9 | |||
|
|
28328def5d | ||
| d3bf544abc | |||
|
|
24435712c4 | ||
| 3f7b4d05e6 | |||
|
|
a79c5f3008 | ||
| a5299a2fee | |||
|
|
a65215684c | ||
| 82f2dfcfa3 | |||
|
|
0433d3a35e | ||
| 141673fc80 | |||
|
|
db56a95819 | ||
| 89bd726fa8 | |||
|
|
238748bc98 | ||
| b21d550836 | |||
|
|
91eaf534c8 | ||
| d33e38e059 | |||
|
|
d8ef35d5bd | ||
| 5e318a1ab6 | |||
|
|
394d126b2e | ||
| 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.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.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.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.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.CLI/ZB.MOM.WW.OtOpcUa.Client.CLI.csproj"/>
|
||||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.UI/ZB.MOM.WW.OtOpcUa.Client.UI.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.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.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.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.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.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"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Client.UI.Tests/ZB.MOM.WW.OtOpcUa.Client.UI.Tests.csproj"/>
|
||||||
|
|||||||
1051
src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs
Normal file
1051
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,36 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
|
|||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
||||||
: IDriver, 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>
|
||||||
|
private const uint StatusBadNotSupported = 0x803D0000u;
|
||||||
|
/// <summary>OPC UA StatusCode used when the tag is declared read-only.</summary>
|
||||||
|
private const uint StatusBadNotWritable = 0x803B0000u;
|
||||||
|
/// <summary>OPC UA StatusCode used when write fails validation (e.g. out-of-range value).</summary>
|
||||||
|
private const uint StatusBadInternalError = 0x80020000u;
|
||||||
|
/// <summary>OPC UA StatusCode used for socket / timeout / protocol-layer faults.</summary>
|
||||||
|
private const uint StatusBadCommunicationError = 0x80050000u;
|
||||||
|
/// <summary>OPC UA StatusCode used when S7 returns <c>ErrorCode.WrongCPU</c> / PUT/GET disabled.</summary>
|
||||||
|
private const uint StatusBadDeviceFailure = 0x80550000u;
|
||||||
|
|
||||||
|
private readonly Dictionary<string, S7TagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly Dictionary<string, S7ParsedAddress> _parsedByName = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
private readonly S7DriverOptions _options = options;
|
private readonly S7DriverOptions _options = options;
|
||||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||||
|
|
||||||
@@ -68,7 +96,29 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
|||||||
await plc.OpenAsync(cts.Token).ConfigureAwait(false);
|
await plc.OpenAsync(cts.Token).ConfigureAwait(false);
|
||||||
|
|
||||||
Plc = plc;
|
Plc = plc;
|
||||||
|
|
||||||
|
// Parse every tag's address once at init so config typos fail fast here instead
|
||||||
|
// of surfacing as BadInternalError on every Read against the bad tag. The parser
|
||||||
|
// also rejects bit-offset > 7, DB 0, unknown area letters, etc.
|
||||||
|
_tagsByName.Clear();
|
||||||
|
_parsedByName.Clear();
|
||||||
|
foreach (var t in _options.Tags)
|
||||||
|
{
|
||||||
|
var parsed = S7AddressParser.Parse(t.Address); // throws FormatException
|
||||||
|
_tagsByName[t.Name] = t;
|
||||||
|
_parsedByName[t.Name] = parsed;
|
||||||
|
}
|
||||||
|
|
||||||
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
|
_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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -89,6 +139,17 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
|||||||
|
|
||||||
public Task ShutdownAsync(CancellationToken cancellationToken)
|
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 */ }
|
try { Plc?.Close(); } catch { /* best-effort — tearing down anyway */ }
|
||||||
Plc = null;
|
Plc = null;
|
||||||
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
||||||
@@ -106,6 +167,339 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
|||||||
|
|
||||||
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
|
|
||||||
|
// ---- IReadable ----
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
||||||
|
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var plc = RequirePlc();
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var results = new DataValueSnapshot[fullReferences.Count];
|
||||||
|
|
||||||
|
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
for (var i = 0; i < fullReferences.Count; i++)
|
||||||
|
{
|
||||||
|
var name = fullReferences[i];
|
||||||
|
if (!_tagsByName.TryGetValue(name, out var tag))
|
||||||
|
{
|
||||||
|
results[i] = new DataValueSnapshot(null, StatusBadNodeIdUnknown, null, now);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var value = await ReadOneAsync(plc, tag, cancellationToken).ConfigureAwait(false);
|
||||||
|
results[i] = new DataValueSnapshot(value, 0u, now, now);
|
||||||
|
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||||
|
}
|
||||||
|
catch (NotSupportedException)
|
||||||
|
{
|
||||||
|
results[i] = new DataValueSnapshot(null, StatusBadNotSupported, null, now);
|
||||||
|
}
|
||||||
|
catch (global::S7.Net.PlcException pex)
|
||||||
|
{
|
||||||
|
// S7.Net's PlcException carries an ErrorCode; PUT/GET-disabled on
|
||||||
|
// S7-1200/1500 surfaces here. Map to BadDeviceFailure so operators see a
|
||||||
|
// device-config problem (toggle PUT/GET in TIA Portal) rather than a
|
||||||
|
// transient fault — per driver-specs.md §5.
|
||||||
|
results[i] = new DataValueSnapshot(null, StatusBadDeviceFailure, null, now);
|
||||||
|
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, pex.Message);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
results[i] = new DataValueSnapshot(null, StatusBadCommunicationError, null, now);
|
||||||
|
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally { _gate.Release(); }
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<object> ReadOneAsync(global::S7.Net.Plc plc, S7TagDefinition tag, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var addr = _parsedByName[tag.Name];
|
||||||
|
// S7.Net's string-based ReadAsync returns object where the boxed .NET type depends on
|
||||||
|
// the size suffix: DBX=bool, DBB=byte, DBW=ushort, DBD=uint. Our S7DataType enum
|
||||||
|
// specifies the SEMANTIC type (Int16 vs UInt16 vs Float32 etc.); the reinterpret below
|
||||||
|
// converts the raw unsigned boxed value into the requested type without issuing an
|
||||||
|
// extra PLC round-trip.
|
||||||
|
var raw = await plc.ReadAsync(tag.Address, ct).ConfigureAwait(false)
|
||||||
|
?? throw new System.IO.InvalidDataException($"S7.Net returned null for '{tag.Address}'");
|
||||||
|
|
||||||
|
return (tag.DataType, addr.Size, raw) switch
|
||||||
|
{
|
||||||
|
(S7DataType.Bool, S7Size.Bit, bool b) => b,
|
||||||
|
(S7DataType.Byte, S7Size.Byte, byte by) => by,
|
||||||
|
(S7DataType.UInt16, S7Size.Word, ushort u16) => u16,
|
||||||
|
(S7DataType.Int16, S7Size.Word, ushort u16) => unchecked((short)u16),
|
||||||
|
(S7DataType.UInt32, S7Size.DWord, uint u32) => u32,
|
||||||
|
(S7DataType.Int32, S7Size.DWord, uint u32) => unchecked((int)u32),
|
||||||
|
(S7DataType.Float32, S7Size.DWord, uint u32) => BitConverter.UInt32BitsToSingle(u32),
|
||||||
|
|
||||||
|
(S7DataType.Int64, _, _) => throw new NotSupportedException("S7 Int64 reads land in a follow-up PR"),
|
||||||
|
(S7DataType.UInt64, _, _) => throw new NotSupportedException("S7 UInt64 reads land in a follow-up PR"),
|
||||||
|
(S7DataType.Float64, _, _) => throw new NotSupportedException("S7 Float64 (LReal) reads land in a follow-up PR"),
|
||||||
|
(S7DataType.String, _, _) => throw new NotSupportedException("S7 STRING reads land in a follow-up PR"),
|
||||||
|
(S7DataType.DateTime, _, _) => throw new NotSupportedException("S7 DateTime reads land in a follow-up PR"),
|
||||||
|
|
||||||
|
_ => throw new System.IO.InvalidDataException(
|
||||||
|
$"S7 Read type-mismatch: tag '{tag.Name}' declared {tag.DataType} but address '{tag.Address}' " +
|
||||||
|
$"parsed as Size={addr.Size}; S7.Net returned {raw.GetType().Name}"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- IWritable ----
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
|
||||||
|
IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var plc = RequirePlc();
|
||||||
|
var results = new WriteResult[writes.Count];
|
||||||
|
|
||||||
|
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
for (var i = 0; i < writes.Count; i++)
|
||||||
|
{
|
||||||
|
var w = writes[i];
|
||||||
|
if (!_tagsByName.TryGetValue(w.FullReference, out var tag))
|
||||||
|
{
|
||||||
|
results[i] = new WriteResult(StatusBadNodeIdUnknown);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!tag.Writable)
|
||||||
|
{
|
||||||
|
results[i] = new WriteResult(StatusBadNotWritable);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await WriteOneAsync(plc, tag, w.Value, cancellationToken).ConfigureAwait(false);
|
||||||
|
results[i] = new WriteResult(0u);
|
||||||
|
}
|
||||||
|
catch (NotSupportedException)
|
||||||
|
{
|
||||||
|
results[i] = new WriteResult(StatusBadNotSupported);
|
||||||
|
}
|
||||||
|
catch (global::S7.Net.PlcException)
|
||||||
|
{
|
||||||
|
results[i] = new WriteResult(StatusBadDeviceFailure);
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
results[i] = new WriteResult(StatusBadInternalError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally { _gate.Release(); }
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task WriteOneAsync(global::S7.Net.Plc plc, S7TagDefinition tag, object? value, CancellationToken ct)
|
||||||
|
{
|
||||||
|
// S7.Net's Plc.WriteAsync(string address, object value) expects the boxed value to
|
||||||
|
// match the address's size-suffix type: DBX=bool, DBB=byte, DBW=ushort, DBD=uint.
|
||||||
|
// Our S7DataType lets the caller pass short/int/float; convert to the unsigned
|
||||||
|
// wire representation before handing off.
|
||||||
|
var boxed = tag.DataType switch
|
||||||
|
{
|
||||||
|
S7DataType.Bool => (object)Convert.ToBoolean(value),
|
||||||
|
S7DataType.Byte => (object)Convert.ToByte(value),
|
||||||
|
S7DataType.UInt16 => (object)Convert.ToUInt16(value),
|
||||||
|
S7DataType.Int16 => (object)unchecked((ushort)Convert.ToInt16(value)),
|
||||||
|
S7DataType.UInt32 => (object)Convert.ToUInt32(value),
|
||||||
|
S7DataType.Int32 => (object)unchecked((uint)Convert.ToInt32(value)),
|
||||||
|
S7DataType.Float32 => (object)BitConverter.SingleToUInt32Bits(Convert.ToSingle(value)),
|
||||||
|
|
||||||
|
S7DataType.Int64 => throw new NotSupportedException("S7 Int64 writes land in a follow-up PR"),
|
||||||
|
S7DataType.UInt64 => throw new NotSupportedException("S7 UInt64 writes land in a follow-up PR"),
|
||||||
|
S7DataType.Float64 => throw new NotSupportedException("S7 Float64 (LReal) writes land in a follow-up PR"),
|
||||||
|
S7DataType.String => throw new NotSupportedException("S7 STRING writes land in a follow-up PR"),
|
||||||
|
S7DataType.DateTime => throw new NotSupportedException("S7 DateTime writes land in a follow-up PR"),
|
||||||
|
_ => throw new InvalidOperationException($"Unknown S7DataType {tag.DataType}"),
|
||||||
|
};
|
||||||
|
await plc.WriteAsync(tag.Address, boxed, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||||
|
|
||||||
public async ValueTask DisposeAsync()
|
public async ValueTask DisposeAsync()
|
||||||
|
|||||||
@@ -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,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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unit tests for <see cref="S7Driver"/>'s <c>IReadable</c>/<c>IWritable</c> surface
|
||||||
|
/// that don't require a live PLC — covers error paths (not-initialized, unknown tag,
|
||||||
|
/// read-only write rejection, unsupported data types). Wire-level round-trip tests
|
||||||
|
/// against a live S7 or a mock-server land in a follow-up PR since S7.Net doesn't ship
|
||||||
|
/// an in-process fake and an adequate mock is non-trivial.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class S7DriverReadWriteTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Initialize_rejects_invalid_tag_address_and_fails_fast()
|
||||||
|
{
|
||||||
|
// Bad address at init time must throw; the alternative (deferring the parse to the
|
||||||
|
// first read) would surface the config bug as BadInternalError on every subsequent
|
||||||
|
// Read which is impossible for an operator to diagnose from the OPC UA client.
|
||||||
|
var opts = new S7DriverOptions
|
||||||
|
{
|
||||||
|
Host = "192.0.2.1", // reserved — will never complete TCP handshake
|
||||||
|
Timeout = TimeSpan.FromMilliseconds(250),
|
||||||
|
Tags = [new S7TagDefinition("BadTag", "NOT-AN-S7-ADDRESS", S7DataType.Int16)],
|
||||||
|
};
|
||||||
|
using var drv = new S7Driver(opts, "s7-bad-tag");
|
||||||
|
|
||||||
|
// Either the TCP connect fails first (Exception) or the parser fails (FormatException)
|
||||||
|
// — both are acceptable since both are init-time fail-fast. What matters is that we
|
||||||
|
// don't return a "healthy" driver with a latent bad tag.
|
||||||
|
await Should.ThrowAsync<Exception>(async () =>
|
||||||
|
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReadAsync_without_initialize_throws_InvalidOperationException()
|
||||||
|
{
|
||||||
|
using var drv = new S7Driver(new S7DriverOptions { Host = "192.0.2.1" }, "s7-uninit");
|
||||||
|
await Should.ThrowAsync<InvalidOperationException>(async () =>
|
||||||
|
await drv.ReadAsync(["Any"], TestContext.Current.CancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WriteAsync_without_initialize_throws_InvalidOperationException()
|
||||||
|
{
|
||||||
|
using var drv = new S7Driver(new S7DriverOptions { Host = "192.0.2.1" }, "s7-uninit");
|
||||||
|
await Should.ThrowAsync<InvalidOperationException>(async () =>
|
||||||
|
await drv.WriteAsync(
|
||||||
|
[new(FullReference: "Any", Value: (short)0)],
|
||||||
|
TestContext.Current.CancellationToken));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user