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
+
+
+
+
+
+
+
+
+
+
+
+
+