Skip to content
Closed
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 .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ repos:
rev: v6.0.0
hooks:
- id: trailing-whitespace
exclude: '\.bin$'
- id: end-of-file-fixer
exclude: '\.bin$'
- id: check-ast
- id: check-shebang-scripts-are-executable
- id: check-json
Expand Down
127 changes: 125 additions & 2 deletions s7/_s7commplus_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ def connect(
tls_cert=tls_cert,
tls_key=tls_key,
tls_ca=tls_ca,
password=password or "",
)

if password is not None and self._connection.tls_active:
Expand All @@ -123,6 +124,9 @@ def db_read(self, db_number: int, start: int, size: int) -> bytes:
if self._connection is None:
raise RuntimeError("Not connected")

if self._connection.requires_substreamed:
return self._db_read_substreamed(db_number, start, size)

payload = _build_read_payload([(db_number, start, size)])
response = self._connection.send_request(FunctionCode.GET_MULTI_VARIABLES, payload)
results = _parse_read_response(response)
Expand All @@ -132,6 +136,14 @@ def db_read(self, db_number: int, start: int, size: int) -> bytes:
raise RuntimeError("Read failed: PLC returned error for item")
return results[0]

def _db_read_substreamed(self, db_number: int, start: int, size: int) -> bytes:
access_area = Ids.DB_ACCESS_AREA_BASE + (db_number & 0xFFFF)
payload = _build_substreamed_read_payload(
self._connection.session_id, access_area, Ids.DB_VALUE_ACTUAL, [start + 1, size]
)
response = self._connection.send_request(FunctionCode.GET_VAR_SUBSTREAMED, payload)
return _parse_substreamed_read_response(response)

def db_write(self, db_number: int, start: int, data: bytes) -> None:
"""Write raw bytes to a data block.

Expand All @@ -143,10 +155,21 @@ def db_write(self, db_number: int, start: int, data: bytes) -> None:
if self._connection is None:
raise RuntimeError("Not connected")

if self._connection.requires_substreamed:
self._db_write_substreamed(db_number, start, data)
return

payload = _build_write_payload([(db_number, start, data)])
response = self._connection.send_request(FunctionCode.SET_MULTI_VARIABLES, payload)
_parse_write_response(response)

def _db_write_substreamed(self, db_number: int, start: int, data: bytes) -> None:
access_area = Ids.DB_ACCESS_AREA_BASE + (db_number & 0xFFFF)
payload = _build_substreamed_write_payload(
self._connection.session_id, access_area, Ids.DB_VALUE_ACTUAL, [start + 1, len(data)], data
)
self._connection.send_request(FunctionCode.SET_VAR_SUBSTREAMED, payload)

def db_read_multi(self, items: list[tuple[int, int, int]]) -> list[bytes]:
"""Read multiple data block regions in a single request.

Expand All @@ -159,6 +182,9 @@ def db_read_multi(self, items: list[tuple[int, int, int]]) -> list[bytes]:
if self._connection is None:
raise RuntimeError("Not connected")

if self._connection.requires_substreamed:
return [self._db_read_substreamed(db, start, size) for db, start, size in items]

payload = _build_read_payload(items)
response = self._connection.send_request(FunctionCode.GET_MULTI_VARIABLES, payload)
parsed = _parse_read_response(response)
Expand All @@ -179,6 +205,13 @@ def read_area(self, area_rid: int, start: int, size: int) -> bytes:
if self._connection is None:
raise RuntimeError("Not connected")

if self._connection.requires_substreamed:
payload = _build_substreamed_read_payload(
self._connection.session_id, area_rid, Ids.CONTROLLER_AREA_VALUE_ACTUAL, [start + 1, size]
)
response = self._connection.send_request(FunctionCode.GET_VAR_SUBSTREAMED, payload)
return _parse_substreamed_read_response(response)

payload = _build_area_read_payload(area_rid, start, size)
response = self._connection.send_request(FunctionCode.GET_MULTI_VARIABLES, payload)
results = _parse_read_response(response)
Expand All @@ -197,6 +230,13 @@ def write_area(self, area_rid: int, start: int, data: bytes) -> None:
if self._connection is None:
raise RuntimeError("Not connected")

if self._connection.requires_substreamed:
payload = _build_substreamed_write_payload(
self._connection.session_id, area_rid, Ids.CONTROLLER_AREA_VALUE_ACTUAL, [start + 1, len(data)], data
)
self._connection.send_request(FunctionCode.SET_VAR_SUBSTREAMED, payload)
return

payload = _build_area_write_payload(area_rid, start, data)
response = self._connection.send_request(FunctionCode.SET_MULTI_VARIABLES, payload)
_parse_write_response(response)
Expand Down Expand Up @@ -284,7 +324,10 @@ def explore(self, explore_id: int = 0) -> bytes:
if self._connection is None:
raise RuntimeError("Not connected")

payload = _build_explore_payload(explore_id)
if self._connection._session_key is not None:
payload = _build_explore_payload_v3(explore_id if explore_id else 0x38)
else:
payload = _build_explore_payload(explore_id)
response = self._connection.send_request(FunctionCode.EXPLORE, payload, integrity_tail=5, reassemble=True)
return response

Expand Down Expand Up @@ -388,7 +431,14 @@ def list_datablocks(self) -> list[dict[str, Any]]:
if self._connection is None:
raise RuntimeError("Not connected")

payload = _build_explore_request(Ids.NATIVE_THE_PLC_PROGRAM_RID, [Ids.OBJECT_VARIABLE_TYPE_NAME, Ids.BLOCK_BLOCK_NUMBER])
if self._connection._session_key is not None:
# V1-initial PLCs: explore the DB wildcard address (0x8A11FFFF)
# matching TIA Portal's browse pattern
payload = _build_explore_payload_v3(0x8A11FFFF)
else:
payload = _build_explore_request(
Ids.NATIVE_THE_PLC_PROGRAM_RID, [Ids.OBJECT_VARIABLE_TYPE_NAME, Ids.BLOCK_BLOCK_NUMBER]
)
response = self._connection.send_request(FunctionCode.EXPLORE, payload, integrity_tail=5, reassemble=True)
return _parse_explore_datablocks(response)

Expand Down Expand Up @@ -686,6 +736,66 @@ def _parse_write_response(response: bytes) -> None:
raise RuntimeError(f"Write failed: {err_str}")


def _build_substreamed_read_payload(session_id: int, access_area: int, access_sub_area: int, lids: list[int]) -> bytes:
"""Build a GET_VAR_SUBSTREAMED payload for data access on V1-initial PLCs."""
oq = encode_object_qualifier()
payload = bytearray()
payload += struct.pack(">I", session_id)
payload += bytes([0x20, 0x04])
num_fields = 4 + len(lids)
payload += encode_uint32_vlq(num_fields)
payload += encode_uint32_vlq(0) # SymbolCRC
payload += encode_uint32_vlq(access_area)
payload += encode_uint32_vlq(len(lids) + 1)
payload += encode_uint32_vlq(access_sub_area)
for lid in lids:
payload += encode_uint32_vlq(lid)
payload += oq
payload += bytes([0x00])
payload += encode_uint32_vlq(1)
payload += encode_uint32_vlq(1)
payload += struct.pack(">I", 0)
return bytes(payload)


def _build_substreamed_write_payload(
session_id: int, access_area: int, access_sub_area: int, lids: list[int], data: bytes
) -> bytes:
"""Build a SET_VAR_SUBSTREAMED payload for data access on V1-initial PLCs."""
oq = encode_object_qualifier()
payload = bytearray()
payload += struct.pack(">I", session_id)
payload += bytes([0x20, 0x04])
num_fields = 4 + len(lids)
payload += encode_uint32_vlq(num_fields)
payload += encode_uint32_vlq(0) # SymbolCRC
payload += encode_uint32_vlq(access_area)
payload += encode_uint32_vlq(len(lids) + 1)
payload += encode_uint32_vlq(access_sub_area)
for lid in lids:
payload += encode_uint32_vlq(lid)
payload += oq
payload += bytes([0x00])
payload += encode_uint32_vlq(1)
payload += encode_pvalue_blob(data)
payload += encode_uint32_vlq(1)
payload += struct.pack(">I", 0)
return bytes(payload)


def _parse_substreamed_read_response(response: bytes) -> bytes:
"""Parse a GET_VAR_SUBSTREAMED response and extract the data bytes."""
offset = 0
return_value, consumed = decode_uint64_vlq(response, offset)
offset += consumed
if return_value != 0:
raise RuntimeError(f"Substreamed read failed with return value 0x{return_value:X}")
if offset >= len(response):
raise RuntimeError("Substreamed read response empty")
raw_bytes, consumed = decode_pvalue_to_bytes(response, offset)
return raw_bytes


def _build_area_read_payload(area_rid: int, start: int, size: int) -> bytes:
"""Build a GetMultiVariables payload for controller memory area access.

Expand Down Expand Up @@ -813,6 +923,19 @@ def _build_explore_payload(explore_id: int = 0) -> bytes:
return bytes(payload)


def _build_explore_payload_v3(explore_id: int, sequence: int = 10) -> bytes:
"""Build a V3-style EXPLORE request payload matching TIA Portal format.

V1-initial PLCs use a 4-byte big-endian InObjectId followed by
fixed parameters, rather than the VLQ-based format.
"""
payload = struct.pack(">I", explore_id)
payload += bytes([0x00, 0x01, 0x00, 0x01, 0x00, 0x00])
payload += bytes([sequence & 0xFF])
payload += bytes([0x00, 0x00, 0x00, 0x00, 0x00])
return payload


def _build_invoke_payload(state: int) -> bytes:
"""Build an INVOKE request payload for SetPlcOperatingState.

Expand Down
18 changes: 4 additions & 14 deletions s7/_s7commplus_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -651,16 +651,7 @@ def _build_response_header(self, function_code: int, seq_num: int) -> bytes:
def _handle_init_ssl(self, seq_num: int) -> bytes:
"""Handle InitSSL -- respond to SSL initialization (V1 emulation, no real TLS)."""
response = bytearray()
response += struct.pack(
">BHHHHIB",
Opcode.RESPONSE,
0x0000,
FunctionCode.INIT_SSL,
0x0000,
seq_num,
0x00000000,
0x00, # Transport flags
)
response += self._build_response_header(FunctionCode.INIT_SSL, seq_num)
response += encode_uint32_vlq(0) # Return code: success
response += struct.pack(">I", 0)
return bytes(response)
Expand All @@ -671,18 +662,17 @@ def _handle_create_object(self, seq_num: int, request_data: bytes) -> bytes:
session_id = self._next_session_id
self._next_session_id += 1

# Build CreateObject response — uses a 14-byte header (with SessionId)
# unlike other responses which use the 10-byte _build_response_header.
# Response header is 10 bytes (no SessionId); real PLCs return
# the session id in the body as ObjectIds[0].
response = bytearray()

response += struct.pack(
">BHHHHIB",
">BHHHHB",
Opcode.RESPONSE,
0x0000, # Reserved
FunctionCode.CREATE_OBJECT,
0x0000, # Reserved
seq_num,
session_id,
0x00, # Transport flags
)

Expand Down
Loading
Loading