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
@@ -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).");
}
}
}