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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user