Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ CHANGES

Major release: new `s7commplus` package with S7CommPlus protocol support.

* Echo calling and called TSAP parameters in pure-Python server connection
confirmations, and restart the receive deadline after each TPKT header.
* Decode corroborating CPU execution attributes so S7CommPlus `get_cpu_state()`
distinguishes RUN from STOP on S7-1500 and returns UNKNOWN for absent or
inconsistent state attributes, including S7-1200 responses that omit them.
Expand Down
38 changes: 30 additions & 8 deletions snap7/server/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2676,8 +2676,10 @@ class ServerISOConnection:
COTP_DC = 0xC0 # Disconnect Confirm
COTP_DT = 0xF0 # Data Transfer

# COTP parameter code for TPDU size (ISO 8073)
# COTP parameter codes (ISO 8073)
COTP_PARAM_PDU_SIZE = 0xC0
COTP_PARAM_CALLING_TSAP = 0xC1
COTP_PARAM_CALLED_TSAP = 0xC2

def __init__(self, client_socket: socket.socket):
"""Initialize server ISO connection."""
Expand All @@ -2687,6 +2689,8 @@ def __init__(self, client_socket: socket.socket):
self.src_ref = 0x0001 # Server reference
self.dst_ref = 0x0000 # Client reference (assigned during handshake)
self.tpdu_size = 0x0A # Default: 1024 bytes (2^10)
self.calling_tsap: bytes | None = None
self.called_tsap: bytes | None = None

def accept_connection(self) -> bool:
"""Accept ISO connection from client."""
Expand Down Expand Up @@ -2735,9 +2739,9 @@ def receive_data(self) -> bytes:
"""
fragments: list[bytes] = []
total_size = 0
deadline = time.monotonic() + self.RECEIVE_DEADLINE
while True:
tpkt_header = self._recv_exact(4, deadline)
header_deadline = time.monotonic() + self.RECEIVE_DEADLINE
tpkt_header = self._recv_exact(4, header_deadline)
version, reserved, length = struct.unpack(">BBH", tpkt_header)

if version != 3:
Expand All @@ -2747,7 +2751,11 @@ def receive_data(self) -> bytes:
if remaining <= 0:
raise S7ConnectionError("Invalid TPKT length")

payload = self._recv_exact(remaining, deadline)
frame_deadline = time.monotonic() + self.RECEIVE_DEADLINE
try:
payload = self._recv_exact(remaining, frame_deadline)
except TimeoutError as e:
raise S7ConnectionError("Receive deadline exceeded after TPKT header") from e

if len(payload) < 3:
raise S7ConnectionError("Invalid COTP DT: too short")
Expand Down Expand Up @@ -2802,18 +2810,25 @@ def _parse_cotp_cr(self, data: bytes) -> bool:
# Store client reference
self.dst_ref = src_ref

# Parse variable parameters for TPDU size
# Parse variable parameters used in the connection confirmation.
self.calling_tsap = None
self.called_tsap = None
offset = 7
while offset + 2 <= len(data):
param_code = data[offset]
param_len = data[offset + 1]
if offset + 2 + param_len > len(data):
break
param_data = data[offset + 2 : offset + 2 + param_len]
if param_code == self.COTP_PARAM_PDU_SIZE and param_len == 1:
exponent = data[offset + 2]
if 7 <= exponent <= 13:
self.tpdu_size = exponent
logger.debug(f"Client requested TPDU size 2^{exponent} = {1 << exponent}")
elif param_code == self.COTP_PARAM_CALLING_TSAP:
self.calling_tsap = param_data
elif param_code == self.COTP_PARAM_CALLED_TSAP:
self.called_tsap = param_data
offset += 2 + param_len

logger.debug(f"Received COTP CR from client ref {src_ref}")
Expand All @@ -2826,8 +2841,15 @@ def _build_cotp_cc(self) -> bytes:
negotiated maximum segment size and don't fall back to the
ISO 8073 class-0 default of 128 bytes.
"""
pdu_size_param = struct.pack(">BBB", self.COTP_PARAM_PDU_SIZE, 1, self.tpdu_size)
pdu_length = 6 + len(pdu_size_param)
parameters = bytearray(struct.pack(">BBB", self.COTP_PARAM_PDU_SIZE, 1, self.tpdu_size))
if self.calling_tsap is not None:
parameters.extend(struct.pack(">BB", self.COTP_PARAM_CALLING_TSAP, len(self.calling_tsap)))
parameters.extend(self.calling_tsap)
if self.called_tsap is not None:
parameters.extend(struct.pack(">BB", self.COTP_PARAM_CALLED_TSAP, len(self.called_tsap)))
parameters.extend(self.called_tsap)

pdu_length = 6 + len(parameters)
base_pdu = struct.pack(
">BBHHB",
pdu_length, # PDU length
Expand All @@ -2837,7 +2859,7 @@ def _build_cotp_cc(self) -> bytes:
0x00, # Class/option
)

return base_pdu + pdu_size_param
return base_pdu + parameters

def _build_cotp_dc(self) -> bytes:
"""Build COTP Disconnect Confirm."""
Expand Down
32 changes: 31 additions & 1 deletion tests/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from ctypes import c_char
from datetime import datetime
from threading import Thread
from unittest.mock import MagicMock
from unittest.mock import MagicMock, patch

import pytest

Expand Down Expand Up @@ -397,6 +397,17 @@ def test_connection_confirm_has_valid_length_and_tpdu_size(self) -> None:
assert connection_confirm == bytes.fromhex("09d0000f000100c00109")
assert connection_confirm[0] == len(connection_confirm) - 1

def test_connection_confirm_echoes_request_tsaps(self) -> None:
client_socket = MagicMock()
connection = ServerISOConnection(client_socket)
connection_request = bytes.fromhex("11e00000000f00c1020100c2020102c0010a")

assert connection._parse_cotp_cr(connection_request)

connection_confirm = connection._build_cotp_cc()
assert connection_confirm == bytes.fromhex("11d0000f000100c0010ac1020100c2020102")
assert connection_confirm[0] == len(connection_confirm) - 1

def test_disconnect_confirm_has_valid_length(self) -> None:
client_socket = MagicMock()
connection = ServerISOConnection(client_socket)
Expand Down Expand Up @@ -446,6 +457,25 @@ def test_partial_frame_timeout_closes_connection(self) -> None:
with pytest.raises(S7ConnectionError, match="partial frame"):
connection._recv_exact(4, time.monotonic() + 1)

def test_payload_gets_fresh_deadline_after_header(self) -> None:
client_socket = MagicMock()
connection = ServerISOConnection(client_socket)
connection._recv_exact = MagicMock(side_effect=[b"\x03\x00\x00\x08", b"\x02\xf0\x80x"])

with patch("snap7.server.time.monotonic", side_effect=[100.0, 104.0]):
assert connection.receive_data() == b"x"

assert connection._recv_exact.call_args_list[0].args == (4, 105.0)
assert connection._recv_exact.call_args_list[1].args == (4, 109.0)

def test_timeout_after_header_closes_connection(self) -> None:
client_socket = MagicMock()
connection = ServerISOConnection(client_socket)
connection._recv_exact = MagicMock(side_effect=[b"\x03\x00\x00\x08", TimeoutError("timed out")])

with pytest.raises(S7ConnectionError, match="after TPKT header"):
connection.receive_data()

def test_reassembled_request_size_is_bounded(self) -> None:
client_socket = MagicMock()
connection = ServerISOConnection(client_socket)
Expand Down
Loading