1f7ab80b81
A span's block is the union of its members' ranges, so one member that runs past the DB's real end (or over-declares a STRING) made the CPU refuse the whole block, and every neighbour within it was Bad on every poll. The planner's stated invariant (a grouped read cannot fault where per-tag reads would succeed) was false; the multi-var path already fell back, spans did not. - A refused multi-member span hands its members back; they are read one at a time for that pass, bypassing the multi-var pass. - _noGroup remembers the members refused on their own (or every member when none was) and keeps them out of later plans, so the rest group again. It is cleared by every initialize publish. Non-refusal span failures are unchanged (every member stamped). - The once-per-tag refusal Warning memo (R07:H4) is now also cleared by the publish, and shutdown resets the write-side Degraded owner, as documented. - Live gate: MW10 + MD254 (2 bytes past the 256-byte M area) merged into one refused block; MW10 keeps reading 4097 on every pass. Refs #742 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
421 lines
26 KiB
Markdown
421 lines
26 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. Bit offsets must be 0-7, byte offsets non-negative, DB numbers >= 1.
|
||
|
||
**Tag shape rules are judged per tag, at every gate** (`S7TagShapeRules`, called by
|
||
`S7TagDefinitionFactory.FromTagConfig`): the AdminUI editor refuses the tag, the deploy gate
|
||
(`Inspect`) warns, and the driver's init **skips** it (reported through `ISkippedTagReporter`; it reads
|
||
`BadNodeIdUnknown`) while every other tag keeps working. Refused: an address that does not parse, a
|
||
wide/structured type that is an array or is not byte-addressed (`DBB`/`MB`/`IB`/`QB`), a narrow type at the
|
||
wrong access width (`Int32` on `DBW0`, `Bool` on `DBB0`...), a Timer not typed `Float64` or a Counter not typed
|
||
`Int32`, a writable or array Timer/Counter, a writable `Bool` array, and a `Bool` array that does not start
|
||
at bit 0 (a Bool array reads bit 0 of each byte, so `DB1.DBX0.3` used to silently return bit 0 of bytes
|
||
0..n). These used to be init guards that THREW, so one typo faulted every tag on the PLC and the actor
|
||
retried the same init every 10 s forever (R07:H3).
|
||
|
||
## 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](https://docs.tia.siemens.cloud/r/en-us/v21/stl-s7-300-s7-400/stl-mnemonic-s7-300-s7-400/counter-operations-s7-300-s7-400/l-load-counter-s7-300-s7-400)
|
||
and separate `CV` and `CV_BCD` outputs for [S_CD](https://docs.tia.siemens.cloud/r/en-us/v21/lad-s7-300-s7-400/counter-operations-s7-300-s7-400/simatic-counters-s7-300-s7-400/s_cd-assign-parameters-and-count-down-s7-300-s7-400?contentId=reR_xapFX_Ike2opHIvfzg).
|
||
|
||
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)](https://cache.industry.siemens.com/dl/files/777/109986777/att_1322640/v1/s7_1500_compare_table_en.pdf?download=true).
|
||
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 refusal of the item (object does not exist, address out of range, type inconsistent, hardware fault) | `BadDeviceFailure` | unchanged — counts as *answered*, advances `LastSuccessfulRead`; one Warning per tag |
|
||
| Other device-layer fault (`PlcException` that is neither a refusal nor connection-fatal) | `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 cleared by a clean read, or by the next write batch the CPU
|
||
answers without a failure.
|
||
|
||
**A refusal is an answer (R07:H4).** S7.Net's read paths raise the CPU's item error as a bare
|
||
`Exception` ("Received error from PLC: Object does not exist." — live-probed on the snap7 fixture), its
|
||
write paths as a `PlcException(WriteData)`; both are classified over the whole exception chain, and the
|
||
access-denied text inside either is the `Faulted` alert. A refusal is deterministic, so it neither
|
||
degrades health nor reopens the connection, and because it proves the CPU is reachable it advances
|
||
`LastSuccessfulRead` and restores `Healthy` after an earlier comms fault. Before this, a driver whose
|
||
every tag was refused (a wrong DB number) sat `Degraded` with a frozen timestamp and the comms-loss
|
||
watchdog reconnected it every 90 s against a perfectly reachable PLC.
|
||
|
||
**A dead handle ends the pass's wire traffic (R07:P1).** Once a read pass or write batch marks the
|
||
connection dead (a wire op blew its deadline on a frozen-but-established peer, a socket dropped), the
|
||
remaining spans / multi-var chunks / tags / writes of that pass are stamped `BadCommunicationError`
|
||
without I/O; the next call reopens. A multi-var chunk that dies on a transport loss no longer falls back
|
||
to one doomed per-tag read per member. Without this an op that ignores its cancellation token would cost
|
||
one full deadline per remaining item under the gate, and `Degraded` (and so the comms-loss watchdog's
|
||
clock) only became visible after the whole pass.
|
||
|
||
**A refused span falls back per member (R07:P2).** A grouped block read covers the union of its members'
|
||
ranges, so one member the CPU refuses (running past the DB's real end, an over-declared `stringLength`)
|
||
used to make every neighbour in the block Bad on every poll. When the CPU refuses a multi-member span the
|
||
driver re-reads its members one at a time for that pass and remembers which tags must not be grouped —
|
||
the members refused on their own, or every member when none was — so later passes group only the rest.
|
||
The memo lasts until the next initialize. A span failure that is not a refusal still stamps every member.
|
||
|
||
**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. 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](../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 per tag** (see the tag shape rules above): 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
|