From 0575280a3b090359d7d941a775935b4bc7d49151 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 19 Apr 2026 00:03:09 -0400 Subject: [PATCH] Phase 3 PR 62 -- Siemens S7 native driver project scaffold (S7comm via S7netplus). First non-Modbus in-process driver. Creates src/ZB.MOM.WW.OtOpcUa.Driver.S7 (.NET 10, x64 -- S7netplus is managed, no bitness constraint like MXAccess) + tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests + slnx entries. Depends on S7netplus 0.20.0 which is the latest version on NuGet resolvable in this cache (0.21.0 per driver-specs.md is not yet published; 0.20.0 covers the same Plc+CpuType+ReadAsync surface). S7DriverOptions captures the connection settings documented in driver-specs.md \u00A75: Host, Port (default 102 ISO-on-TCP), CpuType (default S71500 per most-common deployment), Rack=0, Slot=0 (S7-1200/1500 onboard PN convention; S7-300/400 operators must override to slot 2 or 3), Timeout=5s, Tags list + Probe settings with default MW0 probe address. S7TagDefinition uses S7.Net-style address strings (DB1.DBW0, M0.0, I0.0, QD4) with an S7DataType enum (Bool, Byte, Int16, UInt16, Int32, UInt32, Int64, UInt64, Float32, Float64, String, DateTime -- the full type matrix from the spec); StringLength defaults to 254 (S7 STRING max). S7Driver implements the IDriver-only subset per the PR plan: InitializeAsync opens a managed Plc with the configured CpuType + Host + Rack + Slot, pins WriteTimeout / ReadTimeout on the underlying TcpClient, awaits Plc.OpenAsync with a linked CTS bounded by Options.Timeout so the ISO handshake itself respects the configured bound; health transitions Unknown -> Initializing -> Healthy on success or Unknown -> Initializing -> Faulted on handshake failure, with a best-effort Plc.Close() on the faulted path so retries don't leak the TcpClient. ShutdownAsync closes the Plc and flips health back to Unknown. DisposeAsync routes through ShutdownAsync + disposes the SemaphoreSlim. Internal Gate + Plc accessors are exposed to the test project (InternalsVisibleTo) so PRs 63-65 can stack read/write/subscribe on the same serialization semaphore per the S7netplus documented 'one Plc per PLC, SemaphoreSlim-serialized' pattern. ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe are all deliberately omitted from this PR -- they depend on the S7AddressParser (PR 63) and land sequenced in PRs 64-65. Unit tests (S7DriverScaffoldTests, 5 facts): default options target S7-1500 / port 102 / slot 0, default probe interval 5s, tag defaults to writable with StringLength 254, driver reports DriverType=S7 + Unknown health pre-init, Initialize against RFC-5737 reserved IP 192.0.2.1 with 250ms timeout transitions to Faulted and throws (tests the connect-failure path doesn't leave the driver in an ambiguous state). 5/5 pass. dotnet build ZB.MOM.WW.OtOpcUa.slnx: 0 errors. No regression in Modbus / Galaxy suites. PR 63 ships S7AddressParser next, PR 64 wires IReadable/IWritable over S7netplus, PR 65 adds discovery + polling-overlay subscribe + probe. --- ZB.MOM.WW.OtOpcUa.slnx | 2 + src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs | 119 ++++++++++++++++++ .../S7DriverOptions.cs | 112 +++++++++++++++++ .../ZB.MOM.WW.OtOpcUa.Driver.S7.csproj | 27 ++++ .../S7DriverScaffoldTests.cs | 66 ++++++++++ .../ZB.MOM.WW.OtOpcUa.Driver.S7.Tests.csproj | 31 +++++ 6 files changed, 357 insertions(+) create mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs create mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverOptions.cs create mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.S7/ZB.MOM.WW.OtOpcUa.Driver.S7.csproj create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverScaffoldTests.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests.csproj diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx index e562c0a..97f2481 100644 --- a/ZB.MOM.WW.OtOpcUa.slnx +++ b/ZB.MOM.WW.OtOpcUa.slnx @@ -9,6 +9,7 @@ + @@ -26,6 +27,7 @@ + diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs new file mode 100644 index 0000000..393f7ca --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs @@ -0,0 +1,119 @@ +using S7.Net; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.S7; + +/// +/// Siemens S7 native driver — speaks S7comm over ISO-on-TCP (port 102) via the S7netplus +/// library. First implementation of for an in-process .NET Standard +/// PLC protocol that is NOT Modbus, validating that the v2 driver-capability interfaces +/// generalize beyond Modbus + Galaxy. +/// +/// +/// +/// PR 62 ships the scaffold: only (Initialize / Reinitialize / +/// Shutdown / GetHealth). , , +/// , , +/// land in PRs 63-65 once the address parser (PR 63) is in place. +/// +/// +/// Single-connection policy: S7netplus documented pattern is one +/// Plc instance per PLC, serialized with a . +/// Parallelising reads against a single S7 CPU doesn't help — the CPU scans the +/// communication mailbox at most once per cycle (2-10 ms) and queues concurrent +/// requests wire-side anyway. Multiple client-side connections just waste the CPU's +/// 8-64 connection-resource budget. +/// +/// +public sealed class S7Driver(S7DriverOptions options, string driverInstanceId) + : IDriver, IDisposable, IAsyncDisposable +{ + private readonly S7DriverOptions _options = options; + private readonly SemaphoreSlim _gate = new(1, 1); + + /// + /// Per-connection gate. Internal so PRs 63-65 (read/write/subscribe) can serialize on + /// the same semaphore without exposing it publicly. Single-connection-per-PLC is a + /// hard requirement of S7netplus — see class remarks. + /// + internal SemaphoreSlim Gate => _gate; + + /// + /// Active S7.Net PLC connection. Null until returns; null + /// after . Read-only outside this class; PR 64's Read/Write + /// will take the before touching it. + /// + internal Plc? Plc { get; private set; } + + private DriverHealth _health = new(DriverState.Unknown, null, null); + private bool _disposed; + + public string DriverInstanceId => driverInstanceId; + public string DriverType => "S7"; + + public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) + { + _health = new DriverHealth(DriverState.Initializing, null, null); + try + { + var plc = new Plc(_options.CpuType, _options.Host, _options.Rack, _options.Slot); + // S7netplus writes timeouts into the underlying TcpClient via Plc.WriteTimeout / + // Plc.ReadTimeout (milliseconds). Set before OpenAsync so the handshake itself + // honours the bound. + plc.WriteTimeout = (int)_options.Timeout.TotalMilliseconds; + plc.ReadTimeout = (int)_options.Timeout.TotalMilliseconds; + + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(_options.Timeout); + await plc.OpenAsync(cts.Token).ConfigureAwait(false); + + Plc = plc; + _health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null); + } + catch (Exception ex) + { + // Clean up a partially-constructed Plc so a retry from the caller doesn't leak + // the TcpClient. S7netplus's Close() is best-effort and idempotent. + try { Plc?.Close(); } catch { } + Plc = null; + _health = new DriverHealth(DriverState.Faulted, null, ex.Message); + throw; + } + } + + public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) + { + await ShutdownAsync(cancellationToken).ConfigureAwait(false); + await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false); + } + + public Task ShutdownAsync(CancellationToken cancellationToken) + { + try { Plc?.Close(); } catch { /* best-effort — tearing down anyway */ } + Plc = null; + _health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null); + return Task.CompletedTask; + } + + public DriverHealth GetHealth() => _health; + + /// + /// Approximate memory footprint. The Plc instance + one 240-960 byte PDU buffer is + /// under 4 KB; return 0 because the contract asks for a + /// driver-attributable growth number and S7.Net doesn't expose one. + /// + public long GetMemoryFootprint() => 0; + + public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + 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(); + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverOptions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverOptions.cs new file mode 100644 index 0000000..8f0e4ca --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverOptions.cs @@ -0,0 +1,112 @@ +using S7NetCpuType = global::S7.Net.CpuType; + +namespace ZB.MOM.WW.OtOpcUa.Driver.S7; + +/// +/// Siemens S7 native (S7comm / ISO-on-TCP port 102) driver configuration. Bound from the +/// driver's DriverConfig JSON at DriverHost.RegisterAsync. Unlike the Modbus +/// driver the S7 driver uses the PLC's *native* protocol — port 102 ISO-on-TCP rather +/// than Modbus's 502, and S7-specific area codes (DB, M, I, Q) rather than holding- +/// register / coil tables. +/// +/// +/// +/// The driver requires PUT/GET communication enabled in the TIA Portal +/// hardware config for S7-1200/1500. The factory default disables PUT/GET access, +/// so a driver configured against a freshly-flashed CPU will see a hard error +/// (S7.Net surfaces it as Plc.ReadAsync returning ErrorCode.Accessing). +/// The driver maps that specifically to BadNotSupported and flags it as a +/// configuration alert rather than a transient fault — blind Polly retry is wasted +/// effort when the PLC will keep refusing every request. +/// +/// +/// See docs/v2/driver-specs.md §5 for the full specification. +/// +/// +public sealed class S7DriverOptions +{ + /// PLC IP address or hostname. + public string Host { get; init; } = "127.0.0.1"; + + /// TCP port. ISO-on-TCP is 102 on every S7 model; override only for unusual NAT setups. + public int Port { get; init; } = 102; + + /// + /// CPU family. Determines the ISO-TSAP slot byte that S7.Net uses during connection + /// setup — pick the family that matches the target PLC exactly. + /// + public S7NetCpuType CpuType { get; init; } = S7NetCpuType.S71500; + + /// + /// Hardware rack number. Almost always 0; relevant only for distributed S7-400 racks + /// with multiple CPUs. + /// + public short Rack { get; init; } = 0; + + /// + /// CPU slot. Conventions per family: S7-300 = slot 2, S7-400 = slot 2 or 3, + /// S7-1200 / S7-1500 = slot 0 (onboard PN). S7.Net uses this to build the remote + /// TSAP. Wrong slot → connection refused during handshake. + /// + public short Slot { get; init; } = 0; + + /// Connect + per-operation timeout. + public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(5); + + /// Pre-declared tag map. S7 has a symbol-table protocol but S7.Net does not expose it, so the driver operates off a static tag list configured per-site. Address grammar documented in S7AddressParser (PR 63). + public IReadOnlyList Tags { get; init; } = []; + + /// + /// Background connectivity-probe settings. When enabled, the driver runs a tick loop + /// that issues a cheap read against every + /// and raises OnHostStatusChanged on + /// Running ↔ Stopped transitions. + /// + public S7ProbeOptions Probe { get; init; } = new(); +} + +public sealed class S7ProbeOptions +{ + public bool Enabled { get; init; } = true; + public TimeSpan Interval { get; init; } = TimeSpan.FromSeconds(5); + public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2); + + /// + /// Address to probe for liveness. DB1.DBW0 is the convention if the PLC project + /// reserves a small fingerprint DB for health checks (per docs/v2/s7.md); + /// if not, pick any valid Merker word like MW0. + /// + public string ProbeAddress { get; init; } = "MW0"; +} + +/// +/// One S7 variable as exposed by the driver. Addresses use S7.Net syntax — see +/// S7AddressParser (PR 63) for the grammar. +/// +/// Tag name; OPC UA browse name + driver full reference. +/// S7 address string, e.g. DB1.DBW0, M0.0, I0.0, QD4. Grammar documented in S7AddressParser (PR 63). +/// Logical data type — drives the underlying S7.Net read/write width. +/// When true the driver accepts writes for this tag. +/// For DataType = String: S7-string max length. Default 254 (S7 max). +public sealed record S7TagDefinition( + string Name, + string Address, + S7DataType DataType, + bool Writable = true, + int StringLength = 254); + +public enum S7DataType +{ + Bool, + Byte, + Int16, + UInt16, + Int32, + UInt32, + Int64, + UInt64, + Float32, + Float64, + String, + DateTime, +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.S7/ZB.MOM.WW.OtOpcUa.Driver.S7.csproj b/src/ZB.MOM.WW.OtOpcUa.Driver.S7/ZB.MOM.WW.OtOpcUa.Driver.S7.csproj new file mode 100644 index 0000000..e1459d3 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.S7/ZB.MOM.WW.OtOpcUa.Driver.S7.csproj @@ -0,0 +1,27 @@ + + + + net10.0 + enable + enable + latest + true + true + $(NoWarn);CS1591 + ZB.MOM.WW.OtOpcUa.Driver.S7 + ZB.MOM.WW.OtOpcUa.Driver.S7 + + + + + + + + + + + + + + + diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverScaffoldTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverScaffoldTests.cs new file mode 100644 index 0000000..5122f66 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverScaffoldTests.cs @@ -0,0 +1,66 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests; + +/// +/// Scaffold-level tests that don't need a live S7 PLC — exercise driver lifecycle shape, +/// default option values, and failure-mode transitions. PR 64 adds IReadable/IWritable +/// tests against a mock-server, PR 65 adds discovery + subscribe. +/// +[Trait("Category", "Unit")] +public sealed class S7DriverScaffoldTests +{ + [Fact] + public void Default_options_target_S7_1500_slot_0_on_port_102() + { + var opts = new S7DriverOptions(); + opts.Port.ShouldBe(102, "ISO-on-TCP is always 102 for S7; documented in driver-specs.md §5"); + opts.CpuType.ShouldBe(global::S7.Net.CpuType.S71500); + opts.Rack.ShouldBe((short)0); + opts.Slot.ShouldBe((short)0, "S7-1200/1500 onboard PN ports are slot 0 by convention"); + } + + [Fact] + public void Default_probe_interval_is_reasonable_for_S7_scan_cycle() + { + // S7 PLCs scan 2-10 ms but comms mailbox typically processed once per scan. + // 5 s default probe is lightweight — ~0.001% of comms budget. + new S7ProbeOptions().Interval.ShouldBe(TimeSpan.FromSeconds(5)); + } + + [Fact] + public void Tag_definition_defaults_to_writable_with_S7_max_string_length() + { + var tag = new S7TagDefinition("T", "DB1.DBW0", S7DataType.Int16); + tag.Writable.ShouldBeTrue(); + tag.StringLength.ShouldBe(254, "S7 STRING type max length is 254 chars"); + } + + [Fact] + public void Driver_instance_reports_type_and_id_before_connect() + { + var opts = new S7DriverOptions { Host = "127.0.0.1" }; + using var drv = new S7Driver(opts, "s7-test"); + drv.DriverType.ShouldBe("S7"); + drv.DriverInstanceId.ShouldBe("s7-test"); + drv.GetHealth().State.ShouldBe(DriverState.Unknown, "health starts Unknown until InitializeAsync runs"); + } + + [Fact] + public async Task Initialize_against_unreachable_host_transitions_to_Faulted_and_throws() + { + // Pick an RFC 5737 reserved-for-documentation IP so the connect attempt fails fast + // (no DNS mismatch, no accidental traffic to a real PLC). + var opts = new S7DriverOptions { Host = "192.0.2.1", Timeout = TimeSpan.FromMilliseconds(250) }; + using var drv = new S7Driver(opts, "s7-unreach"); + + await Should.ThrowAsync(async () => + await drv.InitializeAsync("{}", TestContext.Current.CancellationToken)); + + var health = drv.GetHealth(); + health.State.ShouldBe(DriverState.Faulted, "unreachable host must flip the driver to Faulted so operators see it"); + health.LastError.ShouldNotBeNull(); + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests.csproj b/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests.csproj new file mode 100644 index 0000000..ac8877e --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests.csproj @@ -0,0 +1,31 @@ + + + + net10.0 + enable + enable + false + true + ZB.MOM.WW.OtOpcUa.Driver.S7.Tests + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + +