dohertj2 dohertj2
  • Joined on 2026-02-20
dohertj2 created branch phase-3-pr54-s7-research-doc in dohertj2/lmxopcua 2026-04-18 22:53:08 -04:00
dohertj2 pushed to phase-3-pr54-s7-research-doc at dohertj2/lmxopcua 2026-04-18 22:53:08 -04:00
9e2b5b330f Phase 3 PR 54 -- Siemens S7 Modbus TCP quirks research document. 485-line doc at docs/v2/s7.md mirroring the docs/v2/dl205.md template for the Siemens SIMATIC S7 family (S7-1200 / S7-1500 / S7-300 / S7-400 / ET 200SP / CP 343-1 / CP 443-1 / CP 343-1 Lean / MODBUSPN). Siemens S7 is fundamentally different from DL260: there is no fixed Modbus memory map baked into firmware -- every deployment runs MB_SERVER (S7-1200/1500/ET 200SP), MODBUSCP (S7-300/400 + CP), or MODBUSPN (S7-300/400 PN) library blocks wired up to user DBs via the MB_HOLD_REG / ADDR parameters. The driver's job is therefore to handle per-site CONFIG rather than per-family QUIRKS, and the doc makes that explicit. Key findings worth flagging for the PR 56+ implementation track: (1) S7 has no fixed memory map -- must accept per-site DriverConfig, cannot assume vendor-standard layout. (2) MB_SERVER requires NON-optimized DBs in TIA Portal; optimized DBs cause the library to return STATUS 0x8383 on every access -- the single most common S7 Modbus deployment bug in the field. (3) Word order is ABCD by default (big-endian bytes + big-endian words) across all Siemens S7 Modbus paths, which is the OPPOSITE of DL260 CDAB -- the Modbus driver's S7 profile default must be ByteOrder.BigEndian, not WordSwap. (4) MB_SERVER listens on ONE port per FB instance; multi-client support requires running MB_SERVER on 502 / 503 / 504 / ... simultaneously -- most clients assume port 502 multiplexes, which is wrong on S7. (5) CP 343-1 Lean is SERVER-ONLY and requires the separate 2XV9450-1MB00 MODBUS TCP CP library license; client mode calls return immediate error on Lean. (6) MB_SERVER does NOT filter Unit ID, accepts any value. Means the driver can't use Unit ID to detect 'direct vs gateway' topology. (7) FC23 Read-Write Multiple, FC22 Mask Write, FC20/21 File Records, FC43 Device Identification all return exception 01 Illegal Function on every S7 variant -- the driver MUST NOT attempt bulk-read optimisation via FC23 when talking to S7. (8) STOP-mode read/write behaviour is non-deterministic across firmware bands: reads may return cached data (library internal buffer), writes may succeed-silently or return exception 04 depending on CPU firmware version -- flagged as 'driver treats both as unavailable, do not distinguish'. Unconfirmed rumours flagged separately: 'V2.0+ reverses float byte order' claim (cited but not reproduced), STOP-mode caching location (folklore, no primary source). Per-model test differentiation section names the tests as S7_<model>_<behavior> matching the DL205 template convention (e.g. S7_1200_MB_SERVER_requires_non_optimized_DB, S7_343_1_Lean_rejects_client_mode, S7_FC23_returns_IllegalFunction). 31 cited references across the Siemens Industry Online Support entry-ID system (68011496 for MB_SERVER FAQ, etc.), TIA Portal library manuals, and three third-party driver vendor release notes (Kepware, Ignition, FactoryTalk). This is a pure documentation PR -- no code, no tests, no csproj changes. Per-quirk implementation lands in PRs 56+. Research conducted 2026-04-18 against latest publicly-available Siemens documentation; STOP-mode behaviour and MB_SERVER versioning specifically cross-checked against Siemens forum answers from 2024-2025.
dohertj2 created branch phase-3-pr55-mitsubishi-research-doc in dohertj2/lmxopcua 2026-04-18 22:53:08 -04:00
dohertj2 pushed to v2 at dohertj2/lmxopcua 2026-04-18 22:35:44 -04:00
d5c6280333 Merge pull request 'Phase 3 PR 53 -- Transport reconnect-on-drop + SO_KEEPALIVE (DL260 no-keepalive quirk)' (#52) from phase-3-pr53-dl205-reconnect into v2
793c787315 Phase 3 PR 53 -- Transport reconnect-on-drop + SO_KEEPALIVE for DL205 no-keepalive quirk. AutomationDirect H2-ECOM100 does NOT send TCP keepalives per docs/v2/dl205.md behavioral-oddities section -- any NAT/firewall device between the gateway and the PLC can silently close an idle socket after 2-5 minutes of inactivity. The PLC itself never notices and the first SendAsync after the drop would previously surface as IOException / EndOfStreamException / SocketException to the caller even though the PLC is perfectly healthy. PR 53 makes ModbusTcpTransport survive mid-session socket drops: SendAsync wraps the previous body as SendOnceAsync; on the first attempt, if the failure is a socket-layer error (IOException, SocketException, EndOfStreamException, ObjectDisposedException) AND autoReconnect is enabled (default true), the transport tears down the dead socket, calls ConnectAsync to re-establish, and resends the PDU exactly once. Deliberately single-retry -- further failures propagate so the driver health surface reflects the real state, no masking a dead PLC. Protocol-layer failures (e.g. ModbusException with exception code 02) are specifically NOT caught by the reconnect path -- they would just come back with the same exception code after the reconnect, so retrying is wasted wire time. Socket-level vs protocol-level is a discriminator inside IsSocketLevelFailure. Also enables SO_KEEPALIVE on the TcpClient with aggressive timing: TcpKeepAliveTime=30s, TcpKeepAliveInterval=10s, TcpKeepAliveRetryCount=3. Total time-to-detect-dead-socket = 30 + 10*3 = 60s, vs the Windows default 2-hour idle + 9 retries = 2h40min. Best-effort: older OSes that don't expose the fine-grained keepalive knobs silently skip them (catch {}). New ModbusDriverOptions.AutoReconnect bool (default true) threads through to the default transport factory in ModbusDriver -- callers wanting the old 'fail loud on drop' behavior can set AutoReconnect=false, or use a custom transportFactory that ignores the option. Unit tests: ModbusTcpReconnectTests boots a FlakeyModbusServer in-process (real TcpListener on loopback) that serves one valid FC03 response then forcibly shuts down the socket. Transport_recovers_from_mid_session_drop_and_retries_successfully issues two consecutive SendAsync calls and asserts both return valid PDUs -- the second must trigger the reconnect path transparently. Transport_without_AutoReconnect_propagates_drop_to_caller asserts the legacy behavior when the opt-out is taken. Validates real socket semantics rather than mocked exceptions. 142/142 Modbus.Tests pass (113 prior + 2 mapper + 2 reconnect + 25 accumulated across PRs 45-52); 11/11 DL205 integration tests still pass with MODBUS_SIM_PROFILE=dl205 -- no regression from the transport change.
Compare 2 commits »
dohertj2 merged pull request dohertj2/lmxopcua#52 2026-04-18 22:35:42 -04:00
Phase 3 PR 53 -- Transport reconnect-on-drop + SO_KEEPALIVE (DL260 no-keepalive quirk)
dohertj2 created pull request dohertj2/lmxopcua#52 2026-04-18 22:35:38 -04:00
Phase 3 PR 53 -- Transport reconnect-on-drop + SO_KEEPALIVE (DL260 no-keepalive quirk)
dohertj2 pushed to v2 at dohertj2/lmxopcua 2026-04-18 22:35:35 -04:00
476ce9b7c5 Merge pull request 'Phase 3 PR 52 -- Modbus exception-code -> OPC UA StatusCode translation' (#51) from phase-3-pr52-dl205-exception-codes into v2
cde018aec1 Phase 3 PR 52 -- Modbus exception-code -> OPC UA StatusCode translation. Before this PR every server-side Modbus exception AND every transport-layer failure collapsed to BadInternalError (0x80020000) in the driver's Read/Write results, making field diagnosis 'is this a tag misconfig or a driver bug?' impossible from the OPC UA client side. PR 52 adds a MapModbusExceptionToStatus helper that translates per spec: 01 Illegal Function -> BadNotSupported (0x803D0000); 02 Illegal Data Address -> BadOutOfRange (0x803C0000); 03 Illegal Data Value -> BadOutOfRange; 04 Server Failure -> BadDeviceFailure (0x80550000); 05/06 Acknowledge/Busy -> BadDeviceFailure; 0A/0B Gateway -> BadCommunicationError (0x80050000); unknown -> BadInternalError fallback. Non-Modbus failures (socket drop, timeout, malformed frame) in ReadAsync are now distinguished from tag-level faults: they map to BadCommunicationError so operators check network/PLC reachability rather than tag definitions. Why per-DL205: docs/v2/dl205.md documents DL205/DL260 returning only codes 01-04 with specific triggers -- exception 04 specifically means 'CPU in PROGRAM mode during a protected write', which is operator-recoverable by switching the CPU to RUN; surfacing it as BadDeviceFailure (not BadInternalError) makes the fix obvious. Changes in ModbusDriver: Read catch-chain now ModbusException first (-> mapper), generic Exception second (-> BadCommunicationError); Write catch-chain same pattern but generic Exception stays BadInternalError because write failures can legitimately come from EncodeRegister (out-of-range value) which is a driver-layer fault. Unit tests: MapModbusExceptionToStatus theory exercising every code in the table including the 0xFF fallback; Read_surface_exception_02_as_BadOutOfRange with an ExceptionRaisingTransport that forces code 02; Write_surface_exception_04_as_BadDeviceFailure for CPU-mode faults; Read_non_modbus_failure_maps_to_BadCommunicationError with a NonModbusFailureTransport that raises EndOfStreamException. 115/115 Modbus.Tests pass. Integration test: DL205ExceptionCodeTests.DL205_FC03_at_unmapped_register_returns_BadOutOfRange reads HR[16383] which is beyond the seeded uint16 cells on the dl205.json profile; pymodbus returns exception 02 and the driver surfaces BadOutOfRange. 11/11 DL205 integration tests pass with MODBUS_SIM_PROFILE=dl205.
Compare 2 commits »
dohertj2 merged pull request dohertj2/lmxopcua#51 2026-04-18 22:35:34 -04:00
Phase 3 PR 52 -- Modbus exception-code -> OPC UA StatusCode translation
dohertj2 created pull request dohertj2/lmxopcua#51 2026-04-18 22:35:30 -04:00
Phase 3 PR 52 -- Modbus exception-code -> OPC UA StatusCode translation
dohertj2 pushed to v2 at dohertj2/lmxopcua 2026-04-18 22:35:28 -04:00
954bf55d28 Merge pull request 'Phase 3 PR 51 -- DL260 X-input FC02 discrete-input mapping end-to-end test' (#50) from phase-3-pr51-dl205-xinput into v2
9892a0253d Phase 3 PR 51 -- DL260 X-input FC02 discrete-input mapping end-to-end test. Integration test DL205XInputTests reads FC02 at the DirectLogicAddress.XInputToDiscrete-resolved address and asserts two behaviors against the dl205.json pymodbus profile: (1) X20 octal (=decimal 16 = Modbus DI 16) reads ON, proving the helper correctly octal-parses the trailing number and adds it to the 0 base; (2) X21 octal reads OFF (not exception) -- per docs/v2/dl205.md §I/O-mapping, 'reading a non-populated X input returns zero, not an exception' on DL260, because the CPU sizes the discrete-input table to the configured I/O not the installed hardware. Pymodbus models this by returning the default 0 value for any DI bit in the configured 'di size' range that wasn't explicitly seeded, matching real DL260 behaviour. Test uses X20 rather than X0 to sidestep a shared-blocks conflict: pymodbus places FC01/FC02 bit-address 0..15 into cell 0, but cell 0 is already uint16-typed (V0 marker = 0xCAFE) per the register-zero quirk test, and shared-blocks semantics allow only one type per cell. X20 octal = DI 16 lands in cell 1 which is free, so both the V0 quirk AND the X-input quirk can coexist in one profile. dl205.json: bits cell 1 seeded value=9 (bits 0 and 3 set -> X20, X23 octal = ON), write-range extended to include cell 1 (though X-inputs are read-only; the write-range entry is required by pymodbus for ANY cell referenced in a bits section even if only reads are expected -- pymodbus validates write-access uniformly). 10/10 DL205 integration tests pass with MODBUS_SIM_PROFILE=dl205. No driver code changes -- the XInputToDiscrete helper + FC02 read path already landed in PRs 50 and 21 respectively. This PR closes the integration-test gap that docs/v2/dl205.md called out under test name DL205_Xinput_unpopulated_reads_as_zero.
Compare 2 commits »
dohertj2 merged pull request dohertj2/lmxopcua#50 2026-04-18 22:35:27 -04:00
Phase 3 PR 51 -- DL260 X-input FC02 discrete-input mapping end-to-end test
dohertj2 created pull request dohertj2/lmxopcua#50 2026-04-18 22:35:23 -04:00
Phase 3 PR 51 -- DL260 X-input FC02 discrete-input mapping end-to-end test
dohertj2 pushed to v2 at dohertj2/lmxopcua 2026-04-18 22:35:20 -04:00
9fb3cf7512 Merge pull request 'Phase 3 PR 50 -- DL260 bit-memory helpers (Y/C/X/SP) + coil integration tests' (#49) from phase-3-pr50-dl205-coil-mapping into v2
b5464f11ee Phase 3 PR 50 -- DL260 bit-memory address helpers (Y/C/X/SP) + live coil integration tests. Adds four new static helpers to DirectLogicAddress covering every discrete-memory bank on the DL260: YOutputToCoil (Y0=coil 2048), CRelayToCoil (C0=coil 3072), XInputToDiscrete (X0=DI 0), SpecialToDiscrete (SP0=DI 1024). Each helper takes the DirectLOGIC ladder-logic address (e.g. 'Y0', 'Y17', 'C1777') and adds the octal-decoded offset to the bank's Modbus base per the DL260 user manual's I/O-configuration chapter table. Uses the same 'octal-walk + reject 8/9' pattern as UserVMemoryToPdu so misaligned addresses fail loudly with a clear ArgumentException rather than silently hitting the wrong coil. Fixes a pymodbus-config bug surfaced during integration-test validation: dl205.json had bits entries at cell indices 2048 / 3072 / 4000, but pymodbus's ModbusSimulatorContext.validate divides bit addresses by 16 before indexing into the shared cell array -- so Modbus coil 2048 reads cell 128, not cell 2048. The sim was returning Illegal Data Address (exception 02) for every bit read in the Y/C/scratch range. Moved bits entries to cells 128 (Y bank marker = 0b101 for Y0=ON, Y1=OFF, Y2=ON), 192 (C bank marker = 0b101 for C0/C1/C2), 250 (scratch cell covering coils 4000..4015). write list updated to the correct cell addresses. Unit tests: YOutputToCoil theory sweep (Y0->2048, Y1->2049, Y7->2055, Y10->2056 octal-to-decimal, Y17->2063, Y777->2559 top of DL260 Y range), CRelayToCoil theory (C0->3072 through C1777->4095), XInputToDiscrete theory, SpecialToDiscrete theory (with case-insensitive 'SP' prefix). Bit_address_rejects_non_octal_digits (Y8/C9/X18), Bit_address_rejects_empty, accepts_lowercase_prefix, accepts_bare_octal_without_prefix. 48/48 Modbus.Tests pass. Integration tests: DL205CoilMappingTests with three facts -- DL260_Y0_maps_to_coil_2048 (FC01 at Y0 returns ON), DL260_C0_maps_to_coil_3072 (FC01 at C0 returns ON), DL260_scratch_Crelay_supports_write_then_read (FC05 write + FC01 read round-trip at coil 4000 proves the DL-mapped coil bank is fully read/write capable end-to-end). 9/9 DL205 integration tests pass against the pymodbus dl205 profile with MODBUS_SIM_PROFILE=dl205. Caller opts into the helpers per tag the same way as PR 47's V-memory helper -- pass DirectLogicAddress.YOutputToCoil("Y0") as the ModbusTagDefinition Address; no driver-wide DL-family flag. PR 51 adds the X-input read-side integration test (there's nothing to write since X-inputs are FC02 discrete inputs, read-only); PR 52 exception-code translation; PR 53 transport reconnect-on-drop since DL260 doesn't send TCP keepalives.
Compare 2 commits »
dohertj2 merged pull request dohertj2/lmxopcua#49 2026-04-18 22:35:19 -04:00
Phase 3 PR 50 -- DL260 bit-memory helpers (Y/C/X/SP) + coil integration tests
dohertj2 created pull request dohertj2/lmxopcua#49 2026-04-18 22:35:15 -04:00
Phase 3 PR 50 -- DL260 bit-memory helpers (Y/C/X/SP) + coil integration tests
dohertj2 created branch phase-3-pr53-dl205-reconnect in dohertj2/lmxopcua 2026-04-18 22:34:05 -04:00
dohertj2 pushed to phase-3-pr53-dl205-reconnect at dohertj2/lmxopcua 2026-04-18 22:34:05 -04:00
793c787315 Phase 3 PR 53 -- Transport reconnect-on-drop + SO_KEEPALIVE for DL205 no-keepalive quirk. AutomationDirect H2-ECOM100 does NOT send TCP keepalives per docs/v2/dl205.md behavioral-oddities section -- any NAT/firewall device between the gateway and the PLC can silently close an idle socket after 2-5 minutes of inactivity. The PLC itself never notices and the first SendAsync after the drop would previously surface as IOException / EndOfStreamException / SocketException to the caller even though the PLC is perfectly healthy. PR 53 makes ModbusTcpTransport survive mid-session socket drops: SendAsync wraps the previous body as SendOnceAsync; on the first attempt, if the failure is a socket-layer error (IOException, SocketException, EndOfStreamException, ObjectDisposedException) AND autoReconnect is enabled (default true), the transport tears down the dead socket, calls ConnectAsync to re-establish, and resends the PDU exactly once. Deliberately single-retry -- further failures propagate so the driver health surface reflects the real state, no masking a dead PLC. Protocol-layer failures (e.g. ModbusException with exception code 02) are specifically NOT caught by the reconnect path -- they would just come back with the same exception code after the reconnect, so retrying is wasted wire time. Socket-level vs protocol-level is a discriminator inside IsSocketLevelFailure. Also enables SO_KEEPALIVE on the TcpClient with aggressive timing: TcpKeepAliveTime=30s, TcpKeepAliveInterval=10s, TcpKeepAliveRetryCount=3. Total time-to-detect-dead-socket = 30 + 10*3 = 60s, vs the Windows default 2-hour idle + 9 retries = 2h40min. Best-effort: older OSes that don't expose the fine-grained keepalive knobs silently skip them (catch {}). New ModbusDriverOptions.AutoReconnect bool (default true) threads through to the default transport factory in ModbusDriver -- callers wanting the old 'fail loud on drop' behavior can set AutoReconnect=false, or use a custom transportFactory that ignores the option. Unit tests: ModbusTcpReconnectTests boots a FlakeyModbusServer in-process (real TcpListener on loopback) that serves one valid FC03 response then forcibly shuts down the socket. Transport_recovers_from_mid_session_drop_and_retries_successfully issues two consecutive SendAsync calls and asserts both return valid PDUs -- the second must trigger the reconnect path transparently. Transport_without_AutoReconnect_propagates_drop_to_caller asserts the legacy behavior when the opt-out is taken. Validates real socket semantics rather than mocked exceptions. 142/142 Modbus.Tests pass (113 prior + 2 mapper + 2 reconnect + 25 accumulated across PRs 45-52); 11/11 DL205 integration tests still pass with MODBUS_SIM_PROFILE=dl205 -- no regression from the transport change.
dohertj2 created branch phase-3-pr52-dl205-exception-codes in dohertj2/lmxopcua 2026-04-18 22:34:04 -04:00
dohertj2 pushed to phase-3-pr52-dl205-exception-codes at dohertj2/lmxopcua 2026-04-18 22:34:04 -04:00
cde018aec1 Phase 3 PR 52 -- Modbus exception-code -> OPC UA StatusCode translation. Before this PR every server-side Modbus exception AND every transport-layer failure collapsed to BadInternalError (0x80020000) in the driver's Read/Write results, making field diagnosis 'is this a tag misconfig or a driver bug?' impossible from the OPC UA client side. PR 52 adds a MapModbusExceptionToStatus helper that translates per spec: 01 Illegal Function -> BadNotSupported (0x803D0000); 02 Illegal Data Address -> BadOutOfRange (0x803C0000); 03 Illegal Data Value -> BadOutOfRange; 04 Server Failure -> BadDeviceFailure (0x80550000); 05/06 Acknowledge/Busy -> BadDeviceFailure; 0A/0B Gateway -> BadCommunicationError (0x80050000); unknown -> BadInternalError fallback. Non-Modbus failures (socket drop, timeout, malformed frame) in ReadAsync are now distinguished from tag-level faults: they map to BadCommunicationError so operators check network/PLC reachability rather than tag definitions. Why per-DL205: docs/v2/dl205.md documents DL205/DL260 returning only codes 01-04 with specific triggers -- exception 04 specifically means 'CPU in PROGRAM mode during a protected write', which is operator-recoverable by switching the CPU to RUN; surfacing it as BadDeviceFailure (not BadInternalError) makes the fix obvious. Changes in ModbusDriver: Read catch-chain now ModbusException first (-> mapper), generic Exception second (-> BadCommunicationError); Write catch-chain same pattern but generic Exception stays BadInternalError because write failures can legitimately come from EncodeRegister (out-of-range value) which is a driver-layer fault. Unit tests: MapModbusExceptionToStatus theory exercising every code in the table including the 0xFF fallback; Read_surface_exception_02_as_BadOutOfRange with an ExceptionRaisingTransport that forces code 02; Write_surface_exception_04_as_BadDeviceFailure for CPU-mode faults; Read_non_modbus_failure_maps_to_BadCommunicationError with a NonModbusFailureTransport that raises EndOfStreamException. 115/115 Modbus.Tests pass. Integration test: DL205ExceptionCodeTests.DL205_FC03_at_unmapped_register_returns_BadOutOfRange reads HR[16383] which is beyond the seeded uint16 cells on the dl205.json profile; pymodbus returns exception 02 and the driver surfaces BadOutOfRange. 11/11 DL205 integration tests pass with MODBUS_SIM_PROFILE=dl205.
dohertj2 pushed to phase-3-pr51-dl205-xinput at dohertj2/lmxopcua 2026-04-18 22:34:01 -04:00
9892a0253d Phase 3 PR 51 -- DL260 X-input FC02 discrete-input mapping end-to-end test. Integration test DL205XInputTests reads FC02 at the DirectLogicAddress.XInputToDiscrete-resolved address and asserts two behaviors against the dl205.json pymodbus profile: (1) X20 octal (=decimal 16 = Modbus DI 16) reads ON, proving the helper correctly octal-parses the trailing number and adds it to the 0 base; (2) X21 octal reads OFF (not exception) -- per docs/v2/dl205.md §I/O-mapping, 'reading a non-populated X input returns zero, not an exception' on DL260, because the CPU sizes the discrete-input table to the configured I/O not the installed hardware. Pymodbus models this by returning the default 0 value for any DI bit in the configured 'di size' range that wasn't explicitly seeded, matching real DL260 behaviour. Test uses X20 rather than X0 to sidestep a shared-blocks conflict: pymodbus places FC01/FC02 bit-address 0..15 into cell 0, but cell 0 is already uint16-typed (V0 marker = 0xCAFE) per the register-zero quirk test, and shared-blocks semantics allow only one type per cell. X20 octal = DI 16 lands in cell 1 which is free, so both the V0 quirk AND the X-input quirk can coexist in one profile. dl205.json: bits cell 1 seeded value=9 (bits 0 and 3 set -> X20, X23 octal = ON), write-range extended to include cell 1 (though X-inputs are read-only; the write-range entry is required by pymodbus for ANY cell referenced in a bits section even if only reads are expected -- pymodbus validates write-access uniformly). 10/10 DL205 integration tests pass with MODBUS_SIM_PROFILE=dl205. No driver code changes -- the XInputToDiscrete helper + FC02 read path already landed in PRs 50 and 21 respectively. This PR closes the integration-test gap that docs/v2/dl205.md called out under test name DL205_Xinput_unpopulated_reads_as_zero.