Compare commits
10 Commits
phase-3-pr
...
phase-3-pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
238748bc98 | ||
| b21d550836 | |||
|
|
91eaf534c8 | ||
| d33e38e059 | |||
|
|
d8ef35d5bd | ||
| 5e318a1ab6 | |||
|
|
394d126b2e | ||
| 0eab1271be | |||
|
|
d5034c40f7 | ||
| 5e67c49f7c |
@@ -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"/>
|
||||||
|
|||||||
393
src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs
Normal file
393
src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs
Normal file
@@ -0,0 +1,393 @@
|
|||||||
|
using Opc.Ua;
|
||||||
|
using Opc.Ua.Client;
|
||||||
|
using Opc.Ua.Configuration;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// OPC UA Client (gateway) driver. Opens a <see cref="Session"/> against a remote OPC UA
|
||||||
|
/// server and re-exposes its address space through the local OtOpcUa server. PR 66 ships
|
||||||
|
/// the scaffold: <see cref="IDriver"/> only (connect / close / health). Browse, read,
|
||||||
|
/// write, subscribe, and probe land in PRs 67-69.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Builds its own <see cref="ApplicationConfiguration"/> rather than reusing
|
||||||
|
/// <c>Client.Shared</c> — Client.Shared is oriented at the interactive CLI; this
|
||||||
|
/// driver is an always-on service component with different session-lifetime needs
|
||||||
|
/// (keep-alive monitor, session transfer on reconnect, multi-year uptime).
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Session lifetime</b>: a single <see cref="Session"/> per driver instance.
|
||||||
|
/// Subscriptions multiplex onto that session; SDK reconnect handler takes the session
|
||||||
|
/// down and brings it back up on remote-server restart — the driver must re-send
|
||||||
|
/// subscriptions + TransferSubscriptions on reconnect to avoid dangling
|
||||||
|
/// monitored-item handles. That mechanic lands in PR 69.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string driverInstanceId)
|
||||||
|
: IDriver, IReadable, IWritable, IDisposable, IAsyncDisposable
|
||||||
|
{
|
||||||
|
// OPC UA StatusCode constants the driver surfaces for local-side faults. Upstream-server
|
||||||
|
// StatusCodes are passed through verbatim per driver-specs.md §8 "cascading quality" —
|
||||||
|
// downstream clients need to distinguish 'remote source down' from 'local driver failure'.
|
||||||
|
private const uint StatusBadNodeIdInvalid = 0x80330000u;
|
||||||
|
private const uint StatusBadInternalError = 0x80020000u;
|
||||||
|
private const uint StatusBadCommunicationError = 0x80050000u;
|
||||||
|
|
||||||
|
private readonly OpcUaClientDriverOptions _options = options;
|
||||||
|
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||||
|
|
||||||
|
/// <summary>Active OPC UA session. Null until <see cref="InitializeAsync"/> returns cleanly.</summary>
|
||||||
|
internal ISession? Session { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Per-connection gate. PRs 67+ serialize read/write/browse on this.</summary>
|
||||||
|
internal SemaphoreSlim Gate => _gate;
|
||||||
|
|
||||||
|
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
public string DriverInstanceId => driverInstanceId;
|
||||||
|
public string DriverType => "OpcUaClient";
|
||||||
|
|
||||||
|
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_health = new DriverHealth(DriverState.Initializing, null, null);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var appConfig = await BuildApplicationConfigurationAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
// Endpoint selection: let the stack pick the best matching endpoint for the
|
||||||
|
// requested security policy/mode so the driver doesn't have to hand-validate.
|
||||||
|
// UseSecurity=false when SecurityMode=None shortcuts around cert validation
|
||||||
|
// entirely and is the typical dev-bench configuration.
|
||||||
|
var useSecurity = _options.SecurityMode != OpcUaSecurityMode.None;
|
||||||
|
// The non-obsolete SelectEndpointAsync overloads all require an ITelemetryContext
|
||||||
|
// parameter. Passing null is valid — the SDK falls through to its built-in default
|
||||||
|
// trace sink. Plumbing a telemetry context through every driver surface is out of
|
||||||
|
// scope; the driver emits its own logs via the health surface anyway.
|
||||||
|
var selected = await CoreClientUtils.SelectEndpointAsync(
|
||||||
|
appConfig, _options.EndpointUrl, useSecurity,
|
||||||
|
telemetry: null!,
|
||||||
|
ct: cancellationToken).ConfigureAwait(false);
|
||||||
|
var endpointConfig = EndpointConfiguration.Create(appConfig);
|
||||||
|
endpointConfig.OperationTimeout = (int)_options.Timeout.TotalMilliseconds;
|
||||||
|
var endpoint = new ConfiguredEndpoint(null, selected, endpointConfig);
|
||||||
|
|
||||||
|
var identity = _options.AuthType switch
|
||||||
|
{
|
||||||
|
OpcUaAuthType.Anonymous => new UserIdentity(new AnonymousIdentityToken()),
|
||||||
|
// The UserIdentity(string, string) overload was removed in favour of
|
||||||
|
// (string, byte[]) to make the password encoding explicit. UTF-8 is the
|
||||||
|
// overwhelmingly common choice for Basic256Sha256-secured sessions.
|
||||||
|
OpcUaAuthType.Username => new UserIdentity(
|
||||||
|
_options.Username ?? string.Empty,
|
||||||
|
System.Text.Encoding.UTF8.GetBytes(_options.Password ?? string.Empty)),
|
||||||
|
OpcUaAuthType.Certificate => throw new NotSupportedException(
|
||||||
|
"Certificate authentication lands in a follow-up PR; for now use Anonymous or Username"),
|
||||||
|
_ => new UserIdentity(new AnonymousIdentityToken()),
|
||||||
|
};
|
||||||
|
|
||||||
|
// All Session.Create* static methods are marked [Obsolete] in SDK 1.5.378; the
|
||||||
|
// non-obsolete path is DefaultSessionFactory.Instance.CreateAsync (which is the
|
||||||
|
// 8-arg signature matching our driver config — ApplicationConfiguration +
|
||||||
|
// ConfiguredEndpoint, no transport-waiting-connection or reverse-connect-manager
|
||||||
|
// required for the standard opc.tcp direct-connect case).
|
||||||
|
// DefaultSessionFactory's parameterless ctor is also obsolete in 1.5.378; the
|
||||||
|
// current constructor requires an ITelemetryContext. Passing null is tolerated —
|
||||||
|
// the factory falls back to its internal default sink, same as the telemetry:null
|
||||||
|
// on SelectEndpointAsync above.
|
||||||
|
var session = await new DefaultSessionFactory(telemetry: null!).CreateAsync(
|
||||||
|
appConfig,
|
||||||
|
endpoint,
|
||||||
|
false, // updateBeforeConnect
|
||||||
|
_options.SessionName,
|
||||||
|
(uint)_options.SessionTimeout.TotalMilliseconds,
|
||||||
|
identity,
|
||||||
|
null, // preferredLocales
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
session.KeepAliveInterval = (int)_options.KeepAliveInterval.TotalMilliseconds;
|
||||||
|
|
||||||
|
Session = session;
|
||||||
|
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
try { if (Session is Session s) await s.CloseAsync().ConfigureAwait(false); } catch { }
|
||||||
|
Session = null;
|
||||||
|
_health = new DriverHealth(DriverState.Faulted, null, ex.Message);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Build a minimal in-memory <see cref="ApplicationConfiguration"/>. Certificates live
|
||||||
|
/// under the OS user profile — on Windows that's <c>%LocalAppData%\OtOpcUa\pki</c>
|
||||||
|
/// — so multiple driver instances in the same OtOpcUa server process share one
|
||||||
|
/// certificate store without extra config.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<ApplicationConfiguration> BuildApplicationConfigurationAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
// The default ctor is obsolete in favour of the ITelemetryContext overload; suppress
|
||||||
|
// locally rather than plumbing a telemetry context all the way through the driver
|
||||||
|
// surface — the driver emits no per-request telemetry of its own and the SDK's
|
||||||
|
// internal fallback is fine for a gateway use case.
|
||||||
|
#pragma warning disable CS0618
|
||||||
|
var app = new ApplicationInstance
|
||||||
|
{
|
||||||
|
ApplicationName = _options.SessionName,
|
||||||
|
ApplicationType = ApplicationType.Client,
|
||||||
|
};
|
||||||
|
#pragma warning restore CS0618
|
||||||
|
|
||||||
|
var pkiRoot = Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||||
|
"OtOpcUa", "pki");
|
||||||
|
|
||||||
|
var config = new ApplicationConfiguration
|
||||||
|
{
|
||||||
|
ApplicationName = _options.SessionName,
|
||||||
|
ApplicationType = ApplicationType.Client,
|
||||||
|
ApplicationUri = _options.ApplicationUri,
|
||||||
|
SecurityConfiguration = new SecurityConfiguration
|
||||||
|
{
|
||||||
|
ApplicationCertificate = new CertificateIdentifier
|
||||||
|
{
|
||||||
|
StoreType = CertificateStoreType.Directory,
|
||||||
|
StorePath = Path.Combine(pkiRoot, "own"),
|
||||||
|
SubjectName = $"CN={_options.SessionName}",
|
||||||
|
},
|
||||||
|
TrustedPeerCertificates = new CertificateTrustList
|
||||||
|
{
|
||||||
|
StoreType = CertificateStoreType.Directory,
|
||||||
|
StorePath = Path.Combine(pkiRoot, "trusted"),
|
||||||
|
},
|
||||||
|
TrustedIssuerCertificates = new CertificateTrustList
|
||||||
|
{
|
||||||
|
StoreType = CertificateStoreType.Directory,
|
||||||
|
StorePath = Path.Combine(pkiRoot, "issuers"),
|
||||||
|
},
|
||||||
|
RejectedCertificateStore = new CertificateTrustList
|
||||||
|
{
|
||||||
|
StoreType = CertificateStoreType.Directory,
|
||||||
|
StorePath = Path.Combine(pkiRoot, "rejected"),
|
||||||
|
},
|
||||||
|
AutoAcceptUntrustedCertificates = _options.AutoAcceptCertificates,
|
||||||
|
},
|
||||||
|
TransportQuotas = new TransportQuotas { OperationTimeout = (int)_options.Timeout.TotalMilliseconds },
|
||||||
|
ClientConfiguration = new ClientConfiguration
|
||||||
|
{
|
||||||
|
DefaultSessionTimeout = (int)_options.SessionTimeout.TotalMilliseconds,
|
||||||
|
},
|
||||||
|
DisableHiResClock = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
await config.ValidateAsync(ApplicationType.Client, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
// Attach a cert-validator handler that honours the AutoAccept flag. Without this,
|
||||||
|
// AutoAcceptUntrustedCertificates on the config alone isn't always enough in newer
|
||||||
|
// SDK versions — the validator raises an event the app has to handle.
|
||||||
|
if (_options.AutoAcceptCertificates)
|
||||||
|
{
|
||||||
|
config.CertificateValidator.CertificateValidation += (s, e) =>
|
||||||
|
{
|
||||||
|
if (e.Error.StatusCode == StatusCodes.BadCertificateUntrusted)
|
||||||
|
e.Accept = true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure an application certificate exists. The SDK auto-generates one if missing.
|
||||||
|
app.ApplicationConfiguration = config;
|
||||||
|
await app.CheckApplicationInstanceCertificatesAsync(silent: true, lifeTimeInMonths: null, ct)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await ShutdownAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ShutdownAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try { if (Session is Session s) await s.CloseAsync(cancellationToken).ConfigureAwait(false); }
|
||||||
|
catch { /* best-effort */ }
|
||||||
|
try { Session?.Dispose(); } catch { }
|
||||||
|
Session = null;
|
||||||
|
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public DriverHealth GetHealth() => _health;
|
||||||
|
public long GetMemoryFootprint() => 0;
|
||||||
|
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
|
|
||||||
|
// ---- IReadable ----
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
||||||
|
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var session = RequireSession();
|
||||||
|
var results = new DataValueSnapshot[fullReferences.Count];
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
|
||||||
|
// Parse NodeIds up-front. Tags whose reference doesn't parse get BadNodeIdInvalid
|
||||||
|
// and are omitted from the wire request — saves a round-trip against the upstream
|
||||||
|
// server for a fault the driver can detect locally.
|
||||||
|
var toSend = new ReadValueIdCollection();
|
||||||
|
var indexMap = new List<int>(fullReferences.Count); // maps wire-index -> results-index
|
||||||
|
for (var i = 0; i < fullReferences.Count; i++)
|
||||||
|
{
|
||||||
|
if (!TryParseNodeId(session, fullReferences[i], out var nodeId))
|
||||||
|
{
|
||||||
|
results[i] = new DataValueSnapshot(null, StatusBadNodeIdInvalid, null, now);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
toSend.Add(new ReadValueId { NodeId = nodeId, AttributeId = Attributes.Value });
|
||||||
|
indexMap.Add(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toSend.Count == 0) return results;
|
||||||
|
|
||||||
|
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var resp = await session.ReadAsync(
|
||||||
|
requestHeader: null,
|
||||||
|
maxAge: 0,
|
||||||
|
timestampsToReturn: TimestampsToReturn.Both,
|
||||||
|
nodesToRead: toSend,
|
||||||
|
ct: cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var values = resp.Results;
|
||||||
|
for (var w = 0; w < values.Count; w++)
|
||||||
|
{
|
||||||
|
var r = indexMap[w];
|
||||||
|
var dv = values[w];
|
||||||
|
// Preserve the upstream StatusCode verbatim — including Bad codes per
|
||||||
|
// §8's cascading-quality rule. Also preserve SourceTimestamp so downstream
|
||||||
|
// clients can detect stale upstream data.
|
||||||
|
results[r] = new DataValueSnapshot(
|
||||||
|
Value: dv.Value,
|
||||||
|
StatusCode: dv.StatusCode.Code,
|
||||||
|
SourceTimestampUtc: dv.SourceTimestamp == DateTime.MinValue ? null : dv.SourceTimestamp,
|
||||||
|
ServerTimestampUtc: dv.ServerTimestamp == DateTime.MinValue ? now : dv.ServerTimestamp);
|
||||||
|
}
|
||||||
|
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Transport / timeout / session-dropped — fan out the same fault across every
|
||||||
|
// tag in this batch. Per-tag StatusCode stays BadCommunicationError (not
|
||||||
|
// BadInternalError) so operators distinguish "upstream unreachable" from
|
||||||
|
// "driver bug".
|
||||||
|
for (var w = 0; w < indexMap.Count; w++)
|
||||||
|
{
|
||||||
|
var r = indexMap[w];
|
||||||
|
results[r] = new DataValueSnapshot(null, StatusBadCommunicationError, null, now);
|
||||||
|
}
|
||||||
|
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally { _gate.Release(); }
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- IWritable ----
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
|
||||||
|
IReadOnlyList<Core.Abstractions.WriteRequest> writes, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var session = RequireSession();
|
||||||
|
var results = new WriteResult[writes.Count];
|
||||||
|
|
||||||
|
var toSend = new WriteValueCollection();
|
||||||
|
var indexMap = new List<int>(writes.Count);
|
||||||
|
for (var i = 0; i < writes.Count; i++)
|
||||||
|
{
|
||||||
|
if (!TryParseNodeId(session, writes[i].FullReference, out var nodeId))
|
||||||
|
{
|
||||||
|
results[i] = new WriteResult(StatusBadNodeIdInvalid);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
toSend.Add(new WriteValue
|
||||||
|
{
|
||||||
|
NodeId = nodeId,
|
||||||
|
AttributeId = Attributes.Value,
|
||||||
|
Value = new DataValue(new Variant(writes[i].Value)),
|
||||||
|
});
|
||||||
|
indexMap.Add(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toSend.Count == 0) return results;
|
||||||
|
|
||||||
|
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var resp = await session.WriteAsync(
|
||||||
|
requestHeader: null,
|
||||||
|
nodesToWrite: toSend,
|
||||||
|
ct: cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var codes = resp.Results;
|
||||||
|
for (var w = 0; w < codes.Count; w++)
|
||||||
|
{
|
||||||
|
var r = indexMap[w];
|
||||||
|
// Pass upstream WriteResult StatusCode through verbatim. Success codes
|
||||||
|
// include Good (0) and any warning-level Good* variants; anything with
|
||||||
|
// the severity bits set is a Bad.
|
||||||
|
results[r] = new WriteResult(codes[w].Code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
for (var w = 0; w < indexMap.Count; w++)
|
||||||
|
results[indexMap[w]] = new WriteResult(StatusBadCommunicationError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally { _gate.Release(); }
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parse a tag's full-reference string as a NodeId. Accepts the standard OPC UA
|
||||||
|
/// serialized forms (<c>ns=2;s=…</c>, <c>i=2253</c>, <c>ns=4;g=…</c>, <c>ns=3;b=…</c>).
|
||||||
|
/// Empty + malformed strings return false; the driver surfaces that as
|
||||||
|
/// <see cref="StatusBadNodeIdInvalid"/> without a wire round-trip.
|
||||||
|
/// </summary>
|
||||||
|
internal static bool TryParseNodeId(ISession session, string fullReference, out NodeId nodeId)
|
||||||
|
{
|
||||||
|
nodeId = NodeId.Null;
|
||||||
|
if (string.IsNullOrWhiteSpace(fullReference)) return false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
nodeId = NodeId.Parse(session.MessageContext, fullReference);
|
||||||
|
return !NodeId.IsNull(nodeId);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ISession RequireSession() =>
|
||||||
|
Session ?? throw new InvalidOperationException("OpcUaClientDriver not initialized");
|
||||||
|
|
||||||
|
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
if (_disposed) return;
|
||||||
|
_disposed = true;
|
||||||
|
try { await ShutdownAsync(CancellationToken.None).ConfigureAwait(false); }
|
||||||
|
catch { /* disposal is best-effort */ }
|
||||||
|
_gate.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
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>.</summary>
|
||||||
|
public string EndpointUrl { get; init; } = "opc.tcp://localhost:4840";
|
||||||
|
|
||||||
|
/// <summary>Security policy. One of <c>None</c>, <c>Basic256Sha256</c>, <c>Aes128_Sha256_RsaOaep</c>.</summary>
|
||||||
|
public string SecurityPolicy { get; init; } = "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>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>OPC UA message security mode.</summary>
|
||||||
|
public enum OpcUaSecurityMode
|
||||||
|
{
|
||||||
|
None,
|
||||||
|
Sign,
|
||||||
|
SignAndEncrypt,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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>
|
||||||
216
src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7AddressParser.cs
Normal file
216
src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7AddressParser.cs
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Siemens S7 memory area. The driver's tag-address parser maps every S7 tag string into
|
||||||
|
/// exactly one of these + an offset. Values match the on-wire S7 area codes only
|
||||||
|
/// incidentally — S7.Net uses its own <c>DataType</c> enum (<c>DataBlock</c>, <c>Memory</c>,
|
||||||
|
/// <c>Input</c>, <c>Output</c>, <c>Timer</c>, <c>Counter</c>) so the adapter layer translates.
|
||||||
|
/// </summary>
|
||||||
|
public enum S7Area
|
||||||
|
{
|
||||||
|
DataBlock,
|
||||||
|
Memory, // M (Merker / marker byte)
|
||||||
|
Input, // I (process-image input)
|
||||||
|
Output, // Q (process-image output)
|
||||||
|
Timer,
|
||||||
|
Counter,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Access width for a DB / M / I / Q address. Timers and counters are always 16-bit
|
||||||
|
/// opaque (not user-addressable via size suffixes).
|
||||||
|
/// </summary>
|
||||||
|
public enum S7Size
|
||||||
|
{
|
||||||
|
Bit, // X
|
||||||
|
Byte, // B
|
||||||
|
Word, // W — 16-bit
|
||||||
|
DWord, // D — 32-bit
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parsed form of an S7 tag-address string. Produced by <see cref="S7AddressParser.Parse"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Area">Memory area (DB, M, I, Q, T, C).</param>
|
||||||
|
/// <param name="DbNumber">Data block number; only meaningful when <paramref name="Area"/> is <see cref="S7Area.DataBlock"/>.</param>
|
||||||
|
/// <param name="Size">Access width. Always <see cref="S7Size.Word"/> for Timer and Counter.</param>
|
||||||
|
/// <param name="ByteOffset">Byte offset into the area (for DB/M/I/Q) or the timer/counter number.</param>
|
||||||
|
/// <param name="BitOffset">Bit position 0-7 when <paramref name="Size"/> is <see cref="S7Size.Bit"/>; 0 otherwise.</param>
|
||||||
|
public readonly record struct S7ParsedAddress(
|
||||||
|
S7Area Area,
|
||||||
|
int DbNumber,
|
||||||
|
S7Size Size,
|
||||||
|
int ByteOffset,
|
||||||
|
int BitOffset);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses Siemens S7 address strings into <see cref="S7ParsedAddress"/>. Accepts the
|
||||||
|
/// Siemens TIA-Portal / STEP 7 Classic syntax documented in <c>docs/v2/driver-specs.md</c> §5:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><c>DB{n}.DB{X|B|W|D}{offset}[.bit]</c> — e.g. <c>DB1.DBX0.0</c>, <c>DB1.DBW0</c>, <c>DB1.DBD4</c></item>
|
||||||
|
/// <item><c>M{B|W|D}{offset}</c> or <c>M{offset}.{bit}</c> — e.g. <c>MB0</c>, <c>MW0</c>, <c>MD4</c>, <c>M0.0</c></item>
|
||||||
|
/// <item><c>I{B|W|D}{offset}</c> or <c>I{offset}.{bit}</c> — e.g. <c>IB0</c>, <c>IW0</c>, <c>ID0</c>, <c>I0.0</c></item>
|
||||||
|
/// <item><c>Q{B|W|D}{offset}</c> or <c>Q{offset}.{bit}</c> — e.g. <c>QB0</c>, <c>QW0</c>, <c>QD0</c>, <c>Q0.0</c></item>
|
||||||
|
/// <item><c>T{n}</c> — e.g. <c>T0</c>, <c>T15</c></item>
|
||||||
|
/// <item><c>C{n}</c> — e.g. <c>C0</c>, <c>C10</c></item>
|
||||||
|
/// </list>
|
||||||
|
/// Grammar is case-insensitive. Leading/trailing whitespace tolerated. Bit specifiers
|
||||||
|
/// must be 0-7; byte offsets must be non-negative; DB numbers must be >= 1.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Parse is deliberately strict — the parser rejects syntactic garbage up-front so a bad
|
||||||
|
/// tag config fails at driver init time instead of surfacing as a misleading
|
||||||
|
/// <c>BadInternalError</c> on every Read against that tag.
|
||||||
|
/// </remarks>
|
||||||
|
public static class S7AddressParser
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Parse an S7 address. Throws <see cref="FormatException"/> on any syntax error with
|
||||||
|
/// the offending input echoed in the message so operators can correlate to the tag
|
||||||
|
/// config that produced the fault.
|
||||||
|
/// </summary>
|
||||||
|
public static S7ParsedAddress Parse(string address)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(address))
|
||||||
|
throw new FormatException("S7 address must not be empty");
|
||||||
|
var s = address.Trim().ToUpperInvariant();
|
||||||
|
|
||||||
|
// --- DB{n}.DB{X|B|W|D}{offset}[.bit] ---
|
||||||
|
if (s.StartsWith("DB") && TryParseDataBlock(s, out var dbResult))
|
||||||
|
return dbResult;
|
||||||
|
|
||||||
|
if (s.Length < 2)
|
||||||
|
throw new FormatException($"S7 address '{address}' is too short to parse");
|
||||||
|
|
||||||
|
var areaChar = s[0];
|
||||||
|
var rest = s.Substring(1);
|
||||||
|
|
||||||
|
switch (areaChar)
|
||||||
|
{
|
||||||
|
case 'M': return ParseMIQ(S7Area.Memory, rest, address);
|
||||||
|
case 'I': return ParseMIQ(S7Area.Input, rest, address);
|
||||||
|
case 'Q': return ParseMIQ(S7Area.Output, rest, address);
|
||||||
|
case 'T': return ParseTimerOrCounter(S7Area.Timer, rest, address);
|
||||||
|
case 'C': return ParseTimerOrCounter(S7Area.Counter, rest, address);
|
||||||
|
default:
|
||||||
|
throw new FormatException($"S7 address '{address}' starts with unknown area '{areaChar}' (expected DB/M/I/Q/T/C)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Try-parse variant for callers that can't afford an exception on bad input (e.g.
|
||||||
|
/// config validation pages in the Admin UI). Returns <c>false</c> for any input that
|
||||||
|
/// would throw from <see cref="Parse"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static bool TryParse(string address, out S7ParsedAddress result)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
result = Parse(address);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (FormatException)
|
||||||
|
{
|
||||||
|
result = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParseDataBlock(string s, out S7ParsedAddress result)
|
||||||
|
{
|
||||||
|
result = default;
|
||||||
|
// Split on first '.': left side must be DB{n}, right side DB{X|B|W|D}{offset}[.bit]
|
||||||
|
var dot = s.IndexOf('.');
|
||||||
|
if (dot < 0) return false;
|
||||||
|
var head = s.Substring(0, dot); // DB{n}
|
||||||
|
var tail = s.Substring(dot + 1); // DB{X|B|W|D}{offset}[.bit]
|
||||||
|
|
||||||
|
if (head.Length < 3) return false;
|
||||||
|
if (!int.TryParse(head.AsSpan(2), out var dbNumber) || dbNumber < 1)
|
||||||
|
throw new FormatException($"S7 DB number in '{s}' must be a positive integer");
|
||||||
|
|
||||||
|
if (!tail.StartsWith("DB") || tail.Length < 4)
|
||||||
|
throw new FormatException($"S7 DB address tail '{tail}' must start with DB{{X|B|W|D}}");
|
||||||
|
|
||||||
|
var sizeChar = tail[2];
|
||||||
|
var offsetStart = 3;
|
||||||
|
var size = sizeChar switch
|
||||||
|
{
|
||||||
|
'X' => S7Size.Bit,
|
||||||
|
'B' => S7Size.Byte,
|
||||||
|
'W' => S7Size.Word,
|
||||||
|
'D' => S7Size.DWord,
|
||||||
|
_ => throw new FormatException($"S7 DB size '{sizeChar}' in '{s}' must be X/B/W/D"),
|
||||||
|
};
|
||||||
|
|
||||||
|
var (byteOffset, bitOffset) = ParseOffsetAndOptionalBit(tail, offsetStart, size, s);
|
||||||
|
result = new S7ParsedAddress(S7Area.DataBlock, dbNumber, size, byteOffset, bitOffset);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static S7ParsedAddress ParseMIQ(S7Area area, string rest, string original)
|
||||||
|
{
|
||||||
|
if (rest.Length == 0)
|
||||||
|
throw new FormatException($"S7 address '{original}' has no offset");
|
||||||
|
|
||||||
|
var first = rest[0];
|
||||||
|
S7Size size;
|
||||||
|
int offsetStart;
|
||||||
|
switch (first)
|
||||||
|
{
|
||||||
|
case 'B': size = S7Size.Byte; offsetStart = 1; break;
|
||||||
|
case 'W': size = S7Size.Word; offsetStart = 1; break;
|
||||||
|
case 'D': size = S7Size.DWord; offsetStart = 1; break;
|
||||||
|
default:
|
||||||
|
// No size prefix => bit-level address requires explicit .bit. Size stays Bit;
|
||||||
|
// ParseOffsetAndOptionalBit will demand the dot.
|
||||||
|
size = S7Size.Bit;
|
||||||
|
offsetStart = 0;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
var (byteOffset, bitOffset) = ParseOffsetAndOptionalBit(rest, offsetStart, size, original);
|
||||||
|
return new S7ParsedAddress(area, DbNumber: 0, size, byteOffset, bitOffset);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static S7ParsedAddress ParseTimerOrCounter(S7Area area, string rest, string original)
|
||||||
|
{
|
||||||
|
if (rest.Length == 0)
|
||||||
|
throw new FormatException($"S7 address '{original}' has no {area} number");
|
||||||
|
if (!int.TryParse(rest, out var number) || number < 0)
|
||||||
|
throw new FormatException($"S7 {area} number in '{original}' must be a non-negative integer");
|
||||||
|
return new S7ParsedAddress(area, DbNumber: 0, S7Size.Word, number, BitOffset: 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (int byteOffset, int bitOffset) ParseOffsetAndOptionalBit(
|
||||||
|
string s, int start, S7Size size, string original)
|
||||||
|
{
|
||||||
|
var offsetEnd = start;
|
||||||
|
while (offsetEnd < s.Length && s[offsetEnd] >= '0' && s[offsetEnd] <= '9')
|
||||||
|
offsetEnd++;
|
||||||
|
if (offsetEnd == start)
|
||||||
|
throw new FormatException($"S7 address '{original}' has no byte-offset digits");
|
||||||
|
|
||||||
|
if (!int.TryParse(s.AsSpan(start, offsetEnd - start), out var byteOffset) || byteOffset < 0)
|
||||||
|
throw new FormatException($"S7 byte offset in '{original}' must be non-negative");
|
||||||
|
|
||||||
|
// No bit-suffix: done unless size is Bit with no prefix, which requires one.
|
||||||
|
if (offsetEnd == s.Length)
|
||||||
|
{
|
||||||
|
if (size == S7Size.Bit)
|
||||||
|
throw new FormatException($"S7 address '{original}' needs a .{{bit}} suffix for bit access");
|
||||||
|
return (byteOffset, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (s[offsetEnd] != '.')
|
||||||
|
throw new FormatException($"S7 address '{original}' has unexpected character after offset");
|
||||||
|
|
||||||
|
if (size != S7Size.Bit)
|
||||||
|
throw new FormatException($"S7 address '{original}' has a bit suffix but the size is {size} — bit access needs X (DB) or no size prefix (M/I/Q)");
|
||||||
|
|
||||||
|
if (!int.TryParse(s.AsSpan(offsetEnd + 1), out var bitOffset) || bitOffset is < 0 or > 7)
|
||||||
|
throw new FormatException($"S7 bit offset in '{original}' must be 0-7");
|
||||||
|
|
||||||
|
return (byteOffset, bitOffset);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,90 @@
|
|||||||
|
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.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,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,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>
|
||||||
119
tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7AddressParserTests.cs
Normal file
119
tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7AddressParserTests.cs
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
|
||||||
|
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class S7AddressParserTests
|
||||||
|
{
|
||||||
|
// --- Data blocks ---
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("DB1.DBX0.0", 1, S7Size.Bit, 0, 0)]
|
||||||
|
[InlineData("DB1.DBX0.7", 1, S7Size.Bit, 0, 7)]
|
||||||
|
[InlineData("DB1.DBB0", 1, S7Size.Byte, 0, 0)]
|
||||||
|
[InlineData("DB1.DBW0", 1, S7Size.Word, 0, 0)]
|
||||||
|
[InlineData("DB1.DBD4", 1, S7Size.DWord, 4, 0)]
|
||||||
|
[InlineData("DB10.DBW100", 10, S7Size.Word, 100, 0)]
|
||||||
|
[InlineData("DB1.DBX15.3", 1, S7Size.Bit, 15, 3)]
|
||||||
|
public void Parse_data_block_addresses(string input, int db, S7Size size, int byteOff, int bitOff)
|
||||||
|
{
|
||||||
|
var r = S7AddressParser.Parse(input);
|
||||||
|
r.Area.ShouldBe(S7Area.DataBlock);
|
||||||
|
r.DbNumber.ShouldBe(db);
|
||||||
|
r.Size.ShouldBe(size);
|
||||||
|
r.ByteOffset.ShouldBe(byteOff);
|
||||||
|
r.BitOffset.ShouldBe(bitOff);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("db1.dbw0", 1, S7Size.Word, 0)]
|
||||||
|
[InlineData(" DB1.DBW0 ", 1, S7Size.Word, 0)] // trim whitespace
|
||||||
|
public void Parse_is_case_insensitive_and_trims(string input, int db, S7Size size, int off)
|
||||||
|
{
|
||||||
|
var r = S7AddressParser.Parse(input);
|
||||||
|
r.Area.ShouldBe(S7Area.DataBlock);
|
||||||
|
r.DbNumber.ShouldBe(db);
|
||||||
|
r.Size.ShouldBe(size);
|
||||||
|
r.ByteOffset.ShouldBe(off);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- M / I / Q ---
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("MB0", S7Area.Memory, S7Size.Byte, 0, 0)]
|
||||||
|
[InlineData("MW10", S7Area.Memory, S7Size.Word, 10, 0)]
|
||||||
|
[InlineData("MD4", S7Area.Memory, S7Size.DWord, 4, 0)]
|
||||||
|
[InlineData("M0.0", S7Area.Memory, S7Size.Bit, 0, 0)]
|
||||||
|
[InlineData("M255.7", S7Area.Memory, S7Size.Bit, 255, 7)]
|
||||||
|
[InlineData("IB0", S7Area.Input, S7Size.Byte, 0, 0)]
|
||||||
|
[InlineData("IW0", S7Area.Input, S7Size.Word, 0, 0)]
|
||||||
|
[InlineData("I0.0", S7Area.Input, S7Size.Bit, 0, 0)]
|
||||||
|
[InlineData("QB0", S7Area.Output, S7Size.Byte, 0, 0)]
|
||||||
|
[InlineData("QW0", S7Area.Output, S7Size.Word, 0, 0)]
|
||||||
|
[InlineData("Q0.0", S7Area.Output, S7Size.Bit, 0, 0)]
|
||||||
|
[InlineData("QD4", S7Area.Output, S7Size.DWord, 4, 0)]
|
||||||
|
public void Parse_MIQ_addresses(string input, S7Area area, S7Size size, int byteOff, int bitOff)
|
||||||
|
{
|
||||||
|
var r = S7AddressParser.Parse(input);
|
||||||
|
r.Area.ShouldBe(area);
|
||||||
|
r.DbNumber.ShouldBe(0);
|
||||||
|
r.Size.ShouldBe(size);
|
||||||
|
r.ByteOffset.ShouldBe(byteOff);
|
||||||
|
r.BitOffset.ShouldBe(bitOff);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Timers / counters ---
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("T0", S7Area.Timer, 0)]
|
||||||
|
[InlineData("T15", S7Area.Timer, 15)]
|
||||||
|
[InlineData("C0", S7Area.Counter, 0)]
|
||||||
|
[InlineData("C10", S7Area.Counter, 10)]
|
||||||
|
public void Parse_timer_and_counter(string input, S7Area area, int number)
|
||||||
|
{
|
||||||
|
var r = S7AddressParser.Parse(input);
|
||||||
|
r.Area.ShouldBe(area);
|
||||||
|
r.ByteOffset.ShouldBe(number);
|
||||||
|
r.Size.ShouldBe(S7Size.Word, "timers + counters are 16-bit opaque");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Reject garbage ---
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData(" ")]
|
||||||
|
[InlineData("Z0")] // unknown area
|
||||||
|
[InlineData("DB")] // no number or tail
|
||||||
|
[InlineData("DB1")] // no tail
|
||||||
|
[InlineData("DB1.")] // empty tail
|
||||||
|
[InlineData("DB1.DBX0")] // bit size without .bit
|
||||||
|
[InlineData("DB1.DBX0.8")] // bit 8 out of range
|
||||||
|
[InlineData("DB1.DBW0.0")] // word with bit suffix
|
||||||
|
[InlineData("DB0.DBW0")] // db 0 invalid
|
||||||
|
[InlineData("DBA.DBW0")] // non-numeric db
|
||||||
|
[InlineData("DB1.DBQ0")] // invalid size letter
|
||||||
|
[InlineData("M")] // no offset
|
||||||
|
[InlineData("M0")] // bit access needs .bit
|
||||||
|
[InlineData("M0.8")] // bit 8
|
||||||
|
[InlineData("MB-1")] // negative offset
|
||||||
|
[InlineData("MW")] // no offset digits
|
||||||
|
[InlineData("TA")] // non-numeric timer
|
||||||
|
public void Parse_rejects_invalid(string bad)
|
||||||
|
=> Should.Throw<FormatException>(() => S7AddressParser.Parse(bad));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryParse_returns_false_for_garbage_without_throwing()
|
||||||
|
{
|
||||||
|
S7AddressParser.TryParse("not-an-address", out var r).ShouldBeFalse();
|
||||||
|
r.ShouldBe(default);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryParse_returns_true_for_valid_address()
|
||||||
|
{
|
||||||
|
S7AddressParser.TryParse("DB1.DBW0", out var r).ShouldBeTrue();
|
||||||
|
r.DbNumber.ShouldBe(1);
|
||||||
|
r.Size.ShouldBe(S7Size.Word);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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