Files
lmxopcua/docs/drivers/S7.md
2026-10-13 11:34:59 -04:00

22 KiB
Raw Permalink Blame History

Siemens S7 Driver

Getting-started guide for the Siemens S7 driver. This is the short path — for the full per-field spec read docs/v2/driver-specs.md §5, for hands-on CLI testing read Driver.S7.Cli.md, and for the test-harness map read S7-Test-Fixture.md.

What it talks to

Siemens S7 PLCs — S7-300, S7-400, S7-1200, S7-1500, plus S7-200 / S7-200 Smart / LOGO! 0BA8 — over the native S7comm protocol on ISO-on-TCP, TCP port 102. The wire is spoken by the pure-managed S7netplus (S7.Net) library: no native DLL, no P/Invoke, no out-of-process isolation. The driver runs in-process in the OtOpcUa server's .NET 10 AnyCPU host on every OS the server runs on.

This is the leanest OtOpcUa driver — read/write/subscribe/discover plus a connectivity probe, and nothing else. It implements no alarm source and no per-call host resolver (a single S7 instance targets a single CPU).

Project split

Project Target Role
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/ net10.0 In-process driver — hosts the S7.Net.Plc connection and the address parser
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/ net10.0 Dependency-free config records + enums (S7DriverOptions, S7CpuType, S7DataType) bound from DriverConfig JSON

Minimum deployment

Register the driver instance in the central config DB (or appsettings.json). No separate service, no DLL deployment:

"Drivers": {
  "s7-line-1": {
    "Type": "S7",
    "Config": {
      "Host": "10.20.30.40",
      "CpuType": "S71500",
      "Rack": 0,
      "Slot": 0,
      "Tags": [
        { "Name": "Running", "Address": "DB1.DBX0.0", "DataType": "Bool",    "Writable": false },
        { "Name": "Speed",   "Address": "DB1.DBD4",    "DataType": "Float32", "Writable": true }
      ]
    }
  }
}

S7 exposes a symbol table, but S7.Net does not surface it — so the driver operates off a static, per-site tag list, not live symbol discovery.

Rack / slot / CPU family

CpuType selects the ISO-TSAP slot byte used during the connection handshake; pick the family that matches the PLC exactly. Rack is almost always 0 (relevant only for distributed S7-400 racks). Slot conventions per family: S7-300 = slot 2, S7-400 = slot 2 or 3, S7-1200 / S7-1500 = slot 0 (onboard PN). The CpuType JSON strings are S7300, S7400, S71200, S71500 and the legacy families S7200 (S7-200), S7200Smart (S7-200 Smart) and Logo0BA8 (LOGO! 0BA8). For the three legacy families S7.Net uses fixed TSAPs, so Rack / Slot are not consulted — leave them 0 / 0; support is limited S7comm and unverified against 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).

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 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7AddressParser.cs:

Area Example Meaning
Data block DB1.DBX0.0 / DB1.DBW0 / DB1.DBD4 / DB1.DBB8 DB number + size suffix X(bit) / B(byte) / W(word) / D(dword), optional .bit for DBX
Merker (M) MB0 / MW0 / MD4 / M0.0 Marker byte; size prefix B/W/D, or bare offset .bit for bit access
Input (I) IB0 / IW0 / I0.0 Process-image input
Output (Q) QB0 / QW0 / Q0.0 Process-image output
Timer T0 / T15 Timer area (read-only — see Wide types section)
Counter C0 / C10 Counter area (read-only — see Wide types section)

Parsing is strict and runs once at InitializeAsync so a config typo fails fast at load instead of surfacing as BadInternalError on every read. Bit offsets must be 0-7, byte offsets non-negative, DB numbers >= 1.

Data types

S7DataType declares the semantic type; S7.Net returns an unsigned boxed value (bool / byte / ushort / uint) that the driver reinterprets without an extra PLC round-trip.

DataType S7 Type Width Read Write
Bool BOOL 1 bit yes yes
Byte BYTE 1 byte yes yes
Int16 INT 2 bytes yes yes
UInt16 WORD 2 bytes yes yes
Int32 DINT 4 bytes yes yes
UInt32 DWORD 4 bytes yes yes
Float32 REAL 4 bytes yes yes
Int64 LINT 8 bytes yes yes
UInt64 ULINT/LWORD 8 bytes yes yes
Float64 LREAL 8 bytes yes yes
String STRING StringLength + 2 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 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, 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.

DataType Address form Bytes read Example
Int64 DB{n}.DBB{offset} / MB{offset} / IB{offset} / QB{offset} 8 DB1.DBB8 → LINT at bytes 8–15
UInt64 same 8 DB2.DBB16 → ULINT at bytes 16–23
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 are deferred — see Deferrals below.

Timer read

Address: T{n} (e.g. T0, T15). DataType must be Float64. The OPC UA node is a scalar Float64 whose value is the timer's elapsed time in seconds (as a double). Timers are read-only this phase; a write attempt returns BadNotWritable.

Counter read

Address: C{n} (e.g. C0, C10). DataType must be Int32. The OPC UA node is a scalar Int32 containing the unsigned raw big-endian word, widened to Int32: (bytes[0] << 8) | bytes[1]. S7.Net's Counter.FromByteArray performs no BCD reinterpretation; for example, bytes 01 23 surface as 291. This describes the current decoder, not verified agreement with a PLC's displayed count. Counters are read-only this phase; a write attempt returns BadNotWritable.

The S7comm ReadVar C-area response encoding remains unverified on hardware. PLC instruction representations do not by themselves establish network encoding: Siemens documents a binary current value for L: Load counter and separate CV and CV_BCD outputs for S_CD.

IEC counter values stored in DB memory use the DB address and declared integer type, a different read path from C{n}. CPU family alone does not select the counter kind or prove its wire representation. In particular, Siemens also lists legacy SIMATIC counters for S7-1500 in its instruction comparison (pages 23–24). The driver has no counter encoding selector; hardware-faithful counter support requires independent raw-response evidence before any interpretation change.

Counter evidence follow-up (#616)

The existing S7_1500SpanReadTests exercises the python-snap7 profile's synthetic raw words (C0 = 4711, C7 = 31337). These are outside the classic SIMATIC count range 0..999 documented by S_CD. These expectations describe raw-word handling; they do not establish classic counter count semantics. The recorded device-0 run cannot prove nonzero numbering. The nonzero test is environment-gated, but its options remain CpuType = S71500, rack 0, slot 0: setting S7_TIMER_NUMBER_ADDRESSING=1 does not configure a classic CPU or make the profile a classic counter gate.

Before claiming hardware validation or closing #616:

  • Prepare a separate target-specific semantic gate with the actual CPU type, rack, slot and TSAP/connection settings. Keep the synthetic raw-word profile as its own evidence of the existing decoder contract.
  • Record CPU model/order number, firmware, endpoint, connection settings and driver/S7netplus plus simulator versions where applicable.
  • Observe independent PLC-side current counts and binary/BCD reference outputs for valid classic counts such as 123, a second distinguishing count (42 or 999), and zero. Record the instruction/counter kind and DB reference addresses; do not derive reference values from the driver's output.
  • Capture the actual two-byte S7comm C-area responses alongside those references. Do not infer response bytes from a simulator seed or expected BCD value.
  • Verify several distinct nonzero timer/counter numbers against their independent PLC values and captured requests/responses, and verify the read-only contract.

The raw-byte/count and nonzero-numbering gates remain open. Password/protection support inherited from #303/#632 is a separate follow-up, not evidence supplied by these counter checks.

Deferrals (not yet implemented)

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, 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)). S7 also implements the optional IDriverDiagnostics capability (explicitly, string-valued: S7.NegotiatedPduSize = "480", omitted while 0 = not negotiated yet), so the value rides the driver-health snapshot and shows under the driver name on the AdminUI /hosts page (Gitea #640). See docs/Telemetry.md ("Driver diagnostics ride driver-health").

Capability surface

S7Driver : IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe (src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs).

Capability Path Notes
IReadable ReadAsync → S7.Net.Plc.ReadAsync One request/response per tag, serialized on a per-PLC semaphore
IWritable WriteAsync → S7.Net.Plc.WriteAsync Read-only tags (Writable=false) return BadNotWritable
ITagDiscovery DiscoverAsync Emits a flat S7/ folder of the configured tags — no live browse
ISubscribable per-tag poll loop with capped exponential backoff S7 has no push model; floor is 100 ms (the CPU services the comms mailbox once per scan)
IHostConnectivityProbe periodic S7.Net.Plc.ReadStatusAsync (CPU-status PDU) host:port host key; Running/Stopped transitions raise OnHostStatusChanged

Single-connection policy

One S7.Net.Plc instance per PLC, serialized with a SemaphoreSlim. Parallelising reads against a single CPU doesn't help — the CPU scans its comms mailbox at most once per cycle and queues concurrent requests wire-side anyway, while wasting the CPU's 8-64 connection-resource budget.

PUT/GET communication

S7-1200 / S7-1500 ship with PUT/GET access disabled by default. A driver pointed at a freshly-flashed CPU sees a hard access-denied fault. The driver maps it specifically to BadNotSupported, flags the instance Faulted (a configuration alert, not a transient fault), and does not blind-retry — because the CPU will keep refusing. Fix: enable PUT/GET communication in TIA Portal under Protection & Security for the CPU.

Error mapping

Condition StatusCode Health
Tag not in config BadNodeIdUnknown unchanged
Read-only tag written BadNotWritable unchanged
Unimplemented data type BadNotSupported unchanged
PUT/GET denied BadNotSupported Faulted (config alert)
CPU / hardware fault BadDeviceFailure Degraded
Socket / timeout BadCommunicationError Degraded
Reconnect suppressed (backoff window) BadCommunicationError Degraded
Array write of the wrong shape / length / element type BadTypeMismatch unchanged
Bool / wide-type array write BadNotSupported unchanged

Health is decided once per pass, worst state wins. A read pass and a write batch each accumulate their outcomes and write health once (Healthy < Degraded < Faulted), so a PUT/GET-denied Faulted item is not erased by a later Degraded or successful sibling. The write-side Faulted is sticky against reads: a clean read pass (reads often work against a write-protected CPU) leaves Faulted in place and only advances LastSuccessfulRead; the next write that succeeds clears it. A transient Degraded write failure is still cleared by a clean read, as before.

Connect backoff. A dead PLC costs one connect attempt per backoff window (1 s doubling to a 30 s cap), not one per read / poll tick / probe tick: inside the window EnsureConnectedAsync fails fast (BadCommunicationError, Degraded) without a connect. A successful connect clears the window, so a fresh drop after a healthy period reconnects immediately; worst-case recovery lag is the 30 s cap.

Testing

  • Unit tests — tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/ cover the address parser, the reinterpret/box conversions, and the driver lifecycle.
  • Integration fixture — a Docker S7 simulator on the shared docker host; see S7-Test-Fixture.md for the coverage map and endpoint. The wide/date-time codecs (STRING, WSTRING, CHAR, WCHAR, DTL, DATE, TOD, TIME, S5TIME) and the TSAP override keys have live snap7 coverage in S7_1500CodecTests; an unrepresentable STRING/DT write is BadOutOfRange (not a comms fault) like the other codecs.
  • CLI — Driver.S7.Cli.md documents the standalone read/write/probe CLI for manual checks against a real or simulated CPU.

1-D array support

An S7 tag becomes a 1-D OPC UA array node when its TagConfig JSON carries "isArray": true and "arrayLength": N (N ≥ 1). The canonical rule: isArray: true + arrayLength >= 1 → array; isArray: false (any length) → scalar.

Read mechanism — the driver issues a single ReadBytesAsync call over the contiguous memory span starting at the declared address for N × (bytes per element) bytes, then loops over the response buffer decoding each element individually using the same reinterpret/box logic as scalar reads. This keeps wire round-trips at 1 per array tag regardless of N.

Supported element types — the narrow scalar types (Bool, Byte, Int16, UInt16, Int32, UInt32, Float32). Wide types (Int64, UInt64, Float64, String, DateTime) and Timer/Counter are deferred for array contexts. The ReadBytesAsync network half is a thin S7.Net call; the decode half is fully unit-tested.

Unit test coverage — tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/ covers the contiguous-block read and per-element decode loop. Integration fixture is down during normal dev (the S7 sim is on the shared Docker host but currently offline).

Live-verify — integration-fixture-gated (not Mac-verifiable without the S7 sim up).

Array writes (S7-016) — a writable Byte / Int16 / UInt16 / Int32 / UInt32 / Float32 array is written as ONE contiguous WriteBytesAsync of count x element-bytes (the inverse of the read decode), and the value must be an array of exactly arrayLength elements (else BadTypeMismatch, no health impact). Writable Bool arrays are refused at init (NotSupportedException): the array read is byte-granular, so a matching write would overwrite the 7 neighbouring bits of every byte it touches, and a safe bit-packed write needs read-modify-write. Mark Bool arrays writable: false. A null array element is rejected (BadTypeMismatch, nothing written) rather than coerced to 0. An array write larger than one PDU is split by S7.Net into several writes and is not atomic — a mid-write failure can leave the PLC array partly updated. The server exposes these as writable OPC UA array nodes (#610) for exactly the element types above — a Bool / wide-type S7 array stays read-only at the node (BadNotWritable), see Uns.md §Array writes. The driver path is also reachable directly through the Client CLI / driver-level callers and is proven by the unit suite plus the snap7 fixture test (S7_1500ArrayWriteTests); the AdminUI tag editor rejects wide-type and writable-Bool arrays at author time.

Deferrals — multi-dimensional arrays (out of scope by decision, see Uns.md), per-element historization, wide-type array elements (Int64, UInt64, Float64, String, DateTime), and Timer/Counter array elements.

See Uns.md §Array tags for the cross-driver coverage matrix and the UI authoring flow.

Further reading