chore: organize solution into module folders (Core/Server/Drivers/Client/Tooling)

Group all 69 projects into category subfolders under src/ and tests/ so the
Rider Solution Explorer mirrors the module structure. Folders: Core, Server,
Drivers (with a nested Driver CLIs subfolder), Client, Tooling.

- Move every project folder on disk with git mv (history preserved as renames).
- Recompute relative paths in 57 .csproj files: cross-category ProjectReferences,
  the lib/ HintPath+None refs in Driver.Historian.Wonderware, and the external
  mxaccessgw refs in Driver.Galaxy and its test project.
- Rebuild ZB.MOM.WW.OtOpcUa.slnx with nested solution folders.
- Re-prefix project paths in functional scripts (e2e, compliance, smoke SQL,
  integration, install).

Build green (0 errors); unit tests pass. Docs left for a separate pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-17 01:55:28 -04:00
parent 69f02fed7f
commit a25593a9c6
1044 changed files with 365 additions and 343 deletions

View 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 &gt;= 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);
}
}

View File

@@ -0,0 +1,514 @@
using S7.Net;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
/// <summary>
/// Siemens S7 native driver — speaks S7comm over ISO-on-TCP (port 102) via the S7netplus
/// library. First implementation of <see cref="IDriver"/> for an in-process .NET Standard
/// PLC protocol that is NOT Modbus, validating that the v2 driver-capability interfaces
/// generalize beyond Modbus + Galaxy.
/// </summary>
/// <remarks>
/// <para>
/// PR 62 ships the scaffold: <see cref="IDriver"/> only (Initialize / Reinitialize /
/// Shutdown / GetHealth). <see cref="ITagDiscovery"/>, <see cref="IReadable"/>,
/// <see cref="IWritable"/>, <see cref="ISubscribable"/>, <see cref="IHostConnectivityProbe"/>
/// land in PRs 63-65 once the address parser (PR 63) is in place.
/// </para>
/// <para>
/// <b>Single-connection policy</b>: S7netplus documented pattern is one
/// <c>Plc</c> instance per PLC, serialized with a <see cref="SemaphoreSlim"/>.
/// 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.
/// </para>
/// </remarks>
public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
: 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 SemaphoreSlim _gate = new(1, 1);
/// <summary>
/// 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.
/// </summary>
internal SemaphoreSlim Gate => _gate;
/// <summary>
/// Active S7.Net PLC connection. Null until <see cref="InitializeAsync"/> returns; null
/// after <see cref="ShutdownAsync"/>. Read-only outside this class; PR 64's Read/Write
/// will take the <see cref="_gate"/> before touching it.
/// </summary>
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.Port, _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;
// 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);
// 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)
{
// 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 { _probeCts?.Cancel(); } catch { }
_probeCts?.Dispose();
_probeCts = null;
foreach (var state in _subscriptions.Values)
{
try { state.Cts.Cancel(); } catch { }
state.Cts.Dispose();
}
_subscriptions.Clear();
try { Plc?.Close(); } catch { /* best-effort — tearing down anyway */ }
Plc = null;
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
return Task.CompletedTask;
}
public DriverHealth GetHealth() => _health;
/// <summary>
/// Approximate memory footprint. The Plc instance + one 240-960 byte PDU buffer is
/// under 4 KB; return 0 because the <see cref="IDriver"/> contract asks for a
/// driver-attributable growth number and S7.Net doesn't expose one.
/// </summary>
public long GetMemoryFootprint() => 0;
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,
WriteIdempotent: t.WriteIdempotent));
}
return Task.CompletedTask;
}
private static DriverDataType MapDataType(S7DataType t) => t switch
{
S7DataType.Bool => DriverDataType.Boolean,
S7DataType.Byte => DriverDataType.Int32, // no 8-bit in DriverDataType yet
S7DataType.Int16 or S7DataType.UInt16 or S7DataType.Int32 or S7DataType.UInt32 => DriverDataType.Int32,
S7DataType.Int64 or S7DataType.UInt64 => DriverDataType.Int32, // widens; lossy for >2^31-1
S7DataType.Float32 => DriverDataType.Float32,
S7DataType.Float64 => DriverDataType.Float64,
S7DataType.String => DriverDataType.String,
S7DataType.DateTime => DriverDataType.DateTime,
_ => DriverDataType.Int32,
};
// ---- ISubscribable (polling overlay) ----
public Task<ISubscriptionHandle> SubscribeAsync(
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
{
var id = Interlocked.Increment(ref _nextSubscriptionId);
var cts = new CancellationTokenSource();
// Floor at 100 ms — S7 CPUs scan 2-10 ms but the comms mailbox is processed at most
// once per scan; sub-100 ms polling just queues wire-side with worse latency.
var interval = publishingInterval < TimeSpan.FromMilliseconds(100)
? TimeSpan.FromMilliseconds(100)
: publishingInterval;
var handle = new S7SubscriptionHandle(id);
var state = new SubscriptionState(handle, [.. fullReferences], interval, cts);
_subscriptions[id] = state;
_ = Task.Run(() => PollLoopAsync(state, cts.Token), cts.Token);
return Task.FromResult<ISubscriptionHandle>(handle);
}
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
{
if (handle is S7SubscriptionHandle h && _subscriptions.TryRemove(h.Id, out var state))
{
state.Cts.Cancel();
state.Cts.Dispose();
}
return Task.CompletedTask;
}
private async Task PollLoopAsync(SubscriptionState state, CancellationToken ct)
{
// Initial-data push per OPC UA Part 4 convention.
try { await PollOnceAsync(state, forceRaise: true, ct).ConfigureAwait(false); }
catch (OperationCanceledException) { return; }
catch { /* first-read error — polling continues */ }
while (!ct.IsCancellationRequested)
{
try { await Task.Delay(state.Interval, ct).ConfigureAwait(false); }
catch (OperationCanceledException) { return; }
try { await PollOnceAsync(state, forceRaise: false, ct).ConfigureAwait(false); }
catch (OperationCanceledException) { return; }
catch { /* transient polling error — loop continues, health surface reflects it */ }
}
}
private async Task PollOnceAsync(SubscriptionState state, bool forceRaise, CancellationToken ct)
{
var snapshots = await ReadAsync(state.TagReferences, ct).ConfigureAwait(false);
for (var i = 0; i < state.TagReferences.Count; i++)
{
var tagRef = state.TagReferences[i];
var current = snapshots[i];
var lastSeen = state.LastValues.TryGetValue(tagRef, out var prev) ? prev : default;
if (forceRaise || !Equals(lastSeen?.Value, current.Value) || lastSeen?.StatusCode != current.StatusCode)
{
state.LastValues[tagRef] = current;
OnDataChange?.Invoke(this, new DataChangeEventArgs(state.Handle, tagRef, current));
}
}
}
private sealed record SubscriptionState(
S7SubscriptionHandle Handle,
IReadOnlyList<string> TagReferences,
TimeSpan Interval,
CancellationTokenSource Cts)
{
public System.Collections.Concurrent.ConcurrentDictionary<string, DataValueSnapshot> LastValues { get; }
= new(StringComparer.OrdinalIgnoreCase);
}
private sealed record S7SubscriptionHandle(long Id) : ISubscriptionHandle
{
public string DiagnosticId => $"s7-sub-{Id}";
}
// ---- IHostConnectivityProbe ----
/// <summary>
/// Host identifier surfaced in <see cref="GetHostStatuses"/>. <c>host:port</c> format
/// matches the Modbus driver's convention so the Admin UI dashboard renders both
/// family's rows uniformly.
/// </summary>
public string HostName => $"{_options.Host}:{_options.Port}";
public IReadOnlyList<HostConnectivityStatus> GetHostStatuses()
{
lock (_probeLock)
return [new HostConnectivityStatus(HostName, _hostState, _hostStateChangedUtc)];
}
private async Task ProbeLoopAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
var success = false;
try
{
// Probe via S7.Net's low-cost GetCpuStatus — returns the CPU state (Run/Stop)
// and is intentionally light on the comms mailbox. Single-word Plc.ReadAsync
// would also work but GetCpuStatus doubles as a "PLC actually up" check.
using var probeCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
probeCts.CancelAfter(_options.Probe.Timeout);
var plc = Plc;
if (plc is null) throw new InvalidOperationException("Plc dropped during probe");
await _gate.WaitAsync(probeCts.Token).ConfigureAwait(false);
try
{
_ = await plc.ReadStatusAsync(probeCts.Token).ConfigureAwait(false);
success = true;
}
finally { _gate.Release(); }
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { return; }
catch { /* transport/timeout/exception — treated as Stopped below */ }
TransitionTo(success ? HostState.Running : HostState.Stopped);
try { await Task.Delay(_options.Probe.Interval, ct).ConfigureAwait(false); }
catch (OperationCanceledException) { return; }
}
}
private void TransitionTo(HostState newState)
{
HostState old;
lock (_probeLock)
{
old = _hostState;
if (old == newState) return;
_hostState = newState;
_hostStateChangedUtc = DateTime.UtcNow;
}
OnHostStatusChanged?.Invoke(this, new HostStatusChangedEventArgs(HostName, old, newState));
}
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
public async ValueTask DisposeAsync()
{
if (_disposed) return;
_disposed = true;
try { await ShutdownAsync(CancellationToken.None).ConfigureAwait(false); }
catch { /* disposal is best-effort */ }
_gate.Dispose();
}
}

View File

@@ -0,0 +1,125 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
using S7NetCpuType = global::S7.Net.CpuType;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
/// <summary>
/// Static factory registration helper for <see cref="S7Driver"/>. Server's Program.cs
/// calls <see cref="Register"/> once at startup; the bootstrapper (task #248) then
/// materialises S7 DriverInstance rows from the central config DB into live driver
/// instances. Mirrors <c>GalaxyProxyDriverFactoryExtensions</c>.
/// </summary>
public static class S7DriverFactoryExtensions
{
public const string DriverTypeName = "S7";
public static void Register(DriverFactoryRegistry registry)
{
ArgumentNullException.ThrowIfNull(registry);
registry.Register(DriverTypeName, CreateInstance);
}
internal static S7Driver CreateInstance(string driverInstanceId, string driverConfigJson)
{
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
var dto = JsonSerializer.Deserialize<S7DriverConfigDto>(driverConfigJson, JsonOptions)
?? throw new InvalidOperationException(
$"S7 driver config for '{driverInstanceId}' deserialised to null");
if (string.IsNullOrWhiteSpace(dto.Host))
throw new InvalidOperationException(
$"S7 driver config for '{driverInstanceId}' missing required Host");
var options = new S7DriverOptions
{
Host = dto.Host!,
Port = dto.Port ?? 102,
CpuType = ParseEnum<S7NetCpuType>(dto.CpuType, driverInstanceId, "CpuType",
fallback: S7NetCpuType.S71500),
Rack = dto.Rack ?? 0,
Slot = dto.Slot ?? 0,
Timeout = TimeSpan.FromMilliseconds(dto.TimeoutMs ?? 5_000),
Tags = dto.Tags is { Count: > 0 }
? [.. dto.Tags.Select(t => BuildTag(t, driverInstanceId))]
: [],
Probe = new S7ProbeOptions
{
Enabled = dto.Probe?.Enabled ?? true,
Interval = TimeSpan.FromMilliseconds(dto.Probe?.IntervalMs ?? 5_000),
Timeout = TimeSpan.FromMilliseconds(dto.Probe?.TimeoutMs ?? 2_000),
ProbeAddress = dto.Probe?.ProbeAddress ?? "MW0",
},
};
return new S7Driver(options, driverInstanceId);
}
private static S7TagDefinition BuildTag(S7TagDto t, string driverInstanceId) =>
new(
Name: t.Name ?? throw new InvalidOperationException(
$"S7 config for '{driverInstanceId}' has a tag missing Name"),
Address: t.Address ?? throw new InvalidOperationException(
$"S7 tag '{t.Name}' in '{driverInstanceId}' missing Address"),
DataType: ParseEnum<S7DataType>(t.DataType, driverInstanceId, "DataType",
tagName: t.Name),
Writable: t.Writable ?? true,
StringLength: t.StringLength ?? 254,
WriteIdempotent: t.WriteIdempotent ?? false);
private static T ParseEnum<T>(string? raw, string driverInstanceId, string field,
string? tagName = null, T? fallback = null) where T : struct, Enum
{
if (string.IsNullOrWhiteSpace(raw))
{
if (fallback.HasValue) return fallback.Value;
throw new InvalidOperationException(
$"S7 tag '{tagName ?? "<unnamed>"}' in '{driverInstanceId}' missing {field}");
}
return Enum.TryParse<T>(raw, ignoreCase: true, out var v)
? v
: throw new InvalidOperationException(
$"S7 {(tagName is null ? "config" : $"tag '{tagName}'")} has unknown {field} '{raw}'. " +
$"Expected one of {string.Join(", ", Enum.GetNames<T>())}");
}
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
};
internal sealed class S7DriverConfigDto
{
public string? Host { get; init; }
public int? Port { get; init; }
public string? CpuType { get; init; }
public short? Rack { get; init; }
public short? Slot { get; init; }
public int? TimeoutMs { get; init; }
public List<S7TagDto>? Tags { get; init; }
public S7ProbeDto? Probe { get; init; }
}
internal sealed class S7TagDto
{
public string? Name { get; init; }
public string? Address { get; init; }
public string? DataType { get; init; }
public bool? Writable { get; init; }
public int? StringLength { get; init; }
public bool? WriteIdempotent { get; init; }
}
internal sealed class S7ProbeDto
{
public bool? Enabled { get; init; }
public int? IntervalMs { get; init; }
public int? TimeoutMs { get; init; }
public string? ProbeAddress { get; init; }
}
}

View File

@@ -0,0 +1,120 @@
using S7NetCpuType = global::S7.Net.CpuType;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
/// <summary>
/// Siemens S7 native (S7comm / ISO-on-TCP port 102) driver configuration. Bound from the
/// driver's <c>DriverConfig</c> JSON at <c>DriverHost.RegisterAsync</c>. 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.
/// </summary>
/// <remarks>
/// <para>
/// The driver requires <b>PUT/GET communication enabled</b> 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 <c>Plc.ReadAsync</c> returning <c>ErrorCode.Accessing</c>).
/// The driver maps that specifically to <c>BadNotSupported</c> 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.
/// </para>
/// <para>
/// See <c>docs/v2/driver-specs.md</c> §5 for the full specification.
/// </para>
/// </remarks>
public sealed class S7DriverOptions
{
/// <summary>PLC IP address or hostname.</summary>
public string Host { get; init; } = "127.0.0.1";
/// <summary>TCP port. ISO-on-TCP is 102 on every S7 model; override only for unusual NAT setups.</summary>
public int Port { get; init; } = 102;
/// <summary>
/// CPU family. Determines the ISO-TSAP slot byte that S7.Net uses during connection
/// setup — pick the family that matches the target PLC exactly.
/// </summary>
public S7NetCpuType CpuType { get; init; } = S7NetCpuType.S71500;
/// <summary>
/// Hardware rack number. Almost always 0; relevant only for distributed S7-400 racks
/// with multiple CPUs.
/// </summary>
public short Rack { get; init; } = 0;
/// <summary>
/// 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.
/// </summary>
public short Slot { get; init; } = 0;
/// <summary>Connect + per-operation timeout.</summary>
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(5);
/// <summary>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).</summary>
public IReadOnlyList<S7TagDefinition> Tags { get; init; } = [];
/// <summary>
/// Background connectivity-probe settings. When enabled, the driver runs a tick loop
/// that issues a cheap read against <see cref="S7ProbeOptions.ProbeAddress"/> every
/// <see cref="S7ProbeOptions.Interval"/> and raises <c>OnHostStatusChanged</c> on
/// Running ↔ Stopped transitions.
/// </summary>
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);
/// <summary>
/// Address to probe for liveness. DB1.DBW0 is the convention if the PLC project
/// reserves a small fingerprint DB for health checks (per <c>docs/v2/s7.md</c>);
/// if not, pick any valid Merker word like <c>MW0</c>.
/// </summary>
public string ProbeAddress { get; init; } = "MW0";
}
/// <summary>
/// One S7 variable as exposed by the driver. Addresses use S7.Net syntax — see
/// <c>S7AddressParser</c> (PR 63) for the grammar.
/// </summary>
/// <param name="Name">Tag name; OPC UA browse name + driver full reference.</param>
/// <param name="Address">S7 address string, e.g. <c>DB1.DBW0</c>, <c>M0.0</c>, <c>I0.0</c>, <c>QD4</c>. Grammar documented in <c>S7AddressParser</c> (PR 63).</param>
/// <param name="DataType">Logical data type — drives the underlying S7.Net read/write width.</param>
/// <param name="Writable">When true the driver accepts writes for this tag.</param>
/// <param name="StringLength">For <c>DataType = String</c>: S7-string max length. Default 254 (S7 max).</param>
/// <param name="WriteIdempotent">
/// Per <c>docs/v2/plan.md</c> decisions #44, #45, #143 — flag a tag as safe to replay on
/// write timeout / failure. Default <c>false</c>; writes do not auto-retry. Safe candidates
/// on S7: DB word/dword set-points holding analog values, configuration DBs where the same
/// value can be written again without side-effects. Unsafe: M (merker) bits or Q (output)
/// coils that drive edge-triggered routines in the PLC program.
/// </param>
public sealed record S7TagDefinition(
string Name,
string Address,
S7DataType DataType,
bool Writable = true,
int StringLength = 254,
bool WriteIdempotent = false);
public enum S7DataType
{
Bool,
Byte,
Int16,
UInt16,
Int32,
UInt32,
Int64,
UInt64,
Float32,
Float64,
String,
DateTime,
}

View File

@@ -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.S7</RootNamespace>
<AssemblyName>ZB.MOM.WW.OtOpcUa.Driver.S7</AssemblyName>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
</ItemGroup>
<ItemGroup>
<PackageReference Include="S7netplus" Version="0.20.0"/>
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.S7.Tests"/>
</ItemGroup>
</Project>