976 lines
56 KiB
Markdown
976 lines
56 KiB
Markdown
# Siemens SIMATIC S7 (S7-1200 / S7-1500 / S7-300 / S7-400 / ET 200SP) — Modbus TCP quirks
|
||
|
||
Siemens S7 PLCs do *not* speak Modbus TCP natively at the OS/firmware level. Every
|
||
S7 Modbus-TCP-server deployment is either (a) the **`MB_SERVER`** library block
|
||
running on the CPU's PROFINET port (S7-1200 / S7-1500 / CPU 1510SP-series
|
||
ET 200SP), or (b) the **`MODBUSCP`** function block running on a separate
|
||
communication processor (**CP 343-1 / CP 343-1 Lean** on S7-300, **CP 443-1** on
|
||
S7-400), or (c) the **`MODBUSPN`** block on an S7-1500 PN port via a licensed
|
||
library. That means the quirks a Modbus client has to cope with are as much
|
||
"this is how the user's PLC programmer wired the library block up" as "this is
|
||
how the firmware behaves" — the byte-order and coil-mapping rules aren't
|
||
hard-wired into silicon like they are on a DL260. This document catalogues the
|
||
behaviours a driver has to handle across the supported model/CP variants, cites
|
||
primary sources, and names the ModbusPal integration test we'd write for each
|
||
(convention from `docs/v2/modbus-test-plan.md`: `S7_<model>_<behavior>`).
|
||
|
||
## Model / CP Capability Matrix
|
||
|
||
| PLC family | Modbus TCP server mechanism | Modbus TCP client mechanism | License required? | Typical port 502 source |
|
||
|---------------------|------------------------------------|------------------------------------|-----------------------|-----------------------------------------------------------|
|
||
| S7-1200 (V4.0+) | `MB_SERVER` on integrated PN port | `MB_CLIENT` | No (in TIA Portal) | CPU's onboard Ethernet [1][2] |
|
||
| S7-1500 (all) | `MB_SERVER` on integrated PN port | `MB_CLIENT` | No (in TIA Portal) | CPU's onboard Ethernet [1][3] |
|
||
| S7-1500 + CP 1543-1 | `MB_SERVER` on CP's IP | `MB_CLIENT` | No | Separate CP IP address [1] |
|
||
| ET 200SP CPU (1510SP, 1512SP) | `MB_SERVER` on PN port | `MB_CLIENT` | No | CPU's onboard Ethernet [3] |
|
||
| S7-300 + CP 343-1 / CP 343-1 Lean | `MODBUSCP` (FB `MODBUSCP`, instance DB per connection) | Same FB, client mode | **Yes — 2XV9450-1MB00** per CP | CP's Ethernet port [4][5] |
|
||
| S7-400 + CP 443-1 | `MODBUSCP` | `MODBUSCP` client mode | **Yes — 2XV9450-1MB00** per CP | CP's Ethernet port [4] |
|
||
| S7-400H + CP 443-1 (redundant H) | `MODBUSCP_REDUNDANT` / paired FBs | Not typical | Yes | Paired CPs in H-system [6] |
|
||
| S7-300 / S7-400 CPU PN (e.g. CPU 315-2 PN/DP) | `MODBUSPN` library | `MODBUSPN` client mode | **Yes** — Modbus-TCP PN CPU lib | CPU's PN port [7] |
|
||
| "CP 343-1 Lean" | **Server only** (no client mode supported by Lean) | — | Yes, but with restrictions | CP's Ethernet port [4][5] |
|
||
|
||
- **CP 343-1 Lean is server-only.** It can host `MODBUSCP` in server mode only;
|
||
client calls return an immediate error. A surprising number of "Lean + client
|
||
doesn't work" forum posts trace back to this [5].
|
||
- **Pure OPC UA / PROFINET CPs (CP 1542SP-1, CP 1543-1)** support Modbus TCP on
|
||
S7-1500 via the same `MB_SERVER`/`MB_CLIENT` instructions by passing the
|
||
CP's `hw_identifier`. There is no separate "Modbus CP" license needed on
|
||
S7-1500, unlike S7-300/400 [1].
|
||
- **No S7 Modbus server supports function codes 20/21 (file records),
|
||
22 (mask write), 23 (read-write multiple), or 43 (device identification).**
|
||
Sending any of these returns exception `01` (Illegal Function) on every S7
|
||
variant [1][4]. Our driver must not negotiate FC23 as a "bulk-read optimization"
|
||
when the profile is S7.
|
||
|
||
Test names:
|
||
`S7_1200_MBSERVER_Loads_OB1_Cyclic`,
|
||
`S7_CP343_Lean_Client_Mode_Rejected`,
|
||
`S7_All_FC23_Returns_IllegalFunction`.
|
||
|
||
## Address / DB Mapping
|
||
|
||
S7 Modbus servers **do not auto-expose PLC memory** — the PLC programmer has to
|
||
wire one area per Modbus table to a DB or process-image region. This is the
|
||
single biggest difference vs. DL205/Modicon/etc., where the memory map is
|
||
fixed at the factory. Our driver must therefore be tolerant of "the same
|
||
`40001` means completely different things on two S7-1200s on the same site."
|
||
|
||
### S7-1200 / S7-1500 `MB_SERVER`
|
||
|
||
The `MB_SERVER` instance exposes four Modbus tables to each connected client;
|
||
each table's backing storage is a per-block parameter [1][8]:
|
||
|
||
| Modbus table | FCs | Backing parameter | Default / typical backing |
|
||
|---------------------|-------------|-----------------------------|-----------------------------|
|
||
| Coils (0x) | FC01, FC05, FC15 | *implicit* — Q process image | `%Q0.0`–`%Q1023.7` (→ coil addresses 0–8191) [1][9] |
|
||
| Discrete Inputs (1x)| FC02 | *implicit* — I process image | `%I0.0`–`%I1023.7` (→ discrete addresses 0–8191) [1][9] |
|
||
| Input Registers (3x)| FC04 | *implicit* — M memory or DB (version-dependent) | Some firmware routes FC04 through the same MB_HOLD_REG buffer [1][8] |
|
||
| Holding Registers (4x)| FC03, FC06, FC16 | `MB_HOLD_REG` pointer | User DB (e.g. `DB10.DBW0`) or `%MW` area [1][2][8] |
|
||
|
||
- **`MB_HOLD_REG` is a pointer (VARIANT / ANY) into a user-defined DB** whose
|
||
first byte is holding-register 0 (`40001` in 1-based Modicon form). Byte
|
||
offset 2 is register 1, byte offset 4 is register 2, etc. [1][2].
|
||
- **The DB *must* have "Optimized block access" UNCHECKED.** Optimized DBs let
|
||
the compiler reorder fields for alignment; Modbus requires fixed byte
|
||
offsets. With optimized access on, the compiler accepts the project but
|
||
`MB_SERVER` returns STATUS `0x8383` (misaligned access) or silently reads
|
||
zeros [8][10][11]. This is the #1 support-forum complaint.
|
||
- **FC01/FC02/FC05/FC15 hit the Q and I process images directly — not the
|
||
`MB_HOLD_REG` DB.** Coil address 0 = `%Q0.0`, coil 1 = `%Q0.1`, coil 8 =
|
||
`%Q1.0`. The S7-1200 system manual publishes this mapping as `00001 → Q0.0`
|
||
through `09999 → Q1023.7` and `10001 → I0.0` through `19999 → I1023.7` in
|
||
1-based form; on the wire (0-based) that's coils 0-8191 and discrete inputs
|
||
0-8191 [9].
|
||
- **`%M` markers are NOT automatically exposed.** To expose `%M` over Modbus
|
||
the programmer must either (a) copy `%M` to the `MB_HOLD_REG` DB each scan,
|
||
or (b) define an Array\[0..n\] of Bool inside that DB and copy bits in/out
|
||
of `%M`. Siemens has no "MB_COIL_REG" parameter analogous to
|
||
`MB_HOLD_REG` — this confuses users migrating from Schneider [9][12].
|
||
- **Bit ordering within a Modbus holding register sourced from an `Array of
|
||
Bool`**: S7 stores bool\[0\] at `DBX0.0` which is bit 0 of byte 0 which is
|
||
the **low byte, low bit** of Modbus register `40001`. A naive client that
|
||
reads register `40001` and masks `0x0001` gets bool\[0\]. A client that
|
||
masks `0x8000` gets bool\[15\] because the high byte of the Modbus register
|
||
is the *second* byte of the DB. Siemens programmers routinely get this
|
||
wrong in the DB-via-DBX form; `Array[0..n] of Bool` is the recommended
|
||
layout because it aligns naturally [12][13].
|
||
|
||
### S7-300/400 + CP 343-1 / CP 443-1 `MODBUSCP`
|
||
|
||
Different paradigm: per-connection **parameter DB** (template
|
||
`MODBUS_PARAM_CP`) declares a table of up to 8 register-area mappings. Each
|
||
mapping is a tuple `(data_type, DB#, start_offset, length)` where `data_type`
|
||
picks the Modbus table [4]:
|
||
|
||
- `B#16#1` = Coils
|
||
- `B#16#2` = Discrete Inputs
|
||
- `B#16#3` = Holding Registers
|
||
- `B#16#4` = Input Registers
|
||
|
||
The `holding_register_start` and analogous `coils_start` parameters declare
|
||
**which Modbus address range** the CP will serve, and the DB pointers say
|
||
where in S7 memory that range lives [4][14]. Unlike `MB_SERVER`, the CP does
|
||
not reach into `%Q`/`%I` directly — *everything* goes through a DB. If an
|
||
address outside the declared ranges is requested, the CP returns exception
|
||
`02` (Illegal Data Address) [4].
|
||
|
||
Test names:
|
||
`S7_1200_FC03_Reg0_Reads_DB10_DBW0`,
|
||
`S7_1200_Optimized_DB_Returns_0x8383_MisalignedAccess`,
|
||
`S7_1200_FC01_Coil0_Reads_Q0_0`,
|
||
`S7_CP343_FC03_Outside_ParamBlock_Range_Returns_IllegalDataAddress`.
|
||
|
||
## Data Types and Byte Order
|
||
|
||
Siemens CPUs store scalars **big-endian** internally ("Motorola format"), which
|
||
is the same byte order Modbus specifies inside each register. So for 16-bit
|
||
values (`Int`, `Word`, `UInt`) the on-the-wire layout is straightforward
|
||
`AB` — high byte of the PLC value in the high byte of the Modbus register
|
||
[15][16]. No byte-swap trap for 16-bit types.
|
||
|
||
The trap is 32-bit types (`DInt`, `DWord`, `Real`). Here's what actually
|
||
happens across the S7 family:
|
||
|
||
### S7-1200 / S7-1500 `MB_SERVER`
|
||
|
||
- **The backing DB stores 32-bit values in big-endian byte order, high word
|
||
first** — i.e. `ABCD` when viewed as two consecutive Modbus registers. A
|
||
`Real` at `DB10.DBD0` with value `0x12345678` reads over Modbus as
|
||
register 0 = `0x1234`, register 1 = `0x5678` [15][16][17].
|
||
- **This is `ABCD`, *not* `CDAB`.** Clients that hard-code CDAB (common default
|
||
for meters and VFDs) will get wildly wrong floats. Configure the S7 profile
|
||
with `WordOrder = ABCD` (aka "big-endian word + big-endian byte" aka
|
||
"high-word first") [15][17].
|
||
- **`MB_SERVER` does not swap.** It's a direct memcpy from the DB bytes to
|
||
the Modbus payload. Whatever byte order the ladder programmer stored into
|
||
the DB is what the client receives [17]. This means a programmer who used
|
||
`MOVE_BLK` from two separate `Word`s into `DBD` with the "wrong" order can
|
||
produce `CDAB` without realising.
|
||
- **`Real` is IEEE 754 single-precision** — unambiguous, no BCD trap like on
|
||
DL series [15].
|
||
- **Strings**: S7 `String[n]` has a 2-byte header (max length, current length)
|
||
*before* the character bytes. A client reading a string over Modbus gets
|
||
the header in the first register and then the characters two-per-register
|
||
in high-byte-first order. `WString` is UTF-16 and the header is 4 bytes
|
||
[18]. Our driver's string decoder must expose the "skip header" option for
|
||
S7 profile.
|
||
|
||
### S7-300/400 `MODBUSCP` (CP 343-1 / CP 443-1)
|
||
|
||
- The CP writes the exact DB bytes onto the wire — again `ABCD` if the DB
|
||
stores `DInt`/`Real` in native Siemens order [4].
|
||
- **`MODBUSCP` has no `data_type` byte-swap knob.** (The `data_type` parameter
|
||
names the Modbus table, not the byte order — see the Address Mapping
|
||
section.) If the other end of the link expects `CDAB`, the programmer has
|
||
to swap words in ladder before writing the DB [4][14].
|
||
|
||
### Operator-reported oddity
|
||
|
||
- Some S7 drivers (Kepware's "Siemens TCP/IP Ethernet" driver, Ignition's
|
||
"Siemens S7" driver) expose a per-tag `Float Byte Order` with options
|
||
`ABCD`/`CDAB`/`BADC`/`DCBA` because end-users have encountered every
|
||
permutation in the field — not because the PLC natively swaps, but because
|
||
ladder programmers have historically stored floats every which way [19].
|
||
Our S7 Modbus profile should default to `ABCD` but expose a per-tag
|
||
override.
|
||
- **Unconfirmed rumour**: that S7-1500 firmware V2.0+ reverses float byte
|
||
order for `MB_CLIENT` only. Not reproduced; the Siemens forum thread that
|
||
launched it was a user error (the remote server was the swapper, not the
|
||
S7) [20]. Treat as false until proven.
|
||
|
||
Test names:
|
||
`S7_1200_Real_WordOrder_ABCD_Default`,
|
||
`S7_1200_DInt_HighWord_First_At_DBD0`,
|
||
`S7_1200_String_Header_First_Two_Bytes`,
|
||
`S7_CP343_No_Internal_ByteSwap`.
|
||
|
||
## Coil / Discrete Input Mapping
|
||
|
||
On `MB_SERVER` the mapping from coil address → S7 bit is fixed at the
|
||
process-image level [1][9][12]:
|
||
|
||
| Modbus coil / discrete input addr | S7 address | Notes |
|
||
|-----------------------------------|---------------|-------------------------------------|
|
||
| Coil 0 (FC01/05/15) | `%Q0.0` | bit 0 of output byte 0 |
|
||
| Coil 7 | `%Q0.7` | bit 7 of output byte 0 |
|
||
| Coil 8 | `%Q1.0` | bit 0 of output byte 1 |
|
||
| Coil 8191 (max) | `%Q1023.7` | highest exposed output bit |
|
||
| Discrete input 0 (FC02) | `%I0.0` | bit 0 of input byte 0 |
|
||
| Discrete input 8191 | `%I1023.7` | highest exposed input bit |
|
||
|
||
Formulas:
|
||
|
||
```
|
||
coil_addr = byte_index * 8 + bit_index (e.g. %Q5.3 → coil 43)
|
||
discr_addr = byte_index * 8 + bit_index (e.g. %I10.2 → disc 82)
|
||
```
|
||
|
||
- **1-based Modicon form adds 1:** coil 0 (wire) = `00001` (Modicon), etc.
|
||
Our driver sends the 0-based PDU form, so `%Q0.0` writes to wire address 0.
|
||
- **Writing FC05/FC15 to `%Q` is accepted even while the CPU is in STOP** —
|
||
the PLC's process image doesn't care about the user program state. But the
|
||
output won't propagate to the physical module until RUN (see STOP section
|
||
below) [1][21].
|
||
- **`%M` markers require a DB-backed `Array of Bool`** as described in the
|
||
Address Mapping section. Our driver can't assume "coil N = MN.0" like it
|
||
can on Modicon — on S7 it's always Q/I unless the programmer built a
|
||
mapping DB [12].
|
||
- **Bit-inside-holding-register**: for `Array of Bool` inside the
|
||
`MB_HOLD_REG` DB, bool[0] is bit 0 of byte 0 → **low byte, low bit** of
|
||
Modbus register 40001. Most third-party clients probe this in the low
|
||
byte, so the common case works; the less-common case (bool[8]) is bit 0 of
|
||
byte 1 → **high byte, low bit** of Modbus register 40001. Clients that
|
||
test only bool[0] will pass and miss the mis-alignment on bool[8] [12][13].
|
||
|
||
Test names:
|
||
`S7_1200_Coil_0_Is_Q0_0`,
|
||
`S7_1200_Coil_8_Is_Q1_0`,
|
||
`S7_1200_Discrete_Input_7_Is_I0_7`,
|
||
`S7_1200_Coil_Write_In_STOP_Accepted_But_Output_Frozen`.
|
||
|
||
## Function Code Support & Max Registers Per Request
|
||
|
||
| FC | Name | S7-1200 / S7-1500 MB_SERVER | CP 343-1 / CP 443-1 MODBUSCP | Max qty per request |
|
||
|----|----------------------------|-----------------------------|------------------------------|--------------------------------|
|
||
| 01 | Read Coils | Yes | Yes | 2000 bits (spec) |
|
||
| 02 | Read Discrete Inputs | Yes | Yes | 2000 bits (spec) |
|
||
| 03 | Read Holding Registers | Yes | Yes | **125** (spec max) |
|
||
| 04 | Read Input Registers | Yes | Yes | **125** |
|
||
| 05 | Write Single Coil | Yes | Yes | 1 |
|
||
| 06 | Write Single Register | Yes | Yes | 1 |
|
||
| 15 | Write Multiple Coils | Yes | Yes | 1968 bits (spec) — *see note* |
|
||
| 16 | Write Multiple Registers | Yes | Yes | **123** (spec max for TCP) |
|
||
| 07 | Read Exception Status | No (RTU only) | No | — |
|
||
| 17 | Report Server ID | No | No | — |
|
||
| 20/21 | Read/Write File Record | No | No | — |
|
||
| 22 | Mask Write Register | No | No | — |
|
||
| 23 | Read/Write Multiple | No | No | — |
|
||
| 43 | Read Device Identification | No | No | — |
|
||
|
||
- **S7-1200/1500 honour the full spec maxima** for FC03/04 (125) and FC16
|
||
(123) [1][22]. No sub-spec cap like DL260's 100-register FC16 limit.
|
||
- **FC15 (Write Multiple Coils) on `MB_SERVER`** writes into `%Q`, which maxes
|
||
out at 1024 bytes = 8192 bits, but the spec's 1968-bit per-request limit
|
||
caps any single call first [1][9].
|
||
- **`MB_HOLD_REG` buffer size is bounded by DB size** — max DB size on
|
||
S7-1200 is 64 KB, on S7-1500 is much larger (several MB depending on CPU),
|
||
so the practical `MB_HOLD_REG` limit is 32767 16-bit registers on S7-1200
|
||
and effectively unbounded on S7-1500 [22][23]. The *per-request* limit is
|
||
still 125.
|
||
- **Read past the end of `MB_HOLD_REG`** returns exception `02` (Illegal
|
||
Data Address) at the start of the overflow register, not a partial read
|
||
[1][8].
|
||
- **Request larger than spec max** (e.g. FC03 quantity 126) returns exception
|
||
`03` (Illegal Data Value). Verified on S7-1200 V4.2 [1][24].
|
||
- **CP 343-1 `MODBUSCP` per-request maxima are spec** (125/125/123/1968/2000),
|
||
matching the standard [4]. The CP's `MODBUS_PARAM_CP` caps the total
|
||
*exposed* range, not the per-call quantity.
|
||
|
||
Test names:
|
||
`S7_1200_FC03_126_Registers_Returns_IllegalDataValue`,
|
||
`S7_1200_FC16_124_Registers_Returns_IllegalDataValue`,
|
||
`S7_1200_FC03_Past_MB_HOLD_REG_End_Returns_IllegalDataAddress`,
|
||
`S7_1200_FC17_ReportServerId_Returns_IllegalFunction`.
|
||
|
||
## Exception Codes
|
||
|
||
S7 Modbus servers return only the four standard exception codes [1][4]:
|
||
|
||
| Code | Name | Triggered by |
|
||
|------|-----------------------|----------------------------------------------------------------------|
|
||
| 01 | Illegal Function | FC not in the supported list (17, 20-23, 43, any undefined FC) |
|
||
| 02 | Illegal Data Address | Register outside `MB_HOLD_REG` / outside `MODBUSCP` param-block range |
|
||
| 03 | Illegal Data Value | Quantity exceeds spec (FC03/04 > 125, FC16 > 123, FC01/02 > 2000, FC15 > 1968) |
|
||
| 04 | Server Failure | Runtime error inside MB_SERVER (DB access fault, corrupt DB header, MB_SERVER disabled mid-request) [1][24] |
|
||
|
||
- **No proprietary exception codes (05/06/0A/0B) are used** on any S7
|
||
Modbus server [1][4]. Our driver's status-code mapper can treat these as
|
||
"never observed" on the S7 profile.
|
||
- **CPU in STOP → `MB_SERVER` keeps running if it's in OB1 of the firmware's
|
||
communication task, but OB1 itself is not scanned.** In practice:
|
||
- Holding-register *reads* (FC03) continue to return the last DB values
|
||
frozen at the moment the CPU entered STOP. The `MB_SERVER` block is in
|
||
OB1 so it isn't re-invoked; however the TCP stack keeps the socket open
|
||
and returns cached data on subsequent polls [1][21]. **Unconfirmed**
|
||
whether this is cached in the CP or in the CPU's communication processor;
|
||
behaviour varies between firmware 4.0 and 4.5 [21].
|
||
- Holding-register *writes* (FC06/FC16) during STOP return exception `04`
|
||
(Server Failure) on S7-1200 V4.2+, and return success-but-discarded on
|
||
older firmware [1][24]. Our driver should treat FC06/FC16 during STOP as
|
||
non-deterministic and not rely on the response code.
|
||
- Coil *writes* (FC05/FC15) to `%Q` are *accepted* by the process image
|
||
during STOP, but the physical output freezes at its last RUN-mode value
|
||
(or the configured STOP-mode substitute value) until RUN resumes [1][21].
|
||
- **Writing a read-only address via FC06/FC16**: returns `02` (Illegal Data
|
||
Address), not `04`. S7 does not have "write-protected" holding registers —
|
||
the programmer either exposes a DB for read-write or doesn't expose it at
|
||
all [1][12].
|
||
|
||
STATUS codes (returned in the `STATUS` output of the block, not on the wire):
|
||
|
||
- `0x0000` — no error.
|
||
- `0x7001` — first call, connection being established.
|
||
- `0x7002` — subsequent cyclic call, connection in progress.
|
||
- `0x8383` — data access error (optimized DB, DB too small, or type mismatch)
|
||
[10][24].
|
||
- `0x8188` — invalid parameter combination (e.g. MB_MODE out of range) [24].
|
||
- `0x80C8` — mismatched UNIT_ID between MB_CLIENT and `MB_SERVER` [25].
|
||
|
||
Test names:
|
||
`S7_1200_FC03_Outside_HoldReg_Returns_IllegalDataAddress`,
|
||
`S7_1200_FC16_In_STOP_Returns_ServerFailure`,
|
||
`S7_1200_FC03_In_STOP_Returns_Cached_Values`,
|
||
`S7_1200_No_Proprietary_ExceptionCodes_0x05_0x06_0x0A_0x0B`.
|
||
|
||
## Connection Behavior
|
||
|
||
- **Max simultaneous Modbus TCP connections**:
|
||
- **S7-1200**: shares a pool of 8 open-communication connections across
|
||
all TCP/UDP/Modbus use. On a CPU 1211C you get 8 total; on 1215C/1217C
|
||
still 8 shared among PG/HMI/OUC/Modbus. Each `MB_SERVER` instance
|
||
reserves one. A typical site with a PG + 1 HMI + 2 Modbus clients uses
|
||
4 of the 8 [1][26].
|
||
- **S7-1500**: up to **8 concurrent Modbus TCP server connections** per
|
||
`MB_SERVER` port, across multiple `MB_SERVER` instance DBs each with a
|
||
unique port. Total open-communication resources depend on CPU (e.g.
|
||
CPU 1515-2 PN supports 128 OUC connections total; Modbus is a subset)
|
||
[1][27].
|
||
- **CP 343-1 Lean**: up to **8** simultaneous Modbus TCP connections on
|
||
port 502 [4][5]. Exceeding this refuses at TCP accept.
|
||
- **CP 443-1 Advanced**: up to **16** simultaneous Modbus TCP connections
|
||
[4].
|
||
- **Multi-connection model on `MB_SERVER`**: one instance DB per connection.
|
||
An instance DB listening on port 502 serves exactly one connection at a
|
||
time; to serve N simultaneous clients you need N instance DBs each with a
|
||
unique port (502/503/504...). **This is a real trap** — most users expect
|
||
port 502 to multiplex [27][28]. Our driver must not assume port 502 is the
|
||
only listener.
|
||
- **Keep-alive**: S7-1500's TCP stack does send TCP keepalives (default
|
||
every ~30 s) but the interval is not exposed as a configurable. S7-1200 is
|
||
the same. CP 343-1 keepalives are configured via HW Config → CP properties
|
||
→ Options → "Send keepalive" (default **off** on older firmware, default
|
||
**on** on firmware V3.0+) [1][29]. Driver-side keepalive is still
|
||
advisable for S7-300/CP 343-1 on old firmware.
|
||
- **Idle-timeout close**: `MB_SERVER` does *not* close idle sockets on its
|
||
own. However, the TCP stack on S7-1500 will close a socket that fails
|
||
three consecutive keepalive probes (~2 minutes). Forum reports describe
|
||
`MB_SERVER` connections "dying overnight" on S7-1500 when an HMI stops
|
||
polling — the fix is to enable driver-side periodic reads or driver-side
|
||
TCP keepalive [29][30].
|
||
- **Reconnect after power cycle**: MB_SERVER starts listening ~1-2 seconds
|
||
after the CPU reaches RUN. If the client reconnects during STARTUP OB
|
||
(OB100), the connection is refused until OB1 runs the block at least once.
|
||
Our driver should back off and retry on `ECONNREFUSED` for the first 5
|
||
seconds after a power-cycle detection [1][24].
|
||
- **Unit Identifier**: `MB_SERVER` accepts **any** Unit ID by default — there
|
||
is no configurable filter; the PLC ignores the Unit ID field entirely.
|
||
`MB_CLIENT` defaults to Unit ID = 255 as "ignore" [25][31]. Some
|
||
third-party Modbus-TCP gateways *require* a specific Unit ID; sending
|
||
anything to S7 is safe. **CP 343-1 `MODBUSCP`** also accepts any Unit ID
|
||
in server mode, but the parameter DB exposes a `single_write` / `unit_id`
|
||
field on newer firmware to allow filtering [4].
|
||
|
||
Test names:
|
||
`S7_1200_9th_TCP_Connection_Refused_On_8_Conn_Pool`,
|
||
`S7_1500_Port_503_Required_For_Second_Instance`,
|
||
`S7_1200_Reconnect_After_Power_Cycle_Succeeds_Within_5s`,
|
||
`S7_1200_Unit_ID_Ignored_Any_Accepted`.
|
||
|
||
## Behavioral Oddities
|
||
|
||
- **Transaction ID echo** is reliable on all S7 variants. `MB_SERVER` copies
|
||
the MBAP TxId verbatim. No known firmware that drops TxId under load [1][31].
|
||
- **Request serialization**: a single `MB_SERVER` instance serializes
|
||
requests from its one connected client — the block processes one PDU per
|
||
call and calls happen once per OB1 scan. OB1 scan time of 5-50 ms puts an
|
||
upper bound on throughput at ~20-200 requests/sec per connection [1][30].
|
||
Multiple `MB_SERVER` instances (one per port) run in parallel because OB1
|
||
calls them sequentially within the same scan.
|
||
- **OB1 scan coupling**: `MB_SERVER` must be called cyclically from OB1 (or
|
||
another cyclic OB). If the programmer puts it in a conditional branch
|
||
that doesn't fire every scan, requests time out. The STATUS `0x7002`
|
||
"in progress" is *expected* between calls, not an error [1][24].
|
||
- **Optimized DB backing `MB_HOLD_REG`** — already covered in Address
|
||
Mapping; STATUS becomes `0x8383`. This is the most common deployment bug
|
||
on S7-1500 projects migrated from older S7-1200 examples [10][11].
|
||
- **CPU STOP behaviour** — covered in Exception Codes section. The short
|
||
version: reads may return stale data without error; writes return exception
|
||
04 on modern firmware.
|
||
- **Partial-frame disconnect**: S7-1200/1500 TCP stack closes the socket on
|
||
any MBAP header where the `Length` field doesn't match the PDU length.
|
||
Driver must detect half-close and reconnect [1][29].
|
||
- **MBAP `Protocol ID` must be 0**. Any non-zero value causes the CP/CPU to
|
||
drop the frame silently (no response, no RST) on S7-1500 firmware V2.0
|
||
through V2.9; firmware V3.0+ sends an RST [1][30]. *Unconfirmed* whether
|
||
V3.1 still sends RST or returns to silent drop.
|
||
- **FC01/FC02 access outside `%Q`/`%I` range**: on S7-1200, requesting
|
||
coil address 8192 (= `%Q1024.0`) returns exception `02` (Illegal Data
|
||
Address) [1][9]. The 8192-bit hard cap is a process-image size limit on
|
||
the CPU, not a Modbus protocol limit.
|
||
- **`MB_CLIENT` UNIT_ID mismatch with remote `MB_SERVER`** produces STATUS
|
||
`0x80C8` on the client side, and the server silently discards the frame
|
||
(no response on the wire) [25]. This matters for Modbus-TCP-to-RTU
|
||
gateway scenarios where the Unit ID picks the RTU slave.
|
||
- **Non-IEEE REAL / BCD**: S7 does *not* use BCD like DirectLOGIC. `Real` is
|
||
always IEEE 754 single-precision. `LReal` (8-byte double) occupies 4
|
||
Modbus registers in `ABCDEFGH` order (big-endian byte, big-endian word)
|
||
[15][18].
|
||
- **`MODBUSCP` single-write** on CP 343-1: a parameter `single_write` in the
|
||
param DB controls whether FC06 on a register in the "holding register"
|
||
area triggers a callback to the user program vs. updates the DB directly.
|
||
Default is direct update. If a ladder programmer enables the callback
|
||
without implementing the callback OB, FC06 writes hang for 5 seconds then
|
||
return exception `04` [4].
|
||
|
||
Test names:
|
||
`S7_1200_TxId_Preserved_Across_Burst_Of_50_Requests`,
|
||
`S7_1200_MBSERVER_Throughput_Capped_By_OB1_Scan`,
|
||
`S7_1200_MBAP_ProtocolID_NonZero_Frame_Dropped`,
|
||
`S7_1200_Partial_MBAP_Causes_Half_Close`.
|
||
|
||
## Model-specific Differences Worth Separate Test Coverage
|
||
|
||
- **S7-1200 V4.0 vs V4.4+**: Older firmware does not support `WString` over
|
||
`MB_HOLD_REG` and returns `0x8383` if the DB contains one [18][24]. Test
|
||
both firmware bands separately.
|
||
- **S7-1500 vs S7-1200**: S7-1500 supports multiple `MB_SERVER` instances on
|
||
the *same* CPU with different ports cleanly; S7-1200 can too but its
|
||
8-connection pool is shared tighter [1][27]. Throughput per-connection is
|
||
~5× faster on S7-1500 because the comms task runs on a dedicated core.
|
||
- **S7-300 + CP 343-1 vs S7-1200/1500**: parameter-block mapping (not
|
||
`MB_HOLD_REG` pointer), per-connection license, no `%Q`/`%I` direct
|
||
access for coils (everything goes through a DB), different STATUS codes
|
||
(`DONE`/`ERROR`/`STATUS` word pairs vs. the single STATUS word) [4][14].
|
||
Driver-side it's a different profile.
|
||
- **CP 343-1 Lean vs CP 343-1 Advanced**: Lean is server-only; Advanced is
|
||
client + server. Lean's max connections = 8; Advanced = 16 [4][5].
|
||
- **CP 443-1 in S7-400H**: uses `MODBUSCP_REDUNDANT` which presents two
|
||
Ethernet endpoints that fail over. Our driver's redundancy support should
|
||
recognize the S7-400H profile as "two IP addresses, same server state,
|
||
advertise via `ServerUriArray`" [6].
|
||
- **ET 200SP CPU (1510SP / 1512SP)**: behaves as S7-1500 from `MB_SERVER`
|
||
perspective. No known deltas [3].
|
||
|
||
## Performance (native S7comm driver)
|
||
|
||
This section covers the native S7comm driver (`ZB.MOM.WW.OtOpcUa.Driver.S7`),
|
||
not the Modbus-on-S7 quirks above. Both share a CPU but use different ports,
|
||
different libraries, and different optimization levers.
|
||
|
||
### Block-read coalescing
|
||
|
||
The S7 driver runs a coalescing planner before every read pass: same-area /
|
||
same-DB tags are sorted by byte offset and merged into single
|
||
`Plc.ReadBytesAsync` requests when the gap between them is small. Reading
|
||
`DB1.DBW0`, `DB1.DBW2`, `DB1.DBW4` issues **one** 6-byte byte-range read
|
||
covering offsets 0..6, sliced client-side instead of three multi-var items
|
||
(let alone three individual `Plc.ReadAsync` round-trips). On a 50-tag
|
||
contiguous workload this reduces wire traffic from 50 single reads (or 3
|
||
multi-var batches at the 19-item PDU ceiling) to **1 byte-range PDU**.
|
||
|
||
#### Default 16-byte gap-merge threshold
|
||
|
||
The planner merges two adjacent ranges when the gap between them is at most
|
||
16 bytes. The default reflects the cost arithmetic on a 240-byte default
|
||
PDU: an S7 request frame is ~30 bytes and a per-item response header is
|
||
~12 bytes, so over-fetching 16 bytes (which decode-time discards) is
|
||
cheaper than paying for one extra PDU round-trip.
|
||
|
||
The math also holds for 480/960-byte PDUs but the relative cost flips —
|
||
on a 960-byte PDU you can fit a much larger request and the over-fetch
|
||
ceiling is less of a concern. Sites running the extended PDU on S7-1500
|
||
can safely raise the threshold (see operator guidance below).
|
||
|
||
#### Opaque-size opt-out for STRING / array / structured-timestamp tags
|
||
|
||
Variable-width and header-prefixed tag types **never** participate in
|
||
coalescing:
|
||
|
||
- **STRING / WSTRING** carry a 2-byte (or 4-byte) length header, and the
|
||
per-tag width depends on the configured `StringLength`.
|
||
- **CHAR / WCHAR** are routed through the dedicated `S7StringCodec` decode
|
||
path, which expects an exact byte slice, not an offset into a larger
|
||
buffer.
|
||
- **DTL / DT / S5TIME / TIME / TOD / DATE-as-DateTime** route through
|
||
`S7DateTimeCodec` for the same reason.
|
||
- **Arrays** (`ElementCount > 1`) carry a per-tag width of `N × elementBytes`
|
||
and would silently mis-decode if the slice landed mid-block.
|
||
|
||
Each opaque-size tag emits its own standalone `Plc.ReadBytesAsync` call.
|
||
A STRING in the middle of a contiguous run of DBWs will split the
|
||
neighbour reads into "before STRING" and "after STRING" merged ranges
|
||
without straddling the STRING's bytes — verified by the
|
||
`S7BlockCoalescingPlannerTests` unit suite.
|
||
|
||
#### Operator tuning: `BlockCoalescingGapBytes`
|
||
|
||
Surface knob in the driver options:
|
||
|
||
```jsonc
|
||
{
|
||
"Host": "10.0.0.50",
|
||
"Port": 102,
|
||
"CpuType": "S71500",
|
||
"BlockCoalescingGapBytes": 16, // default
|
||
// ...
|
||
}
|
||
```
|
||
|
||
Tuning guidance:
|
||
|
||
- **Raise the threshold (32-64 bytes)** when the PLC has chatty firmware
|
||
(S7-1200 with default 240-byte PDU and many DBs scattered every few
|
||
bytes). One fewer PDU round-trip beats over-fetching a kilobyte.
|
||
- **Lower the threshold (4-8 bytes)** when DBs are sparsely populated
|
||
with hot tags far apart — over-fetching dead bytes wastes the PDU
|
||
envelope and the saved round-trip never materialises.
|
||
- **Set to 0** to disable gap merging entirely (only literally adjacent
|
||
ranges with `gap == 0` coalesce). Useful as a debugging knob: if a
|
||
driver is misreading values you can flip the threshold to 0 to confirm
|
||
the slice math isn't the culprit.
|
||
- **Per-DB tuning isn't supported yet** — the knob is global per driver
|
||
instance. If a site needs different policies for two DBs they live in
|
||
different drivers (different `Host:Port` rows in the config DB).
|
||
|
||
#### Diagnostics counters
|
||
|
||
The driver surfaces three coalescing counters via `DriverHealth.Diagnostics`
|
||
under the standard `<DriverType>.<Counter>` naming convention:
|
||
|
||
- `S7.TotalBlockReads` — number of `Plc.ReadBytesAsync` calls issued by
|
||
the coalesced path. A fully-coalesced contiguous workload bumps this
|
||
by 1 per `ReadAsync`.
|
||
- `S7.TotalMultiVarBatches` — `Plc.ReadMultipleVarsAsync` batches issued
|
||
for residual singletons that didn't merge. With perfect coalescing this
|
||
stays at 0.
|
||
- `S7.TotalSingleReads` — per-tag fallbacks (strings, dates, arrays,
|
||
64-bit ints, anything that bypasses both the coalescer and the packer).
|
||
|
||
Observe via the `driver-diagnostics` RPC (`/api/v2/drivers/{id}/diagnostics`)
|
||
or the Admin UI's per-driver dashboard.
|
||
|
||
### Diagnostics surfacing
|
||
|
||
Beyond the coalescing counters above, the S7 driver also surfaces the
|
||
**negotiated PDU size** captured during the COTP/S7comm handshake under the
|
||
same `<DriverType>.<Counter>` naming convention:
|
||
|
||
- `S7.NegotiatedPduSize` — the PDU envelope size advertised by the CPU
|
||
during `Plc.OpenAsync`. Default S7-1500 CPUs negotiate **240 bytes**;
|
||
CPUs running the extended PDU advertise **480 or 960 bytes**. The value
|
||
is `0` before the first successful connect and is reset to `0` on
|
||
driver shutdown so an operator inspecting the Admin UI dashboard can
|
||
immediately tell whether the driver is currently online.
|
||
|
||
Together these counters answer the most common operator questions about
|
||
S7 driver health without reaching for a Wireshark capture:
|
||
|
||
- "Is the driver actually connected?" → `S7.NegotiatedPduSize > 0`
|
||
- "Is coalescing working?" → `S7.TotalBlockReads` climbing while
|
||
`S7.TotalMultiVarBatches` stays flat
|
||
- "Why is throughput poor?" → `S7.NegotiatedPduSize` is 240 instead of 960
|
||
(operator can switch the CPU to extended PDU if the project allows)
|
||
|
||
The values render alongside Modbus / OPC UA Client metrics in the Admin
|
||
UI driver-diagnostics panel — same RPC, same dashboard row layout.
|
||
|
||
### Per-tag scan groups
|
||
|
||
Before PR-S7-C3, `ISubscribable.SubscribeAsync` took **one** publishing
|
||
interval and applied it to every tag in the input list. A site that wanted
|
||
mixed cadences — say a 100 ms HMI pulse, a 1 s dashboard tile, and a 10 s
|
||
slow-poll for trend data — had to issue **three separate subscribe calls**,
|
||
each with its own list of tags. That works, but it pushes the partitioning
|
||
problem up to the caller (the OPC UA address space layer) and means an
|
||
operator can't express "this tag is slow-poll" purely in driver config.
|
||
|
||
PR-S7-C3 adds **per-tag scan groups** so a single `SubscribeAsync` call
|
||
naturally splits into N independent poll loops:
|
||
|
||
- `S7TagDefinition.ScanGroup` (string, optional) — the group identifier the
|
||
tag belongs to. Tags with no group (or with a group not declared in the
|
||
rate map below) keep the legacy behaviour and inherit the
|
||
subscription-default publishing interval.
|
||
- `S7DriverOptions.ScanGroupIntervals` (`IReadOnlyDictionary<string, TimeSpan>`,
|
||
optional) — the rate map. Group names are matched case-insensitively. Any
|
||
group with a non-positive interval (≤ 0 ms) is silently dropped at config
|
||
load and tags falling back to that group land in the default partition.
|
||
|
||
At subscribe time the driver buckets the input tag list by **resolved
|
||
publishing interval** (per-tag group → map lookup → fallback to the
|
||
subscription default), then spins up one background poll loop per distinct
|
||
interval. Each loop owns its own `CancellationTokenSource` and its own
|
||
`LastValues` cache; `UnsubscribeAsync` cancels and disposes every per-group
|
||
loop together so a multi-rate subscription can't leak background tasks.
|
||
|
||
#### JSON config example
|
||
|
||
```json
|
||
{
|
||
"Host": "10.0.0.50",
|
||
"ScanGroupIntervalsMs": {
|
||
"Fast": 100,
|
||
"Medium": 1000,
|
||
"Slow": 10000
|
||
},
|
||
"Tags": [
|
||
{ "Name": "PressureSetpoint", "Address": "DB1.DBW0", "DataType": "Int16", "ScanGroup": "Fast" },
|
||
{ "Name": "BatchTotal", "Address": "DB1.DBD10", "DataType": "Int32", "ScanGroup": "Medium" },
|
||
{ "Name": "TrendBucket", "Address": "DB1.DBD20", "DataType": "Float32", "ScanGroup": "Slow" }
|
||
]
|
||
}
|
||
```
|
||
|
||
A single `SubscribeAsync(["PressureSetpoint","BatchTotal","TrendBucket"], 1s)`
|
||
call against this driver produces **three independent poll loops** —
|
||
the fast HMI tag ticks at 100 ms, the dashboard tile at 1 s, the trend
|
||
bucket at 10 s. The caller-supplied 1 s default is unused because every
|
||
tag carries an explicit group.
|
||
|
||
#### 100 ms floor applies per partition
|
||
|
||
The `100 ms` floor that protects the S7 mailbox from sub-scan polling
|
||
applies to **both** the subscription default **and** every per-group rate.
|
||
A typo'd entry like `{"TooFast": 25}` is silently floored to 100 ms at
|
||
partition-build time — the driver never schedules a sub-100 ms `Task.Delay`
|
||
even if the operator tries.
|
||
|
||
#### `_gate` contention caveat — "1 connection / 1 mailbox"
|
||
|
||
Partitioning into N poll loops does **not** parallelise wire-level reads.
|
||
S7netplus's documented pattern is one `Plc` instance per CPU, and the
|
||
driver enforces that with a per-instance `SemaphoreSlim` (`_gate`) that
|
||
every read takes before touching the socket. All N partitions share the
|
||
same gate, so the **mailbox is still strictly serial** — what the multi-rate
|
||
split actually buys you is **cadence decoupling**:
|
||
|
||
- Before PR-S7-C3: every tag ticked at the slowest configured interval (or
|
||
required three separate subscribe calls and three separate logical
|
||
subscription handles, complicating the address-space layer).
|
||
- After PR-S7-C3: a 100 ms HMI tag isn't blocked behind a 10 s slow-poll
|
||
batch's `Task.Delay`. While Slow is sleeping, the gate is free and Fast
|
||
acquires it, polls, releases. The CPU sees more frequent small requests
|
||
rather than infrequent large ones — which is what you want for a
|
||
responsive HMI surface.
|
||
|
||
The caveat to be aware of: if Fast's per-tick read takes longer than its
|
||
tick interval (e.g. 100 ms tick but 200 ms gate-held read because Medium
|
||
or Slow happens to be mid-read on the gate), Fast's effective cadence
|
||
slows to "as fast as the gate lets me." That's a property of S7netplus's
|
||
single-connection design, not of partitioning — three separate driver
|
||
instances against the same CPU would just waste the CPU's
|
||
8-64-connection-resource budget without speeding anything up.
|
||
|
||
#### Diagnostics
|
||
|
||
Partition counts aren't yet surfaced under
|
||
`DriverHealth.Diagnostics` (planned for a follow-up alongside per-partition
|
||
tick rate). Tests can call the internal helpers `S7Driver.GetPartitionCount`
|
||
and `S7Driver.GetPartitionSummary` to inspect the resolved partitioning of
|
||
a live subscription handle.
|
||
|
||
### Deadband / on-change
|
||
|
||
Before PR-S7-C4 the subscription poll loop emitted `OnDataChange` whenever
|
||
the freshly-read value differed from the last cached one — a strict
|
||
`!Equals(prev, current)` test. That's correct for booleans and discrete
|
||
state, but for analog tags (Float32 / Float64 / scaled integer set-points)
|
||
it floods the OPC UA subscription queue with insignificant noise: the last
|
||
counts of an ADC's least-significant-bit jitter, sub-percent setpoint drift,
|
||
sensor-grade flutter on a flow rate. PR-S7-C4 lets the operator configure
|
||
**per-tag deadband thresholds** so the driver suppresses uninteresting
|
||
publishes at source, before they cross the OPC UA boundary.
|
||
|
||
Two knobs, both optional, both per-tag:
|
||
|
||
- `DeadbandAbsolute` (`double?`) — minimum value change in raw units.
|
||
Suppress when `|new - prev| < DeadbandAbsolute`.
|
||
- `DeadbandPercent` (`double?`, 0..100) — minimum value change as a
|
||
percentage of the previous published value. Suppress when
|
||
`|new - prev| < |prev| * DeadbandPercent / 100`.
|
||
|
||
When both knobs are set the filters are **OR'd** — the value publishes if
|
||
**either** threshold says publish. This matches Kepware's documented
|
||
"either threshold triggers" semantics and mirrors the AbLegacy driver's
|
||
shipped behaviour for cross-driver consistency.
|
||
|
||
#### JSON config example
|
||
|
||
```json
|
||
{
|
||
"Host": "10.0.0.50",
|
||
"Tags": [
|
||
{ "Name": "BoilerPressure", "Address": "DB1.DBD0", "DataType": "Float32",
|
||
"DeadbandAbsolute": 0.5 },
|
||
|
||
{ "Name": "FlowRate", "Address": "DB1.DBD4", "DataType": "Float32",
|
||
"DeadbandPercent": 1.0 },
|
||
|
||
{ "Name": "Temperature", "Address": "DB1.DBD8", "DataType": "Float32",
|
||
"DeadbandAbsolute": 0.1, "DeadbandPercent": 0.5 }
|
||
]
|
||
}
|
||
```
|
||
|
||
`BoilerPressure` only republishes after a 0.5-bar change; `FlowRate` only
|
||
when the rate moves by more than 1% of its last published value;
|
||
`Temperature` whenever **either** `0.1 °C absolute` **or** `0.5% of last`
|
||
is satisfied.
|
||
|
||
#### Edge cases
|
||
|
||
- **First sample.** `PollOnceAsync` gates `forceRaise` and the
|
||
no-prior-value case ahead of the deadband filter — the first sample for
|
||
a tag always publishes (otherwise an OPC UA subscription would never see
|
||
an initial-data push).
|
||
- **Status-code change.** Any transition in the OPC UA `StatusCode` channel
|
||
(`Bad → Good`, `Good → Bad`, etc.) bypasses deadband and publishes,
|
||
because quality is a semantically different signal from value.
|
||
- **Non-numeric types.** `String` / `WString` / `Char` / `WChar` /
|
||
`DateTime` / byte-array tags ignore deadband entirely and keep the
|
||
legacy `!Equals` semantics. Configuring `DeadbandAbsolute` on a
|
||
`String` tag is harmless — the filter just doesn't engage.
|
||
- **`NaN` samples.** If either `prev` or `current` is `NaN`, the filter
|
||
publishes. NaN never equals NaN; treating it as "changed" surfaces the
|
||
degenerate float to the client rather than hiding it.
|
||
- **`±Infinity` samples.** Same rationale as NaN — degenerate values are
|
||
always published, never deadbanded.
|
||
- **Sign flip.** A tag swinging `+10 → -10` produces `|delta|=20`; the
|
||
deadband math operates on the **absolute** delta so a sign flip with
|
||
`DeadbandAbsolute=1` always publishes. This is the right answer for
|
||
bidirectional set-points (positive / negative torque, valve-direction
|
||
flags encoded as signed scalars).
|
||
- **Near-zero baseline (`|prev| < 1e-6`).** A percent threshold against a
|
||
zero or near-zero baseline diverges (any tiny change is "infinity
|
||
percent"), so the driver falls back to absolute when `|prev| < 1e-6`:
|
||
- If `DeadbandAbsolute` is also configured, that threshold takes over.
|
||
- If only `DeadbandPercent` is set (no absolute fallback), the sample
|
||
publishes — there's no usable threshold and silently dropping changes
|
||
against a near-zero baseline would mask a genuine signal.
|
||
|
||
The `1e-6` cutoff is a deliberately conservative floor: floats below
|
||
`~1e-7` are already in denormal-precision territory; anything above
|
||
`~1e-6` carries enough magnitude that `|prev| * pct / 100` produces a
|
||
meaningful threshold.
|
||
|
||
#### Implementation notes
|
||
|
||
- The filter is the pure-function helper `S7Driver.ShouldPublish(tag,
|
||
prev, current)`. It's exposed at `internal` scope so unit tests can
|
||
drive every decision branch (NaN, ±Inf, sign flip, near-zero baseline,
|
||
both-set OR semantics) without spinning up a partition or poll loop.
|
||
- `LastValues` continues to cache the **last published** snapshot, not
|
||
the last polled one. After a deadband suppression the next sample
|
||
compares against the cached (previously published) value, so a slow
|
||
drift that never crosses the threshold in any single tick still gets
|
||
caught the moment cumulative drift exceeds the threshold.
|
||
- Deadband is a **publish-time** filter, not a wire-level one — every
|
||
configured tag is still read every tick, the filter only decides
|
||
whether to invoke `OnDataChange`. The mailbox / PDU / coalescing path
|
||
is untouched.
|
||
|
||
## Pre-flight PUT/GET enablement
|
||
|
||
S7-1200 / S7-1500 CPUs ship with **PUT/GET communication disabled by
|
||
default**. The COTP / S7comm handshake itself succeeds against these
|
||
locked-down CPUs (you can `OpenAsync` / negotiate PDU size cleanly), so
|
||
the failure surfaces only on the *first* `Plc.ReadAsync` — at which
|
||
point the driver is already past `InitializeAsync`, has flipped to
|
||
`DriverState.Healthy`, and dependent code (subscriptions, Admin UI) is
|
||
binding against a connection it can't actually use. Operators see
|
||
`BadDeviceFailure` per tag instead of a single, actionable
|
||
configuration error.
|
||
|
||
PR-S7-C5 adds a **post-`OpenAsync` pre-flight probe**: a tiny 2-byte
|
||
read against `Probe.ProbeAddress` (default `MW0`). If the PLC rejects
|
||
that read with the wire-level "function not allowed in current
|
||
operating state" response (S7 error family `D6 05` / `85 00`),
|
||
S7netplus surfaces the rejection as `PlcException` with one of
|
||
`ErrorCode.WrongCPU_Type` (CPU drops the connection mid-response) or
|
||
`ErrorCode.ReadData` (CPU sends an S7-level error byte). The driver
|
||
classifies that pair as "PUT/GET disabled" and throws a typed
|
||
`S7PutGetDisabledException` from `InitializeAsync` so the operator sees
|
||
the TIA-Portal fix path immediately:
|
||
|
||
> PUT/GET communication is disabled on the PLC. Enable it in TIA Portal:
|
||
> *Device → Properties → Protection & Security → Connection mechanisms →
|
||
> "Permit access with PUT/GET communication from remote partner"*.
|
||
> Re-deploy the hardware config and restart the S7 driver.
|
||
|
||
`S7PreflightClassifier.IsPutGetDisabled(PlcException)` is the pure
|
||
function that decides whether a given `PlcException` qualifies; it
|
||
matches **only** `WrongCPU_Type` and `ReadData`. Other error codes
|
||
(`ConnectionError`, `IPAddressNotAvailable`, `WrongVarFormat`, …)
|
||
indicate transport / framing faults rather than PUT/GET gating, so the
|
||
driver re-throws the original `PlcException` unchanged and the existing
|
||
`DriverState.Faulted` path takes over with the original message.
|
||
|
||
### Knobs
|
||
|
||
Two opt-out knobs on `S7ProbeOptions`:
|
||
|
||
- `ProbeAddress` (`string?`, default `"MW0"`) — address probed by both
|
||
the background liveness loop and the pre-flight read. Set to `null`
|
||
(or empty string in JSON) to skip the pre-flight entirely. Useful
|
||
for sites where no fingerprint address has been wired and an arbitrary
|
||
read at `MW0` would itself be misleading.
|
||
- `SkipPreflight` (`bool`, default `false`) — opt out of the pre-flight
|
||
read while keeping the background probe. Init succeeds against a
|
||
PUT/GET-disabled CPU; per-tag reads still surface `BadDeviceFailure`
|
||
at runtime. Useful for staged deployments where the operator hasn't
|
||
enabled PUT/GET yet but wants the driver visible in the Admin UI.
|
||
|
||
### Why `MW0`?
|
||
|
||
The convention from `Driver.S7.Cli.md`'s `probe` command. `MW0` exists
|
||
on every S7 CPU regardless of project — Merker memory is universal —
|
||
so it's a safe default that doesn't require a per-site DB to be wired.
|
||
Sites with a dedicated fingerprint DB can override to e.g.
|
||
`DB1.DBW0`.
|
||
|
||
### JSON config example
|
||
|
||
```json
|
||
{
|
||
"Host": "10.0.0.50",
|
||
"Probe": {
|
||
"Enabled": true,
|
||
"IntervalMs": 5000,
|
||
"TimeoutMs": 2000,
|
||
"ProbeAddress": "DB1.DBW0",
|
||
"SkipPreflight": false
|
||
}
|
||
}
|
||
```
|
||
|
||
To skip the pre-flight (defer the check to first read):
|
||
|
||
```json
|
||
{
|
||
"Host": "10.0.0.50",
|
||
"Probe": { "SkipPreflight": true }
|
||
}
|
||
```
|
||
|
||
To skip the probe entirely (no pre-flight, no liveness loop):
|
||
|
||
```json
|
||
{
|
||
"Host": "10.0.0.50",
|
||
"Probe": { "Enabled": false, "ProbeAddress": "" }
|
||
}
|
||
```
|
||
|
||
## TSAP / Connection Type
|
||
|
||
S7comm runs on top of ISO-on-TCP (RFC 1006), and the COTP connection-request
|
||
PDU carries a 16-bit **TSAP pair** (local + remote) that the CPU validates
|
||
before any S7comm payload flows. S7netplus's default `Plc(CpuType, host, port,
|
||
rack, slot)` constructor picks a **PG-class** TSAP pair via
|
||
`TsapPair.GetDefaultTsapPair`. That choice works against most lab S7-1200 /
|
||
S7-1500 CPUs and against TIA Portal itself, but **hardened deployments**
|
||
(security-config'd S7-1500, ET 200SP, locked-down PROFINET projects) reject
|
||
PG class outright at COTP-handshake time, returning the same connection-refused
|
||
shape as a wrong slot byte.
|
||
|
||
PR-S7-C2 surfaces a `TsapMode` enum on `S7DriverOptions` so an operator can
|
||
force a specific class without re-flashing the PLC project. It applies equally
|
||
to the Admin-UI-driven config DB row and to the `otopcua-s7-cli` test client.
|
||
|
||
### Raw-TSAP byte table
|
||
|
||
The high byte is the connection class. The local low byte is conventionally
|
||
`0x00` (caller / unprivileged), and the remote low byte is
|
||
`(rack << 5) | slot` per the S7 spec — the same convention S7netplus's
|
||
`TsapPair.GetDefaultTsapPair(CpuType, rack, slot)` uses for the remote endpoint.
|
||
|
||
| Class | High byte | Local TSAP (rack=0/slot=0) | Remote TSAP (rack=0/slot=0) | Remote TSAP (rack=0/slot=2) | Typical use |
|
||
|----------|-----------|----------------------------|------------------------------|------------------------------|----------------------------------------------|
|
||
| PG | `0x01` | `0x0100` | `0x0100` | `0x0102` | TIA Portal, dev laptops, lab S7-1200/1500 |
|
||
| OP | `0x02` | `0x0200` | `0x0200` | `0x0202` | Operator panels, hardened-CPU S7-1500 |
|
||
| S7-Basic | `0x03` | `0x0300` | `0x0300` | `0x0302` | WinCC BasicPanel SDK, S7-Basic clients |
|
||
| Other | caller | caller-supplied | caller-supplied | caller-supplied | escape hatch — unusual fixed-TSAP firmware |
|
||
|
||
### `TsapMode` enum
|
||
|
||
| Mode | Behaviour |
|
||
|-----------|----------------------------------------------------------------------------------------------------------------------------------|
|
||
| `Auto` | Existing behaviour — S7netplus picks the TSAP pair from `CpuType`. Explicit `LocalTsap` / `RemoteTsap` are ignored under `Auto`. |
|
||
| `Pg` | Force PG class (high byte `0x01`). Local / remote computed from rack + slot. |
|
||
| `Op` | Force OP class (high byte `0x02`). |
|
||
| `S7Basic` | Force S7-Basic class (high byte `0x03`). |
|
||
| `Other` | Caller-supplied `LocalTsap` + `RemoteTsap`. Both must be set or driver init throws `InvalidOperationException`. |
|
||
|
||
Explicit `LocalTsap` / `RemoteTsap` overrides win over the class-derived
|
||
defaults under any non-`Auto` mode — a site that needs a fixed source-TSAP for
|
||
firewall reasons can pin `LocalTsap` while keeping `TsapMode = Pg` for the
|
||
remote computation.
|
||
|
||
### Worked example: hardened S7-1500 requiring OP class
|
||
|
||
```jsonc
|
||
{
|
||
"Host": "10.50.12.30",
|
||
"CpuType": "S71500",
|
||
"Rack": 0,
|
||
"Slot": 0,
|
||
"TsapMode": "Op",
|
||
"Tags": [ /* … */ ]
|
||
}
|
||
```
|
||
|
||
This produces local = `0x0200`, remote = `0x0200` (rack=0, slot=0). The same
|
||
PLC under `TsapMode = "Auto"` (PG class) returns COTP rejection — same packet
|
||
capture shape as a wrong-slot misconfig, which is the failure-mode footnote
|
||
under §5 of `driver-specs.md`.
|
||
|
||
### Why not just expose `LocalTsap` / `RemoteTsap` directly?
|
||
|
||
Most operators don't know the byte format off-hand and reach for `Pg` /
|
||
`Op` / `S7Basic` based on Siemens-doc terminology. Keeping the enum lets the
|
||
Admin UI render a dropdown with sensible labels, while the `ushort?` fields
|
||
stay available as the manual escape hatch when a site has truly unusual
|
||
firmware (e.g. third-party S7-protocol gateways with fixed proprietary
|
||
TSAPs). Both paths are exercised in the unit-test mapping table.
|
||
|
||
### Live-firmware verification
|
||
|
||
The PG/OP/S7-Basic byte table above is the documented Siemens convention; the
|
||
actual handshake is verified against the dev-box S7-1500 lab rig (a hardened
|
||
project that rejects PG and accepts OP). That test is documented in
|
||
`tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests` but only runs against
|
||
real firmware — the pymodbus-style "TSAP simulator" doesn't exist for S7.
|
||
|
||
## References
|
||
|
||
1. Siemens Industry Online Support, *Modbus/TCP Communication between SIMATIC S7-1500 / S7-1200 and Modbus/TCP Controllers with Instructions `MB_CLIENT` and `MB_SERVER`*, Entry ID 102020340, V6 (Feb 2021). https://cache.industry.siemens.com/dl/files/340/102020340/att_118119/v6/net_modbus_tcp_s7-1500_s7-1200_en.pdf
|
||
2. Siemens TIA Portal Online Docs, *MB_SERVER instruction*. https://docs.tia.siemens.cloud/r/simatic_s7_1200_manual_collection_eses_20/communication-processor-and-modbus-tcp/modbus-communication/modbus-tcp/modbus-tcp-instructions/mb_server-communicate-using-profinet-as-modbus-tcp-server-instruction
|
||
3. Siemens, *SIMATIC S7-1500 Communication Function Manual* (covers ET 200SP CPU). http://public.eandm.com/Public_Docs/s71500_communication_function_manual_en-US_en-US.pdf
|
||
4. Siemens Industry Online Support, *SIMATIC Modbus/TCP communication using CP 343-1 and CP 443-1 — Programming Manual*, Entry ID 103447617. https://cache.industry.siemens.com/dl/files/617/103447617/att_106971/v1/simatic_modbus_tcp_cp_en-US_en-US.pdf
|
||
5. Siemens Industry Online Support FAQ *"Which technical data applies for the SIMATIC Modbus/TCP software for CP 343-1 / CP 443-1?"*, Entry ID 104946406. https://www.industry-mobile-support.siemens-info.com/en/article/detail/104946406
|
||
6. Siemens Industry Online Support, *Redundant Modbus/TCP communication via CP 443-1 in S7-400H systems*, Entry ID 109739212. https://cache.industry.siemens.com/dl/files/212/109739212/att_887886/v1/SIMATIC_modbus_tcp_cp_red_e_en-US.pdf
|
||
7. Siemens Industry Online Support, *SIMATIC MODBUS (TCP) PN CPU Library — Programming and Operating Manual 06/2014*, Entry ID 75330636. https://support.industry.siemens.com/cs/attachments/75330636/ModbusTCPPNCPUen.pdf
|
||
8. DMC Inc., *Using an S7-1200 PLC as a Modbus TCP Slave*. https://www.dmcinfo.com/blog/27313/using-an-s7-1200-plc-as-a-modbus-tcp-slave/
|
||
9. Siemens, *SIMATIC S7-1200 System Manual* (V4.x), "MB_SERVER" pages 736-742. https://www.manualslib.com/manual/1453610/Siemens-S7-1200.html?page=736
|
||
10. lamaPLC, *Simatic Modbus S7 error- and statuscodes*. https://www.lamaplc.com/doku.php?id=simatic:errorcodes
|
||
11. ScadaProtocols, *How to Configure Modbus TCP on Siemens S7-1200 (TIA Portal Step-by-Step)*. https://scadaprotocols.com/modbus-tcp-siemens-s7-1200-tia-portal/
|
||
12. Industrial Monitor Direct, *Reading and Writing Memory Bits via Modbus TCP on S7-1200*. https://industrialmonitordirect.com/blogs/knowledgebase/reading-and-writing-memory-bits-via-modbus-tcp-on-s7-1200
|
||
13. PLCtalk forum *"Siemens S7-1200 modbus understanding"*. https://www.plctalk.net/forums/threads/siemens-s7-1200-modbus-understanding.104119/
|
||
14. Siemens SIMATIC S7 Manual, "Function block MODBUSCP — Functionality" (ManualsLib p29). https://www.manualslib.com/manual/1580661/Siemens-Simatic-S7.html?page=29
|
||
15. Chipkin, *How Real (Floating Point) and 32-bit Data is Encoded in Modbus*. https://store.chipkin.com/articles/how-real-floating-point-and-32-bit-data-is-encoded-in-modbus-rtu-messages
|
||
16. Siemens Industry Online Support forum, *MODBUS DATA conversion in S7-1200 CPU*, Entry ID 97287. https://support.industry.siemens.com/forum/WW/en/posts/modbus-data-converson-in-s7-1200-cpu/97287
|
||
17. Industrial Monitor Direct, *Siemens S7-1500 MB_SERVER Modbus TCP Configuration Guide*. https://industrialmonitordirect.com/de/blogs/knowledgebase/siemens-s7-1500-mb-server-modbus-tcp-configuration-guide
|
||
18. Siemens TIA Portal, *Data types in SIMATIC S7-1200/1500 — String/WString header layout* (system manual, "Elementary Data Types").
|
||
19. Kepware / PTC, *Siemens TCP/IP Ethernet Driver Help*, "Byte / Word Order" tag property. https://www.opcturkey.com/uploads/siemens-tcp-ip-ethernet-manual.pdf
|
||
20. Siemens SiePortal forum, *Transfer float out of words*, Entry ID 187811. https://sieportal.siemens.com/en-ww/support/forum/posts/transfer-float-out-of-words/187811 _(operator-reported "S7 swaps float" claim — traced to remote-device issue; **unconfirmed**.)_
|
||
21. Siemens SiePortal forum, *S7-1200 communication with Modbus TCP*, Entry ID 133086. https://support.industry.siemens.com/forum/WW/en/posts/s7-1200-communication-with-modbus-tcp/133086
|
||
22. Siemens SiePortal forum, *S7-1500 MB Server Holding Register Max Word*, Entry ID 224636. https://support.industry.siemens.com/forum/WW/en/posts/s7-1500-mb-server-holding-register-max-word/224636
|
||
23. Siemens, *SIMATIC S7-1500 Technical Specifications* — CPU-specific DB size limits in each CPU manual's "Memory" table.
|
||
24. Siemens TIA Portal Online Docs, *Error messages (S7-1200, S7-1500) — Modbus instructions*. https://docs.tia.siemens.cloud/r/en-us/v20/modbus-rtu-s7-1200-s7-1500/error-messages-s7-1200-s7-1500
|
||
25. Industrial Monitor Direct, *Fix Siemens S7-1500 MB_Client UnitID Error 80C8*. https://industrialmonitordirect.com/blogs/knowledgebase/troubleshooting-mb-client-on-s7-1500-cpu-1515sp-modbus-tcp
|
||
26. Siemens SiePortal forum, *How many TCP connections can the S7-1200 make?*, Entry ID 275570. https://support.industry.siemens.com/forum/WW/en/posts/how-many-tcp-connections-can-the-s7-1200-make/275570
|
||
27. Siemens SiePortal forum, *Simultaneous connections of Modbus TCP*, Entry ID 189626. https://support.industry.siemens.com/forum/ww/en/posts/simultaneous-connections-of-modbus-tcp/189626
|
||
28. Siemens SiePortal forum, *How many Modbus TCP IP clients can read simultaneously from S7-1517*, Entry ID 261569. https://support.industry.siemens.com/forum/WW/en/posts/how-many-modbus-tcp-ip-client-can-read-simultaneously-in-s7-1517/261569
|
||
29. Industrial Monitor Direct, *Troubleshooting Intermittent Modbus TCP Connections on S7-1500 PLC*. https://industrialmonitordirect.com/blogs/knowledgebase/troubleshooting-intermittent-modbus-tcp-connections-on-s7-1500-plc
|
||
30. PLCtalk forum *"S7-1500 modbus tcp speed?"*. https://www.plctalk.net/forums/threads/s7-1500-modbus-tcp-speed.114046/
|
||
31. Siemens SiePortal forum, *MB_Unit_ID parameter in Modbus TCP*, Entry ID 156635. https://support.industry.siemens.com/forum/WW/en/posts/mb-unit-id-parameter-in-modbus-tcp/156635
|