fix(s7): numeric TSAP loads in form, shared TSAP conflict error, Test Connect read detects PUT/GET disabled, pin Int32 ms rounding (#632 #638)

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 14:38:49 -04:00
parent 34c52defb3
commit ff055ebd79
10 changed files with 171 additions and 3 deletions
@@ -56,6 +56,7 @@ public sealed class S7DriverOptions
/// <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>
[System.Text.Json.Serialization.JsonConverter(typeof(S7TsapJsonConverter))]
public string? LocalTsap { get; init; }
/// <summary>
@@ -63,6 +64,7 @@ public sealed class S7DriverOptions
/// (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>
[System.Text.Json.Serialization.JsonConverter(typeof(S7TsapJsonConverter))]
public string? RemoteTsap { get; init; }
/// <summary>
@@ -72,6 +74,11 @@ public sealed class S7DriverOptions
/// </summary>
public S7ConnectionType? ConnectionType { get; init; }
/// <summary>The authoring/load error for setting both <see cref="RemoteTsap"/> and <see cref="ConnectionType"/>; shared by the factory and the AdminUI form so they never disagree.</summary>
public const string TsapConflictDetail =
"sets both RemoteTsap and ConnectionType; ConnectionType only fills the first byte of the " +
"slot-derived remote TSAP, so choose one.";
/// <summary>Connect + per-operation timeout.</summary>
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(5);
@@ -160,6 +167,31 @@ public sealed class S7DriverOptions
public const int DefaultSpanReadMaxSpanBytes = 220;
}
/// <summary>
/// Reads a TSAP as either hex text (<c>"03.01"</c>) or a JSON number (<c>769</c>, the form the driver
/// factory also accepts) and always writes the hex text. Without it a numeric TSAP would fail to bind to
/// the string property, and a typed consumer (the AdminUI form, the Test Connect probe) would fall back
/// to defaults — a form Save would then silently erase the authored config.
/// </summary>
public sealed class S7TsapJsonConverter : System.Text.Json.Serialization.JsonConverter<string?>
{
/// <inheritdoc />
public override string? Read(ref System.Text.Json.Utf8JsonReader reader, Type typeToConvert, System.Text.Json.JsonSerializerOptions options)
{
if (reader.TokenType == System.Text.Json.JsonTokenType.Null) return null;
if (reader.TokenType == System.Text.Json.JsonTokenType.Number && reader.TryGetUInt16(out var n))
return $"{n >> 8:X2}.{n & 0xFF:X2}";
if (reader.TokenType == System.Text.Json.JsonTokenType.String) return reader.GetString();
throw new System.Text.Json.JsonException("S7 TSAP must be hex text such as \"03.01\" or a 16-bit number.");
}
/// <inheritdoc />
public override void Write(System.Text.Json.Utf8JsonWriter writer, string? value, System.Text.Json.JsonSerializerOptions options)
{
if (value is null) writer.WriteNullValue(); else writer.WriteStringValue(value);
}
}
public sealed class S7ProbeOptions
{
/// <summary>Gets or sets a value indicating whether probing is enabled.</summary>
@@ -2335,7 +2335,7 @@ public sealed class S7Driver
/// no typed error code for it, so the inner message is the only discriminator
/// S7.Net exposes.
/// </summary>
private static bool IsAccessDenied(PlcException pex)
internal static bool IsAccessDenied(PlcException pex)
{
for (Exception? e = pex; e is not null; e = e.InnerException)
{
@@ -96,8 +96,7 @@ public static class S7DriverFactoryExtensions
: 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.");
$"S7 driver config for '{driverInstanceId}' {S7DriverOptions.TsapConflictDetail}");
return new S7DriverOptions
{
@@ -80,7 +80,15 @@ public sealed class S7DriverProbe : IDriverProbe
sw.Stop();
if (plc.IsConnected)
{
// A handshake alone passes on a CPU with PUT/GET disabled (S7-1200/1500 default) — every
// later read would fail. One minimal read proves data access.
if (await CheckDataAccessAsync(
t => plc.ReadBytesAsync(global::S7.Net.DataType.Memory, 0, 0, 2, t), handshakeCts.Token)
is { } denied)
return denied;
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);
}
@@ -104,6 +112,38 @@ public sealed class S7DriverProbe : IDriverProbe
}
}
/// <summary>
/// Runs one minimal read after the handshake. Returns null when data access works — including when the
/// CPU merely rejects that particular address (MB0 not present / out of range: the connection is fine).
/// PUT/GET-disabled yields the actionable message; any other fault is a probe failure with its message.
/// </summary>
/// <param name="read">The minimal read.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>A failure result, or null on success.</returns>
internal static async Task<DriverProbeResult?> CheckDataAccessAsync(Func<CancellationToken, Task<byte[]>> read, CancellationToken ct)
{
try
{
await read(ct).ConfigureAwait(false);
return null;
}
catch (OperationCanceledException) { throw; }
catch (PlcException pex) when (S7Driver.IsAccessDenied(pex))
{
return new(false,
"S7 connected but data access is denied — enable PUT/GET communication in TIA Portal " +
$"(Protection & Security) for this CPU. PLC reported: {pex.Message}", null);
}
catch (PlcException pex) when (pex.ErrorCode == ErrorCode.ReadData)
{
return null; // address not available on this CPU — not a connection problem
}
catch (Exception ex)
{
return new(false, $"S7 connected but a test read failed: {ex.Message}", null);
}
}
private static (string host, int port) ExtractTarget(S7DriverOptions opts)
=> (opts.Host, opts.Port);
}
@@ -287,6 +287,8 @@ internal static class S7ValueCodecs
}
}
// Fractional input is rounded (Convert.ToInt64, banker's), matching the driver's existing Int32 write
// path (BoxValueForWrite → Convert.ToInt32). "Refused, not rounded" applies to S5TIME's time-base grid.
private static long ToMilliseconds(object? value, string type)
{
try { return value is TimeSpan ts ? (long)ts.TotalMilliseconds : Convert.ToInt64(value, System.Globalization.CultureInfo.InvariantCulture); }