Skip to content

Commit 050bf89

Browse files
gijzelaerrclaude
andcommitted
Add S7 routing support for multi-subnet PLC access
Implement routing parameters in the COTP Connection Request PDU so clients can reach PLCs behind a gateway on another subnet. The new ISOTCPConnection.set_routing() method appends subnet ID (0xC6) and routing TSAP (0xC7) parameters to the CR, and Client.connect_routed() provides a high-level entry point that mirrors connect() but accepts gateway and destination rack/slot/subnet. Closes #615 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 310ff53 commit 050bf89

4 files changed

Lines changed: 333 additions & 0 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ markers =[
5959
"logo",
6060
"mainloop",
6161
"partner",
62+
"routing",
6263
"server",
6364
"util",
6465
"conformance: protocol conformance tests"

snap7/client.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,76 @@ def connect(self, address: str, rack: int, slot: int, tcp_port: int = 102) -> "C
404404

405405
return self
406406

407+
def connect_routed(
408+
self,
409+
host: str,
410+
router_rack: int,
411+
router_slot: int,
412+
subnet: int,
413+
dest_rack: int,
414+
dest_slot: int,
415+
port: int = 102,
416+
timeout: float = 5.0,
417+
) -> "Client":
418+
"""Connect to an S7 PLC via a routing gateway on another subnet.
419+
420+
The gateway PLC (identified by *host*, *router_rack*, *router_slot*)
421+
forwards the connection to the target PLC (identified by *subnet*,
422+
*dest_rack*, *dest_slot*) through S7 routing parameters embedded in
423+
the COTP Connection Request.
424+
425+
Args:
426+
host: IP address of the routing gateway PLC
427+
router_rack: Rack number of the gateway PLC
428+
router_slot: Slot number of the gateway PLC
429+
subnet: Subnet ID of the target network (0x0000-0xFFFF)
430+
dest_rack: Rack number of the destination PLC
431+
dest_slot: Slot number of the destination PLC
432+
port: TCP port (default 102)
433+
timeout: Connection timeout in seconds
434+
435+
Returns:
436+
Self for method chaining
437+
"""
438+
self.host = host
439+
self.port = port
440+
self.rack = router_rack
441+
self.slot = router_slot
442+
self._params[Parameter.RemotePort] = port
443+
444+
# Remote TSAP targets the gateway rack/slot
445+
self.remote_tsap = 0x0100 | (router_rack << 5) | router_slot
446+
447+
try:
448+
start_time = time.time()
449+
450+
self.connection = ISOTCPConnection(
451+
host=host,
452+
port=port,
453+
local_tsap=self.local_tsap,
454+
remote_tsap=self.remote_tsap,
455+
)
456+
self.connection.set_routing(subnet, dest_rack, dest_slot)
457+
self.connection.connect(timeout=timeout)
458+
459+
# Setup communication and negotiate PDU length
460+
self._setup_communication()
461+
462+
self.connected = True
463+
self._exec_time = int((time.time() - start_time) * 1000)
464+
logger.info(
465+
f"Connected (routed) to {host}:{port} via rack {router_rack} slot {router_slot}, "
466+
f"subnet {subnet:#06x} -> rack {dest_rack} slot {dest_slot}"
467+
)
468+
except Exception as e:
469+
self.disconnect()
470+
if isinstance(e, S7Error):
471+
raise
472+
else:
473+
raise S7ConnectionError(f"Routed connection failed: {e}")
474+
475+
return self
476+
407477
def disconnect(self) -> int:
408478
"""Disconnect from S7 PLC.
409479

snap7/connection.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,10 @@ class ISOTCPConnection:
6161
COTP_PARAM_CALLING_TSAP = 0xC1
6262
COTP_PARAM_CALLED_TSAP = 0xC2
6363

64+
# S7 routing parameter codes
65+
COTP_PARAM_SUBNET_ID = 0xC6
66+
COTP_PARAM_ROUTING_TSAP = 0xC7
67+
6468
def __init__(
6569
self,
6670
host: str,
@@ -94,6 +98,29 @@ def __init__(
9498
self.src_ref = 0x0001 # Source reference
9599
self.dst_ref = 0x0000 # Destination reference (assigned by peer)
96100

101+
# Routing parameters (set via connect_routed)
102+
self._routing: bool = False
103+
self._subnet_id: int = 0
104+
self._routing_tsap: int = 0
105+
106+
def set_routing(self, subnet_id: int, dest_rack: int, dest_slot: int) -> None:
107+
"""Configure S7 routing parameters for multi-subnet access.
108+
109+
When routing is enabled, the COTP Connection Request includes
110+
additional parameters that instruct the gateway PLC to forward
111+
the connection to a target PLC on another subnet.
112+
113+
Args:
114+
subnet_id: Subnet ID of the target network (2 bytes)
115+
dest_rack: Rack number of the destination PLC
116+
dest_slot: Slot number of the destination PLC
117+
"""
118+
self._routing = True
119+
self._subnet_id = subnet_id & 0xFFFF
120+
# Routing TSAP encodes the final target rack/slot the same way
121+
# as a normal remote TSAP.
122+
self._routing_tsap = 0x0100 | (dest_rack << 5) | dest_slot
123+
97124
def connect(self, timeout: float = 5.0) -> None:
98125
"""
99126
Establish ISO on TCP connection.
@@ -279,6 +306,16 @@ def _build_cotp_cr(self) -> bytes:
279306

280307
parameters = calling_tsap + called_tsap + pdu_size_param
281308

309+
# Append routing parameters when routing is enabled
310+
if self._routing:
311+
subnet_param = struct.pack(">BBH", self.COTP_PARAM_SUBNET_ID, 2, self._subnet_id)
312+
routing_tsap_param = struct.pack(">BBH", self.COTP_PARAM_ROUTING_TSAP, 2, self._routing_tsap)
313+
parameters += subnet_param + routing_tsap_param
314+
logger.debug(
315+
f"COTP CR with routing: subnet={self._subnet_id:#06x}, "
316+
f"routing_tsap={self._routing_tsap:#06x}"
317+
)
318+
282319
# Update PDU length to include parameters
283320
total_length = 6 + len(parameters)
284321
pdu = struct.pack(">B", total_length) + base_pdu[1:] + parameters

tests/test_routing.py

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
"""Tests for S7 routing support (multi-subnet PLC access)."""
2+
3+
import struct
4+
5+
import pytest
6+
7+
from snap7.connection import ISOTCPConnection
8+
from snap7.client import Client
9+
10+
# Use a unique port to avoid conflicts with other test suites
11+
ROUTING_TEST_PORT = 11102
12+
13+
14+
@pytest.mark.routing
15+
class TestRoutingTSAP:
16+
"""Test TSAP construction for routed connections."""
17+
18+
def test_remote_tsap_encodes_rack_slot(self) -> None:
19+
"""Remote TSAP should encode rack and slot per S7 spec."""
20+
rack, slot = 0, 2
21+
expected = 0x0100 | (rack << 5) | slot # 0x0102
22+
conn = ISOTCPConnection("127.0.0.1", remote_tsap=expected)
23+
assert conn.remote_tsap == 0x0102
24+
25+
def test_routing_tsap_encodes_dest_rack_slot(self) -> None:
26+
"""Routing TSAP should encode destination rack/slot."""
27+
conn = ISOTCPConnection("127.0.0.1")
28+
conn.set_routing(subnet_id=0x0001, dest_rack=0, dest_slot=3)
29+
assert conn._routing_tsap == 0x0100 | (0 << 5) | 3 # 0x0103
30+
31+
def test_routing_tsap_higher_rack(self) -> None:
32+
"""Routing TSAP with rack=2, slot=1."""
33+
conn = ISOTCPConnection("127.0.0.1")
34+
conn.set_routing(subnet_id=0x0002, dest_rack=2, dest_slot=1)
35+
assert conn._routing_tsap == 0x0100 | (2 << 5) | 1 # 0x0141
36+
37+
38+
@pytest.mark.routing
39+
class TestCOTPCRRouting:
40+
"""Test COTP Connection Request PDU generation with routing."""
41+
42+
def _parse_cotp_cr(self, pdu: bytes) -> dict[str, object]:
43+
"""Parse a COTP CR PDU into its components for inspection."""
44+
result: dict[str, object] = {}
45+
pdu_len = pdu[0]
46+
result["pdu_len"] = pdu_len
47+
result["pdu_type"] = pdu[1]
48+
result["dst_ref"] = struct.unpack(">H", pdu[2:4])[0]
49+
result["src_ref"] = struct.unpack(">H", pdu[4:6])[0]
50+
result["class_opt"] = pdu[6]
51+
52+
# Parse variable-part parameters
53+
params: dict[int, bytes] = {}
54+
offset = 7
55+
while offset < len(pdu):
56+
if offset + 2 > len(pdu):
57+
break
58+
code = pdu[offset]
59+
length = pdu[offset + 1]
60+
data = pdu[offset + 2 : offset + 2 + length]
61+
params[code] = data
62+
offset += 2 + length
63+
64+
result["params"] = params
65+
return result
66+
67+
def test_standard_cr_has_no_routing_params(self) -> None:
68+
"""A non-routed CR should not contain routing parameters."""
69+
conn = ISOTCPConnection("127.0.0.1")
70+
pdu = conn._build_cotp_cr()
71+
parsed = self._parse_cotp_cr(pdu)
72+
params = parsed["params"]
73+
assert isinstance(params, dict)
74+
assert ISOTCPConnection.COTP_PARAM_SUBNET_ID not in params
75+
assert ISOTCPConnection.COTP_PARAM_ROUTING_TSAP not in params
76+
77+
def test_routed_cr_contains_subnet_param(self) -> None:
78+
"""A routed CR must include the subnet ID parameter (0xC6)."""
79+
conn = ISOTCPConnection("127.0.0.1")
80+
conn.set_routing(subnet_id=0x0001, dest_rack=0, dest_slot=2)
81+
pdu = conn._build_cotp_cr()
82+
parsed = self._parse_cotp_cr(pdu)
83+
params = parsed["params"]
84+
assert isinstance(params, dict)
85+
assert ISOTCPConnection.COTP_PARAM_SUBNET_ID in params
86+
subnet_data = params[ISOTCPConnection.COTP_PARAM_SUBNET_ID]
87+
assert struct.unpack(">H", subnet_data)[0] == 0x0001
88+
89+
def test_routed_cr_contains_routing_tsap(self) -> None:
90+
"""A routed CR must include the routing TSAP parameter (0xC7)."""
91+
conn = ISOTCPConnection("127.0.0.1")
92+
conn.set_routing(subnet_id=0x0001, dest_rack=0, dest_slot=2)
93+
pdu = conn._build_cotp_cr()
94+
parsed = self._parse_cotp_cr(pdu)
95+
params = parsed["params"]
96+
assert isinstance(params, dict)
97+
assert ISOTCPConnection.COTP_PARAM_ROUTING_TSAP in params
98+
tsap_data = params[ISOTCPConnection.COTP_PARAM_ROUTING_TSAP]
99+
expected_tsap = 0x0100 | (0 << 5) | 2
100+
assert struct.unpack(">H", tsap_data)[0] == expected_tsap
101+
102+
def test_routed_cr_pdu_length_is_consistent(self) -> None:
103+
"""The PDU length byte must equal len(pdu) - 1."""
104+
conn = ISOTCPConnection("127.0.0.1")
105+
conn.set_routing(subnet_id=0x00FF, dest_rack=1, dest_slot=1)
106+
pdu = conn._build_cotp_cr()
107+
# The first byte is the length of the rest of the PDU
108+
assert pdu[0] == len(pdu) - 1
109+
110+
def test_standard_cr_pdu_length_is_consistent(self) -> None:
111+
"""Non-routed PDU length byte must also be consistent."""
112+
conn = ISOTCPConnection("127.0.0.1")
113+
pdu = conn._build_cotp_cr()
114+
assert pdu[0] == len(pdu) - 1
115+
116+
def test_routed_cr_still_has_standard_params(self) -> None:
117+
"""Routing should not remove the standard TSAP / PDU size params."""
118+
conn = ISOTCPConnection("127.0.0.1")
119+
conn.set_routing(subnet_id=0x0001, dest_rack=0, dest_slot=3)
120+
pdu = conn._build_cotp_cr()
121+
parsed = self._parse_cotp_cr(pdu)
122+
params = parsed["params"]
123+
assert isinstance(params, dict)
124+
assert ISOTCPConnection.COTP_PARAM_CALLING_TSAP in params
125+
assert ISOTCPConnection.COTP_PARAM_CALLED_TSAP in params
126+
assert ISOTCPConnection.COTP_PARAM_PDU_SIZE in params
127+
128+
129+
@pytest.mark.routing
130+
class TestRoutedFrameValidity:
131+
"""Test that routed connections produce valid protocol frames."""
132+
133+
def test_routed_cr_wrapped_in_tpkt(self) -> None:
134+
"""A routed CR wrapped in TPKT should have correct TPKT header."""
135+
conn = ISOTCPConnection("127.0.0.1")
136+
conn.set_routing(subnet_id=0x0005, dest_rack=0, dest_slot=1)
137+
cr_pdu = conn._build_cotp_cr()
138+
tpkt = conn._build_tpkt(cr_pdu)
139+
140+
# TPKT header: version=3, reserved=0, length=total
141+
assert tpkt[0] == 3
142+
assert tpkt[1] == 0
143+
total_len = struct.unpack(">H", tpkt[2:4])[0]
144+
assert total_len == len(tpkt)
145+
assert tpkt[4:] == cr_pdu
146+
147+
def test_subnet_id_truncated_to_16_bits(self) -> None:
148+
"""Subnet IDs larger than 16 bits should be masked."""
149+
conn = ISOTCPConnection("127.0.0.1")
150+
conn.set_routing(subnet_id=0x1FFFF, dest_rack=0, dest_slot=1)
151+
# 0x1FFFF & 0xFFFF == 0xFFFF
152+
assert conn._subnet_id == 0xFFFF
153+
154+
155+
@pytest.mark.routing
156+
@pytest.mark.server
157+
class TestClientConnectRouted:
158+
"""Test Client.connect_routed against the built-in server."""
159+
160+
def test_connect_routed_to_server(self) -> None:
161+
"""Client.connect_routed should negotiate PDU with a local server.
162+
163+
The server does not validate routing parameters in the COTP CR,
164+
so the connection handshake should succeed.
165+
"""
166+
from snap7.server import Server
167+
from snap7.type import SrvArea
168+
from ctypes import c_char
169+
170+
server = Server()
171+
size = 100
172+
db_data = bytearray(size)
173+
db_array = (c_char * size).from_buffer(db_data)
174+
server.register_area(SrvArea.DB, 1, db_array)
175+
server.start(tcp_port=ROUTING_TEST_PORT)
176+
177+
try:
178+
client = Client()
179+
client.connect_routed(
180+
host="127.0.0.1",
181+
router_rack=0,
182+
router_slot=2,
183+
subnet=0x0001,
184+
dest_rack=0,
185+
dest_slot=3,
186+
port=ROUTING_TEST_PORT,
187+
)
188+
assert client.get_connected()
189+
190+
# Verify we can do a basic read through the routed connection
191+
data = client.db_read(1, 0, 10)
192+
assert len(data) == 10
193+
194+
client.disconnect()
195+
finally:
196+
server.stop()
197+
198+
def test_connect_routed_returns_self(self) -> None:
199+
"""connect_routed should return self for method chaining."""
200+
from snap7.server import Server
201+
from snap7.type import SrvArea
202+
from ctypes import c_char
203+
204+
server = Server()
205+
size = 10
206+
db_data = bytearray(size)
207+
db_array = (c_char * size).from_buffer(db_data)
208+
server.register_area(SrvArea.DB, 1, db_array)
209+
server.start(tcp_port=ROUTING_TEST_PORT + 1)
210+
211+
try:
212+
client = Client()
213+
result = client.connect_routed(
214+
host="127.0.0.1",
215+
router_rack=0,
216+
router_slot=2,
217+
subnet=0x0002,
218+
dest_rack=0,
219+
dest_slot=1,
220+
port=ROUTING_TEST_PORT + 1,
221+
)
222+
assert result is client
223+
client.disconnect()
224+
finally:
225+
server.stop()

0 commit comments

Comments
 (0)