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:
+65
-8
@@ -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 << 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}).");
|
||||
}
|
||||
}
|
||||
}
|
||||
+36
@@ -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.";
|
||||
|
||||
+13
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user