Compare commits
7 Commits
phase-3-pr
...
phase-3-pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c506ea298a | ||
| d5c6280333 | |||
| 476ce9b7c5 | |||
| 954bf55d28 | |||
| 9fb3cf7512 | |||
|
|
793c787315 | ||
|
|
cde018aec1 |
451
docs/v2/mitsubishi.md
Normal file
451
docs/v2/mitsubishi.md
Normal file
@@ -0,0 +1,451 @@
|
||||
# Mitsubishi Electric MELSEC — Modbus TCP quirks
|
||||
|
||||
Mitsubishi's MELSEC family speaks Modbus TCP through a patchwork of add-on modules
|
||||
and built-in Ethernet ports, not a single unified stack. The module names are
|
||||
confusingly similar (`QJ71MB91` is *serial* RTU, `QJ71MT91` is the TCP/IP module
|
||||
[9]; `LJ71MT91` is the L-series equivalent; `RJ71EN71` is the iQ-R Ethernet module
|
||||
with a MODBUS/TCP *slave* mode bolted on [8]; `FX3U-ENET`, `FX3U-ENET-P502`,
|
||||
`FX3U-ENET-ADP`, `FX3GE` built-in, and `FX5U` built-in are all different code
|
||||
paths) — and every one of the categories below has at least one trap a textbook
|
||||
Modbus client gets wrong: hex-numbered X/Y devices colliding with decimal Modbus
|
||||
addresses, a user-defined "device assignment" parameter block that means *no two
|
||||
sites are identical*, CDAB-vs-ABCD word order driven by how the ladder built the
|
||||
32-bit value, sub-spec FC16 caps on the older QJ71MT91, and an FX3U port-502
|
||||
licensing split that makes `FX3U-ENET` and `FX3U-ENET-P502` different SKUs.
|
||||
This document catalogues each quirk, cites primary sources, and names the
|
||||
ModbusPal integration test we'd write for it (convention from
|
||||
`docs/v2/modbus-test-plan.md`: `Mitsubishi_<model>_<behavior>`).
|
||||
|
||||
## Models and server/client capability
|
||||
|
||||
| Model | Family | Modbus TCP server | Modbus TCP client | Source |
|
||||
|------------------------|----------|-------------------|-------------------|--------|
|
||||
| `QJ71MT91` | MELSEC-Q | Yes (slave) | Yes (master) | [9] |
|
||||
| `QJ71MB91` | MELSEC-Q | **Serial only** — RS-232/422/485 RTU, *not TCP* | — | [1][3] |
|
||||
| `LJ71MT91` | MELSEC-L | Yes (slave) | Yes (master) | [10] |
|
||||
| `RJ71EN71` / `RnENCPU` | MELSEC iQ-R | Yes (slave) | Yes (master) | [8] |
|
||||
| `RJ71C24` / `RJ71C24-R2` | MELSEC iQ-R | RTU (serial) | RTU (serial) | [13] |
|
||||
| iQ-R built-in Ethernet | CPU | Yes (slave) | Yes (master) | [7] |
|
||||
| iQ-F `FX5U` built-in Ethernet | CPU | Yes, firmware ≥ 1.060 [11] | Yes | [7][11][12] |
|
||||
| `FX3U-ENET` | FX3U bolt-on | Yes (slave), but **not on port 502** [5] | Yes | [4][5] |
|
||||
| `FX3U-ENET-P502` | FX3U bolt-on | Yes (slave), port 502 enabled | Yes | [5] |
|
||||
| `FX3U-ENET-ADP` | FX3U adapter | **No MODBUS** [5] | No MODBUS | [5] |
|
||||
| `FX3GE` built-in | FX3GE CPU | No MODBUS (needs ENET module) [6] | No | [6] |
|
||||
| `FX3G` + `FX3U-ENET` | FX3G | Yes via ENET module | Yes | [6] |
|
||||
|
||||
- A common integration mistake is to buy `FX3U-ENET-ADP` expecting MODBUS —
|
||||
that adapter speaks only MC protocol / SLMP. Our driver should surface a clear
|
||||
capability error, not "connection refused", when the operator's device tag
|
||||
says `FX3U-ENET-ADP` [5].
|
||||
- Older forum threads assert the FX5U is "client only" [12] — that was true on
|
||||
firmware ≤ 1.040. Firmware 1.060 and later ship the parameter-driven MODBUS
|
||||
TCP server built-in and need no function blocks [11].
|
||||
|
||||
## Modbus device assignment (the parameter block)
|
||||
|
||||
Unlike a DL260 where the CPU exposes a *fixed* V-memory-to-Modbus mapping, every
|
||||
MELSEC MODBUS-TCP module exposes a **Modbus Device Assignment Parameter** block
|
||||
that the engineer configures in GX Works2 / GX Configurator-MB / GX Works3.
|
||||
Each of the four Modbus tables (Coil, Input, Input Register, Holding Register)
|
||||
can be split into up to 16 independent "assignment" entries, each binding a
|
||||
contiguous Modbus address range to a MELSEC device head (`M0`, `D0`, `X0`,
|
||||
`Y0`, `B0`, `W0`, `SM0`, `SD0`, `R0`, etc.) and a point count [3][7][8][9].
|
||||
|
||||
- **There is no canonical "MELSEC Modbus mapping"**. Two sites running the same
|
||||
QJ71MT91 module can expose completely different Modbus layouts. Our driver
|
||||
must treat the mapping as site-data (config-file-driven), not as a device
|
||||
profile constant.
|
||||
- **Default values do exist** — both GX Configurator-MB (for Q/L series) and
|
||||
GX Works3 (for iQ-R / iQ-F / FX5) ship a "dedicated pattern" default that is
|
||||
applied when the engineer does not override the assignment. Per the FX5
|
||||
MODBUS Communication manual (JY997D56101) and the QJ71MT91 manual, the FX5
|
||||
dedicated default is [3][7][11]:
|
||||
|
||||
| Modbus table | Modbus range (0-based) | MELSEC device | Head |
|
||||
|--------------------|------------------------|---------------|------|
|
||||
| Coil (FC01/05/15) | 0 – 7679 | M | M0 |
|
||||
| Coil | 8192 – 8959 | Y | Y0 |
|
||||
| Input (FC02) | 0 – 7679 | M | M0 |
|
||||
| Input | 8192 – 8959 | X | X0 |
|
||||
| Input Register (FC04) | 0 – 6143 | D | D0 |
|
||||
| Holding Register (FC03/06/16) | 0 – 6143 | D | D0 |
|
||||
|
||||
This matches the widely circulated "FC03 @ 0 = D0" convention that shows up
|
||||
in Ubidots / Ignition / AdvancedHMI integration guides [6][12].
|
||||
|
||||
- **X/Y in the default mapping occupy a second, non-zero Modbus range** (8192+
|
||||
on FX5; similar on Q/L/iQ-R). Driver users who expect "X0 = coil 0" will be
|
||||
reading M0 instead. Document this clearly.
|
||||
- **Assignment-range collisions silently disable the slave.** The QJ71MT91
|
||||
manual states explicitly that if any two of assignments 1-16 duplicate the
|
||||
head Modbus device number, the slave function is inactive with no clear
|
||||
error — the module just won't respond [9]. The driver probe will look like a
|
||||
simple timeout; the site engineer has to open GX Configurator-MB to diagnose.
|
||||
|
||||
Test names:
|
||||
`Mitsubishi_FX5U_default_mapping_coil_0_is_M0`,
|
||||
`Mitsubishi_FX5U_default_mapping_holding_0_is_D0`,
|
||||
`Mitsubishi_QJ71MT91_duplicate_assignment_head_disables_slave`.
|
||||
|
||||
## X/Y addressing — hex on MELSEC, decimal on Modbus
|
||||
|
||||
**MELSEC X (input) and Y (output) device numbers are hexadecimal on Q / L /
|
||||
iQ-R** and **octal** on FX / iQ-F (with a GX Works3 toggle) [14][15].
|
||||
|
||||
- On a Q CPU, `X20` means decimal **32**, not 20. On an FX5U in default (octal)
|
||||
mode, `X20` means decimal **16**. GX Works3 exposes a project-level option to
|
||||
display FX5U X/Y in hex to match Q/L/iQ-R convention — the same physical
|
||||
input is then called `X10` [14].
|
||||
- The Modbus Device Assignment Parameter block takes the *head device* as a
|
||||
MELSEC-native number, which is interpreted in the CPU's native base
|
||||
(hex for Q/L/iQ-R, octal for FX/iQ-F). After that, **Modbus offsets from
|
||||
the head are plain decimal** — the module does not apply a second hex
|
||||
conversion [3][9].
|
||||
- Example (QJ71MT91 on a Q CPU): assignment "Coil 0 = X0, 512 points" exposes
|
||||
physical `X0` through `X1FF` (hex) as coils 0-511. A client reading coil 32
|
||||
gets the bit `X20` (hex) — i.e. the 33rd input, not the value at "input 20"
|
||||
that the operator wrote on the wiring diagram in decimal.
|
||||
- **Driver bug source**: if the operator's tag configuration says "read X20" and
|
||||
the driver helpfully converts "20" to decimal 20 → coil offset 20, the
|
||||
returned bit is actually `X14` (hex) — off by twelve. Our config layer must
|
||||
preserve the MELSEC-native base that the site engineer sees in GX Works.
|
||||
- Timers/counters (`T`, `C`, `ST`) are always decimal in MELSEC notation.
|
||||
Internal relays (`M`, `B`, `L`), data registers (`D`, `W`, `R`, `ZR`),
|
||||
and special relays/registers (`SM`, `SD`) also decimal. **Only `X` and `Y`
|
||||
(and on Q/L/iQ-R, `B` link relays and `W` link registers) use hex**, and
|
||||
the X/Y decision is itself family-dependent [14][15].
|
||||
|
||||
Test names:
|
||||
`Mitsubishi_Q_X_address_is_hex_X20_equals_coil_offset_32`,
|
||||
`Mitsubishi_FX5U_X_address_is_octal_X20_equals_coil_offset_16`,
|
||||
`Mitsubishi_W_link_register_is_hex_W10_equals_holding_offset_16`.
|
||||
|
||||
## Word order for 32-bit values
|
||||
|
||||
MELSEC stores 32-bit ladder values (`DINT`, `DWORD`, `REAL` / single-precision
|
||||
float) across **two consecutive D-registers, low word first** — i.e., `CDAB`
|
||||
when viewed as a Modbus register pair [2][6].
|
||||
|
||||
```
|
||||
D100 (low word) : 0xCC 0xDD (big-endian bytes within the word)
|
||||
D101 (high word) : 0xAA 0xBB
|
||||
```
|
||||
|
||||
A Modbus master reading D100/D101 as a `float` with default (ABCD) word order
|
||||
gets garbage. Ignition's built-in Modbus driver notes Mitsubishi as a "CDAB
|
||||
device" specifically for this reason [2].
|
||||
|
||||
- **Q / L / iQ-R / iQ-F all agree** — this is a CPU-level convention, not a
|
||||
module choice. Both the QJ71MT91 manual and the FX5 MODBUS Communication
|
||||
manual describe 32-bit access by "reading the lower 16 bits from the start
|
||||
address and the upper 16 bits from start+1" [6][11].
|
||||
- **Byte order within each register is big-endian** (Modbus standard). The
|
||||
module does not byte-swap.
|
||||
- **Configurable?** The MODBUS modules themselves do **not** expose a word-
|
||||
order toggle; the behavior is fixed to how the CPU laid out the value in the
|
||||
two D-registers. If the ladder programmer used an `SWAP` instruction or a
|
||||
union-style assignment, the word order can be whatever they made it — but
|
||||
for values produced by the standard `D→DBL` and `FLT`/`FLT2` instructions
|
||||
it is always CDAB [2].
|
||||
- **FX5U quirk**: the FX5 MODBUS Communication manual tells the programmer to
|
||||
use the `SWAP` instruction *if* the remote Modbus peer requires
|
||||
little-endian *byte* ordering (BADC) [11]. This is only relevant when the
|
||||
FX5U is the Modbus *client*, but it confirms the FX5U's native wire layout
|
||||
is big-endian-byte / little-endian-word (CDAB) on the server side too.
|
||||
- **Rumoured exception**: a handful of MrPLC forum threads report iQ-R
|
||||
RJ71EN71 firmware < 1.05 returning DWORDs in `ABCD` order when accessed via
|
||||
the built-in Ethernet port's MODBUS slave [8]. _Unconfirmed_; treat as a
|
||||
per-site test.
|
||||
|
||||
Test names:
|
||||
`Mitsubishi_Float32_word_order_is_CDAB`,
|
||||
`Mitsubishi_Int32_word_order_is_CDAB`,
|
||||
`Mitsubishi_FX5U_SWAP_instruction_changes_byte_order_not_word_order`.
|
||||
|
||||
## BCD vs binary encoding
|
||||
|
||||
**MELSEC stores integer values in D-registers as plain binary two's-complement**,
|
||||
not BCD [16]. This is the opposite of AutomationDirect DirectLOGIC, where
|
||||
V-memory defaults to BCD and the ladder must explicitly request binary.
|
||||
|
||||
- A ladder `MOV K1234 D100` stores `0x04D2` (1234 decimal) in D100, not
|
||||
`0x1234`. The Modbus master reads `0x04D2` and decodes it as an integer
|
||||
directly — no BCD conversion needed [16].
|
||||
- **Timer / counter current values** (`T0` current value, `C0` count) are
|
||||
stored in binary as word devices on Q/L/iQ-R/iQ-F. The ladder preset
|
||||
(`K...`) is also binary [16][17].
|
||||
- **Timer / counter preset `K` operand in FX3U / earlier FX**: also binary when
|
||||
loaded from a D-register or a `K` constant. The older A-series CPUs had BCD
|
||||
presets on some timer types, but MELSEC-Q, L, iQ-R, iQ-F, and FX3U all use
|
||||
binary presets by default [17].
|
||||
- The FX3U programming manual dedicates `FNC 18 BCD` and `FNC 19 BIN` to
|
||||
explicit conversion — their existence confirms that anything in D-registers
|
||||
that came from a `BCD` instruction output is BCD, but nothing is BCD by
|
||||
default [17].
|
||||
- **7-segment display registers** are a common site-specific exception — many
|
||||
ladders pack `BCD D100` into a D-register so the operator panel can drive
|
||||
a display directly. Our driver should not assume; expose a per-tag
|
||||
"encoding = binary | BCD" knob.
|
||||
|
||||
Test names:
|
||||
`Mitsubishi_D_register_stores_binary_not_BCD`,
|
||||
`Mitsubishi_FX3U_timer_current_value_is_binary`.
|
||||
|
||||
## Max registers per request
|
||||
|
||||
From the FX5 MODBUS Communication manual Chapter 11 [11]:
|
||||
|
||||
| FC | Name | FX5U (built-in) | QJ71MT91 | iQ-R (RJ71EN71 / built-in) | FX3U-ENET |
|
||||
|----|----------------------------|-----------------|--------------|-----------------------------|-----------|
|
||||
| 01 | Read Coils | 1-2000 | 1-2000 [9] | 1-2000 [8] | 1-2000 |
|
||||
| 02 | Read Discrete Inputs | 1-2000 | 1-2000 | 1-2000 | 1-2000 |
|
||||
| 03 | Read Holding Registers | **1-125** | 1-125 [9] | 1-125 [8] | 1-125 |
|
||||
| 04 | Read Input Registers | 1-125 | 1-125 | 1-125 | 1-125 |
|
||||
| 05 | Write Single Coil | 1 | 1 | 1 | 1 |
|
||||
| 06 | Write Single Register | 1 | 1 | 1 | 1 |
|
||||
| 0F | Write Multiple Coils | 1-1968 | 1-1968 | 1-1968 | 1-1968 |
|
||||
| 10 | Write Multiple Registers | **1-123** | 1-123 | 1-123 | 1-123 |
|
||||
| 16 | Mask Write Register | 1 | not supported | 1 | not supported |
|
||||
| 17 | Read/Write Multiple Regs | R:1-125, W:1-121 | not supported | R:1-125, W:1-121 | not supported |
|
||||
|
||||
- **The FX5U / iQ-R native-port limits match the Modbus spec**: 125 for FC03/04,
|
||||
123 for FC16 [11]. No sub-spec caps like DL260's 100-register ceiling.
|
||||
- **QJ71MT91 does not support FC16 (0x16, Mask Write Register) or FC17
|
||||
(0x17, Read/Write Multiple)** — requesting them returns exception `01`
|
||||
Illegal Function [9]. FX5U and iQ-R *do* support both.
|
||||
- **QJ71MT91 device size**: 64k points (65,536) for each of Coil / Input /
|
||||
Input Register / Holding Register, plus up to 4086k points for Extended
|
||||
File Register via a secondary assignment range [9].
|
||||
- **FX3U-ENET / -P502 function code list is a strict subset** of the common
|
||||
eight (FC01/02/03/04/05/06/0F/10). FC16 and FC17 not supported [4].
|
||||
|
||||
Test names:
|
||||
`Mitsubishi_FX5U_FC03_126_registers_returns_IllegalDataValue`,
|
||||
`Mitsubishi_FX5U_FC16_124_registers_returns_IllegalDataValue`,
|
||||
`Mitsubishi_QJ71MT91_FC16_MaskWrite_returns_IllegalFunction`,
|
||||
`Mitsubishi_QJ71MT91_FC23_ReadWrite_returns_IllegalFunction`.
|
||||
|
||||
## Exception codes
|
||||
|
||||
MELSEC MODBUS modules return **only the standard Modbus exception codes 01-04**;
|
||||
no proprietary exception codes are exposed on the wire [8][9][11]. Module-
|
||||
internal diagnostics (buffer-memory error codes like `7380H`) are logged but
|
||||
not returned as Modbus exceptions.
|
||||
|
||||
| Code | Name | MELSEC trigger |
|
||||
|------|----------------------|---------------------------------------------------------|
|
||||
| 01 | Illegal Function | FC17 or FC16 on QJ71MT91/FX3U; FC08 (Diagnostics); FC43 |
|
||||
| 02 | Illegal Data Address | Modbus address outside any assignment range |
|
||||
| 03 | Illegal Data Value | Quantity out of per-FC range (see table above); odd coil-byte count |
|
||||
| 04 | Server Device Failure | See below |
|
||||
|
||||
- **04 (Server Failure) triggers on MELSEC**:
|
||||
- CPU in STOP or PAUSE during a write to an assignment whose "Access from
|
||||
External Device" permission is set to "Disabled in STOP" [9][11].
|
||||
*With the default "always enabled" setting the write succeeds in STOP
|
||||
mode* — another common trap.
|
||||
- CPU errors (parameter error, watchdog) during any access.
|
||||
- Assignment points to a device range that is not configured (e.g. write
|
||||
to `D16384` when CPU D-device size is 12288).
|
||||
- **Write to a "System Area" device** (e.g., `SD` special registers that are
|
||||
CPU-reserved read-only) returns `04`, not `02`, on QJ71MT91 and iQ-R — the
|
||||
assignment is valid, the device exists, but the CPU rejects the write [8][9].
|
||||
- **FX3U-ENET / -P502** returns `04` on any write attempt while the CPU is in
|
||||
STOP, regardless of permission settings — the older firmware does not
|
||||
implement the "Access from External Device" granularity that Q/L/iQ-R/iQ-F
|
||||
expose [4].
|
||||
- **No rumour of proprietary codes 05-0B** from MELSEC; operators sometimes
|
||||
report "exception 0A" but those traces all came from a third-party gateway
|
||||
sitting between the master and the MELSEC module.
|
||||
|
||||
Test names:
|
||||
`Mitsubishi_QJ71MT91_STOP_mode_write_with_Disabled_permission_returns_ServerFailure`,
|
||||
`Mitsubishi_QJ71MT91_STOP_mode_write_with_default_permission_succeeds`,
|
||||
`Mitsubishi_SD_system_register_write_returns_ServerFailure`,
|
||||
`Mitsubishi_FX3U_STOP_mode_write_always_returns_ServerFailure`.
|
||||
|
||||
## Connection behavior
|
||||
|
||||
Max simultaneous Modbus TCP clients, per module [7][8][9][11]:
|
||||
|
||||
| Model | Max TCP connections | Port 502 | Keepalive | Source |
|
||||
|----------------------|---------------------|----------|-----------|--------|
|
||||
| `QJ71MT91` | 16 (shared with master role) | Yes | No | [9] |
|
||||
| `LJ71MT91` | 16 | Yes | No | [10] |
|
||||
| iQ-R built-in / `RJ71EN71` | 16 | Yes | Configurable (KeepAlive = ON in parameter) | [8] |
|
||||
| iQ-F `FX5U` built-in | 8 | Yes | Configurable | [7][11] |
|
||||
| `FX3U-ENET` | 8 TCP, but **not port 502** | No (port < 1024 blocked) | No | [4][5] |
|
||||
| `FX3U-ENET-P502` | 8, port 502 enabled | Yes | No | [5] |
|
||||
|
||||
- **QJ71MT91's 16 is total connections shared between slave-listen and
|
||||
master-initiated sockets** [9]. A site that uses the same module as both
|
||||
master to downstream VFDs and slave to upstream SCADA splits the 16 pool.
|
||||
- **FX3U-ENET port-502 gotcha**: if the engineer loads a configuration with
|
||||
port 502 into a non-P502 ENET module, GX Works shows the download as
|
||||
successful; on next power cycle the module enters error state and the
|
||||
MODBUS listener never starts. This is documented on third-party FX3G
|
||||
integration guides [6].
|
||||
- **CPU STOP → RUN transition**: does **not** drop Modbus connections on any
|
||||
MELSEC family. Existing sockets stay open; outstanding requests during the
|
||||
transition may see exception 04 for a few scans but then resume [8][9].
|
||||
- **CPU reset (power cycle or `SM1255` forced reset)** drops all Modbus
|
||||
connections and the module re-listens after typically 5-10 seconds.
|
||||
- **Idle timeout**: QJ71MT91 and iQ-R have a per-connection "Alive-Check"
|
||||
(idle timer) parameter, default 0 (disabled). If enabled, default 10 s
|
||||
probe interval, 3 retries before close [8][9]. FX5U similar defaults.
|
||||
- **Keep-alive (TCP-level)**: only iQ-R / iQ-F expose a TCP keep-alive option
|
||||
(parameter "KeepAlive" in the Ethernet settings); QJ71MT91 and FX3U-ENET
|
||||
do not — so NAT/firewall idle drops require driver-side pinging.
|
||||
|
||||
Test names:
|
||||
`Mitsubishi_QJ71MT91_17th_connection_refused`,
|
||||
`Mitsubishi_FX5U_9th_connection_refused`,
|
||||
`Mitsubishi_STOP_to_RUN_transition_preserves_socket`,
|
||||
`Mitsubishi_CPU_reset_closes_all_sockets`.
|
||||
|
||||
## Behavioral oddities
|
||||
|
||||
- **Transaction ID echo**: QJ71MT91 and iQ-R reliably echo the MBAP TxId on
|
||||
every response across firmware revisions; no reports of TxId drops under
|
||||
load [8][9]. FX3U-ENET has an older, less-tested TCP stack; at least one
|
||||
MrPLC thread reports out-of-order TxId echoes under heavy polling on
|
||||
firmware < 1.14 [4]. _Unconfirmed_ on current firmware.
|
||||
- **Per-connection request serialization**: all MELSEC slaves serialize
|
||||
requests within a single TCP connection — a new request is not processed
|
||||
until the prior response has been sent. Pipelining multiple requests on one
|
||||
socket causes the module to queue them in buffer memory and respond in
|
||||
order, but **the queue depth is 1** on QJ71MT91 (a second in-flight request
|
||||
is held on the TCP receive buffer, not queued) [9]. Driver should treat
|
||||
Mitsubishi slaves as strictly single-flight per socket.
|
||||
- **Partial-frame handling**: QJ71MT91 and iQ-R close the socket on malformed
|
||||
MBAP length fields. FX5U resynchronises at the next valid MBAP header
|
||||
within 100 ms but will emit an error to `SD` diagnostics [11]. Driver must
|
||||
reconnect on half-close and replay.
|
||||
- **FX3U UDP vs TCP**: `FX3U-ENET` supports both UDP and TCP MODBUS transports;
|
||||
UDP is lossy and reorders under load. Default is TCP. Some legacy SCADA
|
||||
configurations pinned the module to UDP for multicast discovery — do not
|
||||
select UDP unless the site requires it [4].
|
||||
- **Known firmware-revision variants**:
|
||||
- QJ71MT91 ≤ firmware 10052000000 (year-month format): FC15 with coil
|
||||
count that forces byte-count to an odd value silently truncates the
|
||||
last coil. Fixed in later revisions [9]. _Operator-reported_.
|
||||
- FX5U firmware < 1.060: no native MODBUS TCP server — only accessible via
|
||||
a predefined-protocol function block hack. Firmware ≥ 1.060 ships
|
||||
parameter-based server. Our capability probe should read `SD203`
|
||||
(firmware version) and flag < 1.060 as unsupported for server mode [11][12].
|
||||
- iQ-R RJ71EN71 early firmware: possible ABCD word order (rumoured,
|
||||
unconfirmed) [8].
|
||||
- **SD (special-register) reads during assignment-parameter load**: while
|
||||
the CPU is loading a new MODBUS device assignment parameter (~1-2 s), the
|
||||
slave returns exception 04 Server Failure on every request. Happens after
|
||||
a parameter write from GX Configurator-MB [9].
|
||||
- **iQ-R "Station-based block transfer" collision**: if the RJ71EN71 is also
|
||||
running CC-Link IE Control on the same module, a MODBUS/TCP request that
|
||||
arrives during a CCIE cyclic period is delayed to the next scan — visible
|
||||
as jittery response time, not a failure [8].
|
||||
|
||||
Test names:
|
||||
`Mitsubishi_QJ71MT91_single_flight_per_socket`,
|
||||
`Mitsubishi_FX5U_malformed_MBAP_resync_within_100ms`,
|
||||
`Mitsubishi_FX3U_TxId_preserved_across_burst`,
|
||||
`Mitsubishi_FX5U_firmware_below_1_060_reports_no_server_mode`.
|
||||
|
||||
## Model-specific differences for test coverage
|
||||
|
||||
Summary of which quirks differ per model, so test-class naming can reflect them:
|
||||
|
||||
| Quirk | QJ71MT91 | LJ71MT91 | iQ-R (RJ71EN71 / built-in) | iQ-F (FX5U) | FX3U-ENET(-P502) |
|
||||
|------------------------------------------|----------|----------|----------------------------|-------------|------------------|
|
||||
| FC16 Mask-Write supported | No | No | Yes | Yes | No |
|
||||
| FC17 Read/Write Multiple supported | No | No | Yes | Yes | No |
|
||||
| Max connections | 16 | 16 | 16 | 8 | 8 |
|
||||
| X/Y numbering base | hex | hex | hex | octal (default) | octal |
|
||||
| 32-bit word order | CDAB | CDAB | CDAB (firmware-dependent rumour of ABCD) | CDAB | CDAB |
|
||||
| Port 502 supported | Yes | Yes | Yes | Yes | P502 only |
|
||||
| STOP-mode write permission configurable | Yes | Yes | Yes | Yes | No (always blocks) |
|
||||
| TCP keep-alive parameter | No | No | Yes | Yes | No |
|
||||
| Modbus device assignment — max entries | 16 | 16 | 16 | 16 | 8 |
|
||||
| Server via parameter (no FB) | Yes | Yes | Yes | Yes (fw ≥ 1.060) | Yes |
|
||||
|
||||
- **Test file layout**: `Mitsubishi_QJ71MT91_*`, `Mitsubishi_LJ71MT91_*`,
|
||||
`Mitsubishi_iQR_*`, `Mitsubishi_FX5U_*`, `Mitsubishi_FX3U_ENET_*`,
|
||||
`Mitsubishi_FX3U_ENET_P502_*`. iQ-R built-in Ethernet and the RJ71EN71
|
||||
behave identically for MODBUS/TCP slave purposes and can share a file
|
||||
`Mitsubishi_iQR_*`.
|
||||
- **Cross-model shared tests** (word order CDAB, binary not BCD, standard
|
||||
exception codes, 125-register FC03 cap) can live in a single
|
||||
`Mitsubishi_Common_*` fixture.
|
||||
|
||||
## References
|
||||
|
||||
1. Mitsubishi Electric, *MODBUS Interface Module User's Manual — QJ71MB91*
|
||||
(SH-080578ENG), RS-232/422/485 MODBUS RTU serial module for MELSEC-Q —
|
||||
https://dl.mitsubishielectric.com/dl/fa/document/manual/plc/sh080578eng/sh080578engk.pdf
|
||||
2. Inductive Automation, *Ignition Modbus Driver — Mitsubishi Q / iQ-R word
|
||||
order*, documents CDAB convention —
|
||||
https://docs.inductiveautomation.com/docs/8.1/ignition-modules/opc-ua/drivers/modbus-v2
|
||||
and forum discussion https://forum.inductiveautomation.com/t/modbus-tcp-device-word-byte-order/65984
|
||||
3. Mitsubishi Electric, *Programmable Controller User's Manual QJ71MB91 MODBUS
|
||||
Interface Module*, Chapter 7 "Parameter Setting" describing the Modbus
|
||||
Device Assignment Parameter block (assignments 1-16, head-device
|
||||
configuration) —
|
||||
https://www.lcautomation.com/dbdocument/29156/QJ71MB91%20Users%20manual.pdf
|
||||
4. Mitsubishi Electric, *FX3U-ENET User's Manual* (JY997D18101), Chapter on
|
||||
MODBUS/TCP communication; function code support and connection limits —
|
||||
https://dl.mitsubishielectric.com/dl/fa/document/manual/plc_fx/jy997d18101/jy997d18101h.pdf
|
||||
5. Venus Automation, *Mitsubishi FX3U-ENET-P502 Module — Open Port 502 for
|
||||
Modbus TCP/IP* —
|
||||
https://venusautomation.com.au/mitsubishi-fx3u-enet-p502-module-open-port-502-for-modbus-tcp-ip/
|
||||
and FX3U-ENET-ADP user manual (JY997D45801), which confirms the -ADP
|
||||
variant does not support MODBUS —
|
||||
https://dl.mitsubishielectric.com/dl/fa/document/manual/plc_fx/jy997d45801/jy997d45801h.pdf
|
||||
6. XML Control / Ubidots integration notes, *FX3G Modbus* — port-502 trap,
|
||||
D-register mapping default, word order reference —
|
||||
https://sites.google.com/site/xmlcontrol/archive/fx3g-modbus
|
||||
and https://ubidots.com/blog/mitsubishi-plc-as-modbus-tcp-server/
|
||||
7. FA Support Me, *Modbus TCP on Built-in Ethernet port in iQ-F and iQ-R* —
|
||||
confirms 16-connection limit on iQ-R, 8 on iQ-F, parameter-driven
|
||||
configuration via GX Works3 —
|
||||
https://www.fasupportme.com/portal/en/kb/articles/modbus-tcp-on-build-in-ethernet-port-in-iq-f-and-iq-r-en
|
||||
8. Mitsubishi Electric, *MELSEC iQ-R Ethernet User's Manual (Application)*
|
||||
(SH-081259ENG) and *MELSEC iQ-RJ71EN71 User's Manual* Chapter on
|
||||
"Communications Using Modbus/TCP" —
|
||||
https://www.allied-automation.com/wp-content/uploads/2015/02/MITSUBISHI_manual_plc_iq-r_ethernet_users.pdf
|
||||
and https://www.manualslib.com/manual/1533351/Mitsubishi-Electric-Melsec-Iq-Rj71en71.html?page=109
|
||||
9. Mitsubishi Electric, *MODBUS/TCP Interface Module User's Manual — QJ71MT91*
|
||||
(SH-080446ENG), exception codes page 248, device assignment parameter
|
||||
pages 116-124, duplicate-assignment-disables-slave note —
|
||||
https://dl.mitsubishielectric.com/dl/fa/document/manual/plc/sh080446eng/sh080446engj.pdf
|
||||
10. Mitsubishi Electric, *MELSEC-L Network Features* — LJ71MT91 documented as
|
||||
L-series equivalent of QJ71MT91 with identical MODBUS/TCP behavior —
|
||||
https://us.mitsubishielectric.com/fa/en/products/cnt/programmable-controllers/melsec-l-series/network/features/
|
||||
11. Mitsubishi Electric, *MELSEC iQ-F FX5 User's Manual (MODBUS Communication)*
|
||||
(JY997D56101), Chapter 11 "Modbus/TCP Communication Specifications" —
|
||||
function code max-quantity table, frame specification, device assignment
|
||||
defaults —
|
||||
https://dl.mitsubishielectric.com/dl/fa/document/manual/plcf/jy997d56101/jy997d56101h.pdf
|
||||
12. MrPLC forum, *FX5U Modbus-TCP Server (Slave)*, firmware ≥ 1.60 enables
|
||||
native server via parameter; earlier firmware required function block —
|
||||
https://mrplc.com/forums/topic/31883-fx5u-modbus-tcp-server-slave/
|
||||
and Industrial Monitor Direct's "FX5U MODBUS TCP Server Workaround"
|
||||
article (reflects older firmware behavior) —
|
||||
https://industrialmonitordirect.com/blogs/knowledgebase/mitsubishi-fx5u-modbus-tcp-server-configuration-workaround
|
||||
13. Mitsubishi Electric, *MELSEC iQ-R MODBUS and MODBUS/TCP Reference Manual —
|
||||
RJ71C24 / RJ71C24-R2* (BCN-P5999-1060) — RJ71C24 is serial RTU only,
|
||||
not TCP —
|
||||
https://dl.mitsubishielectric.com/dl/fa/document/manual/plc/bcn-p5999-1060/bcnp59991060b.pdf
|
||||
14. HMS Industrial Networks, *eWON and Mitsubishi FX5U PLC* (KB-0264-00) —
|
||||
documents that FX5U X/Y are octal in GX Works3 but hex when viewed as a
|
||||
Q-series PLC through eWON; the project-level hex/octal toggle —
|
||||
https://hmsnetworks.blob.core.windows.net/www/docs/librariesprovider10/downloads-monitored/manuals/knowledge-base/kb-0264-00-en-ewon-and-mitsubishi-fx5u-plc.pdf
|
||||
15. Fernhill Software, *Mitsubishi Melsec PLC Data Address* — documents
|
||||
hex-vs-octal device numbering split across MELSEC families —
|
||||
https://www.fernhillsoftware.com/help/drivers/mitsubishi-melsec/data-address-format.html
|
||||
16. Inductive Automation support, *Understanding Mitsubishi PLCs* — D registers
|
||||
store signed 16-bit binary, not BCD; DINT combines two consecutive D
|
||||
registers —
|
||||
https://support.inductiveautomation.com/hc/en-us/articles/16517576753165-Understanding-Mitsubishi-PLCs
|
||||
17. Mitsubishi Electric, *FXCPU Structured Programming Manual [Device &
|
||||
Common]* (JY997D26001) — FNC 18 BCD and FNC 19 BIN explicit-conversion
|
||||
instructions confirm binary-by-default storage —
|
||||
https://dl.mitsubishielectric.com/dl/fa/document/manual/plc_fx/jy997d26001/jy997d26001l.pdf
|
||||
@@ -37,7 +37,7 @@ public sealed class ModbusDriver(ModbusDriverOptions options, string driverInsta
|
||||
private CancellationTokenSource? _probeCts;
|
||||
private readonly ModbusDriverOptions _options = options;
|
||||
private readonly Func<ModbusDriverOptions, IModbusTransport> _transportFactory =
|
||||
transportFactory ?? (o => new ModbusTcpTransport(o.Host, o.Port, o.Timeout));
|
||||
transportFactory ?? (o => new ModbusTcpTransport(o.Host, o.Port, o.Timeout, o.AutoReconnect));
|
||||
|
||||
private IModbusTransport? _transport;
|
||||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||
@@ -141,9 +141,16 @@ public sealed class ModbusDriver(ModbusDriverOptions options, string driverInsta
|
||||
results[i] = new DataValueSnapshot(value, 0u, now, now);
|
||||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||
}
|
||||
catch (ModbusException mex)
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null, MapModbusExceptionToStatus(mex.ExceptionCode), null, now);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, mex.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null, StatusBadInternalError, null, now);
|
||||
// Non-Modbus-layer failure: socket dropped, timeout, malformed response. Surface
|
||||
// as communication error so callers can distinguish it from tag-level faults.
|
||||
results[i] = new DataValueSnapshot(null, StatusBadCommunicationError, null, now);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||
}
|
||||
}
|
||||
@@ -238,6 +245,10 @@ public sealed class ModbusDriver(ModbusDriverOptions options, string driverInsta
|
||||
await WriteOneAsync(transport, tag, w.Value, cancellationToken).ConfigureAwait(false);
|
||||
results[i] = new WriteResult(0u);
|
||||
}
|
||||
catch (ModbusException mex)
|
||||
{
|
||||
results[i] = new WriteResult(MapModbusExceptionToStatus(mex.ExceptionCode));
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
results[i] = new WriteResult(StatusBadInternalError);
|
||||
@@ -686,6 +697,31 @@ public sealed class ModbusDriver(ModbusDriverOptions options, string driverInsta
|
||||
private const uint StatusBadInternalError = 0x80020000u;
|
||||
private const uint StatusBadNodeIdUnknown = 0x80340000u;
|
||||
private const uint StatusBadNotWritable = 0x803B0000u;
|
||||
private const uint StatusBadOutOfRange = 0x803C0000u;
|
||||
private const uint StatusBadNotSupported = 0x803D0000u;
|
||||
private const uint StatusBadDeviceFailure = 0x80550000u;
|
||||
private const uint StatusBadCommunicationError = 0x80050000u;
|
||||
|
||||
/// <summary>
|
||||
/// Map a server-returned Modbus exception code to the most informative OPC UA
|
||||
/// StatusCode. Keeps the driver's outward-facing status surface aligned with what a
|
||||
/// Modbus engineer would expect when reading the spec: exception 02 (Illegal Data
|
||||
/// Address) surfaces as BadOutOfRange so clients can distinguish "tag wrong" from
|
||||
/// generic BadInternalError, exception 04 (Server Failure) as BadDeviceFailure so
|
||||
/// operators see a CPU-mode problem rather than a driver bug, etc. Per
|
||||
/// <c>docs/v2/dl205.md</c>, DL205/DL260 returns only codes 01-04 — no proprietary
|
||||
/// extensions.
|
||||
/// </summary>
|
||||
internal static uint MapModbusExceptionToStatus(byte exceptionCode) => exceptionCode switch
|
||||
{
|
||||
0x01 => StatusBadNotSupported, // Illegal Function — FC not in supported list
|
||||
0x02 => StatusBadOutOfRange, // Illegal Data Address — register outside mapped range
|
||||
0x03 => StatusBadOutOfRange, // Illegal Data Value — quantity over per-FC cap
|
||||
0x04 => StatusBadDeviceFailure, // Server Failure — CPU in PROGRAM mode during protected write
|
||||
0x05 or 0x06 => StatusBadDeviceFailure, // Acknowledge / Server Busy — long-running op / busy
|
||||
0x0A or 0x0B => StatusBadCommunicationError, // Gateway path unavailable / target failed to respond
|
||||
_ => StatusBadInternalError,
|
||||
};
|
||||
|
||||
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||
public async ValueTask DisposeAsync()
|
||||
|
||||
@@ -45,6 +45,17 @@ public sealed class ModbusDriverOptions
|
||||
/// by shortening the tag's <c>StringLength</c> or splitting it into multiple tags).
|
||||
/// </summary>
|
||||
public ushort MaxRegistersPerWrite { get; init; } = 123;
|
||||
|
||||
/// <summary>
|
||||
/// When <c>true</c> (default) the built-in <see cref="ModbusTcpTransport"/> detects
|
||||
/// mid-transaction socket failures (<see cref="System.IO.EndOfStreamException"/>,
|
||||
/// <see cref="System.Net.Sockets.SocketException"/>) and transparently reconnects +
|
||||
/// retries the PDU exactly once. Required for DL205/DL260 because the H2-ECOM100
|
||||
/// does not send TCP keepalives — intermediate NAT / firewall devices silently close
|
||||
/// idle sockets and the first send after the drop would otherwise surface as a
|
||||
/// connection error to the caller even though the PLC is up.
|
||||
/// </summary>
|
||||
public bool AutoReconnect { get; init; } = true;
|
||||
}
|
||||
|
||||
public sealed class ModbusProbeOptions
|
||||
|
||||
@@ -8,22 +8,40 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
/// support concurrent transactions, but the single-flight model keeps the wire trace
|
||||
/// easy to diagnose and avoids interleaved-response correlation bugs.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Survives mid-transaction socket drops: when a send/read fails with a socket-level
|
||||
/// error (<see cref="IOException"/>, <see cref="SocketException"/>, <see cref="EndOfStreamException"/>)
|
||||
/// the transport disposes the dead socket, reconnects, and retries the PDU exactly
|
||||
/// once. Deliberately limited to a single retry — further failures bubble up so the
|
||||
/// driver's health surface reflects the real state instead of masking a dead PLC.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Why this matters for DL205/DL260: the AutomationDirect H2-ECOM100 does NOT send
|
||||
/// TCP keepalives per <c>docs/v2/dl205.md</c> §behavioral-oddities, so any NAT/firewall
|
||||
/// between the gateway and PLC can silently close an idle socket after 2-5 minutes.
|
||||
/// Also enables OS-level <c>SO_KEEPALIVE</c> so the driver's own side detects a stuck
|
||||
/// socket in reasonable time even when the application is mostly idle.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ModbusTcpTransport : IModbusTransport
|
||||
{
|
||||
private readonly string _host;
|
||||
private readonly int _port;
|
||||
private readonly TimeSpan _timeout;
|
||||
private readonly bool _autoReconnect;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
private TcpClient? _client;
|
||||
private NetworkStream? _stream;
|
||||
private ushort _nextTx;
|
||||
private bool _disposed;
|
||||
|
||||
public ModbusTcpTransport(string host, int port, TimeSpan timeout)
|
||||
public ModbusTcpTransport(string host, int port, TimeSpan timeout, bool autoReconnect = true)
|
||||
{
|
||||
_host = host;
|
||||
_port = port;
|
||||
_timeout = timeout;
|
||||
_autoReconnect = autoReconnect;
|
||||
}
|
||||
|
||||
public async Task ConnectAsync(CancellationToken ct)
|
||||
@@ -39,12 +57,34 @@ public sealed class ModbusTcpTransport : IModbusTransport
|
||||
var target = ipv4 ?? (addresses.Length > 0 ? addresses[0] : System.Net.IPAddress.Loopback);
|
||||
|
||||
_client = new TcpClient(target.AddressFamily);
|
||||
EnableKeepAlive(_client);
|
||||
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(_timeout);
|
||||
await _client.ConnectAsync(target, _port, cts.Token).ConfigureAwait(false);
|
||||
_stream = _client.GetStream();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enable SO_KEEPALIVE with aggressive probe timing. DL205/DL260 doesn't send keepalives
|
||||
/// itself; having the OS probe the socket every ~30s lets the driver notice a dead PLC
|
||||
/// or broken NAT path long before the default 2-hour Windows idle timeout fires.
|
||||
/// Non-fatal if the underlying OS rejects the option (some older Linux / container
|
||||
/// sandboxes don't expose the fine-grained timing levers — the driver still works,
|
||||
/// application-level probe still detects problems).
|
||||
/// </summary>
|
||||
private static void EnableKeepAlive(TcpClient client)
|
||||
{
|
||||
try
|
||||
{
|
||||
client.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true);
|
||||
client.Client.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveTime, 30);
|
||||
client.Client.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveInterval, 10);
|
||||
client.Client.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveRetryCount, 3);
|
||||
}
|
||||
catch { /* best-effort; older OSes may not expose the granular knobs */ }
|
||||
}
|
||||
|
||||
public async Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
|
||||
{
|
||||
if (_disposed) throw new ObjectDisposedException(nameof(ModbusTcpTransport));
|
||||
@@ -53,43 +93,18 @@ public sealed class ModbusTcpTransport : IModbusTransport
|
||||
await _gate.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var txId = ++_nextTx;
|
||||
|
||||
// MBAP: [TxId(2)][Proto=0(2)][Length(2)][UnitId(1)] + PDU
|
||||
var adu = new byte[7 + pdu.Length];
|
||||
adu[0] = (byte)(txId >> 8);
|
||||
adu[1] = (byte)(txId & 0xFF);
|
||||
// protocol id already zero
|
||||
var len = (ushort)(1 + pdu.Length); // unit id + pdu
|
||||
adu[4] = (byte)(len >> 8);
|
||||
adu[5] = (byte)(len & 0xFF);
|
||||
adu[6] = unitId;
|
||||
Buffer.BlockCopy(pdu, 0, adu, 7, pdu.Length);
|
||||
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(_timeout);
|
||||
await _stream.WriteAsync(adu.AsMemory(), cts.Token).ConfigureAwait(false);
|
||||
await _stream.FlushAsync(cts.Token).ConfigureAwait(false);
|
||||
|
||||
var header = new byte[7];
|
||||
await ReadExactlyAsync(_stream, header, cts.Token).ConfigureAwait(false);
|
||||
var respTxId = (ushort)((header[0] << 8) | header[1]);
|
||||
if (respTxId != txId)
|
||||
throw new InvalidDataException($"Modbus TxId mismatch: expected {txId} got {respTxId}");
|
||||
var respLen = (ushort)((header[4] << 8) | header[5]);
|
||||
if (respLen < 1) throw new InvalidDataException($"Modbus response length too small: {respLen}");
|
||||
var respPdu = new byte[respLen - 1];
|
||||
await ReadExactlyAsync(_stream, respPdu, cts.Token).ConfigureAwait(false);
|
||||
|
||||
// Exception PDU: function code has high bit set.
|
||||
if ((respPdu[0] & 0x80) != 0)
|
||||
try
|
||||
{
|
||||
var fc = (byte)(respPdu[0] & 0x7F);
|
||||
var ex = respPdu[1];
|
||||
throw new ModbusException(fc, ex, $"Modbus exception fc={fc} code={ex}");
|
||||
return await SendOnceAsync(unitId, pdu, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (_autoReconnect && IsSocketLevelFailure(ex))
|
||||
{
|
||||
// Mid-transaction drop: tear down the dead socket, reconnect, resend. Single
|
||||
// retry — if it fails again, let it propagate so health/status reflect reality.
|
||||
await TearDownAsync().ConfigureAwait(false);
|
||||
await ConnectAsync(ct).ConfigureAwait(false);
|
||||
return await SendOnceAsync(unitId, pdu, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return respPdu;
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -97,6 +112,68 @@ public sealed class ModbusTcpTransport : IModbusTransport
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<byte[]> SendOnceAsync(byte unitId, byte[] pdu, CancellationToken ct)
|
||||
{
|
||||
if (_stream is null) throw new InvalidOperationException("Transport not connected");
|
||||
var txId = ++_nextTx;
|
||||
|
||||
// MBAP: [TxId(2)][Proto=0(2)][Length(2)][UnitId(1)] + PDU
|
||||
var adu = new byte[7 + pdu.Length];
|
||||
adu[0] = (byte)(txId >> 8);
|
||||
adu[1] = (byte)(txId & 0xFF);
|
||||
// protocol id already zero
|
||||
var len = (ushort)(1 + pdu.Length); // unit id + pdu
|
||||
adu[4] = (byte)(len >> 8);
|
||||
adu[5] = (byte)(len & 0xFF);
|
||||
adu[6] = unitId;
|
||||
Buffer.BlockCopy(pdu, 0, adu, 7, pdu.Length);
|
||||
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(_timeout);
|
||||
await _stream.WriteAsync(adu.AsMemory(), cts.Token).ConfigureAwait(false);
|
||||
await _stream.FlushAsync(cts.Token).ConfigureAwait(false);
|
||||
|
||||
var header = new byte[7];
|
||||
await ReadExactlyAsync(_stream, header, cts.Token).ConfigureAwait(false);
|
||||
var respTxId = (ushort)((header[0] << 8) | header[1]);
|
||||
if (respTxId != txId)
|
||||
throw new InvalidDataException($"Modbus TxId mismatch: expected {txId} got {respTxId}");
|
||||
var respLen = (ushort)((header[4] << 8) | header[5]);
|
||||
if (respLen < 1) throw new InvalidDataException($"Modbus response length too small: {respLen}");
|
||||
var respPdu = new byte[respLen - 1];
|
||||
await ReadExactlyAsync(_stream, respPdu, cts.Token).ConfigureAwait(false);
|
||||
|
||||
// Exception PDU: function code has high bit set.
|
||||
if ((respPdu[0] & 0x80) != 0)
|
||||
{
|
||||
var fc = (byte)(respPdu[0] & 0x7F);
|
||||
var ex = respPdu[1];
|
||||
throw new ModbusException(fc, ex, $"Modbus exception fc={fc} code={ex}");
|
||||
}
|
||||
|
||||
return respPdu;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Distinguish socket-layer failures (eligible for reconnect-and-retry) from
|
||||
/// protocol-layer failures (must propagate — retrying the same PDU won't help if the
|
||||
/// PLC just returned exception 02 Illegal Data Address).
|
||||
/// </summary>
|
||||
private static bool IsSocketLevelFailure(Exception ex) =>
|
||||
ex is EndOfStreamException
|
||||
|| ex is IOException
|
||||
|| ex is SocketException
|
||||
|| ex is ObjectDisposedException;
|
||||
|
||||
private async Task TearDownAsync()
|
||||
{
|
||||
try { if (_stream is not null) await _stream.DisposeAsync().ConfigureAwait(false); }
|
||||
catch { /* best-effort */ }
|
||||
_stream = null;
|
||||
try { _client?.Dispose(); } catch { }
|
||||
_client = null;
|
||||
}
|
||||
|
||||
private static async Task ReadExactlyAsync(Stream s, byte[] buf, CancellationToken ct)
|
||||
{
|
||||
var read = 0;
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.DL205;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the driver's Modbus-exception → OPC UA StatusCode translation end-to-end
|
||||
/// against the dl205.json pymodbus profile. pymodbus returns exception 02 (Illegal Data
|
||||
/// Address) for reads outside the configured register ranges, matching real DL205/DL260
|
||||
/// firmware behavior per <c>docs/v2/dl205.md</c> §exception-codes. The driver must surface
|
||||
/// that as <c>BadOutOfRange</c> (0x803C0000) — not <c>BadInternalError</c> — so the
|
||||
/// operator sees a tag-config diagnosis instead of a generic driver-fault message.
|
||||
/// </summary>
|
||||
[Collection(ModbusSimulatorCollection.Name)]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Device", "DL205")]
|
||||
public sealed class DL205ExceptionCodeTests(ModbusSimulatorFixture sim)
|
||||
{
|
||||
[Fact]
|
||||
public async Task DL205_FC03_at_unmapped_register_returns_BadOutOfRange()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
if (!string.Equals(Environment.GetEnvironmentVariable("MODBUS_SIM_PROFILE"), "dl205",
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Assert.Skip("MODBUS_SIM_PROFILE != dl205 — skipping.");
|
||||
}
|
||||
|
||||
// Address 16383 is the last cell of hr-size=16384 in dl205.json; address 16384 is
|
||||
// beyond the configured HR range. pymodbus validates and returns exception 02
|
||||
// (Illegal Data Address).
|
||||
var options = new ModbusDriverOptions
|
||||
{
|
||||
Host = sim.Host,
|
||||
Port = sim.Port,
|
||||
UnitId = 1,
|
||||
Timeout = TimeSpan.FromSeconds(2),
|
||||
Tags =
|
||||
[
|
||||
new ModbusTagDefinition("Unmapped",
|
||||
ModbusRegion.HoldingRegisters, Address: 16383,
|
||||
DataType: ModbusDataType.UInt16, Writable: false),
|
||||
],
|
||||
Probe = new ModbusProbeOptions { Enabled = false },
|
||||
};
|
||||
await using var driver = new ModbusDriver(options, driverInstanceId: "dl205-exc");
|
||||
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var results = await driver.ReadAsync(["Unmapped"], TestContext.Current.CancellationToken);
|
||||
results[0].StatusCode.ShouldBe(0x803C0000u,
|
||||
"DL205 returns exception 02 for an FC03 at an unmapped register; driver must translate to BadOutOfRange (not BadInternalError)");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the Modbus-exception-code → OPC UA StatusCode mapping added in PR 52.
|
||||
/// Before PR 52 every server exception + every transport failure collapsed to
|
||||
/// BadInternalError (0x80020000), which made field diagnosis "is this a bad tag or a bad
|
||||
/// driver?" impossible. These tests lock in the translation table documented on
|
||||
/// <see cref="ModbusDriver.MapModbusExceptionToStatus"/>.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ModbusExceptionMapperTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData((byte)0x01, 0x803D0000u)] // Illegal Function → BadNotSupported
|
||||
[InlineData((byte)0x02, 0x803C0000u)] // Illegal Data Address → BadOutOfRange
|
||||
[InlineData((byte)0x03, 0x803C0000u)] // Illegal Data Value → BadOutOfRange
|
||||
[InlineData((byte)0x04, 0x80550000u)] // Server Failure → BadDeviceFailure
|
||||
[InlineData((byte)0x05, 0x80550000u)] // Acknowledge (long op) → BadDeviceFailure
|
||||
[InlineData((byte)0x06, 0x80550000u)] // Server Busy → BadDeviceFailure
|
||||
[InlineData((byte)0x0A, 0x80050000u)] // Gateway path unavailable → BadCommunicationError
|
||||
[InlineData((byte)0x0B, 0x80050000u)] // Gateway target failed to respond → BadCommunicationError
|
||||
[InlineData((byte)0xFF, 0x80020000u)] // Unknown code → BadInternalError fallback
|
||||
public void MapModbusExceptionToStatus_returns_informative_status(byte code, uint expected)
|
||||
=> ModbusDriver.MapModbusExceptionToStatus(code).ShouldBe(expected);
|
||||
|
||||
private sealed class ExceptionRaisingTransport(byte exceptionCode) : IModbusTransport
|
||||
{
|
||||
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
|
||||
=> Task.FromException<byte[]>(new ModbusException(pdu[0], exceptionCode, $"fc={pdu[0]} code={exceptionCode}"));
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_surface_exception_02_as_BadOutOfRange_not_BadInternalError()
|
||||
{
|
||||
var transport = new ExceptionRaisingTransport(exceptionCode: 0x02);
|
||||
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16);
|
||||
var opts = new ModbusDriverOptions { Host = "fake", Tags = [tag], Probe = new ModbusProbeOptions { Enabled = false } };
|
||||
await using var drv = new ModbusDriver(opts, "modbus-1", _ => transport);
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var results = await drv.ReadAsync(["T"], TestContext.Current.CancellationToken);
|
||||
results[0].StatusCode.ShouldBe(0x803C0000u, "FC03 at an unmapped register must bubble out as BadOutOfRange so operators can spot a bad tag config");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Write_surface_exception_04_as_BadDeviceFailure()
|
||||
{
|
||||
var transport = new ExceptionRaisingTransport(exceptionCode: 0x04);
|
||||
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16);
|
||||
var opts = new ModbusDriverOptions { Host = "fake", Tags = [tag], Probe = new ModbusProbeOptions { Enabled = false } };
|
||||
await using var drv = new ModbusDriver(opts, "modbus-1", _ => transport);
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var writes = await drv.WriteAsync(
|
||||
[new WriteRequest("T", (short)42)],
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
writes[0].StatusCode.ShouldBe(0x80550000u, "FC06 returning exception 04 (CPU in PROGRAM mode) maps to BadDeviceFailure");
|
||||
}
|
||||
|
||||
private sealed class NonModbusFailureTransport : IModbusTransport
|
||||
{
|
||||
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
|
||||
=> Task.FromException<byte[]>(new EndOfStreamException("socket closed mid-response"));
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_non_modbus_failure_maps_to_BadCommunicationError_not_BadInternalError()
|
||||
{
|
||||
// Socket drop / timeout / malformed frame → transport-layer failure. Should surface
|
||||
// distinctly from tag-level faults so operators know to check the network, not the config.
|
||||
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16);
|
||||
var opts = new ModbusDriverOptions { Host = "fake", Tags = [tag], Probe = new ModbusProbeOptions { Enabled = false } };
|
||||
await using var drv = new ModbusDriver(opts, "modbus-1", _ => new NonModbusFailureTransport());
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var results = await drv.ReadAsync(["T"], TestContext.Current.CancellationToken);
|
||||
results[0].StatusCode.ShouldBe(0x80050000u);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Exercises <see cref="ModbusTcpTransport"/> against a real TCP listener that can close
|
||||
/// its socket mid-session on demand. Verifies the PR 53 reconnect-on-drop behavior: after
|
||||
/// the "first" socket is forcibly torn down, the next SendAsync must re-establish the
|
||||
/// connection and complete the PDU without bubbling an error to the caller.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ModbusTcpReconnectTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Minimal in-process Modbus-TCP stub. Accepts one TCP connection at a time, reads an
|
||||
/// MBAP + PDU, replies with a canned FC03 response echoing the request quantity of
|
||||
/// zeroed bytes, then optionally closes the socket to simulate a NAT/firewall drop.
|
||||
/// </summary>
|
||||
private sealed class FlakeyModbusServer : IAsyncDisposable
|
||||
{
|
||||
private readonly TcpListener _listener;
|
||||
public int Port => ((IPEndPoint)_listener.LocalEndpoint).Port;
|
||||
public int DropAfterNTransactions { get; set; } = int.MaxValue;
|
||||
private readonly CancellationTokenSource _stop = new();
|
||||
private int _txCount;
|
||||
|
||||
public FlakeyModbusServer()
|
||||
{
|
||||
_listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
_listener.Start();
|
||||
_ = Task.Run(AcceptLoopAsync);
|
||||
}
|
||||
|
||||
private async Task AcceptLoopAsync()
|
||||
{
|
||||
while (!_stop.IsCancellationRequested)
|
||||
{
|
||||
TcpClient? client = null;
|
||||
try { client = await _listener.AcceptTcpClientAsync(_stop.Token); }
|
||||
catch { return; }
|
||||
|
||||
_ = Task.Run(() => ServeAsync(client!));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ServeAsync(TcpClient client)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var _ = client;
|
||||
var stream = client.GetStream();
|
||||
while (!_stop.IsCancellationRequested && client.Connected)
|
||||
{
|
||||
var header = new byte[7];
|
||||
if (!await ReadExactly(stream, header)) return;
|
||||
var len = (ushort)((header[4] << 8) | header[5]);
|
||||
var pdu = new byte[len - 1];
|
||||
if (!await ReadExactly(stream, pdu)) return;
|
||||
|
||||
var fc = pdu[0];
|
||||
var qty = (ushort)((pdu[3] << 8) | pdu[4]);
|
||||
var respPdu = new byte[2 + qty * 2];
|
||||
respPdu[0] = fc;
|
||||
respPdu[1] = (byte)(qty * 2);
|
||||
// data bytes stay 0
|
||||
|
||||
var respLen = (ushort)(1 + respPdu.Length);
|
||||
var adu = new byte[7 + respPdu.Length];
|
||||
adu[0] = header[0]; adu[1] = header[1];
|
||||
adu[4] = (byte)(respLen >> 8); adu[5] = (byte)(respLen & 0xFF);
|
||||
adu[6] = header[6];
|
||||
Buffer.BlockCopy(respPdu, 0, adu, 7, respPdu.Length);
|
||||
await stream.WriteAsync(adu);
|
||||
await stream.FlushAsync();
|
||||
|
||||
_txCount++;
|
||||
if (_txCount >= DropAfterNTransactions)
|
||||
{
|
||||
// Simulate NAT/firewall silent close: slam the socket without a
|
||||
// protocol-level goodbye, which is what DL260 + an intermediate
|
||||
// middlebox would look like from the client's perspective.
|
||||
client.Client.Shutdown(SocketShutdown.Both);
|
||||
client.Close();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
private static async Task<bool> ReadExactly(NetworkStream s, byte[] buf)
|
||||
{
|
||||
var read = 0;
|
||||
while (read < buf.Length)
|
||||
{
|
||||
var n = await s.ReadAsync(buf.AsMemory(read));
|
||||
if (n == 0) return false;
|
||||
read += n;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
_stop.Cancel();
|
||||
_listener.Stop();
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Transport_recovers_from_mid_session_drop_and_retries_successfully()
|
||||
{
|
||||
await using var server = new FlakeyModbusServer { DropAfterNTransactions = 1 };
|
||||
await using var transport = new ModbusTcpTransport("127.0.0.1", server.Port, TimeSpan.FromSeconds(2), autoReconnect: true);
|
||||
await transport.ConnectAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
// First transaction succeeds; server then closes the socket.
|
||||
var pdu = new byte[] { 0x03, 0x00, 0x00, 0x00, 0x01 };
|
||||
var first = await transport.SendAsync(unitId: 1, pdu, TestContext.Current.CancellationToken);
|
||||
first[0].ShouldBe((byte)0x03);
|
||||
|
||||
// Second transaction: the connection is dead, but auto-reconnect must transparently
|
||||
// spin up a new socket, resend, and produce a valid response. Before PR 53 this would
|
||||
// surface as EndOfStreamException / IOException to the caller.
|
||||
var second = await transport.SendAsync(unitId: 1, pdu, TestContext.Current.CancellationToken);
|
||||
second[0].ShouldBe((byte)0x03);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Transport_without_AutoReconnect_propagates_drop_to_caller()
|
||||
{
|
||||
await using var server = new FlakeyModbusServer { DropAfterNTransactions = 1 };
|
||||
await using var transport = new ModbusTcpTransport("127.0.0.1", server.Port, TimeSpan.FromSeconds(2), autoReconnect: false);
|
||||
await transport.ConnectAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var pdu = new byte[] { 0x03, 0x00, 0x00, 0x00, 0x01 };
|
||||
_ = await transport.SendAsync(unitId: 1, pdu, TestContext.Current.CancellationToken);
|
||||
|
||||
await Should.ThrowAsync<Exception>(async () =>
|
||||
await transport.SendAsync(unitId: 1, pdu, TestContext.Current.CancellationToken));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user