- Galaxy: deadline bounds the wait, not the subscribe RPC; BadTimeout for all tags and late handles unsubscribed; doc comment relocated - S7: probe connect runs on the connect deadline so a timeout opens the backoff window - S7: a successful write only retires a Faulted the write side owns - S7: null array elements rejected (BadTypeMismatch); multi-PDU array writes documented non-atomic - S7 integration: array write test gated on S7_SIM_ALLOW_WRITES=1 Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
14 KiB
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.
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 |
DateTime |
DATE_AND_TIME | 8 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 & 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
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 |
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 whose value is the current count (raw word from the PLC).
Counters are read-only this phase; a write attempt returns BadNotWritable.
Known limitation — Counter BCD encoding on S7-300/400:
S7.Net'sCounter.FromByteArrayreturns the raw big-endian word without BCD decode. On classic S7-300/400 the C-area word is BCD-encoded (0–999), so the surfaced value can differ from the actual count on that hardware. S7-1200/1500 use IEC/DB counters (plain integers) where the raw word is correct. BCD reinterpretation for legacy C-area is a live-hardware-gated follow-up.
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 typesInt64,UInt64,Float64,String, orDateTime. S7WString(2-byte character strings; distinct from classicSTRING).DTL/DateTimeLong(12-byte Siemens date-and-time-long).- Timer / Counter writes — surfaced as
BadNotWritableuntil write support lands.
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.
- 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.
Note the server still presents every array node read-only to OPC UA clients (Uns.md §Array
tags, #610 item 1) — the driver path is reachable today 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 — server-side exposure of array writes, multi-dimensional arrays, 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
docs/v2/driver-specs.md §5— full per-field spec, DriverConfig JSON shape, and operational stability notes- Driver.S7.Cli.md — standalone S7 driver CLI
- S7-Test-Fixture.md — simulator + test-harness map