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