diff --git a/docs/drivers/S7.md b/docs/drivers/S7.md index 824d8e80..23ce7de8 100644 --- a/docs/drivers/S7.md +++ b/docs/drivers/S7.md @@ -65,6 +65,30 @@ 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.` 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). + +### Test Connect and PUT/GET + +After the handshake, Test Connect reads 2 bytes from `MB0`. A CPU with PUT/GET disabled (the S7-1200/1500 +default) completes the handshake but denies every read, so the probe fails with an actionable "enable +PUT/GET communication" message instead of a false green tick. A CPU that merely rejects that address (no +`MB0`) still passes — the connection is fine; any other read fault fails the probe with its message. + ## Address forms Addresses use Siemens TIA-Portal / STEP 7 Classic syntax, parsed by @@ -102,19 +126,52 @@ 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 fractional millisecond `Double` is rounded to the nearest whole millisecond (banker's rounding), exactly +as the driver does for every other Int32 tag write; only values off the S5TIME time-base grid are refused. +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 +182,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 +214,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` diff --git a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/Commands/WriteCommand.cs b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/Commands/WriteCommand.cs index 4c577290..4294c1f7 100644 --- a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/Commands/WriteCommand.cs +++ b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/Commands/WriteCommand.cs @@ -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."), }; } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7CpuType.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7CpuType.cs index 2c739c89..a17db3bb 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7CpuType.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7CpuType.cs @@ -16,3 +16,20 @@ public enum S7CpuType S71200 = 30, S71500 = 40, } + +/// +/// ISO-TSAP connection resource requested during the handshake — the FIRST byte of the remote TSAP +/// (the second byte is rack × 0x20 + slot). Values are the on-wire bytes. Leave unset to keep +/// S7.Net's per-CPU default ( for the S7-300/400/1200/1500 families). +/// +public enum S7ConnectionType +{ + /// Programming-device (PG) connection resource. + PG = 1, + + /// Operator-panel (OP/HMI) connection resource. + OP = 2, + + /// "S7 basic" / other-communication connection resource — S7.Net's default. + S7Basic = 3, +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7DriverOptions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7DriverOptions.cs index a5771e2e..d94968c3 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7DriverOptions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7DriverOptions.cs @@ -51,6 +51,34 @@ public sealed class S7DriverOptions /// public short Slot { get; init; } = 0; + /// + /// Optional override of the LOCAL ISO-TSAP, as two hex bytes "01.00" (also accepted: + /// "0100", "0x0100"). Null/blank keeps S7.Net's per-CPU default (01.00). + /// Needed for CPUs / gateways that reserve connection resources by TSAP (S7-400H, CP343, LOGO!). + /// + [System.Text.Json.Serialization.JsonConverter(typeof(S7TsapJsonConverter))] + public string? LocalTsap { get; init; } + + /// + /// Optional override of the REMOTE ISO-TSAP, same text form as + /// (e.g. "03.01" = S7-basic resource, rack 0 slot 1). Null/blank keeps the slot-derived default. + /// Mutually exclusive with . + /// + [System.Text.Json.Serialization.JsonConverter(typeof(S7TsapJsonConverter))] + public string? RemoteTsap { get; init; } + + /// + /// Optional connection resource (PG / OP / S7Basic) written into the first byte of the slot-derived + /// remote TSAP, keeping rack × 0x20 + slot as the second byte. Null keeps the default. + /// Mutually exclusive with . + /// + public S7ConnectionType? ConnectionType { get; init; } + + /// The authoring/load error for setting both and ; shared by the factory and the AdminUI form so they never disagree. + public const string TsapConflictDetail = + "sets both RemoteTsap and ConnectionType; ConnectionType only fills the first byte of the " + + "slot-derived remote TSAP, so choose one."; + /// Connect + per-operation timeout. public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(5); @@ -139,6 +167,31 @@ public sealed class S7DriverOptions public const int DefaultSpanReadMaxSpanBytes = 220; } +/// +/// Reads a TSAP as either hex text ("03.01") or a JSON number (769, the form the driver +/// factory also accepts) and always writes the hex text. Without it a numeric TSAP would fail to bind to +/// the string property, and a typed consumer (the AdminUI form, the Test Connect probe) would fall back +/// to defaults — a form Save would then silently erase the authored config. +/// +public sealed class S7TsapJsonConverter : System.Text.Json.Serialization.JsonConverter +{ + /// + public override string? Read(ref System.Text.Json.Utf8JsonReader reader, Type typeToConvert, System.Text.Json.JsonSerializerOptions options) + { + if (reader.TokenType == System.Text.Json.JsonTokenType.Null) return null; + if (reader.TokenType == System.Text.Json.JsonTokenType.Number && reader.TryGetUInt16(out var n)) + return $"{n >> 8:X2}.{n & 0xFF:X2}"; + if (reader.TokenType == System.Text.Json.JsonTokenType.String) return reader.GetString(); + throw new System.Text.Json.JsonException("S7 TSAP must be hex text such as \"03.01\" or a 16-bit number."); + } + + /// + public override void Write(System.Text.Json.Utf8JsonWriter writer, string? value, System.Text.Json.JsonSerializerOptions options) + { + if (value is null) writer.WriteNullValue(); else writer.WriteStringValue(value); + } +} + public sealed class S7ProbeOptions { /// Gets or sets a value indicating whether probing is enabled. @@ -197,5 +250,22 @@ public enum S7DataType Float32, Float64, String, + /// S7 DATE_AND_TIME (DT, 8-byte BCD) → DateTime. The other date/time members below were appended after it. DateTime, + /// S7 WSTRING (UTF-16BE, 4-byte header) → String. StringLength is the declared max in characters (≤ 16382). + WString, + /// S7 CHAR (1 byte, Latin-1) → one-character String. + Char, + /// S7 WCHAR (UTF-16BE code unit) → one-character String. + WChar, + /// S7 DTL (12 bytes, ns resolution) → DateTime. + Dtl, + /// S7 DATE (UInt16 days since 1990-01-01) → DateTime. + Date, + /// S7 TIME_OF_DAY (UInt32 ms since midnight) → Int32 milliseconds. + Tod, + /// S7 TIME (Int32 ms, signed duration) → Int32 milliseconds. + Time, + /// S7 S5TIME (BCD + time base) → Int32 milliseconds. + S5Time, } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7TagDefinitionFactory.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7TagDefinitionFactory.cs index f89fa18a..e4368a25 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7TagDefinitionFactory.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7TagDefinitionFactory.cs @@ -20,6 +20,20 @@ public static class S7TagDefinitionFactory /// S7 string max length (DBSTRING header reserves 2 bytes; 254 chars max). private const int MaxStringLength = 254; + /// S7 WSTRING max length in characters (S7-1500; the 4-byte header's UInt16 is capped at 16382 so the field fits a DB). + private const int MaxWStringLength = 16382; + + /// + /// The declared-length ceiling for a type that carries a stringLength, or null for a type that + /// does not (every non-string type ignores the key, so a copy-paste remnant never blocks parsing). + /// + private static int? MaxLengthFor(S7DataType t) => t switch + { + S7DataType.String => MaxStringLength, + S7DataType.WString => MaxWStringLength, + _ => null, + }; + /// /// Maps an authored TagConfig object to a typed definition keyed by . /// 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(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)."); } } } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/IS7Plc.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/IS7Plc.cs index 9e01f6f0..83966fc8 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/IS7Plc.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/IS7Plc.cs @@ -29,6 +29,13 @@ public interface IS7Plc : IDisposable /// Closes the S7 connection. Best-effort and idempotent. void Close(); + /// + /// PDU size negotiated with the CPU during , 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. + /// + int MaxPduSize => 0; + /// Reads a scalar value by S7 address string (e.g. DB1.DBW0); the boxed type follows the size suffix. /// The S7 address string. /// Cancellation token for the read. @@ -94,6 +101,16 @@ public interface IS7PlcFactory /// Connect + per-operation timeout, applied to the underlying read/write timeouts. /// A new, not-yet-opened . IS7Plc Create(S7CpuType cpuType, string host, int port, short rack, short slot, TimeSpan timeout); + + /// + /// 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 and friends. + /// + /// The driver options. + /// A new, not-yet-opened . + IS7Plc Create(S7DriverOptions options) => + Create(options.CpuType, options.Host, options.Port, options.Rack, options.Slot, options.Timeout); } /// @@ -114,8 +131,23 @@ internal sealed class S7PlcAdapter : IS7Plc /// CPU slot. /// Connect + per-operation timeout. 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); + } + + /// Initializes a new instance with an explicit TSAP pair (LocalTsap / RemoteTsap / ConnectionType override). + /// PLC IP address or hostname. + /// TCP port. + /// The effective TSAP pair. + /// Connect + per-operation timeout. + 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 /// public bool IsConnected => _plc.IsConnected; + /// + public int MaxPduSize => _plc.MaxPDUSize; + /// public Task OpenAsync(CancellationToken cancellationToken) => _plc.OpenAsync(cancellationToken); @@ -187,4 +222,10 @@ internal sealed class S7PlcFactory : IS7PlcFactory /// public IS7Plc Create(S7CpuType cpuType, string host, int port, short rack, short slot, TimeSpan timeout) => new S7PlcAdapter(cpuType, host, port, rack, slot, timeout); + + /// + 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); } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs index e0110870..7c2a0ef1 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs @@ -117,6 +117,8 @@ public sealed class S7Driver private const uint StatusBadNotSupported = 0x803D0000u; /// OPC UA StatusCode used when a written value has the wrong shape / length / element type. private const uint StatusBadTypeMismatch = 0x80740000u; + /// OPC UA StatusCode used when a value is unrepresentable in the S7 type (write) or a PLC buffer does not decode (read). + private const uint StatusBadOutOfRange = 0x803C0000u; /// OPC UA StatusCode used when the tag is declared read-only. private const uint StatusBadNotWritable = 0x803B0000u; /// OPC UA StatusCode used when write fails validation (e.g. out-of-range value). @@ -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 /// . /// + /// + /// 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 and the connect log line. + /// + public int NegotiatedPduSize => Volatile.Read(ref _negotiatedPduSize); + + private int _negotiatedPduSize; + + /// + /// Driver-specific diagnostics key/value pairs (currently S7.NegotiatedPduSize). The core + /// record carries no diagnostics bag, so this is the surface until one exists. + /// + /// A snapshot of the diagnostics. + public IReadOnlyDictionary GetDiagnostics() => new Dictionary + { + ["S7.NegotiatedPduSize"] = NegotiatedPduSize, + }; + private static readonly HashSet 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, }; /// @@ -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 /// true if the tag routes through the byte-buffer codec. 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); /// /// 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; } @@ -2249,7 +2335,7 @@ public sealed class S7Driver /// no typed error code for it, so the inner message is the only discriminator /// S7.Net exposes. /// - private static bool IsAccessDenied(PlcException pex) + internal static bool IsAccessDenied(PlcException pex) { for (Exception? e = pex; e is not null; e = e.InnerException) { @@ -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); } } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverFactoryExtensions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverFactoryExtensions.cs index 8fa880aa..e769dba8 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverFactoryExtensions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverFactoryExtensions.cs @@ -87,9 +87,23 @@ 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(dto.ConnectionType, driverInstanceId, "ConnectionType"); + if (remoteTsap is not null && connectionType is not null) + throw new InvalidOperationException( + $"S7 driver config for '{driverInstanceId}' {S7DriverOptions.TsapConflictDetail}"); + return new S7DriverOptions { Host = dto.Host!, + LocalTsap = localTsap, + RemoteTsap = remoteTsap, + ConnectionType = connectionType, Port = dto.Port ?? 102, CpuType = ParseEnum(dto.CpuType, driverInstanceId, "CpuType", fallback: S7CpuType.S71500), @@ -115,6 +129,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(string? raw, string driverInstanceId, string field, string? tagName = null, T? fallback = null) where T : struct, Enum { @@ -151,6 +182,12 @@ public static class S7DriverFactoryExtensions public short? Rack { get; init; } /// Gets the slot number. public short? Slot { get; init; } + /// Gets the local TSAP override — hex text ("01.00") or a 16-bit number. + public JsonElement? LocalTsap { get; init; } + /// Gets the remote TSAP override — hex text ("03.01") or a 16-bit number. + public JsonElement? RemoteTsap { get; init; } + /// Gets the connection-type name (PG / OP / S7Basic); a string so AdminUI-authored configs bind. + public string? ConnectionType { get; init; } /// Gets the connection timeout in milliseconds. public int? TimeoutMs { get; init; } /// Gets the authored raw tags (RawPath + TagConfig blob + WriteIdempotent) the deploy diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverProbe.cs index 9d0b0380..d936b98a 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverProbe.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverProbe.cs @@ -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,15 @@ public sealed class S7DriverProbe : IDriverProbe sw.Stop(); if (plc.IsConnected) - return new(true, $"S7 connected (CPU {opts.CpuType})", sw.Elapsed); + { + // A handshake alone passes on a CPU with PUT/GET disabled (S7-1200/1500 default) — every + // later read would fail. One minimal read proves data access. + if (await CheckDataAccessAsync( + t => plc.ReadBytesAsync(global::S7.Net.DataType.Memory, 0, 0, 2, t), handshakeCts.Token) + is { } denied) + return denied; + return new(true, $"S7 connected (CPU {opts.CpuType}, PDU {plc.MaxPDUSize} bytes)", sw.Elapsed); + } return new(false, $"Reachable at {host}:{port} but S7 handshake failed: not connected", null); } @@ -100,6 +112,38 @@ public sealed class S7DriverProbe : IDriverProbe } } + /// + /// Runs one minimal read after the handshake. Returns null when data access works — including when the + /// CPU merely rejects that particular address (MB0 not present / out of range: the connection is fine). + /// PUT/GET-disabled yields the actionable message; any other fault is a probe failure with its message. + /// + /// The minimal read. + /// Cancellation token. + /// A failure result, or null on success. + internal static async Task CheckDataAccessAsync(Func> read, CancellationToken ct) + { + try + { + await read(ct).ConfigureAwait(false); + return null; + } + catch (OperationCanceledException) { throw; } + catch (PlcException pex) when (S7Driver.IsAccessDenied(pex)) + { + return new(false, + "S7 connected but data access is denied — enable PUT/GET communication in TIA Portal " + + $"(Protection & Security) for this CPU. PLC reported: {pex.Message}", null); + } + catch (PlcException pex) when (pex.ErrorCode == ErrorCode.ReadData) + { + return null; // address not available on this CPU — not a connection problem + } + catch (Exception ex) + { + return new(false, $"S7 connected but a test read failed: {ex.Message}", null); + } + } + private static (string host, int port) ExtractTarget(S7DriverOptions opts) => (opts.Host, opts.Port); } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Tsap.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Tsap.cs new file mode 100644 index 00000000..59466735 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Tsap.cs @@ -0,0 +1,88 @@ +using System.Globalization; +using S7.Net.Protocol; + +namespace ZB.MOM.WW.OtOpcUa.Driver.S7; + +/// +/// ISO-TSAP helpers for the optional LocalTsap / RemoteTsap / ConnectionType +/// config: parsing the hex text form and resolving the effective handed to +/// S7.Net. Pure — no I/O — so the resolution rules are unit-testable without a PLC. +/// +internal static class S7Tsap +{ + /// + /// Parses a TSAP written as two hex bytes — "01.00", "03:01" (1–2 digits per byte) — or + /// as one 16-bit hex value, "0100" / "0x0301". High byte first. + /// + /// The configured text. + /// The 16-bit TSAP (first << 8 | second). + /// True when is a valid TSAP. + 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); + } + + /// Formats a 16-bit TSAP as canonical "XX.YY" (upper-case hex). + /// The 16-bit TSAP. + /// The canonical text. + internal static string Format(ushort tsap) => $"{tsap >> 8:X2}.{tsap & 0xFF:X2}"; + + /// + /// Resolves the TSAP pair to connect with, or null when the options carry no override (the + /// caller then keeps the plain Plc(cpu, host, port, rack, slot) construction, unchanged). + /// Starts from S7.Net's own per-CPU default for 's rack/slot, then applies, + /// in order: LocalTsap replaces the local TSAP; RemoteTsap replaces the remote TSAP; + /// ConnectionType replaces only the remote TSAP's first byte. + /// + /// The driver options. + /// The effective pair, or null for "no override". + 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); + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7ValueCodecs.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7ValueCodecs.cs new file mode 100644 index 00000000..8017987b --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7ValueCodecs.cs @@ -0,0 +1,300 @@ +using System.Buffers.Binary; +using System.Text; + +namespace ZB.MOM.WW.OtOpcUa.Driver.S7; + +/// +/// 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 BadOutOfRange and, unlike a transport fault, does +/// not degrade driver health or condemn the connection. +/// +internal sealed class S7ValueRangeException(string message) : Exception(message); + +/// +/// Pure byte-level codecs for the S7 string, character and date/time/duration types the narrow +/// (1/2/4-byte) read path cannot express: WSTRING, CHAR, WCHAR, DTL, +/// DATE, TOD, TIME and S5TIME. 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 +/// STRING and DATE_AND_TIME (DT) stay on S7.Net's own codecs. +/// +/// +/// +/// OPC UA mapping (no new DriverDataType): WString/Char/WChar → String; +/// DTL/Date → DateTime; TOD, TIME, S5TIME → Int32 milliseconds (TOD = ms since midnight, +/// TIME = signed duration, S5TIME = duration). All values fit Int32. +/// +/// +/// Writes refuse rather than clamp or truncate: an unrepresentable value throws +/// (→ BadOutOfRange) with nothing sent to the PLC; a value +/// of the wrong shape throws (→ BadTypeMismatch). +/// +/// +internal static class S7ValueCodecs +{ + /// Largest declared WSTRING character count (S7-1500 limit; header is UInt16 but the PDU-safe ceiling is 16382). + internal const int MaxWStringLength = 16382; + + /// First day of the S7 DATE epoch. + private static readonly DateTime DateEpoch = new(1990, 1, 1, 0, 0, 0, DateTimeKind.Unspecified); + + private const int MillisecondsPerDay = 86_400_000; + + // ---- CHAR / WCHAR ---- + + /// Decodes a 1-byte S7 CHAR (Latin-1) to a one-character string. + internal static string DecodeChar(ReadOnlySpan block) + { + RequireLength(block, 1, "CHAR"); + return ((char)block[0]).ToString(); + } + + /// Encodes a one-character string to a 1-byte S7 CHAR. Characters above U+00FF are out of range. + 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]; + } + + /// Decodes a 2-byte S7 WCHAR (UTF-16 big-endian code unit) to a one-character string. + internal static string DecodeWChar(ReadOnlySpan block) + { + RequireLength(block, 2, "WCHAR"); + return ((char)BinaryPrimitives.ReadUInt16BigEndian(block)).ToString(); + } + + /// Encodes a one-character string to a 2-byte S7 WCHAR (UTF-16 big-endian). + 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 ---- + + /// Byte width of a WSTRING reserving characters: 4-byte header + 2 bytes per character. + internal static int WStringWidth(int maxLength) => 4 + (2 * maxLength); + + /// + /// Decodes [maxLen UInt16 BE][curLen UInt16 BE][curLen × UTF-16BE]. Characters past + /// curLen (the reserved padding) are ignored. + /// + internal static string DecodeWString(ReadOnlySpan 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)); + } + + /// + /// Encodes a string into the full reserved WSTRING field ( bytes, zero + /// padded). A value with more UTF-16 code units than is refused, not truncated. + /// + 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) ---- + + /// + /// Decodes DTL: year UInt16 BE, month, day, weekday, hour, minute, second, nanoseconds UInt32 BE. + /// The weekday byte is ignored. An all-zero (uninitialised) buffer is refused. + /// + internal static DateTime DecodeDtl(ReadOnlySpan 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}"); + } + } + + /// 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. + 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) ---- + + /// Decodes DATE (days since 1990-01-01, UInt16 BE) to a midnight DateTime. + internal static DateTime DecodeDate(ReadOnlySpan block) + { + RequireLength(block, 2, "DATE"); + return DateEpoch.AddDays(BinaryPrimitives.ReadUInt16BigEndian(block)); + } + + /// 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. + 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) ---- + + /// Decodes TIME_OF_DAY to Int32 milliseconds since midnight (0–86 399 999). + internal static int DecodeTod(ReadOnlySpan 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; + } + + /// Encodes milliseconds since midnight (or a ) to TOD; outside 0–86 399 999 is refused. + 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) ---- + + /// Decodes IEC TIME to a signed Int32 millisecond duration. + internal static int DecodeTime(ReadOnlySpan block) + { + RequireLength(block, 4, "TIME"); + return BinaryPrimitives.ReadInt32BigEndian(block); + } + + /// Encodes a signed millisecond duration (or ) to TIME; outside the Int32 range is refused. + 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]; + + /// + /// 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. + /// + internal static int DecodeS5Time(ReadOnlySpan 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]; + } + + /// + /// 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. + /// + 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 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})."); + } + } + + // Fractional input is rounded (Convert.ToInt64, banker's), matching the driver's existing Int32 write + // path (BoxValueForWrite → Convert.ToInt32). "Refused, not rounded" applies to S5TIME's time-base grid. + private static long ToMilliseconds(object? value, string type) + { + try { return value is TimeSpan ts ? (long)ts.TotalMilliseconds : Convert.ToInt64(value, System.Globalization.CultureInfo.InvariantCulture); } + catch (Exception ex) when (ex is FormatException or InvalidCastException or OverflowException) + { + throw new S7Driver.S7WriteValueException($"S7 {type} write needs a millisecond count ({ex.Message})."); + } + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/S7DriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/S7DriverForm.razor index da936e66..b68cf28d 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/S7DriverForm.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/S7DriverForm.razor @@ -30,6 +30,36 @@
Controls ISO-TSAP slot byte during handshake.
+
+
+ + + + @foreach (var v in Enum.GetValues()) + { + + } + +
First byte of the remote TSAP. Leave on default unless the CPU needs a PG/OP resource.
+
+
+ + +
Optional hex override, e.g. 01.00.
+
+
+ + +
Optional hex override, e.g. 03.01. Not combinable with Connection type.
+
+
+ @if (_form.TsapConflictError() is { } tsapError) + { + + } @@ -141,6 +171,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