feat(s7): STRING/WSTRING/CHAR/WCHAR and DTL/DATE/TOD/TIME/S5TIME codecs, TSAP/connection-type override, negotiated PDU surfaced (#632 #288 #289 #294 #295)

New S7DataType members WString/Char/WChar/Dtl/Date/Tod/Time/S5Time mapped onto existing
DriverDataType (String / DateTime / Int32 ms); writes refuse out-of-range with BadOutOfRange.
Optional localTsap/remoteTsap/connectionType through options, factory JSON, probe and AdminUI form.
NegotiatedPduSize snapshotted at connect and exposed via GetDiagnostics (DriverHealth has no bag).

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 14:31:44 -04:00
parent d898262c4a
commit 34c52defb3
18 changed files with 1430 additions and 31 deletions
+65 -8
View File
@@ -65,6 +65,23 @@ hardware. A wrong slot causes a connection refusal during the handshake. See
`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7DriverOptions.cs` for the
per-field defaults.
### TSAP / connection-type override
By default S7.Net derives both ISO-TSAPs from `CpuType` / `Rack` / `Slot` (local `01.00`; remote
`03.<rack×0x20+slot>` for the S7-300/400/1200/1500 families). Three optional keys override that
for CPUs and gateways that hand out connection resources by TSAP (S7-400H, CP343, LOGO!):
| Key | Form | Effect |
|---|---|---|
| `localTsap` | `"01.00"` (also `"0100"`, `"0x0100"`, or a 16-bit number) | replaces the local TSAP |
| `remoteTsap` | `"03.01"` (same forms) | replaces the whole remote TSAP |
| `connectionType` | `"PG"` \| `"OP"` \| `"S7Basic"` (string) | writes `1` / `2` / `3` into the **first** remote-TSAP byte, keeping `rack×0x20+slot` as the second |
`remoteTsap` and `connectionType` are mutually exclusive (a config carrying both fails to load); a
malformed TSAP fails to load rather than silently falling back to the slot-derived default. With none of
the three set the connection is built exactly as before. The override applies to the driver, the AdminUI
Test Connect probe and the AdminUI S7 driver form (Connection type / Local TSAP / Remote TSAP fields).
## Address forms
Addresses use Siemens TIA-Portal / STEP 7 Classic syntax, parsed by
@@ -102,19 +119,50 @@ extra PLC round-trip.
| `UInt64` | ULINT/LWORD | 8 bytes | yes | yes |
| `Float64` | LREAL | 8 bytes | yes | yes |
| `String` | STRING | `StringLength + 2` bytes | yes | yes |
| `DateTime` | DATE_AND_TIME | 8 bytes | yes | yes |
| `WString` | WSTRING | `4 + 2 × StringLength` bytes | yes | yes |
| `Char` | CHAR | 1 byte | yes | yes |
| `WChar` | WCHAR | 2 bytes | yes | yes |
| `DateTime` | DATE_AND_TIME (DT) | 8 bytes (BCD) | yes | yes |
| `Dtl` | DTL | 12 bytes | yes | yes |
| `Date` | DATE | 2 bytes | yes | yes |
| `Tod` | TIME_OF_DAY | 4 bytes | yes | yes |
| `Time` | TIME | 4 bytes | yes | yes |
| `S5Time` | S5TIME | 2 bytes | yes | yes |
| Timer (`T{n}`) | TIME | — | yes | **read-only** |
| Counter (`C{n}`) | COUNTER | — | yes | **read-only** |
Wide types (`Int64`, `UInt64`, `Float64`, `String`, `DateTime`) and Timer/Counter require
byte-anchored addressing — see the section below.
Wide types (`Int64`, `UInt64`, `Float64`, `String`, `DateTime`, and the string / date-time
types below) and Timer/Counter require byte-anchored addressing — see the section below.
### String, character and date/time/duration types
These add no new OPC UA data types — they map onto existing ones:
| DataType | OPC UA type | Representation | Write refuses (`BadOutOfRange`, nothing sent) |
|---|---|---|---|
| `String` | String | S7 `[maxLen][curLen][chars]`, ASCII | over `StringLength` (S7.Net behaviour) |
| `WString` | String | `[maxLen UInt16][curLen UInt16][UTF-16BE]`; `stringLength` is the declared max in characters, **0–16382** (default 254) | more UTF-16 code units than `StringLength` |
| `Char` | String (1 char) | 1 byte, Latin-1 | a character above U+00FF |
| `WChar` | String (1 char) | UTF-16BE code unit | — (a string whose length is not 1 is `BadTypeMismatch`) |
| `DateTime` | DateTime | DT, 8-byte BCD (S7.Net codec; 1990–2089) | out-of-range year |
| `Dtl` | DateTime | year UInt16 + month/day/weekday/h/m/s + UInt32 nanoseconds; 100 ns resolution | year outside 1970–2262 |
| `Date` | DateTime (midnight) | UInt16 days since 1990-01-01 (max 2169-06-06) | before 1990-01-01 or after 2169-06-06 |
| `Tod` | **Int32 ms** since midnight | UInt32 ms | outside 0–86 399 999 |
| `Time` | **Int32 ms** (signed duration) | Int32 ms | outside the Int32 range |
| `S5Time` | **Int32 ms** | 2-bit time base (10 ms / 100 ms / 1 s / 10 s) + 3 BCD digits | above 9 990 000 ms, negative, or not exactly representable (resolution is 10 ms up to 9 990 ms, then coarser) — **never rounded** |
A write of a `TimeSpan` is also accepted for `Tod` / `Time` / `S5Time`. A PLC buffer the codec cannot
decode — an uninitialised (all-zero) `Dtl`, a non-BCD `S5Time` digit, a `WString` whose current length
exceeds its reserved length — reads `BadOutOfRange` for that tag only, without degrading driver health
or condemning the connection. `S5Time` writes pick the finest time base that holds the value. The
`Timer` area (`T{n}`) keeps its own S5TIME-to-**seconds** (`Float64`) decode.
## Wide types & Timer/Counter
### Byte-anchored addressing for wide / structured types
Wide types (`Int64`, `UInt64`, `Float64`/LReal, `String`, `DateTime`) are
**byte-anchored**: the address must use the `B` suffix pointing at the **first
Wide types (`Int64`, `UInt64`, `Float64`/LReal, `String`, `DateTime`, `WString`, `Char`,
`WChar`, `Dtl`, `Date`, `Tod`, `Time`, `S5Time`) are **byte-anchored**: the address must use the `B` suffix pointing at the **first
byte** of the value; the driver reads the correct number of contiguous bytes
based on the DataType.
@@ -125,6 +173,8 @@ based on the DataType.
| `Float64` | same | 8 | `DB1.DBB8` → LREAL at bytes 8–15 |
| `DateTime` | same | 8 | `DB3.DBB0` → DATE_AND_TIME at bytes 0–7 |
| `String` | same | `StringLength + 2` | `DB4.DBB0`, StringLength=40 → 42 bytes |
| `WString` | same | `4 + 2 × StringLength` | `DB5.DBB0`, StringLength=10 → 24 bytes |
| `Dtl` | same | 12 | `DB6.DBB0` → DTL at bytes 0–11 |
Using a `W` or `D` suffix with a wide DataType is a config error caught at
`InitializeAsync`. For array tags (`isArray: true`), wide-type element arrays
@@ -155,11 +205,18 @@ The following are explicitly deferred and will produce a config error or
`BadNotSupported` if attempted:
- **Wide-type arrays** — array tags (`isArray: true`) with element types `Int64`,
`UInt64`, `Float64`, `String`, or `DateTime`.
- **`S7WString`** (2-byte character strings; distinct from classic `STRING`).
- **`DTL` / `DateTimeLong`** (12-byte Siemens date-and-time-long).
`UInt64`, `Float64`, `String`, `DateTime`, or any of the string / date-time types above.
- **Timer / Counter writes** — surfaced as `BadNotWritable` until write support lands.
## Negotiated PDU size
The PDU size the CPU negotiates during the connection handshake (typically 240 bytes; 480 / 960 with
extended PDU) is snapshotted at every (re)connect and exposed as `S7Driver.NegotiatedPduSize` /
`S7Driver.GetDiagnostics()["S7.NegotiatedPduSize"]` (0 before the first connect and after shutdown). It is
also written to the `S7Driver connected` / `reconnected` log lines and to the AdminUI Test Connect
message (`S7 connected (CPU S71500, PDU 240 bytes)`). `DriverHealth` has no diagnostics bag in this
codebase, so the value is not yet on the `/hosts` health snapshot — adding one is a Core change.
## Capability surface
`S7Driver : IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe`
@@ -88,7 +88,7 @@ public sealed class WriteCommand : S7CommandBase
internal static object ParseValue(string raw, S7DataType type)
{
if (type == S7DataType.Bool) return ParseBool(raw);
if (type == S7DataType.String) return raw;
if (type is S7DataType.String or S7DataType.WString or S7DataType.Char or S7DataType.WChar) return raw;
try
{
return type switch
@@ -102,7 +102,11 @@ public sealed class WriteCommand : S7CommandBase
S7DataType.UInt64 => (object)ulong.Parse(raw, CultureInfo.InvariantCulture),
S7DataType.Float32 => (object)float.Parse(raw, CultureInfo.InvariantCulture),
S7DataType.Float64 => (object)double.Parse(raw, CultureInfo.InvariantCulture),
S7DataType.DateTime => (object)DateTime.Parse(raw, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind),
S7DataType.DateTime or S7DataType.Dtl or S7DataType.Date
=> (object)DateTime.Parse(raw, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind),
// TOD / TIME / S5TIME are Int32 milliseconds on the OPC UA side.
S7DataType.Tod or S7DataType.Time or S7DataType.S5Time
=> (object)long.Parse(raw, CultureInfo.InvariantCulture),
_ => throw new CliFx.Exceptions.CommandException($"Unsupported DataType '{type}' for write."),
};
}
@@ -16,3 +16,20 @@ public enum S7CpuType
S71200 = 30,
S71500 = 40,
}
/// <summary>
/// ISO-TSAP connection resource requested during the handshake — the FIRST byte of the remote TSAP
/// (the second byte is <c>rack × 0x20 + slot</c>). Values are the on-wire bytes. Leave unset to keep
/// S7.Net's per-CPU default (<see cref="S7Basic"/> for the S7-300/400/1200/1500 families).
/// </summary>
public enum S7ConnectionType
{
/// <summary>Programming-device (PG) connection resource.</summary>
PG = 1,
/// <summary>Operator-panel (OP/HMI) connection resource.</summary>
OP = 2,
/// <summary>"S7 basic" / other-communication connection resource — S7.Net's default.</summary>
S7Basic = 3,
}
@@ -51,6 +51,27 @@ public sealed class S7DriverOptions
/// </summary>
public short Slot { get; init; } = 0;
/// <summary>
/// Optional override of the LOCAL ISO-TSAP, as two hex bytes <c>"01.00"</c> (also accepted:
/// <c>"0100"</c>, <c>"0x0100"</c>). Null/blank keeps S7.Net's per-CPU default (<c>01.00</c>).
/// Needed for CPUs / gateways that reserve connection resources by TSAP (S7-400H, CP343, LOGO!).
/// </summary>
public string? LocalTsap { get; init; }
/// <summary>
/// Optional override of the REMOTE ISO-TSAP, same text form as <see cref="LocalTsap"/>
/// (e.g. <c>"03.01"</c> = S7-basic resource, rack 0 slot 1). Null/blank keeps the slot-derived default.
/// Mutually exclusive with <see cref="ConnectionType"/>.
/// </summary>
public string? RemoteTsap { get; init; }
/// <summary>
/// Optional connection resource (PG / OP / S7Basic) written into the first byte of the slot-derived
/// remote TSAP, keeping <c>rack × 0x20 + slot</c> as the second byte. Null keeps the default.
/// Mutually exclusive with <see cref="RemoteTsap"/>.
/// </summary>
public S7ConnectionType? ConnectionType { get; init; }
/// <summary>Connect + per-operation timeout.</summary>
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(5);
@@ -197,5 +218,22 @@ public enum S7DataType
Float32,
Float64,
String,
/// <summary>S7 DATE_AND_TIME (DT, 8-byte BCD) → DateTime. The other date/time members below were appended after it.</summary>
DateTime,
/// <summary>S7 WSTRING (UTF-16BE, 4-byte header) → String. <c>StringLength</c> is the declared max in characters (≤ 16382).</summary>
WString,
/// <summary>S7 CHAR (1 byte, Latin-1) → one-character String.</summary>
Char,
/// <summary>S7 WCHAR (UTF-16BE code unit) → one-character String.</summary>
WChar,
/// <summary>S7 DTL (12 bytes, ns resolution) → DateTime.</summary>
Dtl,
/// <summary>S7 DATE (UInt16 days since 1990-01-01) → DateTime.</summary>
Date,
/// <summary>S7 TIME_OF_DAY (UInt32 ms since midnight) → Int32 milliseconds.</summary>
Tod,
/// <summary>S7 TIME (Int32 ms, signed duration) → Int32 milliseconds.</summary>
Time,
/// <summary>S7 S5TIME (BCD + time base) → Int32 milliseconds.</summary>
S5Time,
}
@@ -20,6 +20,20 @@ public static class S7TagDefinitionFactory
/// <summary>S7 string max length (DBSTRING header reserves 2 bytes; 254 chars max).</summary>
private const int MaxStringLength = 254;
/// <summary>S7 WSTRING max length in characters (S7-1500; the 4-byte header's UInt16 is capped at 16382 so the field fits a DB).</summary>
private const int MaxWStringLength = 16382;
/// <summary>
/// The declared-length ceiling for a type that carries a <c>stringLength</c>, or null for a type that
/// does not (every non-string type ignores the key, so a copy-paste remnant never blocks parsing).
/// </summary>
private static int? MaxLengthFor(S7DataType t) => t switch
{
S7DataType.String => MaxStringLength,
S7DataType.WString => MaxWStringLength,
_ => null,
};
/// <summary>
/// Maps an authored <c>TagConfig</c> object to a typed definition keyed by <paramref name="rawPath"/>.
/// The input is always an authored TagConfig JSON object (there is no name-vs-blob heuristic in v3).
@@ -55,7 +69,7 @@ public static class S7TagDefinitionFactory
// Range-guard applies only to String tags: an S7 string can't exceed 254 chars, and a
// negative length is meaningless. For non-String types stringLength is irrelevant and any
// authored value (including an out-of-range remnant from copy-paste) must not block parsing.
if (dataType == S7DataType.String && (stringLength < 0 || stringLength > MaxStringLength)) return false;
if (MaxLengthFor(dataType) is { } maxLen && (stringLength < 0 || stringLength > maxLen)) return false;
// Array intent: the canonical sink-side parse (DeploymentArtifact.ExtractTagArray)
// honours arrayLength ONLY when isArray is true AND the prop is a JSON number — mirror
// that here so the driver's def agrees byte-for-byte with the materialised OPC UA node's
@@ -230,14 +244,14 @@ public static class S7TagDefinitionFactory
if (TagConfigJson.DescribeInvalidEnum<S7DataType>(root, DataTypeKey) is { } w) warnings.Add(w);
if (TagConfigJson.TryReadEnumStrict(root, DataTypeKey, S7DataType.Int16, out var dataType)
&& dataType == S7DataType.String)
&& MaxLengthFor(dataType) is { } maxLen)
{
var stringLength = ReadInt(root, "stringLength");
if (stringLength < 0 || stringLength > MaxStringLength)
if (stringLength < 0 || stringLength > maxLen)
{
warnings.Add(
$"S7 String tag has a 'stringLength' of {stringLength} — an S7 string is 0–"
+ $"{MaxStringLength} characters, so the tag is rejected (BadNodeIdUnknown).");
$"S7 {dataType} tag has a 'stringLength' of {stringLength} — an S7 {dataType} is 0–"
+ $"{maxLen} characters, so the tag is rejected (BadNodeIdUnknown).");
}
}
}
@@ -29,6 +29,13 @@ public interface IS7Plc : IDisposable
/// <summary>Closes the S7 connection. Best-effort and idempotent.</summary>
void Close();
/// <summary>
/// PDU size negotiated with the CPU during <see cref="OpenAsync"/>, in bytes (typically 240 on S7-300/400
/// and S7-1200/1500 defaults, 480/960 with extended PDU); 0 when unknown / not yet connected. Default
/// interface member so a test fake that predates it keeps compiling.
/// </summary>
int MaxPduSize => 0;
/// <summary>Reads a scalar value by S7 address string (e.g. <c>DB1.DBW0</c>); the boxed type follows the size suffix.</summary>
/// <param name="address">The S7 address string.</param>
/// <param name="cancellationToken">Cancellation token for the read.</param>
@@ -94,6 +101,16 @@ public interface IS7PlcFactory
/// <param name="timeout">Connect + per-operation timeout, applied to the underlying read/write timeouts.</param>
/// <returns>A new, not-yet-opened <see cref="IS7Plc"/>.</returns>
IS7Plc Create(S7CpuType cpuType, string host, int port, short rack, short slot, TimeSpan timeout);
/// <summary>
/// Creates a connection from the full driver options, so an optional TSAP / connection-type override
/// reaches the handshake. The default forwards to the six-argument overload (no override); the
/// real factory honours <see cref="S7DriverOptions.LocalTsap"/> and friends.
/// </summary>
/// <param name="options">The driver options.</param>
/// <returns>A new, not-yet-opened <see cref="IS7Plc"/>.</returns>
IS7Plc Create(S7DriverOptions options) =>
Create(options.CpuType, options.Host, options.Port, options.Rack, options.Slot, options.Timeout);
}
/// <summary>
@@ -114,8 +131,23 @@ internal sealed class S7PlcAdapter : IS7Plc
/// <param name="slot">CPU slot.</param>
/// <param name="timeout">Connect + per-operation timeout.</param>
public S7PlcAdapter(S7CpuType cpuType, string host, int port, short rack, short slot, TimeSpan timeout)
: this(new Plc(S7CpuTypeMap.ToS7Net(cpuType), host, port, rack, slot), timeout)
{
_plc = new Plc(S7CpuTypeMap.ToS7Net(cpuType), host, port, rack, slot);
}
/// <summary>Initializes a new instance with an explicit TSAP pair (<c>LocalTsap</c> / <c>RemoteTsap</c> / <c>ConnectionType</c> override).</summary>
/// <param name="host">PLC IP address or hostname.</param>
/// <param name="port">TCP port.</param>
/// <param name="tsap">The effective TSAP pair.</param>
/// <param name="timeout">Connect + per-operation timeout.</param>
public S7PlcAdapter(string host, int port, global::S7.Net.Protocol.TsapPair tsap, TimeSpan timeout)
: this(new Plc(host, port, tsap), timeout)
{
}
private S7PlcAdapter(Plc plc, TimeSpan timeout)
{
_plc = plc;
_operationTimeout = timeout;
// S7netplus writes timeouts into the underlying TcpClient via ReadTimeout / WriteTimeout
// (milliseconds). Set before OpenAsync so the handshake itself honours the bound — matches
@@ -132,6 +164,9 @@ internal sealed class S7PlcAdapter : IS7Plc
/// <inheritdoc />
public bool IsConnected => _plc.IsConnected;
/// <inheritdoc />
public int MaxPduSize => _plc.MaxPDUSize;
/// <inheritdoc />
public Task OpenAsync(CancellationToken cancellationToken) => _plc.OpenAsync(cancellationToken);
@@ -187,4 +222,10 @@ internal sealed class S7PlcFactory : IS7PlcFactory
/// <inheritdoc />
public IS7Plc Create(S7CpuType cpuType, string host, int port, short rack, short slot, TimeSpan timeout) =>
new S7PlcAdapter(cpuType, host, port, rack, slot, timeout);
/// <inheritdoc />
public IS7Plc Create(S7DriverOptions options) =>
S7Tsap.Resolve(options) is { } tsap
? new S7PlcAdapter(options.Host, options.Port, tsap, options.Timeout)
: new S7PlcAdapter(options.CpuType, options.Host, options.Port, options.Rack, options.Slot, options.Timeout);
}
@@ -117,6 +117,8 @@ public sealed class S7Driver
private const uint StatusBadNotSupported = 0x803D0000u;
/// <summary>OPC UA StatusCode used when a written value has the wrong shape / length / element type.</summary>
private const uint StatusBadTypeMismatch = 0x80740000u;
/// <summary>OPC UA StatusCode used when a value is unrepresentable in the S7 type (write) or a PLC buffer does not decode (read).</summary>
private const uint StatusBadOutOfRange = 0x803C0000u;
/// <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>
@@ -269,8 +271,7 @@ public sealed class S7Driver
// Build the connection through the factory (the same seam EnsureConnectedAsync's reopen
// uses) so the read/write timeouts are baked in identically on connect and reconnect.
var plc = _plcFactory.Create(
_options.CpuType, _options.Host, _options.Port, _options.Rack, _options.Slot, _options.Timeout);
var plc = _plcFactory.Create(_options);
try
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
@@ -298,6 +299,7 @@ public sealed class S7Driver
Plc = plc;
_plcDead = false;
_connectBackoff.RecordSuccess();
Volatile.Write(ref _negotiatedPduSize, plc.MaxPduSize);
// 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
@@ -310,8 +312,8 @@ public sealed class S7Driver
_initialized = true;
Health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
_logger.LogInformation("S7Driver connected. Driver={DriverInstanceId} Host={Host} CPU={CpuType} Tags={TagCount}",
_driverInstanceId, _options.Host, _options.CpuType, _tagsByRawPath.Count);
_logger.LogInformation("S7Driver connected. Driver={DriverInstanceId} Host={Host} CPU={CpuType} PduSize={PduSize} Tags={TagCount}",
_driverInstanceId, _options.Host, _options.CpuType, _negotiatedPduSize, _tagsByRawPath.Count);
// Kick off the probe loop once the connection is up. Initial HostState stays
// Unknown until the first probe tick succeeds — avoids broadcasting a premature
@@ -332,6 +334,7 @@ public sealed class S7Driver
// the TcpClient. Dispose is best-effort and idempotent.
try { Plc?.Dispose(); } catch { }
Plc = null;
Volatile.Write(ref _negotiatedPduSize, 0);
_initialized = false;
_plcDead = false;
Health = new DriverHealth(DriverState.Faulted, null, ex.Message);
@@ -385,6 +388,7 @@ public sealed class S7Driver
_connectBackoff.RecordSuccess();
try { Plc?.Dispose(); } catch { /* best-effort — tearing down anyway */ }
Plc = null;
Volatile.Write(ref _negotiatedPduSize, 0); // a stale post-teardown reading would read as "online"
Health = new DriverHealth(DriverState.Unknown, Health.LastSuccessfulRead, null);
}
@@ -411,6 +415,25 @@ public sealed class S7Driver
/// authored against a non-byte suffix (DBW/DBD/…) is a config error — see
/// <see cref="RejectUnsupportedTagConfigs"/>.
/// </summary>
/// <summary>
/// PDU size the CPU negotiated on the most recent successful connect, in bytes; 0 before the first
/// connect and after shutdown. Snapshotted at connect so it stays readable while the handle is
/// being replaced. Surfaced through <see cref="GetDiagnostics"/> and the connect log line.
/// </summary>
public int NegotiatedPduSize => Volatile.Read(ref _negotiatedPduSize);
private int _negotiatedPduSize;
/// <summary>
/// Driver-specific diagnostics key/value pairs (currently <c>S7.NegotiatedPduSize</c>). The core
/// <see cref="DriverHealth"/> record carries no diagnostics bag, so this is the surface until one exists.
/// </summary>
/// <returns>A snapshot of the diagnostics.</returns>
public IReadOnlyDictionary<string, object> GetDiagnostics() => new Dictionary<string, object>
{
["S7.NegotiatedPduSize"] = NegotiatedPduSize,
};
private static readonly HashSet<S7DataType> ByteAnchoredDataTypes = new()
{
S7DataType.Int64,
@@ -418,6 +441,14 @@ public sealed class S7Driver
S7DataType.Float64,
S7DataType.String,
S7DataType.DateTime,
S7DataType.WString,
S7DataType.Char,
S7DataType.WChar,
S7DataType.Dtl,
S7DataType.Date,
S7DataType.Tod,
S7DataType.Time,
S7DataType.S5Time,
};
/// <summary>
@@ -1199,6 +1230,11 @@ public sealed class S7Driver
case NotSupportedException:
return StatusBadNotSupported;
case S7ValueRangeException:
// A PLC buffer the type codec refuses (uninitialised DTL, non-BCD S5TIME…) is a data
// problem, not a transport fault — no health change, no connection teardown.
return StatusBadOutOfRange;
case PlcException pex when IsAccessDenied(pex):
// PUT/GET-disabled (S7-1200/1500) / access-protection — a permanent configuration
// fault, NOT a transient one. Blind retry is wasted effort, so map it to
@@ -1312,8 +1348,7 @@ public sealed class S7Driver
/// <returns><c>true</c> if the tag routes through the byte-buffer codec.</returns>
internal static bool IsBufferType(S7TagDefinition tag, S7ParsedAddress addr) =>
addr.Area is S7Area.Timer or S7Area.Counter
|| tag.DataType is S7DataType.Int64 or S7DataType.UInt64 or S7DataType.Float64
or S7DataType.String or S7DataType.DateTime;
|| ByteAnchoredDataTypes.Contains(tag.DataType);
/// <summary>
/// Reads a wide/structured scalar as ONE contiguous byte block via S7.Net's
@@ -1363,6 +1398,11 @@ public sealed class S7Driver
S7DataType.Int64 or S7DataType.UInt64 or S7DataType.Float64 => 8,
S7DataType.DateTime => 8, // 8 = S7 DATE_AND_TIME/DT BCD (S7.Net.Types.DateTime; the 8-byte DT, not 12-byte DTL).
S7DataType.String => tag.StringLength + 2,
S7DataType.WString => S7ValueCodecs.WStringWidth(tag.StringLength),
S7DataType.Char => 1,
S7DataType.WChar or S7DataType.Date or S7DataType.S5Time => 2,
S7DataType.Tod or S7DataType.Time => 4,
S7DataType.Dtl => 12,
_ => throw new InvalidOperationException(
$"S7 ScalarByteWidth called for non-buffer type {tag.DataType} (tag '{tag.Name}')"),
};
@@ -1419,6 +1459,17 @@ public sealed class S7Driver
// System.DateTime explicitly (the cast pins the box type, consistent with the numeric arms).
S7DataType.DateTime => (object)global::S7.Net.Types.DateTime.FromByteArray(block),
// The codecs below live in S7ValueCodecs (pure, big-endian); a malformed PLC buffer throws
// S7ValueRangeException → BadOutOfRange on the read. TOD/TIME/S5TIME surface as Int32 ms.
S7DataType.WString => S7ValueCodecs.DecodeWString(block),
S7DataType.Char => S7ValueCodecs.DecodeChar(block),
S7DataType.WChar => S7ValueCodecs.DecodeWChar(block),
S7DataType.Dtl => (object)S7ValueCodecs.DecodeDtl(block),
S7DataType.Date => (object)S7ValueCodecs.DecodeDate(block),
S7DataType.Tod => (object)S7ValueCodecs.DecodeTod(block),
S7DataType.Time => (object)S7ValueCodecs.DecodeTime(block),
S7DataType.S5Time => (object)S7ValueCodecs.DecodeS5Time(block),
_ => throw new System.IO.InvalidDataException(
$"S7 scalar Read type-mismatch: tag '{tag.Name}' declared {tag.DataType} but address " +
$"'{tag.Address}' parsed as Size={addr.Size}"),
@@ -1484,6 +1535,16 @@ public sealed class S7Driver
// System.DateTime or a parseable timestamp string).
return global::S7.Net.Types.DateTime.ToByteArray(Convert.ToDateTime(value));
// Writes refuse (S7ValueRangeException → BadOutOfRange) rather than truncate or clamp.
case S7DataType.WString: return S7ValueCodecs.EncodeWString(value, tag.StringLength);
case S7DataType.Char: return S7ValueCodecs.EncodeChar(value);
case S7DataType.WChar: return S7ValueCodecs.EncodeWChar(value);
case S7DataType.Dtl: return S7ValueCodecs.EncodeDtl(value);
case S7DataType.Date: return S7ValueCodecs.EncodeDate(value);
case S7DataType.Tod: return S7ValueCodecs.EncodeTod(value);
case S7DataType.Time: return S7ValueCodecs.EncodeTime(value);
case S7DataType.S5Time: return S7ValueCodecs.EncodeS5Time(value);
default:
throw new InvalidOperationException(
$"S7 EncodeScalarBlock called for non-buffer type {tag.DataType} (tag '{tag.Name}')");
@@ -1596,6 +1657,14 @@ public sealed class S7Driver
case (S7DataType.Float64, _):
case (S7DataType.String, _):
case (S7DataType.DateTime, _):
case (S7DataType.WString, _):
case (S7DataType.Char, _):
case (S7DataType.WChar, _):
case (S7DataType.Dtl, _):
case (S7DataType.Date, _):
case (S7DataType.Tod, _):
case (S7DataType.Time, _):
case (S7DataType.S5Time, _):
throw new NotSupportedException(
$"S7 array reads of {tag.DataType} land in a follow-up PR");
@@ -1811,6 +1880,12 @@ public sealed class S7Driver
{
results[i] = new WriteResult(StatusBadNotSupported);
}
catch (S7ValueRangeException)
{
// Value unrepresentable in the S7 type (string over its declared max, DATE < 1990…):
// refused with nothing sent — a client error, no health impact.
results[i] = new WriteResult(StatusBadOutOfRange);
}
catch (S7WriteValueException)
{
// Client-supplied value of the wrong shape — not a device or comms fault.
@@ -1989,6 +2064,14 @@ public sealed class S7Driver
case (S7DataType.Float64, _):
case (S7DataType.String, _):
case (S7DataType.DateTime, _):
case (S7DataType.WString, _):
case (S7DataType.Char, _):
case (S7DataType.WChar, _):
case (S7DataType.Dtl, _):
case (S7DataType.Date, _):
case (S7DataType.Tod, _):
case (S7DataType.Time, _):
case (S7DataType.S5Time, _):
throw new NotSupportedException($"S7 array writes of {tag.DataType} are not supported (tag '{tag.Name}').");
case (S7DataType.Byte, S7Size.Byte):
case (S7DataType.UInt16, S7Size.Word):
@@ -2089,6 +2172,9 @@ public sealed class S7Driver
S7DataType.Float64 => throw new NotSupportedException("S7 Float64 (LReal) is unreachable here — wide types route through the byte-buffer path (EncodeScalarBlock)"),
S7DataType.String => throw new NotSupportedException("S7 STRING is unreachable here — wide types route through the byte-buffer path (EncodeScalarBlock)"),
S7DataType.DateTime => throw new NotSupportedException("S7 DateTime is unreachable here — wide types route through the byte-buffer path (EncodeScalarBlock)"),
S7DataType.WString or S7DataType.Char or S7DataType.WChar or S7DataType.Dtl or S7DataType.Date
or S7DataType.Tod or S7DataType.Time or S7DataType.S5Time
=> throw new NotSupportedException($"S7 {dataType} is unreachable here — wide types route through the byte-buffer path (EncodeScalarBlock)"),
_ => throw new InvalidOperationException($"Unknown S7DataType {dataType}"),
};
@@ -2141,8 +2227,7 @@ public sealed class S7Driver
throw new InvalidOperationException(
$"S7 reconnect to {_options.Host}:{_options.Port} suppressed — backing off after a failed connect attempt.");
var plc = _plcFactory.Create(
_options.CpuType, _options.Host, _options.Port, _options.Rack, _options.Slot, _options.Timeout);
var plc = _plcFactory.Create(_options);
try
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
@@ -2172,8 +2257,9 @@ public sealed class S7Driver
_connectBackoff.RecordSuccess();
Plc = plc;
_logger.LogInformation("S7Driver reconnected. Driver={DriverInstanceId} Host={Host} CPU={CpuType}",
_driverInstanceId, _options.Host, _options.CpuType);
Volatile.Write(ref _negotiatedPduSize, plc.MaxPduSize);
_logger.LogInformation("S7Driver reconnected. Driver={DriverInstanceId} Host={Host} CPU={CpuType} PduSize={PduSize}",
_driverInstanceId, _options.Host, _options.CpuType, _negotiatedPduSize);
return plc;
}
@@ -2306,8 +2392,10 @@ public sealed class S7Driver
S7DataType.UInt64 => DriverDataType.UInt64,
S7DataType.Float32 => DriverDataType.Float32,
S7DataType.Float64 => DriverDataType.Float64,
S7DataType.String => DriverDataType.String,
S7DataType.DateTime => DriverDataType.DateTime,
S7DataType.String or S7DataType.WString or S7DataType.Char or S7DataType.WChar => DriverDataType.String,
S7DataType.DateTime or S7DataType.Dtl or S7DataType.Date => DriverDataType.DateTime,
// TOD / TIME / S5TIME surface as Int32 milliseconds (the default arm; named for the reader).
S7DataType.Tod or S7DataType.Time or S7DataType.S5Time => DriverDataType.Int32,
_ => DriverDataType.Int32,
};
@@ -2492,6 +2580,7 @@ public sealed class S7Driver
_connectBackoff.RecordSuccess();
try { Plc?.Dispose(); } catch { /* best-effort — tearing down anyway */ }
Plc = null;
Volatile.Write(ref _negotiatedPduSize, 0); // a stale post-teardown reading would read as "online"
Health = new DriverHealth(DriverState.Unknown, Health.LastSuccessfulRead, null);
}
}
@@ -87,9 +87,24 @@ public static class S7DriverFactoryExtensions
$"S7 driver config for '{driverInstanceId}' has SpanReadMaxGapBytes {spanReadMaxGapBytes}; " +
"must be >= -1 — -1 disables span grouping and 0 merges only strictly adjacent tags.");
// Optional TSAP / connection-type override. Validated here (the single choke point) so a typo'd
// TSAP fails the deploy rather than silently connecting with the slot-derived default.
var localTsap = NormalizeTsap(dto.LocalTsap, driverInstanceId, "LocalTsap");
var remoteTsap = NormalizeTsap(dto.RemoteTsap, driverInstanceId, "RemoteTsap");
S7ConnectionType? connectionType = string.IsNullOrWhiteSpace(dto.ConnectionType)
? null
: ParseEnum<S7ConnectionType>(dto.ConnectionType, driverInstanceId, "ConnectionType");
if (remoteTsap is not null && connectionType is not null)
throw new InvalidOperationException(
$"S7 driver config for '{driverInstanceId}' sets both RemoteTsap and ConnectionType; " +
"ConnectionType only fills the first byte of the slot-derived remote TSAP, so choose one.");
return new S7DriverOptions
{
Host = dto.Host!,
LocalTsap = localTsap,
RemoteTsap = remoteTsap,
ConnectionType = connectionType,
Port = dto.Port ?? 102,
CpuType = ParseEnum<S7CpuType>(dto.CpuType, driverInstanceId, "CpuType",
fallback: S7CpuType.S71500),
@@ -115,6 +130,23 @@ public static class S7DriverFactoryExtensions
};
}
private static string? NormalizeTsap(JsonElement? raw, string driverInstanceId, string field)
{
if (raw is not { } el || el.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) return null;
string? text = el.ValueKind switch
{
JsonValueKind.String => el.GetString(),
JsonValueKind.Number when el.TryGetUInt16(out var n) => S7Tsap.Format(n),
_ => "?",
};
if (string.IsNullOrWhiteSpace(text)) return null;
if (!S7Tsap.TryParse(text, out var tsap))
throw new InvalidOperationException(
$"S7 driver config for '{driverInstanceId}' has {field} '{text}'; expected two hex bytes " +
"such as \"01.00\" or \"03.01\" (or a 16-bit number).");
return S7Tsap.Format(tsap);
}
private static T ParseEnum<T>(string? raw, string driverInstanceId, string field,
string? tagName = null, T? fallback = null) where T : struct, Enum
{
@@ -151,6 +183,12 @@ public static class S7DriverFactoryExtensions
public short? Rack { get; init; }
/// <summary>Gets the slot number.</summary>
public short? Slot { get; init; }
/// <summary>Gets the local TSAP override — hex text (<c>"01.00"</c>) or a 16-bit number.</summary>
public JsonElement? LocalTsap { get; init; }
/// <summary>Gets the remote TSAP override — hex text (<c>"03.01"</c>) or a 16-bit number.</summary>
public JsonElement? RemoteTsap { get; init; }
/// <summary>Gets the connection-type name (PG / OP / S7Basic); a string so AdminUI-authored configs bind.</summary>
public string? ConnectionType { get; init; }
/// <summary>Gets the connection timeout in milliseconds.</summary>
public int? TimeoutMs { get; init; }
/// <summary>Gets the authored raw tags (RawPath + TagConfig blob + WriteIdempotent) the deploy
@@ -66,7 +66,11 @@ public sealed class S7DriverProbe : IDriverProbe
// The Plc is opened and immediately closed — no reads or writes are performed.
// Use a linked CTS so we can distinguish a real caller cancellation from
// S7netplus's internal socket-read timeout (which also surfaces as OCE).
var plc = new Plc(S7CpuTypeMap.ToS7Net(opts.CpuType), host, port, opts.Rack, opts.Slot);
// An authored TSAP / connection-type override applies to Test Connect too — otherwise the probe
// would succeed (or fail) on a different handshake than the driver will use.
var plc = S7Tsap.Resolve(opts) is { } tsap
? new Plc(host, port, tsap)
: new Plc(S7CpuTypeMap.ToS7Net(opts.CpuType), host, port, opts.Rack, opts.Slot);
plc.ReadTimeout = (int)timeout.TotalMilliseconds;
using var handshakeCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
handshakeCts.CancelAfter(timeout);
@@ -76,7 +80,7 @@ public sealed class S7DriverProbe : IDriverProbe
sw.Stop();
if (plc.IsConnected)
return new(true, $"S7 connected (CPU {opts.CpuType})", sw.Elapsed);
return new(true, $"S7 connected (CPU {opts.CpuType}, PDU {plc.MaxPDUSize} bytes)", sw.Elapsed);
return new(false, $"Reachable at {host}:{port} but S7 handshake failed: not connected", null);
}
@@ -0,0 +1,88 @@
using System.Globalization;
using S7.Net.Protocol;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
/// <summary>
/// ISO-TSAP helpers for the optional <c>LocalTsap</c> / <c>RemoteTsap</c> / <c>ConnectionType</c>
/// config: parsing the hex text form and resolving the effective <see cref="TsapPair"/> handed to
/// S7.Net. Pure — no I/O — so the resolution rules are unit-testable without a PLC.
/// </summary>
internal static class S7Tsap
{
/// <summary>
/// Parses a TSAP written as two hex bytes — <c>"01.00"</c>, <c>"03:01"</c> (1–2 digits per byte) — or
/// as one 16-bit hex value, <c>"0100"</c> / <c>"0x0301"</c>. High byte first.
/// </summary>
/// <param name="text">The configured text.</param>
/// <param name="tsap">The 16-bit TSAP (<c>first &lt;&lt; 8 | second</c>).</param>
/// <returns>True when <paramref name="text"/> is a valid TSAP.</returns>
internal static bool TryParse(string? text, out ushort tsap)
{
tsap = 0;
if (string.IsNullOrWhiteSpace(text)) return false;
var t = text.Trim();
var sep = t.IndexOfAny(['.', ':', '-']);
if (sep >= 0)
{
if (!TryHexByte(t[..sep], out var hi) || !TryHexByte(t[(sep + 1)..], out var lo)) return false;
tsap = (ushort)((hi << 8) | lo);
return true;
}
if (t.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) t = t[2..];
if (t.Length is < 1 or > 4) return false;
return ushort.TryParse(t, NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture, out tsap);
}
private static bool TryHexByte(string s, out byte b)
{
b = 0;
s = s.Trim();
return s.Length is >= 1 and <= 2
&& byte.TryParse(s, NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture, out b);
}
/// <summary>Formats a 16-bit TSAP as canonical <c>"XX.YY"</c> (upper-case hex).</summary>
/// <param name="tsap">The 16-bit TSAP.</param>
/// <returns>The canonical text.</returns>
internal static string Format(ushort tsap) => $"{tsap >> 8:X2}.{tsap & 0xFF:X2}";
/// <summary>
/// Resolves the TSAP pair to connect with, or <c>null</c> when the options carry no override (the
/// caller then keeps the plain <c>Plc(cpu, host, port, rack, slot)</c> construction, unchanged).
/// Starts from S7.Net's own per-CPU default for <paramref name="options"/>'s rack/slot, then applies,
/// in order: <c>LocalTsap</c> replaces the local TSAP; <c>RemoteTsap</c> replaces the remote TSAP;
/// <c>ConnectionType</c> replaces only the remote TSAP's first byte.
/// </summary>
/// <param name="options">The driver options.</param>
/// <returns>The effective pair, or null for "no override".</returns>
internal static TsapPair? Resolve(S7DriverOptions options)
{
ArgumentNullException.ThrowIfNull(options);
var hasLocal = !string.IsNullOrWhiteSpace(options.LocalTsap);
var hasRemote = !string.IsNullOrWhiteSpace(options.RemoteTsap);
if (!hasLocal && !hasRemote && options.ConnectionType is null) return null;
var def = TsapPair.GetDefaultTsapPair(S7CpuTypeMap.ToS7Net(options.CpuType), options.Rack, options.Slot);
var local = def.Local;
var remote = def.Remote;
if (hasLocal)
{
if (!TryParse(options.LocalTsap, out var l))
throw new FormatException($"S7 LocalTsap '{options.LocalTsap}' is not a valid TSAP (expected e.g. \"01.00\").");
local = new Tsap((byte)(l >> 8), (byte)(l & 0xFF));
}
if (hasRemote)
{
if (!TryParse(options.RemoteTsap, out var r))
throw new FormatException($"S7 RemoteTsap '{options.RemoteTsap}' is not a valid TSAP (expected e.g. \"03.01\").");
remote = new Tsap((byte)(r >> 8), (byte)(r & 0xFF));
}
else if (options.ConnectionType is { } ct)
{
remote = new Tsap((byte)ct, remote.SecondByte);
}
return new TsapPair(local, remote);
}
}
@@ -0,0 +1,298 @@
using System.Buffers.Binary;
using System.Text;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
/// <summary>
/// A value the S7 codecs refuse because it is outside what the S7 type can represent —
/// on the WRITE side a caller value that overflows the declared field (a string longer than its
/// declared max length, a DATE before 1990, a TOD past midnight), on the READ side a PLC buffer
/// that does not decode (uninitialised DTL, a non-BCD S5TIME digit, a string whose current length
/// exceeds its reserved length). Maps to <c>BadOutOfRange</c> and, unlike a transport fault, does
/// not degrade driver health or condemn the connection.
/// </summary>
internal sealed class S7ValueRangeException(string message) : Exception(message);
/// <summary>
/// Pure byte-level codecs for the S7 string, character and date/time/duration types the narrow
/// (1/2/4-byte) read path cannot express: <c>WSTRING</c>, <c>CHAR</c>, <c>WCHAR</c>, <c>DTL</c>,
/// <c>DATE</c>, <c>TOD</c>, <c>TIME</c> and <c>S5TIME</c>. Every method works on a raw big-endian S7
/// block, so each wire format is locked by known-byte-vector unit tests without a PLC. Classic
/// <c>STRING</c> and <c>DATE_AND_TIME</c> (DT) stay on S7.Net's own codecs.
/// </summary>
/// <remarks>
/// <para>
/// <b>OPC UA mapping</b> (no new <c>DriverDataType</c>): WString/Char/WChar → String;
/// DTL/Date → DateTime; TOD, TIME, S5TIME → Int32 <b>milliseconds</b> (TOD = ms since midnight,
/// TIME = signed duration, S5TIME = duration). All values fit Int32.
/// </para>
/// <para>
/// <b>Writes refuse rather than clamp or truncate</b>: an unrepresentable value throws
/// <see cref="S7ValueRangeException"/> (→ <c>BadOutOfRange</c>) with nothing sent to the PLC; a value
/// of the wrong shape throws <see cref="S7Driver.S7WriteValueException"/> (→ <c>BadTypeMismatch</c>).
/// </para>
/// </remarks>
internal static class S7ValueCodecs
{
/// <summary>Largest declared WSTRING character count (S7-1500 limit; header is UInt16 but the PDU-safe ceiling is 16382).</summary>
internal const int MaxWStringLength = 16382;
/// <summary>First day of the S7 DATE epoch.</summary>
private static readonly DateTime DateEpoch = new(1990, 1, 1, 0, 0, 0, DateTimeKind.Unspecified);
private const int MillisecondsPerDay = 86_400_000;
// ---- CHAR / WCHAR ----
/// <summary>Decodes a 1-byte S7 CHAR (Latin-1) to a one-character string.</summary>
internal static string DecodeChar(ReadOnlySpan<byte> block)
{
RequireLength(block, 1, "CHAR");
return ((char)block[0]).ToString();
}
/// <summary>Encodes a one-character string to a 1-byte S7 CHAR. Characters above U+00FF are out of range.</summary>
internal static byte[] EncodeChar(object? value)
{
var c = SingleChar(value, "CHAR");
if (c > 0xFF)
throw new S7ValueRangeException($"S7 CHAR holds one 8-bit character; U+{(int)c:X4} is out of range.");
return [(byte)c];
}
/// <summary>Decodes a 2-byte S7 WCHAR (UTF-16 big-endian code unit) to a one-character string.</summary>
internal static string DecodeWChar(ReadOnlySpan<byte> block)
{
RequireLength(block, 2, "WCHAR");
return ((char)BinaryPrimitives.ReadUInt16BigEndian(block)).ToString();
}
/// <summary>Encodes a one-character string to a 2-byte S7 WCHAR (UTF-16 big-endian).</summary>
internal static byte[] EncodeWChar(object? value)
{
var b = new byte[2];
BinaryPrimitives.WriteUInt16BigEndian(b, SingleChar(value, "WCHAR"));
return b;
}
private static char SingleChar(object? value, string type)
{
if (value is char ch) return ch;
var s = value as string ?? throw new S7Driver.S7WriteValueException($"S7 {type} write needs a one-character string.");
if (s.Length != 1)
throw new S7Driver.S7WriteValueException(
$"S7 {type} write needs exactly one character; got {s.Length}.");
return s[0];
}
// ---- WSTRING ----
/// <summary>Byte width of a WSTRING reserving <paramref name="maxLength"/> characters: 4-byte header + 2 bytes per character.</summary>
internal static int WStringWidth(int maxLength) => 4 + (2 * maxLength);
/// <summary>
/// Decodes <c>[maxLen UInt16 BE][curLen UInt16 BE][curLen × UTF-16BE]</c>. Characters past
/// <c>curLen</c> (the reserved padding) are ignored.
/// </summary>
internal static string DecodeWString(ReadOnlySpan<byte> block)
{
if (block.Length < 4)
throw new S7ValueRangeException($"S7 WSTRING block is {block.Length} bytes; the header alone is 4.");
var maxLen = BinaryPrimitives.ReadUInt16BigEndian(block);
var curLen = BinaryPrimitives.ReadUInt16BigEndian(block[2..]);
if (curLen > maxLen)
throw new S7ValueRangeException($"S7 WSTRING current length {curLen} exceeds its reserved length {maxLen}.");
if (4 + (2 * curLen) > block.Length)
throw new S7ValueRangeException(
$"S7 WSTRING current length {curLen} runs past the {block.Length}-byte block.");
return Encoding.BigEndianUnicode.GetString(block.Slice(4, 2 * curLen));
}
/// <summary>
/// Encodes a string into the full reserved WSTRING field (<see cref="WStringWidth"/> bytes, zero
/// padded). A value with more UTF-16 code units than <paramref name="maxLength"/> is refused, not truncated.
/// </summary>
internal static byte[] EncodeWString(object? value, int maxLength)
{
var s = Convert.ToString(value, System.Globalization.CultureInfo.InvariantCulture) ?? "";
if (s.Length > maxLength)
throw new S7ValueRangeException(
$"S7 WSTRING value is {s.Length} characters; the declared maximum is {maxLength}.");
var b = new byte[WStringWidth(maxLength)];
BinaryPrimitives.WriteUInt16BigEndian(b, (ushort)maxLength);
BinaryPrimitives.WriteUInt16BigEndian(b.AsSpan(2), (ushort)s.Length);
Encoding.BigEndianUnicode.GetBytes(s, b.AsSpan(4));
return b;
}
// ---- DTL (12 bytes) ----
/// <summary>
/// Decodes DTL: <c>year UInt16 BE, month, day, weekday, hour, minute, second, nanoseconds UInt32 BE</c>.
/// The weekday byte is ignored. An all-zero (uninitialised) buffer is refused.
/// </summary>
internal static DateTime DecodeDtl(ReadOnlySpan<byte> block)
{
RequireLength(block, 12, "DTL");
var year = BinaryPrimitives.ReadUInt16BigEndian(block);
var nanos = BinaryPrimitives.ReadUInt32BigEndian(block[8..]);
if (year == 0 || block[2] == 0 || block[3] == 0)
throw new S7ValueRangeException("S7 DTL buffer is uninitialised (year/month/day is zero).");
if (nanos > 999_999_999)
throw new S7ValueRangeException($"S7 DTL nanoseconds {nanos} out of range.");
try
{
return new DateTime(year, block[2], block[3], block[5], block[6], block[7], DateTimeKind.Unspecified)
.AddTicks(nanos / 100);
}
catch (ArgumentOutOfRangeException ex)
{
throw new S7ValueRangeException($"S7 DTL buffer is not a valid date/time: {ex.Message}");
}
}
/// <summary>Encodes a DateTime to DTL. Years outside the PLC's 1970–2262 range are refused; sub-100 ns precision is not representable in a DateTime so nanoseconds are a multiple of 100.</summary>
internal static byte[] EncodeDtl(object? value)
{
var dt = ToDateTime(value, "DTL");
if (dt.Year is < 1970 or > 2262)
throw new S7ValueRangeException($"S7 DTL year {dt.Year} is outside 1970–2262.");
var b = new byte[12];
BinaryPrimitives.WriteUInt16BigEndian(b, (ushort)dt.Year);
b[2] = (byte)dt.Month;
b[3] = (byte)dt.Day;
b[4] = (byte)((int)dt.DayOfWeek + 1); // S7: 1 = Sunday … 7 = Saturday
b[5] = (byte)dt.Hour;
b[6] = (byte)dt.Minute;
b[7] = (byte)dt.Second;
BinaryPrimitives.WriteUInt32BigEndian(b.AsSpan(8), (uint)((dt.Ticks % TimeSpan.TicksPerSecond) * 100));
return b;
}
// ---- DATE (UInt16 days since 1990-01-01) ----
/// <summary>Decodes DATE (days since 1990-01-01, UInt16 BE) to a midnight DateTime.</summary>
internal static DateTime DecodeDate(ReadOnlySpan<byte> block)
{
RequireLength(block, 2, "DATE");
return DateEpoch.AddDays(BinaryPrimitives.ReadUInt16BigEndian(block));
}
/// <summary>Encodes the DATE part of a DateTime; dates before 1990-01-01 or past 2169-06-06 (the UInt16 ceiling) are refused. The time-of-day is dropped.</summary>
internal static byte[] EncodeDate(object? value)
{
var days = (ToDateTime(value, "DATE").Date - DateEpoch).Days;
if (days is < 0 or > ushort.MaxValue)
throw new S7ValueRangeException("S7 DATE must be between 1990-01-01 and 2169-06-06 (UInt16 days since 1990-01-01).");
var b = new byte[2];
BinaryPrimitives.WriteUInt16BigEndian(b, (ushort)days);
return b;
}
// ---- TOD (UInt32 ms since midnight) ----
/// <summary>Decodes TIME_OF_DAY to Int32 milliseconds since midnight (0–86 399 999).</summary>
internal static int DecodeTod(ReadOnlySpan<byte> block)
{
RequireLength(block, 4, "TOD");
var ms = BinaryPrimitives.ReadUInt32BigEndian(block);
if (ms >= MillisecondsPerDay)
throw new S7ValueRangeException($"S7 TOD value {ms} ms is past midnight (max 86399999).");
return (int)ms;
}
/// <summary>Encodes milliseconds since midnight (or a <see cref="TimeSpan"/>) to TOD; outside 0–86 399 999 is refused.</summary>
internal static byte[] EncodeTod(object? value)
{
var ms = ToMilliseconds(value, "TOD");
if (ms is < 0 or >= MillisecondsPerDay)
throw new S7ValueRangeException($"S7 TOD value {ms} ms is outside 0–86399999.");
var b = new byte[4];
BinaryPrimitives.WriteUInt32BigEndian(b, (uint)ms);
return b;
}
// ---- TIME (Int32 ms, signed) ----
/// <summary>Decodes IEC TIME to a signed Int32 millisecond duration.</summary>
internal static int DecodeTime(ReadOnlySpan<byte> block)
{
RequireLength(block, 4, "TIME");
return BinaryPrimitives.ReadInt32BigEndian(block);
}
/// <summary>Encodes a signed millisecond duration (or <see cref="TimeSpan"/>) to TIME; outside the Int32 range is refused.</summary>
internal static byte[] EncodeTime(object? value)
{
var ms = ToMilliseconds(value, "TIME");
if (ms is < int.MinValue or > int.MaxValue)
throw new S7ValueRangeException($"S7 TIME value {ms} ms is outside the Int32 range.");
var b = new byte[4];
BinaryPrimitives.WriteInt32BigEndian(b, (int)ms);
return b;
}
// ---- S5TIME (2 bytes) ----
private static readonly int[] S5BaseMs = [10, 100, 1000, 10_000];
/// <summary>
/// Decodes S5TIME — bits 13–12 select the time base (00 = 10 ms, 01 = 100 ms, 10 = 1 s, 11 = 10 s),
/// bits 11–0 are three BCD digits — to Int32 milliseconds. Bits 15–14 are ignored. A non-BCD nibble is refused.
/// </summary>
internal static int DecodeS5Time(ReadOnlySpan<byte> block)
{
RequireLength(block, 2, "S5TIME");
int d2 = block[0] & 0x0F, d1 = block[1] >> 4, d0 = block[1] & 0x0F;
if (d2 > 9 || d1 > 9 || d0 > 9)
throw new S7ValueRangeException("S7 S5TIME holds a non-BCD digit.");
var value = (d2 * 100) + (d1 * 10) + d0;
return value * S5BaseMs[(block[0] >> 4) & 0x03];
}
/// <summary>
/// Encodes a millisecond duration to S5TIME using the finest time base that holds it (value ≤ 999).
/// Refused when it exceeds 9 990 000 ms or is not an exact multiple of a base that fits — no rounding.
/// </summary>
internal static byte[] EncodeS5Time(object? value)
{
var ms = ToMilliseconds(value, "S5TIME");
if (ms < 0 || ms > 999L * 10_000)
throw new S7ValueRangeException($"S7 S5TIME value {ms} ms is outside 0–9990000.");
for (var baseCode = 0; baseCode < S5BaseMs.Length; baseCode++)
{
var unit = S5BaseMs[baseCode];
if (ms % unit != 0 || ms / unit > 999) continue;
var v = (int)(ms / unit);
return [(byte)((baseCode << 4) | (v / 100)), (byte)(((v / 10 % 10) << 4) | (v % 10))];
}
throw new S7ValueRangeException(
$"S7 S5TIME cannot represent {ms} ms exactly (resolution is 10 ms up to 9990 ms, coarser above).");
}
// ---- helpers ----
private static void RequireLength(ReadOnlySpan<byte> block, int expected, string type)
{
if (block.Length < expected)
throw new S7ValueRangeException($"S7 {type} block is {block.Length} bytes; expected {expected}.");
}
private static DateTime ToDateTime(object? value, string type)
{
try { return value is DateTimeOffset dto ? dto.DateTime : Convert.ToDateTime(value, System.Globalization.CultureInfo.InvariantCulture); }
catch (Exception ex) when (ex is FormatException or InvalidCastException or OverflowException)
{
throw new S7Driver.S7WriteValueException($"S7 {type} write needs a date/time value ({ex.Message}).");
}
}
private static long ToMilliseconds(object? value, string type)
{
try { return value is TimeSpan ts ? (long)ts.TotalMilliseconds : Convert.ToInt64(value, System.Globalization.CultureInfo.InvariantCulture); }
catch (Exception ex) when (ex is FormatException or InvalidCastException or OverflowException)
{
throw new S7Driver.S7WriteValueException($"S7 {type} write needs a millisecond count ({ex.Message}).");
}
}
}
@@ -30,6 +30,32 @@
<div class="form-text">Controls ISO-TSAP slot byte during handshake.</div>
</div>
</div>
<div class="row">
<div class="col-md-4 mb-3">
<label class="form-label" for="s7ConnType">Connection type</label>
<InputSelect id="s7ConnType" @bind-Value="_form.ConnectionTypeText" @bind-Value:after="EmitAsync"
class="form-select form-select-sm">
<option value="">Default (slot-derived)</option>
@foreach (var v in Enum.GetValues<S7ConnectionType>())
{
<option value="@v">@v</option>
}
</InputSelect>
<div class="form-text">First byte of the remote TSAP. Leave on default unless the CPU needs a PG/OP resource.</div>
</div>
<div class="col-md-4 mb-3">
<label class="form-label" for="s7LocalTsap">Local TSAP</label>
<InputText id="s7LocalTsap" @bind-Value="_form.LocalTsap" @bind-Value:after="EmitAsync"
class="form-control form-control-sm mono" placeholder="01.00" />
<div class="form-text">Optional hex override, e.g. <code>01.00</code>.</div>
</div>
<div class="col-md-4 mb-3">
<label class="form-label" for="s7RemoteTsap">Remote TSAP</label>
<InputText id="s7RemoteTsap" @bind-Value="_form.RemoteTsap" @bind-Value:after="EmitAsync"
class="form-control form-control-sm mono" placeholder="03.01" />
<div class="form-text">Optional hex override, e.g. <code>03.01</code>. Not combinable with Connection type.</div>
</div>
</div>
</div>
</section>
@@ -141,6 +167,10 @@
public S7CpuType CpuType { get; set; } = S7CpuType.S71500;
public short Rack { get; set; } = 0;
public short Slot { get; set; } = 0;
public string? LocalTsap { get; set; }
public string? RemoteTsap { get; set; }
// Bound to a <select> as text ("" = default) so the nullable enum round-trips through InputSelect.
public string ConnectionTypeText { get; set; } = "";
public int TimeoutSeconds { get; set; } = 5;
// Probe
@@ -160,6 +190,9 @@
CpuType = o.CpuType,
Rack = o.Rack,
Slot = o.Slot,
LocalTsap = o.LocalTsap,
RemoteTsap = o.RemoteTsap,
ConnectionTypeText = o.ConnectionType?.ToString() ?? "",
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
ProbeEnabled = o.Probe.Enabled,
ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds,
@@ -174,6 +207,9 @@
CpuType = CpuType,
Rack = Rack,
Slot = Slot,
LocalTsap = string.IsNullOrWhiteSpace(LocalTsap) ? null : LocalTsap.Trim(),
RemoteTsap = string.IsNullOrWhiteSpace(RemoteTsap) ? null : RemoteTsap.Trim(),
ConnectionType = Enum.TryParse<S7ConnectionType>(ConnectionTypeText, out var ct) ? ct : null,
Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
Probe = new S7ProbeOptions
{
@@ -120,7 +120,9 @@ public sealed class S7TagConfigModel
{
var dt = DataType;
if (dt is S7DataType.Int64 or S7DataType.UInt64 or S7DataType.Float64
or S7DataType.String or S7DataType.DateTime)
or S7DataType.String or S7DataType.DateTime or S7DataType.WString or S7DataType.Char
or S7DataType.WChar or S7DataType.Dtl or S7DataType.Date or S7DataType.Tod
or S7DataType.Time or S7DataType.S5Time)
{
return $"S7 does not support {dt} arrays — use Bool (read-only), Byte, Int16, UInt16, Int32, "
+ "UInt32 or Float32 for an array tag, or a scalar tag.";
@@ -145,4 +145,17 @@ public sealed class WriteCommandParseValueTests
{
ReadCommand.SynthesiseTagName(address, type).ShouldBe(expected);
}
/// <summary>The #288/#289 types parse to the CLR shape the codecs accept.</summary>
[Fact]
public void ParseValue_handles_the_string_and_date_time_codec_types()
{
WriteCommand.ParseValue("héllo", S7DataType.WString).ShouldBe("héllo");
WriteCommand.ParseValue("A", S7DataType.Char).ShouldBe("A");
WriteCommand.ParseValue("2008-12-16T20:30:20", S7DataType.Dtl).ShouldBeOfType<DateTime>();
WriteCommand.ParseValue("2009-12-01", S7DataType.Date).ShouldBeOfType<DateTime>();
WriteCommand.ParseValue("86399999", S7DataType.Tod).ShouldBe(86399999L);
WriteCommand.ParseValue("-1000", S7DataType.Time).ShouldBe(-1000L);
WriteCommand.ParseValue("127000", S7DataType.S5Time).ShouldBe(127000L);
}
}
@@ -0,0 +1,191 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using S7NetDataType = global::S7.Net.DataType;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
/// <summary>
/// TSAP / connection-type override (Gitea #295) and negotiated-PDU surfacing (Gitea #294).
/// </summary>
[Trait("Category", "Unit")]
public sealed class S7TsapAndPduTests
{
// ---- TSAP text ----
[Theory]
[InlineData("01.00", 0x0100)]
[InlineData("03.01", 0x0301)]
[InlineData("3.1", 0x0301)]
[InlineData("0301", 0x0301)]
[InlineData("0x0301", 0x0301)]
[InlineData("03:21", 0x0321)]
[InlineData(" 4d.57 ", 0x4D57)]
public void Tsap_text_parses(string text, int expected)
{
S7Tsap.TryParse(text, out var v).ShouldBeTrue();
v.ShouldBe((ushort)expected);
}
[Theory]
[InlineData("")]
[InlineData("zz.00")]
[InlineData("100.00")]
[InlineData("01.00.00")]
[InlineData("12345")]
[InlineData("-1")]
public void Tsap_garbage_is_rejected(string text) => S7Tsap.TryParse(text, out _).ShouldBeFalse();
[Fact]
public void Tsap_formats_canonically() => S7Tsap.Format(0x0301).ShouldBe("03.01");
// ---- resolution ----
private static (byte, byte, byte, byte) Bytes(S7DriverOptions o)
{
var p = S7Tsap.Resolve(o).ShouldNotBeNull();
return (p.Local.FirstByte, p.Local.SecondByte, p.Remote.FirstByte, p.Remote.SecondByte);
}
[Fact]
public void No_override_resolves_to_null_so_the_plain_constructor_is_kept() =>
S7Tsap.Resolve(new S7DriverOptions { CpuType = S7CpuType.S7300, Rack = 0, Slot = 2 }).ShouldBeNull();
[Fact]
public void Local_override_replaces_only_the_local_tsap()
{
// S7-300 rack 0 slot 2 default remote = 03.02 (S7.Net slot-derived).
var o = new S7DriverOptions { CpuType = S7CpuType.S7300, Rack = 0, Slot = 2, LocalTsap = "02.00" };
Bytes(o).ShouldBe(((byte)2, (byte)0, (byte)3, (byte)2));
}
[Fact]
public void Remote_override_replaces_the_whole_remote_tsap()
{
var o = new S7DriverOptions { CpuType = S7CpuType.S7300, Rack = 0, Slot = 2, RemoteTsap = "03.01" };
Bytes(o).ShouldBe(((byte)1, (byte)0, (byte)3, (byte)1));
}
[Theory]
[InlineData(S7ConnectionType.PG, 1)]
[InlineData(S7ConnectionType.OP, 2)]
[InlineData(S7ConnectionType.S7Basic, 3)]
public void Connection_type_sets_only_the_first_remote_byte_and_keeps_rack_slot(S7ConnectionType ct, int first)
{
// rack 1 slot 3 → second byte 1*0x20 + 3 = 0x23.
var o = new S7DriverOptions { CpuType = S7CpuType.S7400, Rack = 1, Slot = 3, ConnectionType = ct };
var (_, _, rFirst, rSecond) = Bytes(o);
rFirst.ShouldBe((byte)first);
rSecond.ShouldBe((byte)0x23);
}
// ---- factory JSON ----
[Fact]
public void Json_accepts_string_number_and_enum_name_and_normalises()
{
var o = S7DriverFactoryExtensions.ParseOptions("d",
"""{"host":"h","localTsap":"1.0","remoteTsap":769,"cpuType":"S7300"}""");
o.LocalTsap.ShouldBe("01.00");
o.RemoteTsap.ShouldBe("03.01");
o.ConnectionType.ShouldBeNull();
var ct = S7DriverFactoryExtensions.ParseOptions("d", """{"host":"h","connectionType":"pg"}""");
ct.ConnectionType.ShouldBe(S7ConnectionType.PG);
ct.RemoteTsap.ShouldBeNull();
}
[Fact]
public void Json_without_the_keys_leaves_the_defaults()
{
var o = S7DriverFactoryExtensions.ParseOptions("d", """{"host":"h"}""");
o.LocalTsap.ShouldBeNull();
o.RemoteTsap.ShouldBeNull();
o.ConnectionType.ShouldBeNull();
}
[Theory]
[InlineData("""{"host":"h","localTsap":"nope"}""")]
[InlineData("""{"host":"h","remoteTsap":"01.00.00"}""")]
[InlineData("""{"host":"h","remoteTsap":99999999}""")]
[InlineData("""{"host":"h","connectionType":"Bogus"}""")]
[InlineData("""{"host":"h","remoteTsap":"03.01","connectionType":"OP"}""")]
public void Json_rejects_bad_tsap_settings(string json) =>
Should.Throw<InvalidOperationException>(() => S7DriverFactoryExtensions.ParseOptions("d", json));
[Fact]
public async Task Driver_hands_the_full_options_to_the_factory()
{
var factory = new CapturingFactory();
var opts = new S7DriverOptions
{
Host = "192.0.2.1",
Probe = new S7ProbeOptions { Enabled = false },
RemoteTsap = "03.01",
LocalTsap = "01.00",
};
using var drv = new S7Driver(opts, "s7-tsap", factory);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
factory.Seen.ShouldNotBeNull().RemoteTsap.ShouldBe("03.01");
factory.Seen!.LocalTsap.ShouldBe("01.00");
}
// ---- PDU ----
[Fact]
public async Task Negotiated_pdu_size_is_surfaced_after_connect_and_cleared_on_shutdown()
{
var factory = new CapturingFactory { Pdu = 480 };
var opts = new S7DriverOptions { Host = "192.0.2.1", Probe = new S7ProbeOptions { Enabled = false } };
using var drv = new S7Driver(opts, "s7-pdu", factory);
drv.NegotiatedPduSize.ShouldBe(0);
drv.GetDiagnostics()["S7.NegotiatedPduSize"].ShouldBe(0);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
drv.NegotiatedPduSize.ShouldBe(480);
drv.GetDiagnostics()["S7.NegotiatedPduSize"].ShouldBe(480);
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
drv.NegotiatedPduSize.ShouldBe(0);
}
[Fact]
public async Task A_plc_that_does_not_report_a_pdu_size_yields_zero_not_a_throw()
{
var opts = new S7DriverOptions { Host = "192.0.2.1", Probe = new S7ProbeOptions { Enabled = false } };
using var drv = new S7Driver(opts, "s7-pdu0", new ConnectingFakeS7PlcFactory());
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
drv.NegotiatedPduSize.ShouldBe(0);
}
private sealed class CapturingFactory : IS7PlcFactory
{
public S7DriverOptions? Seen { get; private set; }
public int Pdu { get; init; }
public IS7Plc Create(S7CpuType cpuType, string host, int port, short rack, short slot, TimeSpan timeout) =>
new Plc(Pdu);
public IS7Plc Create(S7DriverOptions options)
{
Seen = options;
return new Plc(Pdu);
}
private sealed class Plc(int pdu) : IS7Plc
{
public bool IsConnected { get; private set; }
public int MaxPduSize => IsConnected ? pdu : 0;
public Task OpenAsync(CancellationToken ct) { IsConnected = true; return Task.CompletedTask; }
public void Close() => IsConnected = false;
public Task<object?> ReadAsync(string a, CancellationToken ct) => throw new NotSupportedException();
public Task<byte[]> ReadBytesAsync(S7NetDataType area, int db, int s, int c, CancellationToken ct) => throw new NotSupportedException();
public Task<IReadOnlyList<global::S7.Net.Types.DataItem>> ReadMultipleVarsAsync(
List<global::S7.Net.Types.DataItem> i, CancellationToken ct) => throw new NotSupportedException();
public Task WriteAsync(string a, object v, CancellationToken ct) => throw new NotSupportedException();
public Task WriteBytesAsync(S7NetDataType area, int db, int s, byte[] v, CancellationToken ct) => throw new NotSupportedException();
public Task ReadStatusAsync(CancellationToken ct) => Task.CompletedTask;
public void Dispose() => IsConnected = false;
}
}
}
@@ -0,0 +1,428 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using S7NetDataType = global::S7.Net.DataType;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
/// <summary>
/// Known-byte-vector tests for the S7 string/char/date-time/duration codecs (Gitea #288, #289) —
/// the pure <see cref="S7ValueCodecs"/> plus the driver's dispatch
/// (<see cref="S7Driver.ScalarByteWidth"/> / <c>DecodeScalarBlock</c> / <c>EncodeScalarBlock</c>) and one
/// over-the-seam read/write pass through a memory-backed fake PLC proving status mapping.
/// </summary>
[Trait("Category", "Unit")]
public sealed class S7ValueCodecTests
{
private static S7TagDefinition Tag(S7DataType dt, int stringLength = 254, string address = "DB1.DBB0") =>
new("T", address, dt, StringLength: stringLength);
private static S7ParsedAddress Addr() =>
new(S7Area.DataBlock, DbNumber: 1, S7Size.Byte, ByteOffset: 0, BitOffset: 0);
private static object Decode(S7DataType dt, byte[] block, int stringLength = 254) =>
S7Driver.DecodeScalarBlock(Tag(dt, stringLength), Addr(), block);
private static byte[] Encode(S7DataType dt, object? value, int stringLength = 254) =>
S7Driver.EncodeScalarBlock(Tag(dt, stringLength), value);
// ---- widths ----
[Theory]
[InlineData(S7DataType.Char, 1)]
[InlineData(S7DataType.WChar, 2)]
[InlineData(S7DataType.Date, 2)]
[InlineData(S7DataType.S5Time, 2)]
[InlineData(S7DataType.Tod, 4)]
[InlineData(S7DataType.Time, 4)]
[InlineData(S7DataType.Dtl, 12)]
public void Fixed_widths(S7DataType dt, int width) =>
S7Driver.ScalarByteWidth(Tag(dt), Addr()).ShouldBe(width);
[Theory]
[InlineData(0, 4)]
[InlineData(1, 6)]
[InlineData(5, 14)]
[InlineData(16382, 32768)]
public void WString_width_is_header_plus_two_bytes_per_char(int maxLen, int width) =>
S7Driver.ScalarByteWidth(Tag(S7DataType.WString, maxLen), Addr()).ShouldBe(width);
// ---- WSTRING ----
[Fact]
public void WString_encode_known_vector()
{
// maxLen=5, curLen=2, "Hi" UTF-16BE, 3 zero-padded chars.
Encode(S7DataType.WString, "Hi", 5).ShouldBe(
[0x00, 0x05, 0x00, 0x02, 0x00, 0x48, 0x00, 0x69, 0, 0, 0, 0, 0, 0]);
}
[Fact]
public void WString_decode_ignores_padding_past_current_length()
{
byte[] block = [0x00, 0x05, 0x00, 0x02, 0x00, 0x48, 0x00, 0x69, 0x00, 0x58, 0x00, 0x58, 0, 0];
Decode(S7DataType.WString, block, 5).ShouldBe("Hi");
}
[Theory]
[InlineData("")]
[InlineData("A")]
[InlineData("Grüße Ω")]
[InlineData("12345678")] // exactly at the declared max
public void WString_round_trips_including_boundary_lengths(string s)
{
var bytes = Encode(S7DataType.WString, s, 8);
bytes.Length.ShouldBe(4 + 16);
Decode(S7DataType.WString, bytes, 8).ShouldBe(s);
}
[Fact]
public void WString_empty_with_zero_max_is_a_bare_header()
{
var bytes = Encode(S7DataType.WString, "", 0);
bytes.ShouldBe([0, 0, 0, 0]);
Decode(S7DataType.WString, bytes, 0).ShouldBe("");
}
[Fact]
public void WString_write_over_declared_max_is_refused_not_truncated() =>
Should.Throw<S7ValueRangeException>(() => Encode(S7DataType.WString, "123456789", 8));
[Fact]
public void WString_decode_rejects_curLen_above_maxLen() =>
Should.Throw<S7ValueRangeException>(() =>
Decode(S7DataType.WString, [0x00, 0x02, 0x00, 0x03, 0, 0x41, 0, 0x42, 0, 0x43], 2));
[Fact]
public void WString_decode_rejects_length_running_past_block() =>
Should.Throw<S7ValueRangeException>(() =>
Decode(S7DataType.WString, [0x00, 0x05, 0x00, 0x04, 0, 0x41], 5));
// ---- CHAR / WCHAR ----
[Fact]
public void Char_known_vectors()
{
Encode(S7DataType.Char, "A").ShouldBe([0x41]);
Encode(S7DataType.Char, "é").ShouldBe([0xE9]);
Decode(S7DataType.Char, [0x41]).ShouldBe("A");
Decode(S7DataType.Char, [0xE9]).ShouldBe("é");
}
[Fact]
public void Char_refuses_wide_character_and_wrong_length()
{
Should.Throw<S7ValueRangeException>(() => Encode(S7DataType.Char, "Ā")); // U+0100
Should.Throw<S7Driver.S7WriteValueException>(() => Encode(S7DataType.Char, "AB"));
Should.Throw<S7Driver.S7WriteValueException>(() => Encode(S7DataType.Char, ""));
}
[Fact]
public void WChar_known_vectors()
{
Encode(S7DataType.WChar, "Ω").ShouldBe([0x03, 0xA9]);
Encode(S7DataType.WChar, "A").ShouldBe([0x00, 0x41]);
Decode(S7DataType.WChar, [0x03, 0xA9]).ShouldBe("Ω");
}
// ---- DTL ----
// Siemens documentation example: DTL#2008-12-16-20:30:20.250 (a Tuesday → S7 weekday 3).
private static readonly byte[] DtlVector = [0x07, 0xD8, 0x0C, 0x10, 0x03, 0x14, 0x1E, 0x14, 0x0E, 0xE6, 0xB2, 0x80];
[Fact]
public void Dtl_known_vector_both_ways()
{
var expected = new DateTime(2008, 12, 16, 20, 30, 20, 250);
Decode(S7DataType.Dtl, DtlVector).ShouldBe(expected);
Encode(S7DataType.Dtl, expected).ShouldBe(DtlVector);
}
[Fact]
public void Dtl_keeps_100ns_resolution()
{
var dt = new DateTime(2030, 1, 2, 3, 4, 5).AddTicks(1234567);
Decode(S7DataType.Dtl, Encode(S7DataType.Dtl, dt)).ShouldBe(dt);
}
[Fact]
public void Dtl_uninitialised_zero_buffer_is_rejected_not_year_zero() =>
Should.Throw<S7ValueRangeException>(() => Decode(S7DataType.Dtl, new byte[12]));
[Fact]
public void Dtl_invalid_calendar_fields_are_rejected()
{
var bad = (byte[])DtlVector.Clone();
bad[2] = 13; // month 13
Should.Throw<S7ValueRangeException>(() => Decode(S7DataType.Dtl, bad));
bad = (byte[])DtlVector.Clone();
bad[8] = 0xFF; // nanoseconds > 999,999,999
Should.Throw<S7ValueRangeException>(() => Decode(S7DataType.Dtl, bad));
}
[Theory]
[InlineData(1969)]
[InlineData(2263)]
public void Dtl_write_outside_plc_year_range_is_refused(int year) =>
Should.Throw<S7ValueRangeException>(() => Encode(S7DataType.Dtl, new DateTime(year, 1, 1)));
// ---- DT (existing S7DataType.DateTime — regression vector) ----
[Fact]
public void Dt_still_routes_through_the_8_byte_bcd_codec()
{
var dt = new DateTime(2008, 12, 16, 20, 30, 20, 250);
var bytes = Encode(S7DataType.DateTime, dt);
bytes.Length.ShouldBe(8);
bytes.Take(7).ToArray().ShouldBe([0x08, 0x12, 0x16, 0x20, 0x30, 0x20, 0x25]);
Decode(S7DataType.DateTime, bytes).ShouldBe(dt);
}
// ---- DATE ----
[Theory]
[InlineData(1990, 1, 1, 0x0000)]
[InlineData(2000, 2, 29, 0x0E7F)]
[InlineData(2009, 12, 1, 0x1C6A)]
[InlineData(2168, 12, 31, 0xFF62)]
public void Date_known_vectors(int y, int m, int d, int days)
{
byte[] bytes = [(byte)(days >> 8), (byte)days];
Decode(S7DataType.Date, bytes).ShouldBe(new DateTime(y, m, d));
Encode(S7DataType.Date, new DateTime(y, m, d, 17, 5, 0)).ShouldBe(bytes); // time-of-day dropped
}
[Fact]
public void Date_write_outside_uint16_range_is_refused()
{
Should.Throw<S7ValueRangeException>(() => Encode(S7DataType.Date, new DateTime(1989, 12, 31)));
Should.Throw<S7ValueRangeException>(() => Encode(S7DataType.Date, new DateTime(2169, 6, 7)));
Encode(S7DataType.Date, new DateTime(2169, 6, 6)).ShouldBe([0xFF, 0xFF]);
}
// ---- TOD ----
[Theory]
[InlineData(0, new byte[] { 0, 0, 0, 0 })]
[InlineData(1000, new byte[] { 0, 0, 0x03, 0xE8 })]
[InlineData(86_399_999, new byte[] { 0x05, 0x26, 0x5B, 0xFF })] // TOD#23:59:59.999
public void Tod_known_vectors(int ms, byte[] bytes)
{
Decode(S7DataType.Tod, bytes).ShouldBe(ms);
Encode(S7DataType.Tod, ms).ShouldBe(bytes);
}
[Fact]
public void Tod_boundaries_are_refused_on_both_sides()
{
Should.Throw<S7ValueRangeException>(() => Encode(S7DataType.Tod, 86_400_000));
Should.Throw<S7ValueRangeException>(() => Encode(S7DataType.Tod, -1));
Should.Throw<S7ValueRangeException>(() => Decode(S7DataType.Tod, [0x05, 0x26, 0x5C, 0x00]));
Encode(S7DataType.Tod, TimeSpan.FromHours(1)).ShouldBe([0, 0x36, 0xEE, 0x80]);
}
// ---- TIME ----
[Theory]
[InlineData(86_400_000, new byte[] { 0x05, 0x26, 0x5C, 0x00 })] // T#1d
[InlineData(-1000, new byte[] { 0xFF, 0xFF, 0xFC, 0x18 })] // T#-1s
[InlineData(int.MaxValue, new byte[] { 0x7F, 0xFF, 0xFF, 0xFF })] // T#24d20h31m23s647ms
[InlineData(int.MinValue, new byte[] { 0x80, 0, 0, 0 })]
public void Time_known_vectors(int ms, byte[] bytes)
{
Decode(S7DataType.Time, bytes).ShouldBe(ms);
Encode(S7DataType.Time, ms).ShouldBe(bytes);
}
[Fact]
public void Time_outside_int32_is_refused() =>
Should.Throw<S7ValueRangeException>(() => Encode(S7DataType.Time, (long)int.MaxValue + 1));
// ---- S5TIME ----
[Theory]
[InlineData(0x0000, 0)]
[InlineData(0x0999, 9_990)] // 999 × 10 ms
[InlineData(0x1500, 50_000)] // 500 × 100 ms
[InlineData(0x2127, 127_000)] // W#16#2127 = 127 s (Siemens docs)
[InlineData(0x3999, 9_990_000)] // 999 × 10 s, the maximum
[InlineData(0xC127, 1_270)] // bits 15-14 are ignored (base 00, 127 × 10 ms)
public void S5Time_decode_known_vectors(int word, int ms) =>
Decode(S7DataType.S5Time, [(byte)(word >> 8), (byte)word]).ShouldBe(ms);
[Theory]
[InlineData(0, 0x0000)]
[InlineData(9_990, 0x0999)] // finest base that fits
[InlineData(10_000, 0x1100)] // 100 × 100 ms
[InlineData(127_000, 0x2127)]
[InlineData(9_990_000, 0x3999)]
public void S5Time_encode_picks_the_finest_base_that_fits(int ms, int word) =>
Encode(S7DataType.S5Time, ms).ShouldBe([(byte)(word >> 8), (byte)word]);
[Fact]
public void S5Time_non_bcd_nibble_is_rejected_on_read() =>
Should.Throw<S7ValueRangeException>(() => Decode(S7DataType.S5Time, [0x01, 0x2A]));
[Fact]
public void S5Time_unrepresentable_writes_are_refused()
{
Should.Throw<S7ValueRangeException>(() => Encode(S7DataType.S5Time, 9_990_001)); // above max
Should.Throw<S7ValueRangeException>(() => Encode(S7DataType.S5Time, -10));
Should.Throw<S7ValueRangeException>(() => Encode(S7DataType.S5Time, 15)); // not a multiple of 10 ms
Should.Throw<S7ValueRangeException>(() => Encode(S7DataType.S5Time, 1_000_500)); // no base holds it exactly
}
[Fact]
public void S5Time_round_trips_every_representable_value_in_a_sample()
{
foreach (var ms in new[] { 10, 990, 9_990, 10_000, 99_900, 100_000, 999_000, 1_000_000, 9_990_000 })
Decode(S7DataType.S5Time, Encode(S7DataType.S5Time, ms)).ShouldBe(ms);
}
// ---- type mapping ----
[Theory]
[InlineData(S7DataType.WString, DriverDataType.String)]
[InlineData(S7DataType.Char, DriverDataType.String)]
[InlineData(S7DataType.WChar, DriverDataType.String)]
[InlineData(S7DataType.Dtl, DriverDataType.DateTime)]
[InlineData(S7DataType.Date, DriverDataType.DateTime)]
[InlineData(S7DataType.Tod, DriverDataType.Int32)]
[InlineData(S7DataType.Time, DriverDataType.Int32)]
[InlineData(S7DataType.S5Time, DriverDataType.Int32)]
public async Task Discovery_maps_new_types_onto_existing_driver_types(S7DataType dt, DriverDataType expected)
{
var opts = new S7DriverOptions
{
Host = "192.0.2.1",
Probe = new S7ProbeOptions { Enabled = false },
RawTags = S7RawTags.Entries(Tag(dt, 10) with { Name = "X" }),
};
using var drv = new S7Driver(opts, "s7-map", new ConnectingFakeS7PlcFactory());
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var builder = new RecordingAddressSpaceBuilder();
await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken);
builder.Variables.Single().Attr.DriverDataType.ShouldBe(expected);
}
[Fact]
public async Task New_types_declared_as_arrays_or_off_byte_addresses_fail_init()
{
S7DriverOptions With(S7TagDefinition t) => new()
{
Host = "192.0.2.1",
Probe = new S7ProbeOptions { Enabled = false },
RawTags = S7RawTags.Entries(t),
};
using var arr = new S7Driver(With(Tag(S7DataType.Dtl) with { ArrayCount = 2 }), "a", new ConnectingFakeS7PlcFactory());
await Should.ThrowAsync<NotSupportedException>(() => arr.InitializeAsync("{}", TestContext.Current.CancellationToken));
using var word = new S7Driver(With(Tag(S7DataType.Tod, address: "DB1.DBW0")), "b", new ConnectingFakeS7PlcFactory());
await Should.ThrowAsync<NotSupportedException>(() => word.InitializeAsync("{}", TestContext.Current.CancellationToken));
}
// ---- TagConfig ----
[Fact]
public void TagConfig_accepts_wstring_length_up_to_16382_but_string_stays_254()
{
S7TagDefinitionFactory.FromTagConfig(
"""{"address":"DB1.DBB0","dataType":"WString","stringLength":16382}""", "p", out var d).ShouldBeTrue();
d.DataType.ShouldBe(S7DataType.WString);
d.StringLength.ShouldBe(16382);
S7TagDefinitionFactory.FromTagConfig(
"""{"address":"DB1.DBB0","dataType":"WString","stringLength":16383}""", "p", out _).ShouldBeFalse();
S7TagDefinitionFactory.FromTagConfig(
"""{"address":"DB1.DBB0","dataType":"String","stringLength":255}""", "p", out _).ShouldBeFalse();
S7TagDefinitionFactory.Inspect("""{"address":"DB1.DBB0","dataType":"WString","stringLength":99999}""")
.ShouldHaveSingleItem().ShouldContain("16382");
}
[Theory]
[InlineData("Char")]
[InlineData("WChar")]
[InlineData("Dtl")]
[InlineData("Date")]
[InlineData("Tod")]
[InlineData("Time")]
[InlineData("S5Time")]
public void TagConfig_round_trips_every_new_type_name(string name)
{
var dt = Enum.Parse<S7DataType>(name);
var cfg = S7TagDefinitionFactory.ToTagConfig(Tag(dt));
S7TagDefinitionFactory.FromTagConfig(cfg, "p", out var back).ShouldBeTrue();
back.DataType.ShouldBe(dt);
}
// ---- over the seam: status mapping ----
[Fact]
public async Task Driver_reads_writes_and_maps_range_errors_without_degrading_health()
{
var plc = new MemoryS7Plc();
var opts = new S7DriverOptions
{
Host = "192.0.2.1",
Probe = new S7ProbeOptions { Enabled = false },
RawTags = S7RawTags.Entries(
new S7TagDefinition("Ws", "DB1.DBB0", S7DataType.WString, StringLength: 4),
new S7TagDefinition("Dtl", "DB1.DBB100", S7DataType.Dtl),
new S7TagDefinition("S5", "DB1.DBB120", S7DataType.S5Time)),
};
using var drv = new S7Driver(opts, "s7-codec-e2e", new MemoryS7PlcFactory(plc));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var ct = TestContext.Current.CancellationToken;
// Good write then read-back.
(await drv.WriteAsync([new WriteRequest("Ws", "abcd")], ct))[0].StatusCode.ShouldBe(0u);
plc.Mem.Take(12).ToArray().ShouldBe([0, 4, 0, 4, 0, 0x61, 0, 0x62, 0, 0x63, 0, 0x64]);
var r = await drv.ReadAsync(["Ws"], ct);
r[0].StatusCode.ShouldBe(0u);
r[0].Value.ShouldBe("abcd");
// Over-length write → BadOutOfRange, nothing written, health unharmed.
(await drv.WriteAsync([new WriteRequest("Ws", "abcde")], ct))[0].StatusCode.ShouldBe(0x803C0000u);
plc.Mem.Skip(5).Take(1).Single().ShouldBe((byte)0x61);
// Uninitialised DTL (all zero) reads BadOutOfRange, not a comms fault; the sibling stays Good.
var reads = await drv.ReadAsync(["Dtl", "Ws"], ct);
reads[0].StatusCode.ShouldBe(0x803C0000u);
reads[1].StatusCode.ShouldBe(0u);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
// S5TIME value that fits no base → BadOutOfRange; wrong-shape value → BadTypeMismatch.
(await drv.WriteAsync([new WriteRequest("S5", 15)], ct))[0].StatusCode.ShouldBe(0x803C0000u);
(await drv.WriteAsync([new WriteRequest("S5", "soon")], ct))[0].StatusCode.ShouldBe(0x80740000u);
(await drv.WriteAsync([new WriteRequest("S5", 127_000)], ct))[0].StatusCode.ShouldBe(0u);
(await drv.ReadAsync(["S5"], ct))[0].Value.ShouldBe(127_000);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
// ---- fakes ----
private sealed class MemoryS7PlcFactory(MemoryS7Plc plc) : IS7PlcFactory
{
public IS7Plc Create(S7CpuType cpuType, string host, int port, short rack, short slot, TimeSpan timeout) => plc;
}
/// <summary>Single-DB memory-backed fake: block reads/writes address one flat byte array.</summary>
internal sealed class MemoryS7Plc : IS7Plc
{
public byte[] Mem { get; } = new byte[512];
public bool IsConnected { get; private set; }
public Task OpenAsync(CancellationToken ct) { IsConnected = true; return Task.CompletedTask; }
public void Close() => IsConnected = false;
public Task<object?> ReadAsync(string address, CancellationToken ct) => throw new NotSupportedException();
public Task<byte[]> ReadBytesAsync(S7NetDataType area, int db, int start, int count, CancellationToken ct) =>
Task.FromResult(Mem.AsSpan(start, count).ToArray());
public Task<IReadOnlyList<global::S7.Net.Types.DataItem>> ReadMultipleVarsAsync(
List<global::S7.Net.Types.DataItem> items, CancellationToken ct) => throw new NotSupportedException();
public Task WriteAsync(string address, object value, CancellationToken ct) => throw new NotSupportedException();
public Task WriteBytesAsync(S7NetDataType area, int db, int start, byte[] value, CancellationToken ct)
{
value.CopyTo(Mem, start);
return Task.CompletedTask;
}
public Task ReadStatusAsync(CancellationToken ct) => Task.CompletedTask;
public void Dispose() => IsConnected = false;
}
}
@@ -105,4 +105,37 @@ public sealed class S7DriverPageFormSerializationTests
roundTripped.Probe.Timeout.ShouldBe(TimeSpan.FromSeconds(4));
roundTripped.ProbeTimeoutSeconds.ShouldBe(20);
}
[Fact]
public void FormModel_RoundTrip_PreservesTsapOverrides()
{
var opts = new S7DriverOptions
{
Host = "192.168.1.50",
LocalTsap = "01.00",
RemoteTsap = "03.01",
};
var back = S7DriverForm.FormModel.FromOptions(opts).ToOptions();
back.LocalTsap.ShouldBe("01.00");
back.RemoteTsap.ShouldBe("03.01");
back.ConnectionType.ShouldBeNull();
var withType = S7DriverForm.FormModel.FromOptions(new S7DriverOptions { ConnectionType = S7ConnectionType.OP }).ToOptions();
withType.ConnectionType.ShouldBe(S7ConnectionType.OP);
// Defaults stay absent: a blank form must not invent an override.
var blank = S7DriverForm.FormModel.FromOptions(new S7DriverOptions()).ToOptions();
blank.LocalTsap.ShouldBeNull();
blank.RemoteTsap.ShouldBeNull();
blank.ConnectionType.ShouldBeNull();
}
[Fact]
public void ConnectionType_serialises_as_a_string_the_factory_binds()
{
var json = JsonSerializer.Serialize(
new S7DriverOptions { ConnectionType = S7ConnectionType.PG },
new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, Converters = { new JsonStringEnumConverter() } });
json.ShouldContain("\"connectionType\":\"PG\"");
}
}
@@ -112,6 +112,14 @@ public sealed class S7TagConfigModelTests
[InlineData("Float64")]
[InlineData("String")]
[InlineData("DateTime")]
[InlineData("WString")]
[InlineData("Char")]
[InlineData("WChar")]
[InlineData("Dtl")]
[InlineData("Date")]
[InlineData("Tod")]
[InlineData("Time")]
[InlineData("S5Time")]
public void Validate_rejects_wide_type_arrays(string dataType)
{
var m = S7TagConfigModel.FromJson(