ff055ebd79
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
343 lines
19 KiB
Markdown
343 lines
19 KiB
Markdown
# 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`](../v2/driver-specs.md),
|
||
for hands-on CLI testing read [Driver.S7.Cli.md](../Driver.S7.Cli.md), and for
|
||
the test-harness map read [S7-Test-Fixture.md](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](https://github.com/S7NetPlus/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:
|
||
|
||
```jsonc
|
||
"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` 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`'s
|
||
> `Counter.FromByteArray` returns 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 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)`). `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`
|
||
(`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](S7-Test-Fixture.md) for the coverage map and endpoint.
|
||
- **CLI** — [Driver.S7.Cli.md](../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](../Uns.md#array-writes-610). 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](../Uns.md#array-tags-1-d) for the cross-driver coverage matrix
|
||
and the UI authoring flow.
|
||
|
||
## Further reading
|
||
|
||
- [`docs/v2/driver-specs.md §5`](../v2/driver-specs.md) — full per-field spec,
|
||
DriverConfig JSON shape, and operational stability notes
|
||
- [Driver.S7.Cli.md](../Driver.S7.Cli.md) — standalone S7 driver CLI
|
||
- [S7-Test-Fixture.md](S7-Test-Fixture.md) — simulator + test-harness map
|