# Siemens SIMATIC S7 (S7-1200 / S7-1500 / S7-300 / S7-400 / ET 200SP) — Modbus TCP quirks > **Read first: [Optimized DB constraint (S7Plus)](#optimized-db-constraint-s7plus).** > S7netplus, the wire library this driver is built on, speaks classic S7comm > only — it cannot read Optimized-block-access DBs on S7-1200 / S7-1500. That > is the default in TIA Portal V14+ for new projects. If you skip the section > below, every absolute-offset read against a freshly-created S7-1500 project > will return `BadDeviceFailure`. ## Optimized DB constraint (S7Plus) ### Symptom Against a default new S7-1500 TIA Portal project, an absolute-offset read like `DB1.DBW0` issued by the OtOpcUa S7 driver returns `BadDeviceFailure` (the S7netplus `PlcException` surfaces as `ErrorCode.WrongVarFormat` / `ErrorCode.ReadData` depending on firmware revision). No bytes are returned; the read never reaches the user data; the failure is identical whether PUT/GET is enabled or not. ### Why The OtOpcUa S7 driver is built on [**S7netplus**](https://github.com/S7NetPlus/s7netplus), which implements **classic S7comm** only — the protocol historically used by S7-300 / S7-400 and the legacy "compatibility" path on S7-1200 / S7-1500. Classic S7comm addresses DB contents by **absolute byte offset**: `DB1.DBW0` literally means "give me 2 bytes starting at byte 0 of DB number 1". This works as long as the byte offsets in the program match the byte offsets on the wire. S7-1200 V4 and S7-1500 introduced **Optimized block access**. When checked, the TIA Portal compiler is free to **reorder DB members**, insert padding for alignment, and store members in CPU-internal memory that the absolute-offset read protocol cannot reach. There are no fixed byte offsets to address — the only way to read an Optimized DB is by **symbolic name**, which requires **S7Plus** (the post-2014 protocol Siemens uses for TIA-Portal-aware tooling and OPC UA gateways). S7Plus is undocumented by Siemens. A community Wireshark dissector exists (`s7comm-plus`), but no production-ready open-source library implements the write/subscribe surface end-to-end. **S7netplus does not, and is not on a roadmap to, support S7Plus.** Snap7 v2 / Snap7Net and the various Sharp7 forks are also classic-S7comm-only. ### Default to know about In **TIA Portal V14 and newer, "Optimized block access" is checked by default on every newly-created DB**. A customer who clicks "Add new block → Data block → OK" on a fresh S7-1500 project gets an Optimized DB. The driver cannot read it. ### Supported workarounds The OtOpcUa project supports two workarounds. Pick one per deployment. #### Track 1 — Disable Optimized block access in TIA Portal Per DB the driver reads: 1. In TIA Portal, open the project tree → `` → **Program blocks** → right-click the DB → **Properties**. 2. In the **Attributes** tab, **uncheck "Optimized block access"**. 3. **Compile** the program. 4. **Download** to the PLC (download the changed block; the CPU will go into STOP if the DB layout changed and download-without-reinitialize is refused — schedule a maintenance window). After this, `DB1.DBW0` and friends address absolute byte offsets again and the OtOpcUa S7 driver reads through unmodified. **Trade-off:** Optimized DBs are slightly faster for *the PLC program itself* to access (better alignment, sometimes better cache behaviour) and let the compiler add/remove DB members without renumbering offsets in user code. Disabling Optimized access trades a tiny amount of CPU-side performance and a layout-stability guarantee for absolute-offset wire addressability. For DBs that exist only as a Modbus / S7comm gateway buffer (common pattern), there is no real downside. This is the same prerequisite called out in ["Optimized block access — must be off"](#optimized-block-access--must-be-off) and ["Address / DB Mapping → MB_HOLD_REG"](#address--db-mapping) for the Modbus-TCP path; the constraint is the same and stems from the same absolute-offset-only assumption. #### Track 3 — Bridge via the OpcUaClient driver against the CPU's onboard OPC UA server S7-1500 firmware **V2.5 and later** ship with an **integrated OPC UA server** running on the CPU's PROFINET port (default port 4840). Once enabled in TIA Portal it exposes the entire symbol table — including Optimized DBs — through standard OPC UA, by symbolic name. There is no S7 protocol involved at all from the OtOpcUa side. Configure the bridge once: 1. **TIA Portal side**: - Open the CPU's properties → **OPC UA** → **General** → check **Activate OPC UA server**. - Set the server port (default 4840) and security policy. For a quick bring-up, allow `None` + `UserName` and create a server certificate; for production, use Basic256Sha256 with a CA-issued cert. - Under **OPC UA** → **Server interfaces**, expose the symbols/tags the OtOpcUa side should see. (Whole-symbol-table exposure works; a curated server interface is more secure and faster.) - Compile and download. - Note: this requires a **runtime OPC UA license on the CPU** (Siemens SIMATIC NET OPC UA server license, typically activated via SIMATIC SUM). The license is per CPU, not per client. 2. **OtOpcUa side** — register an `OpcUaClient` driver instance pointing at the CPU. Minimal `DriverConfig` JSON: ```json { "Driver": "OpcUaClient", "Name": "PLC1500_Onboard", "Options": { "EndpointUrl": "opc.tcp://10.0.0.42:4840", "SecurityMode": "SignAndEncrypt", "SecurityPolicy": "Basic256Sha256", "UserName": "OtOpcUa", "Password": "", "WatchModelChanges": true } } ``` The driver handles browse, read, write, and subscriptions through the CPU's symbolic name space. Optimized DBs Just Work — the CPU resolves names internally, so the wire never sees a byte offset. See [`docs/drivers/OpcUaClient.md`](../drivers/OpcUaClient.md) for the full configuration surface (reverse connect, model-change re-import, failover, aggregate functions, redundancy via `ServerUriArray`, etc.). **When to use Track 3 over Track 1**: - The DB layout is owned by an upstream Siemens engineering team that won't disable Optimized access (legitimate concern: shared-DB constraints, compile-time member-renumbering, application notes that mandate optimized blocks). - The customer already licenses OPC UA on the CPU. - Symbolic addressing is preferred end-to-end (no byte-offset bookkeeping in the OtOpcUa tag list; tags survive DB-member additions). - S7-300 / S7-400 are out of scope on this CPU (the onboard OPC UA server is S7-1500 V2.5+ only — see V2.5 firmware change list). **When to use Track 1 over Track 3**: - The CPU is S7-1200 (no onboard OPC UA server even on the V4 firmware line) or older S7-1500 firmware (< V2.5). - The customer won't pay for the SIMATIC NET OPC UA server license on the CPU. - The DBs in question exist purely as gateway buffers and have no significant CPU-program access pattern that would benefit from Optimized access. ### Track 2 — out of scope For completeness, the **Track 2** option that was evaluated and rejected: migrate the OtOpcUa S7 driver off S7netplus to a library that speaks S7Plus. The candidates were: - **Snap7 v2 / Snap7Net** — classic S7comm only. Same Optimized-DB limitation. Not a step forward. - **Sharp7 community forks** — partial S7-1200 / S7-1500 PUT/GET semantics but still classic-S7comm wire format. Not a step forward. - **Custom S7Plus implementation** — possible in principle (the Wireshark `s7comm-plus` dissector covers the wire format), but estimated **≥4 weeks** of engineering for a minimal read/write/subscribe surface, plus ongoing maintenance every time Siemens revs the protocol version (which they do silently with each TIA Portal release). **Track 2 is not on the OtOpcUa roadmap** unless a specific customer funds the engineering and ongoing maintenance. Track 1 + Track 3 together cover every shipping S7 deployment we have visibility into. ### Pre-flight diagnostics The driver does not currently auto-detect Optimized DBs from the `BadDeviceFailure` shape (the same error code is returned for "DB doesn't exist", "DB exists but is too short", etc.). On first encounter of a device-failure error, check the suspect DB's properties in TIA Portal **before** chasing wire-level theories. The auto-detect would require an SZL probe or a symbolic round-trip; tracked but not a v2 deliverable. --- 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 / 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 `.` 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 `.` 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`, 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. ## Symbol import PR-S7-D1 / [#299](https://github.com/dohertj2/lmxopcua/issues/299) — bulk-import TIA Portal "Show all tags" CSV exports and STEP 7 Classic AWL declaration files into the S7 driver's tag list. Operators no longer hand-edit the `Drivers//Config/Tags` JSON for hundred-tag projects. Two formats supported v1: - **TIA Portal CSV** — `Name,Path,Data type,Logical address,Comment,Hmi accessible,…`. en-US (`,`) and DE-locale (`;` separator + `,` decimal) auto-detected. HMI-hidden symbols filter out automatically; UDT-typed rows import as placeholders until PR-S7-D2 ships proper UDT layout. - **STEP 7 Classic AWL** — `VAR_GLOBAL` + `DATA_BLOCK` declarations parsed best-effort with position-based offset assignment. Two surface options: - **CLI**: `otopcua-s7-cli import-symbols --file foo.csv --format tia` emits an `appsettings.json` JSON fragment for hand-merge. - **API**: `S7DriverOptions.AddTiaCsvImport(path, out result)` / `AddAwlImport(path, out result)` for server-side bootstrap paths. Full reference: [`docs/drivers/S7-TIA-Import.md`](../drivers/S7-TIA-Import.md). CLI flag table: [`docs/Driver.S7.Cli.md` "import-symbols"](../Driver.S7.Cli.md#import-symbols). ## UDT / STRUCT support PR-S7-D2 / #300 — UDT-typed DBs are exposed via per-member fan-out at driver init time. The driver reads / writes / subscribes only ever target scalar leaves; the parent UDT pointer never reaches the wire. This keeps the rest of the driver pipeline (address parser, block-coalescing planner, scan-group partitioner, deadband filter) UDT-unaware. ### `S7UdtDefinition` A UDT is declared once in `S7DriverOptions.Udts` and referenced by tags whose `UdtName` is set: ```csharp new S7UdtDefinition( Name: "Pump", Members: [ new S7UdtMember("Pressure", Offset: 0, S7DataType.Float32), new S7UdtMember("Status", Offset: 4, S7DataType.Int16), new S7UdtMember("Enabled", Offset: 6, S7DataType.Bool), ], SizeBytes: 7); ``` Tags adopt the UDT layout via `UdtName`: ```csharp new S7TagDefinition("Pump1", "DB1.DBX0.0", S7DataType.Byte, UdtName: "Pump"); ``` ### Fan-out semantics At `InitializeAsync` time the driver: 1. Walks `_options.Tags`. For each tag with `UdtName`, looks up the UDT in `_options.Udts` (case-insensitive). 2. For each UDT member, computes `parent.Address.ByteOffset + member.Offset` and emits one scalar `S7TagDefinition` per leaf with name `Parent.Member` (dot-separated). 3. Array members emit `Member[0]`, `Member[1]`, ... at stride `elementBytes`. 4. Nested UDT members recurse — array-of-UDT walks at stride `inner.SizeBytes`. 5. The fanned-out leaves replace the parent UDT tag in the driver's tag map. Reads / writes / subscribes that target the parent name surface `BadNodeIdUnknown` — clients must address the leaves directly. ### 4-level nesting cap UDT-of-UDT is supported up to 4 levels deep. Anything deeper throws `InvalidOperationException("UDT nesting depth exceeds 4 levels…")` at Init. This catches accidentally-recursive declarations early; real industrial UDTs rarely go beyond 2 layers. ### Optimized block access — must be off The static-offset model assumes member byte offsets in the declaration match the runtime layout exactly. TIA Portal's "Optimized block access" flag lets the runtime reorder members for memory alignment, breaking that assumption. Same prerequisite as general absolute-offset DB addressing on S7-1200 / 1500: **Optimized block access must be disabled** on any DB that the driver addresses by absolute offset, including UDT-typed DBs. If a customer can't disable Optimized access (e.g., shared-DB constraints), the workaround is to expose the UDT through the symbolic-tag path once that ships — not in PR-S7-D2. See [Optimized DB constraint (S7Plus)](#optimized-db-constraint-s7plus) at the top of this document for the project-wide decision (Track 1 disable in TIA Portal, or Track 3 bridge via the OpcUaClient driver against the CPU's onboard OPC UA server). ### Validation The fan-out rejects, with clear errors: - UDT name not found in `Udts` collection - Member offsets not in ascending order - Member offsets that overlap (a primitive's `[offset, offset+width)` range intersects the next member's offset) - Total members extending past `SizeBytes` - Tag with `UdtName` AND `ElementCount > 1` (array-of-UDT belongs in the UDT layout, not at the parent-tag level) ### Re-import on UDT / FB-interface edit — caveat The static-offset model assumes the declared layout matches the runtime layout exactly. When the underlying UDT or FB interface changes in TIA Portal — a member added, removed, or reordered — the byte offsets shift on the PLC side and the cached `S7UdtDefinition` / instance-DB addresses point at the wrong member. **The driver does not auto-detect interface drift.** After any UDT edit or multi-instance-FB interface edit on the PLC side, the operator must: 1. Recompile + download the updated program in TIA Portal. 2. Re-export "Show all tags" CSV from the updated project. 3. Re-import via `AddTiaCsvImport` (or `import-symbols` CLI) and update the matching `S7UdtDefinition` declarations to mirror the new offsets. 4. Restart the driver instance (Admin UI → Drivers → Reload). A stale UDT layout will silently read / write the wrong byte offsets — the values will look like valid PLC data but reference whichever member used to live at that offset before the edit. The same caveat applies to multi-instance FB-instance DBs imported via PR-S7-D3 / [#301](https://github.com/dohertj2/lmxopcua/issues/301); see [`docs/drivers/S7-TIA-Import.md` "Re-import on FB-interface edit"](../drivers/S7-TIA-Import.md#re-import-on-fb-interface-edit--caveat) for the FB-instance-specific workflow. ## CPU diagnostics (SZL) PR-S7-E1 / [#302](https://github.com/dohertj2/lmxopcua/issues/302) — every S7 CPU answers SZL (System Status List) queries with metadata about itself: CPU type, firmware, order number, scan-cycle min/avg/max, and the diagnostic buffer ring. The driver surfaces those through a virtual `@System.*` address space dispatched against the SZL sub-protocol — no DB / merker tag declarations required. ### Opt-in: `ExposeSystemTags` Off by default. Set `ExposeSystemTags = true` in `S7DriverOptions` and `DiscoverAsync` adds a `Diagnostics/` sub-folder under the driver root with the variables listed below. Knobs: | Option | Default | Notes | | --- | --- | --- | | `ExposeSystemTags` | `false` | Master switch. When `false` the SZL surface is invisible — no extra browse nodes, no extra wire traffic. | | `DiagBufferDepth` | `10` | Number of diagnostic-buffer entries to discover under `DiagBuffer/Entry[N]`. Capped at 50. | | `SzlCacheTtl` | `5 s` | TTL for the per-driver SZL cache. A burst of `@System.*` reads inside this window reuses one wire response per SZL ID. Set to `TimeSpan.Zero` to disable caching (every read hits the wire). | ### `@System.*` address table | Address | OPC UA type | SZL ID | Index | What it is | | --- | --- | --- | --- | --- | | `@System.CpuType` | `String` | `0x0011` | `0x0000` | CPU friendly name (SZL index 0x0007) or MLFB fallback. | | `@System.Firmware` | `String` | `0x0011` | `0x0000` | Firmware version, formatted `Vmaj.min.patch`. | | `@System.OrderNo` | `String` | `0x0011` | `0x0000` | MLFB / order number, e.g. `6ES7 516-3AN01-0AB0`. | | `@System.CycleMs.Min` | `Float64` | `0x0132` | `0x0005` | Shortest scan cycle observed since last reset, in milliseconds. | | `@System.CycleMs.Max` | `Float64` | `0x0132` | `0x0005` | Longest scan cycle observed since last reset, in milliseconds. | | `@System.CycleMs.Avg` | `Float64` | `0x0132` | `0x0005` | Rolling average scan-cycle time, in milliseconds. | | `@System.DiagBuffer.Entry[N]` | `String` | `0x00A0` | `0x0000` | Diagnostic-buffer entry rendered as ` \| 0x \| prio= \| `. `N` ranges from `0` (most recent) through `DiagBufferDepth-1`. | The diagnostic-buffer entries surface as flat strings rather than a structured DataType so dashboards / log scrapers can split / grep them without a custom schema. ### What's wired today vs not-supported S7netplus 0.20 builds SZL request packages internally (`SzlReadRequestPackage` / `WriteSzlReadRequest`) but does **not** expose a public `ReadSzlAsync` API. Until S7netplus catches up (or we ship a raw S7comm PDU helper that side-steps the library), the production [`S7NetSzlReader`](../../src/ZB.MOM.WW.OtOpcUa.Driver.S7/Szl/S7NetSzlReader.cs) returns `null` on every call and every `@System.*` read surfaces as `BadNotSupported`. The browse tree still lights up — operators can wire clients against it — only the values come back not-supported. The parser code (`S7SzlParser`) is fully tested against golden bytes regardless. Flipping the wire path on is a one-method change in `S7NetSzlReader` once the upstream surface is available; no parser / dispatch / cache changes needed. snap7 (the simulator backing the integration profile) also doesn't implement SZL — the integration test [`S7_1500SzlTests`](../../tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/S7_1500/S7_1500SzlTests.cs) asserts the not-supported semantics against snap7 + parks the live-firmware test behind `[Fact(Skip = ...)]` until the wire path lights up. ### Caching Diagnostics shouldn't poll faster than `SzlCacheTtl` — a 100 ms HMI subscription on every `@System.*` tag would otherwise hammer the comms mailbox for data that doesn't change between scans. The per-driver [`S7SzlCache`](../../src/ZB.MOM.WW.OtOpcUa.Driver.S7/Szl/S7SzlCache.cs) de-dups concurrent reads by `(SzlId, SzlIndex)`; one SZL 0x0011 round-trip backs `CpuType` + `Firmware` + `OrderNo` for the whole TTL window. Negative results (SZL not supported) are cached just as aggressively — repeatedly hammering a CPU that already said "not supported" wouldn't help. `SzlCacheTtl = TimeSpan.Zero` disables caching entirely; useful for diagnostics tests where you want every read to hit the wire. ### JSON config example ```json { "DriverConfig": { "Host": "192.168.10.50", "Port": 102, "CpuType": "S71500", "ExposeSystemTags": true, "DiagBufferDepth": 20, "SzlCacheTtl": "00:00:05" } } ``` ## PLC password / protection levels PR-S7-E2 (issue #303) adds a connection-level password option for hardened deployments. The driver emits the password to the PLC immediately after `OpenAsync` succeeds and before the pre-flight PUT/GET probe runs (the same pre-flight read that would otherwise be the first operation a hardened CPU refuses). ### Options | Option | Default | Purpose | | ----------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Password` | `null` | Connection-level password. Secret — never logged. `null` or empty = no password is sent. | | `ProtectionLevel` | `Auto` | Declarative hint about the PLC's protection scheme. One of `Auto`, `None`, `Level1`, `Level2`, `Level3` (S7-300/400 SFC 109/110 levels), or `ConnectionMechanism` (S7-1200/1500 TIA Portal "Protection & Security" pane). | ### S7-300 / S7-400 protection levels (1, 2, 3) S7-300/400 firmware exposes three CPU-side protection levels: * **Level 1** — write protection. Reads work without a password; writes (parameter, DB, M/Q changes) require an unlock. * **Level 2** — read and write protection. Both kinds of operation require the password. * **Level 3** — full protection. Even online presence detection / status list reads require the password. Set `ProtectionLevel = Level1` / `Level2` / `Level3` and supply `Password` to match the level configured in the CPU's HW Config dialog. The level value is descriptive — the driver doesn't switch behaviour between Level1/2/3, since the wire-side `SendPassword` is the same call in all three cases. The hint surfaces in the driver-diagnostics RPC so a "PLC said Level 3 but config says Level 1" mismatch is spottable from the Admin UI. ### S7-1200 / S7-1500 connection mechanism S7-1200/1500 firmware uses a different gate: TIA Portal's "Protection & Security" pane has a single **Connection Mechanism** dropdown that, when set to anything stricter than "No access", requires every PG/HMI/SCADA connection to authenticate after the COTP handshake. The wire-level exchange is the same `SendPassword` call but the diagnostic flag is distinct, so set `ProtectionLevel = ConnectionMechanism` for these families. ### No-log invariant `Password` is a secret. The driver MUST NOT include the password value in log lines, exception messages, or diagnostic surfaces. Specifically: * `S7DriverOptions.ToString()` redacts the field as `***`. * `S7Driver`'s success log line is `S7 password sent for {Host}` — identifier-only, no value. * The "S7netplus does not expose SendPassword" warning logs the host name and driver instance ID only, never the password. * Authentication-failure exceptions wrap the inner `S7.Net.PlcException` but their own message says only "S7 password authentication failed for host '{Host}'" — no password value. Any new logging surface that flows an `S7DriverOptions` value MUST continue to redact. See the FOCAS-F4-d `docs/v2/focas-deployment.md` § "FOCAS password handling" entry for the sister no-log discipline on the FOCAS driver. ### Library limitation — S7netplus 0.20 **S7netplus 0.20.0 (the pinned dependency) does not expose a public `SendPassword` method.** The driver discovers the method reflectively (checking for `SendPasswordAsync(string, CancellationToken)` first, then `SendPassword(string)`) so a future minor release that ships the API will be picked up automatically without a code change here. Until the upstream lands, configuring `Password` on a hardened CPU produces this Init-time warning: ``` [Warning] S7 password is set on driver '' against host '', but the linked S7netplus library does not expose SendPassword; password is being ignored at the wire. Hardened-CPU connect may fail at first read. ``` Init still completes — the COTP/S7comm handshake itself doesn't require the password — but the first read against a hardened CPU will surface `BadDeviceFailure` because PUT/GET-disabled and "level-3 protection" return identical "function not allowed" PDUs at the wire layer. If your S7-1200/1500 deployment requires `ConnectionMechanism`, the near-term workarounds are: 1. **Lower the protection setting** in TIA Portal's Protection & Security pane to "Full access (no protection)" for the duration of the evaluation. 2. **Configure a separate non-hardened connection** on a CP module that the driver can target while keeping the production endpoint hardened. 3. **Track upstream S7netplus** for a `SendPassword` PR (the package owner has discussed adding it; see issue ). For S7-300/400 CPUs, levels 1 and 2 leave at least *read* access open without a password, so most monitoring use cases work without `SendPassword` until the library catches up — only Level 3 and the S7-1200/1500 ConnectionMechanism require the wire-level unlock. ### JSON config example ```json { "DriverConfig": { "Host": "192.168.10.50", "Port": 102, "CpuType": "S71500", "Password": "tia-portal-set-password", "ProtectionLevel": "ConnectionMechanism" } } ``` ## 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