python-snap7 3.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- python_snap7-3.0.0.dist-info/METADATA +94 -0
- python_snap7-3.0.0.dist-info/RECORD +22 -0
- python_snap7-3.0.0.dist-info/WHEEL +5 -0
- python_snap7-3.0.0.dist-info/entry_points.txt +2 -0
- python_snap7-3.0.0.dist-info/licenses/LICENSE +20 -0
- python_snap7-3.0.0.dist-info/top_level.txt +1 -0
- snap7/__init__.py +34 -0
- snap7/client.py +1923 -0
- snap7/connection.py +456 -0
- snap7/datatypes.py +312 -0
- snap7/error.py +444 -0
- snap7/logo.py +222 -0
- snap7/partner.py +680 -0
- snap7/py.typed +0 -0
- snap7/s7protocol.py +1509 -0
- snap7/server/__init__.py +2681 -0
- snap7/server/__main__.py +41 -0
- snap7/type.py +391 -0
- snap7/util/__init__.py +108 -0
- snap7/util/db.py +789 -0
- snap7/util/getters.py +766 -0
- snap7/util/setters.py +733 -0
snap7/server/__init__.py
ADDED
|
@@ -0,0 +1,2681 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pure Python S7 server implementation.
|
|
3
|
+
|
|
4
|
+
Provides a complete S7 server emulator without dependencies on the Snap7 C library.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import socket
|
|
8
|
+
import struct
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
import logging
|
|
12
|
+
from typing import Dict, Optional, List, Callable, Any, Tuple, Type, Union
|
|
13
|
+
from types import TracebackType
|
|
14
|
+
from enum import IntEnum
|
|
15
|
+
from ctypes import Array, c_char
|
|
16
|
+
|
|
17
|
+
from ..s7protocol import S7Protocol, S7Function, S7PDUType, S7UserDataGroup, S7UserDataSubfunction
|
|
18
|
+
from ..datatypes import S7Area, S7WordLen
|
|
19
|
+
from ..error import S7ConnectionError, S7ProtocolError
|
|
20
|
+
from ..type import SrvArea, SrvEvent, Parameter
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ServerState(IntEnum):
|
|
26
|
+
"""S7 server states."""
|
|
27
|
+
|
|
28
|
+
STOPPED = 0
|
|
29
|
+
RUNNING = 1
|
|
30
|
+
ERROR = 2
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class CPUState(IntEnum):
|
|
34
|
+
"""S7 CPU states."""
|
|
35
|
+
|
|
36
|
+
UNKNOWN = 0
|
|
37
|
+
RUN = 8
|
|
38
|
+
STOP = 4
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Server:
|
|
42
|
+
"""
|
|
43
|
+
Pure Python S7 server implementation.
|
|
44
|
+
|
|
45
|
+
Emulates a Siemens S7 PLC for testing and development purposes.
|
|
46
|
+
|
|
47
|
+
Examples:
|
|
48
|
+
>>> import snap7
|
|
49
|
+
>>> server = snap7.Server()
|
|
50
|
+
>>> server.start()
|
|
51
|
+
>>> # ... register areas and handle clients
|
|
52
|
+
>>> server.stop()
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(self, log: bool = True, **kwargs: object) -> None:
|
|
56
|
+
"""
|
|
57
|
+
Initialize S7 server.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
log: Enable event logging
|
|
61
|
+
**kwargs: Ignored. Kept for backwards compatibility.
|
|
62
|
+
"""
|
|
63
|
+
self.server_socket: Optional[socket.socket] = None
|
|
64
|
+
self.server_thread: Optional[threading.Thread] = None
|
|
65
|
+
self.running = False
|
|
66
|
+
self.port = 102
|
|
67
|
+
self.host = "0.0.0.0"
|
|
68
|
+
|
|
69
|
+
# Server state
|
|
70
|
+
self.state = ServerState.STOPPED
|
|
71
|
+
self.cpu_state = CPUState.STOP
|
|
72
|
+
self.client_count = 0
|
|
73
|
+
|
|
74
|
+
# Memory areas
|
|
75
|
+
self.memory_areas: Dict[Tuple[S7Area, int], bytearray] = {}
|
|
76
|
+
self.area_locks: Dict[Tuple[S7Area, int], threading.Lock] = {}
|
|
77
|
+
|
|
78
|
+
# Protocol handler
|
|
79
|
+
self.protocol = S7Protocol()
|
|
80
|
+
|
|
81
|
+
# Event callbacks
|
|
82
|
+
self.event_callback: Optional[Callable[[SrvEvent], None]] = None
|
|
83
|
+
self.read_callback: Optional[Callable[[SrvEvent], None]] = None
|
|
84
|
+
|
|
85
|
+
# Client connections
|
|
86
|
+
self.clients: List[threading.Thread] = []
|
|
87
|
+
self.client_lock = threading.Lock()
|
|
88
|
+
|
|
89
|
+
# Event queue for pick_event
|
|
90
|
+
self._event_queue: List[SrvEvent] = []
|
|
91
|
+
|
|
92
|
+
# Logging
|
|
93
|
+
self._log_enabled = log
|
|
94
|
+
if log:
|
|
95
|
+
self._set_log_callback()
|
|
96
|
+
|
|
97
|
+
logger.info("S7Server initialized (pure Python implementation)")
|
|
98
|
+
|
|
99
|
+
def create(self) -> None:
|
|
100
|
+
"""Create the server (no-op for compatibility)."""
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
def destroy(self) -> None:
|
|
104
|
+
"""Destroy the server."""
|
|
105
|
+
self.stop()
|
|
106
|
+
|
|
107
|
+
def start(self, tcp_port: int = 102) -> int:
|
|
108
|
+
"""
|
|
109
|
+
Start the S7 server.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
tcp_port: TCP port to listen on
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
0 on success
|
|
116
|
+
"""
|
|
117
|
+
if self.running:
|
|
118
|
+
raise S7ConnectionError("Server is already running")
|
|
119
|
+
|
|
120
|
+
self.port = tcp_port
|
|
121
|
+
self.server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
122
|
+
self.server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
123
|
+
# Try to use SO_REUSEPORT if available (Linux, macOS) for faster port reuse
|
|
124
|
+
if hasattr(socket, "SO_REUSEPORT"):
|
|
125
|
+
self.server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
self.server_socket.bind((self.host, self.port))
|
|
129
|
+
self.server_socket.listen(5)
|
|
130
|
+
self.running = True
|
|
131
|
+
self.state = ServerState.RUNNING
|
|
132
|
+
self.cpu_state = CPUState.RUN
|
|
133
|
+
|
|
134
|
+
# Start server thread
|
|
135
|
+
self.server_thread = threading.Thread(target=self._server_loop, daemon=True)
|
|
136
|
+
self.server_thread.start()
|
|
137
|
+
|
|
138
|
+
# Add startup event to queue
|
|
139
|
+
startup_event = SrvEvent()
|
|
140
|
+
startup_event.EvtCode = 0x00010000 # Server started
|
|
141
|
+
self._event_queue.append(startup_event)
|
|
142
|
+
|
|
143
|
+
logger.info(f"S7 Server started on {self.host}:{self.port}")
|
|
144
|
+
return 0
|
|
145
|
+
|
|
146
|
+
except Exception as e:
|
|
147
|
+
self.running = False
|
|
148
|
+
self.state = ServerState.ERROR
|
|
149
|
+
if self.server_socket:
|
|
150
|
+
self.server_socket.close()
|
|
151
|
+
self.server_socket = None
|
|
152
|
+
raise S7ConnectionError(f"Failed to start server: {e}")
|
|
153
|
+
|
|
154
|
+
def stop(self) -> int:
|
|
155
|
+
"""
|
|
156
|
+
Stop the S7 server.
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
0 on success
|
|
160
|
+
"""
|
|
161
|
+
if not self.running:
|
|
162
|
+
return 0
|
|
163
|
+
|
|
164
|
+
self.running = False
|
|
165
|
+
self.state = ServerState.STOPPED
|
|
166
|
+
self.cpu_state = CPUState.STOP
|
|
167
|
+
|
|
168
|
+
# Close server socket
|
|
169
|
+
if self.server_socket:
|
|
170
|
+
self.server_socket.close()
|
|
171
|
+
self.server_socket = None
|
|
172
|
+
|
|
173
|
+
# Wait for server thread to finish
|
|
174
|
+
if self.server_thread and self.server_thread.is_alive():
|
|
175
|
+
self.server_thread.join(timeout=5.0)
|
|
176
|
+
|
|
177
|
+
# Close all client connections
|
|
178
|
+
with self.client_lock:
|
|
179
|
+
for client_thread in self.clients[:]:
|
|
180
|
+
if client_thread.is_alive():
|
|
181
|
+
client_thread.join(timeout=1.0)
|
|
182
|
+
self.clients.clear()
|
|
183
|
+
self.client_count = 0
|
|
184
|
+
|
|
185
|
+
logger.info("S7 Server stopped")
|
|
186
|
+
return 0
|
|
187
|
+
|
|
188
|
+
def register_area(self, area: SrvArea, index: int, userdata: Union[bytearray, "Array[c_char]"]) -> int:
|
|
189
|
+
"""
|
|
190
|
+
Register a memory area with the server.
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
area: Memory area type
|
|
194
|
+
index: Area index/number
|
|
195
|
+
userdata: Initial data for the area (bytearray or ctypes array)
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
0 on success
|
|
199
|
+
"""
|
|
200
|
+
# Map SrvArea to S7Area
|
|
201
|
+
area_mapping = {
|
|
202
|
+
SrvArea.PE: S7Area.PE,
|
|
203
|
+
SrvArea.PA: S7Area.PA,
|
|
204
|
+
SrvArea.MK: S7Area.MK,
|
|
205
|
+
SrvArea.DB: S7Area.DB,
|
|
206
|
+
SrvArea.CT: S7Area.CT,
|
|
207
|
+
SrvArea.TM: S7Area.TM,
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
s7_area = area_mapping.get(area)
|
|
211
|
+
if s7_area is None:
|
|
212
|
+
raise ValueError(f"Unsupported area: {area}")
|
|
213
|
+
|
|
214
|
+
# Convert ctypes array to bytearray if needed
|
|
215
|
+
if isinstance(userdata, bytearray):
|
|
216
|
+
data = userdata
|
|
217
|
+
else:
|
|
218
|
+
data = bytearray(userdata)
|
|
219
|
+
|
|
220
|
+
area_key = (s7_area, index)
|
|
221
|
+
self.memory_areas[area_key] = data
|
|
222
|
+
self.area_locks[area_key] = threading.Lock()
|
|
223
|
+
|
|
224
|
+
logger.info(f"Registered area {area.name} index {index}, size {len(data)}")
|
|
225
|
+
return 0
|
|
226
|
+
|
|
227
|
+
def unregister_area(self, area: SrvArea, index: int) -> int:
|
|
228
|
+
"""
|
|
229
|
+
Unregister a memory area.
|
|
230
|
+
|
|
231
|
+
Args:
|
|
232
|
+
area: Memory area type
|
|
233
|
+
index: Area index
|
|
234
|
+
|
|
235
|
+
Returns:
|
|
236
|
+
0 on success
|
|
237
|
+
"""
|
|
238
|
+
area_mapping = {
|
|
239
|
+
SrvArea.PE: S7Area.PE,
|
|
240
|
+
SrvArea.PA: S7Area.PA,
|
|
241
|
+
SrvArea.MK: S7Area.MK,
|
|
242
|
+
SrvArea.DB: S7Area.DB,
|
|
243
|
+
SrvArea.CT: S7Area.CT,
|
|
244
|
+
SrvArea.TM: S7Area.TM,
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
s7_area = area_mapping.get(area)
|
|
248
|
+
if s7_area is None:
|
|
249
|
+
return 0
|
|
250
|
+
|
|
251
|
+
area_key = (s7_area, index)
|
|
252
|
+
if area_key in self.memory_areas:
|
|
253
|
+
del self.memory_areas[area_key]
|
|
254
|
+
del self.area_locks[area_key]
|
|
255
|
+
logger.info(f"Unregistered area {area.name} index {index}")
|
|
256
|
+
|
|
257
|
+
return 0
|
|
258
|
+
|
|
259
|
+
def lock_area(self, area: SrvArea, index: int) -> int:
|
|
260
|
+
"""
|
|
261
|
+
Lock a memory area.
|
|
262
|
+
|
|
263
|
+
Args:
|
|
264
|
+
area: Memory area type
|
|
265
|
+
index: Area index
|
|
266
|
+
|
|
267
|
+
Returns:
|
|
268
|
+
0 on success
|
|
269
|
+
|
|
270
|
+
Raises:
|
|
271
|
+
RuntimeError: If area is not registered
|
|
272
|
+
"""
|
|
273
|
+
area_mapping = {
|
|
274
|
+
SrvArea.PE: S7Area.PE,
|
|
275
|
+
SrvArea.PA: S7Area.PA,
|
|
276
|
+
SrvArea.MK: S7Area.MK,
|
|
277
|
+
SrvArea.DB: S7Area.DB,
|
|
278
|
+
SrvArea.CT: S7Area.CT,
|
|
279
|
+
SrvArea.TM: S7Area.TM,
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
s7_area = area_mapping.get(area)
|
|
283
|
+
if s7_area is None:
|
|
284
|
+
raise RuntimeError(f"Invalid area: {area}")
|
|
285
|
+
|
|
286
|
+
area_key = (s7_area, index)
|
|
287
|
+
if area_key not in self.area_locks:
|
|
288
|
+
raise RuntimeError(f"Area {area.name} index {index} not registered")
|
|
289
|
+
|
|
290
|
+
self.area_locks[area_key].acquire()
|
|
291
|
+
return 0
|
|
292
|
+
|
|
293
|
+
def unlock_area(self, area: SrvArea, index: int) -> int:
|
|
294
|
+
"""
|
|
295
|
+
Unlock a memory area.
|
|
296
|
+
|
|
297
|
+
Args:
|
|
298
|
+
area: Memory area type
|
|
299
|
+
index: Area index
|
|
300
|
+
|
|
301
|
+
Returns:
|
|
302
|
+
0 on success
|
|
303
|
+
"""
|
|
304
|
+
area_mapping = {
|
|
305
|
+
SrvArea.PE: S7Area.PE,
|
|
306
|
+
SrvArea.PA: S7Area.PA,
|
|
307
|
+
SrvArea.MK: S7Area.MK,
|
|
308
|
+
SrvArea.DB: S7Area.DB,
|
|
309
|
+
SrvArea.CT: S7Area.CT,
|
|
310
|
+
SrvArea.TM: S7Area.TM,
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
s7_area = area_mapping.get(area)
|
|
314
|
+
if s7_area is None:
|
|
315
|
+
return 1
|
|
316
|
+
|
|
317
|
+
area_key = (s7_area, index)
|
|
318
|
+
if area_key in self.area_locks:
|
|
319
|
+
try:
|
|
320
|
+
self.area_locks[area_key].release()
|
|
321
|
+
except RuntimeError:
|
|
322
|
+
pass # Lock not held
|
|
323
|
+
|
|
324
|
+
return 0
|
|
325
|
+
|
|
326
|
+
def get_status(self) -> Tuple[str, str, int]:
|
|
327
|
+
"""
|
|
328
|
+
Get server status.
|
|
329
|
+
|
|
330
|
+
Returns:
|
|
331
|
+
Tuple of (server_status, cpu_status, client_count)
|
|
332
|
+
"""
|
|
333
|
+
server_status_names = {ServerState.STOPPED: "Stopped", ServerState.RUNNING: "Running", ServerState.ERROR: "Error"}
|
|
334
|
+
|
|
335
|
+
cpu_status_names = {CPUState.UNKNOWN: "Unknown", CPUState.RUN: "Run", CPUState.STOP: "Stop"}
|
|
336
|
+
|
|
337
|
+
return (
|
|
338
|
+
server_status_names.get(self.state, "Unknown"),
|
|
339
|
+
cpu_status_names.get(self.cpu_state, "Unknown"),
|
|
340
|
+
self.client_count,
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
def set_events_callback(self, callback: Callable[[SrvEvent], Any]) -> int:
|
|
344
|
+
"""
|
|
345
|
+
Set callback for server events.
|
|
346
|
+
|
|
347
|
+
Args:
|
|
348
|
+
callback: Event callback function
|
|
349
|
+
|
|
350
|
+
Returns:
|
|
351
|
+
0 on success
|
|
352
|
+
"""
|
|
353
|
+
self.event_callback = callback
|
|
354
|
+
logger.info("Event callback set")
|
|
355
|
+
return 0
|
|
356
|
+
|
|
357
|
+
def set_read_events_callback(self, callback: Callable[[SrvEvent], Any]) -> int:
|
|
358
|
+
"""
|
|
359
|
+
Set callback for read events.
|
|
360
|
+
|
|
361
|
+
Args:
|
|
362
|
+
callback: Read event callback function
|
|
363
|
+
|
|
364
|
+
Returns:
|
|
365
|
+
0 on success
|
|
366
|
+
"""
|
|
367
|
+
self.read_callback = callback
|
|
368
|
+
logger.info("Read event callback set")
|
|
369
|
+
return 0
|
|
370
|
+
|
|
371
|
+
def set_rw_area_callback(self, callback: Callable[[Any], int]) -> int:
|
|
372
|
+
"""
|
|
373
|
+
Set callback for read/write area operations.
|
|
374
|
+
|
|
375
|
+
This is a stub for API compatibility with the C library's Srv_SetRWAreaCallback.
|
|
376
|
+
In the native implementation, read/write operations are handled directly.
|
|
377
|
+
|
|
378
|
+
Args:
|
|
379
|
+
callback: RW area callback function
|
|
380
|
+
|
|
381
|
+
Returns:
|
|
382
|
+
0 on success
|
|
383
|
+
"""
|
|
384
|
+
logger.debug("set_rw_area_callback called (stub for API compatibility)")
|
|
385
|
+
return 0
|
|
386
|
+
|
|
387
|
+
def event_text(self, event: SrvEvent) -> str:
|
|
388
|
+
"""
|
|
389
|
+
Get event text description.
|
|
390
|
+
|
|
391
|
+
Args:
|
|
392
|
+
event: Server event
|
|
393
|
+
|
|
394
|
+
Returns:
|
|
395
|
+
Event description string
|
|
396
|
+
"""
|
|
397
|
+
event_texts = {
|
|
398
|
+
0x00004000: "Read operation completed",
|
|
399
|
+
0x00004001: "Write operation completed",
|
|
400
|
+
0x00008000: "Client connected",
|
|
401
|
+
0x00008001: "Client disconnected",
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
return event_texts.get(event.EvtCode, f"Event code: {event.EvtCode:#08x}")
|
|
405
|
+
|
|
406
|
+
def get_mask(self, mask_kind: int) -> int:
|
|
407
|
+
"""
|
|
408
|
+
Get event mask.
|
|
409
|
+
|
|
410
|
+
Args:
|
|
411
|
+
mask_kind: Mask type (0=Event, 1=Log)
|
|
412
|
+
|
|
413
|
+
Returns:
|
|
414
|
+
Event mask value
|
|
415
|
+
"""
|
|
416
|
+
if mask_kind == 0: # mkEvent
|
|
417
|
+
return 0xFFFFFFFF
|
|
418
|
+
elif mask_kind == 1: # mkLog
|
|
419
|
+
return 0xFFFFFFFF
|
|
420
|
+
else:
|
|
421
|
+
raise ValueError(f"Invalid mask kind: {mask_kind}")
|
|
422
|
+
|
|
423
|
+
def set_mask(self, kind: int = 0, mask: int = 0) -> int:
|
|
424
|
+
"""
|
|
425
|
+
Set event mask.
|
|
426
|
+
|
|
427
|
+
Args:
|
|
428
|
+
kind: Mask type (0=Event, 1=Log)
|
|
429
|
+
mask: Mask value
|
|
430
|
+
|
|
431
|
+
Returns:
|
|
432
|
+
0 on success
|
|
433
|
+
"""
|
|
434
|
+
logger.debug(f"Set mask {kind} = {mask:#08x}")
|
|
435
|
+
return 0
|
|
436
|
+
|
|
437
|
+
def set_param(self, param: Parameter, value: int) -> int:
|
|
438
|
+
"""
|
|
439
|
+
Set server parameter.
|
|
440
|
+
|
|
441
|
+
Args:
|
|
442
|
+
param: Parameter type
|
|
443
|
+
value: Parameter value
|
|
444
|
+
|
|
445
|
+
Returns:
|
|
446
|
+
0 on success
|
|
447
|
+
"""
|
|
448
|
+
if param == Parameter.LocalPort:
|
|
449
|
+
self.port = value
|
|
450
|
+
logger.debug(f"Set parameter {param} = {value}")
|
|
451
|
+
return 0
|
|
452
|
+
|
|
453
|
+
def get_param(self, param: Parameter) -> int:
|
|
454
|
+
"""
|
|
455
|
+
Get server parameter.
|
|
456
|
+
|
|
457
|
+
Args:
|
|
458
|
+
param: Parameter type
|
|
459
|
+
|
|
460
|
+
Returns:
|
|
461
|
+
Parameter value
|
|
462
|
+
|
|
463
|
+
Raises:
|
|
464
|
+
RuntimeError: If parameter is not valid for server
|
|
465
|
+
"""
|
|
466
|
+
# Client-only parameters should raise exception
|
|
467
|
+
client_only = [
|
|
468
|
+
Parameter.RemotePort,
|
|
469
|
+
Parameter.PingTimeout,
|
|
470
|
+
Parameter.SendTimeout,
|
|
471
|
+
Parameter.RecvTimeout,
|
|
472
|
+
Parameter.SrcRef,
|
|
473
|
+
Parameter.DstRef,
|
|
474
|
+
Parameter.SrcTSap,
|
|
475
|
+
Parameter.PDURequest,
|
|
476
|
+
]
|
|
477
|
+
if param in client_only:
|
|
478
|
+
raise RuntimeError(f"Parameter {param} not valid for server")
|
|
479
|
+
|
|
480
|
+
param_values = {
|
|
481
|
+
Parameter.LocalPort: self.port,
|
|
482
|
+
Parameter.WorkInterval: 100,
|
|
483
|
+
Parameter.MaxClients: 1024,
|
|
484
|
+
}
|
|
485
|
+
return param_values.get(param, 0)
|
|
486
|
+
|
|
487
|
+
def start_to(self, ip: str, tcp_port: int = 102) -> int:
|
|
488
|
+
"""
|
|
489
|
+
Start server on a specific interface.
|
|
490
|
+
|
|
491
|
+
Args:
|
|
492
|
+
ip: IP address to bind to
|
|
493
|
+
tcp_port: TCP port to listen on
|
|
494
|
+
|
|
495
|
+
Returns:
|
|
496
|
+
0 on success
|
|
497
|
+
"""
|
|
498
|
+
# Validate IP address
|
|
499
|
+
try:
|
|
500
|
+
socket.inet_aton(ip)
|
|
501
|
+
except socket.error:
|
|
502
|
+
raise ValueError(f"Invalid IP address: {ip}")
|
|
503
|
+
|
|
504
|
+
# If already running, stop first
|
|
505
|
+
if self.running:
|
|
506
|
+
self.stop()
|
|
507
|
+
|
|
508
|
+
self.host = ip
|
|
509
|
+
return self.start(tcp_port if tcp_port != 102 else self.port)
|
|
510
|
+
|
|
511
|
+
def set_cpu_status(self, status: int) -> int:
|
|
512
|
+
"""
|
|
513
|
+
Set CPU status.
|
|
514
|
+
|
|
515
|
+
Args:
|
|
516
|
+
status: CPU status code (0=Unknown, 4=Stop, 8=Run)
|
|
517
|
+
|
|
518
|
+
Returns:
|
|
519
|
+
0 on success
|
|
520
|
+
|
|
521
|
+
Raises:
|
|
522
|
+
ValueError: If status is invalid
|
|
523
|
+
"""
|
|
524
|
+
if status not in [0, 4, 8]:
|
|
525
|
+
raise ValueError(f"Invalid CPU status: {status}")
|
|
526
|
+
|
|
527
|
+
if status == 8: # RUN
|
|
528
|
+
self.cpu_state = CPUState.RUN
|
|
529
|
+
elif status == 4: # STOP
|
|
530
|
+
self.cpu_state = CPUState.STOP
|
|
531
|
+
else:
|
|
532
|
+
self.cpu_state = CPUState.UNKNOWN
|
|
533
|
+
return 0
|
|
534
|
+
|
|
535
|
+
def pick_event(self) -> Union[SrvEvent, bool]:
|
|
536
|
+
"""
|
|
537
|
+
Pick an event from the queue.
|
|
538
|
+
|
|
539
|
+
Returns:
|
|
540
|
+
Server event if available, False if no events
|
|
541
|
+
"""
|
|
542
|
+
if self._event_queue:
|
|
543
|
+
return self._event_queue.pop(0)
|
|
544
|
+
return False
|
|
545
|
+
|
|
546
|
+
def clear_events(self) -> int:
|
|
547
|
+
"""
|
|
548
|
+
Clear event queue.
|
|
549
|
+
|
|
550
|
+
Returns:
|
|
551
|
+
0 on success
|
|
552
|
+
"""
|
|
553
|
+
self._event_queue.clear()
|
|
554
|
+
return 0
|
|
555
|
+
|
|
556
|
+
def _set_log_callback(self) -> None:
|
|
557
|
+
"""Set up default logging callback."""
|
|
558
|
+
|
|
559
|
+
def log_callback(event: SrvEvent) -> None:
|
|
560
|
+
event_text = self.event_text(event)
|
|
561
|
+
logger.info(f"Server event: {event_text}")
|
|
562
|
+
|
|
563
|
+
self.set_events_callback(log_callback)
|
|
564
|
+
|
|
565
|
+
def _server_loop(self) -> None:
|
|
566
|
+
"""Main server loop to accept client connections."""
|
|
567
|
+
try:
|
|
568
|
+
while self.running and self.server_socket:
|
|
569
|
+
try:
|
|
570
|
+
self.server_socket.settimeout(0.1) # Short timeout for responsive shutdown
|
|
571
|
+
client_socket, address = self.server_socket.accept()
|
|
572
|
+
|
|
573
|
+
logger.info(f"Client connected from {address}")
|
|
574
|
+
|
|
575
|
+
# Start client handler thread
|
|
576
|
+
client_thread = threading.Thread(target=self._handle_client, args=(client_socket, address), daemon=True)
|
|
577
|
+
|
|
578
|
+
with self.client_lock:
|
|
579
|
+
self.clients.append(client_thread)
|
|
580
|
+
self.client_count += 1
|
|
581
|
+
|
|
582
|
+
client_thread.start()
|
|
583
|
+
|
|
584
|
+
except socket.timeout:
|
|
585
|
+
continue # Check running flag again
|
|
586
|
+
except OSError:
|
|
587
|
+
if self.running: # Only log if we're supposed to be running
|
|
588
|
+
logger.warning("Server socket error in accept loop")
|
|
589
|
+
break
|
|
590
|
+
|
|
591
|
+
except Exception as e:
|
|
592
|
+
logger.error(f"Server loop error: {e}")
|
|
593
|
+
finally:
|
|
594
|
+
self.running = False
|
|
595
|
+
self.state = ServerState.STOPPED
|
|
596
|
+
|
|
597
|
+
def _handle_client(self, client_socket: socket.socket, address: Tuple[str, int]) -> None:
|
|
598
|
+
"""Handle a single client connection."""
|
|
599
|
+
try:
|
|
600
|
+
# Create ISO connection wrapper and establish connection
|
|
601
|
+
connection = ServerISOConnection(client_socket)
|
|
602
|
+
|
|
603
|
+
# Handle ISO connection setup
|
|
604
|
+
if not connection.accept_connection():
|
|
605
|
+
logger.warning(f"Failed to establish ISO connection with {address}")
|
|
606
|
+
return
|
|
607
|
+
|
|
608
|
+
logger.info(f"ISO connection established with {address}")
|
|
609
|
+
|
|
610
|
+
while self.running:
|
|
611
|
+
try:
|
|
612
|
+
# Receive S7 request
|
|
613
|
+
request_data = connection.receive_data()
|
|
614
|
+
|
|
615
|
+
# Process request and generate response
|
|
616
|
+
response_data = self._process_request(request_data, address)
|
|
617
|
+
|
|
618
|
+
# Send response
|
|
619
|
+
if response_data:
|
|
620
|
+
connection.send_data(response_data)
|
|
621
|
+
|
|
622
|
+
except socket.timeout:
|
|
623
|
+
continue
|
|
624
|
+
except (ConnectionResetError, ConnectionAbortedError):
|
|
625
|
+
logger.info(f"Client {address} disconnected")
|
|
626
|
+
break
|
|
627
|
+
except Exception as e:
|
|
628
|
+
logger.error(f"Error handling client {address}: {e}")
|
|
629
|
+
break
|
|
630
|
+
|
|
631
|
+
except Exception as e:
|
|
632
|
+
logger.error(f"Client handler error for {address}: {e}")
|
|
633
|
+
finally:
|
|
634
|
+
try:
|
|
635
|
+
client_socket.close()
|
|
636
|
+
except OSError:
|
|
637
|
+
pass
|
|
638
|
+
|
|
639
|
+
with self.client_lock:
|
|
640
|
+
current_thread = threading.current_thread()
|
|
641
|
+
if current_thread in self.clients:
|
|
642
|
+
self.clients.remove(current_thread)
|
|
643
|
+
self.client_count = max(0, self.client_count - 1)
|
|
644
|
+
|
|
645
|
+
logger.info(f"Client {address} handler finished")
|
|
646
|
+
|
|
647
|
+
def _process_request(self, request_data: bytes, client_address: Tuple[str, int]) -> Optional[bytes]:
|
|
648
|
+
"""
|
|
649
|
+
Process an S7 request and generate response.
|
|
650
|
+
|
|
651
|
+
Args:
|
|
652
|
+
request_data: Raw S7 PDU data
|
|
653
|
+
client_address: Client address for logging
|
|
654
|
+
|
|
655
|
+
Returns:
|
|
656
|
+
Response PDU data or None
|
|
657
|
+
"""
|
|
658
|
+
try:
|
|
659
|
+
# Parse S7 request
|
|
660
|
+
request = self._parse_request(request_data)
|
|
661
|
+
|
|
662
|
+
# Check PDU type first
|
|
663
|
+
pdu_type = request.get("pdu_type", S7PDUType.REQUEST)
|
|
664
|
+
|
|
665
|
+
if pdu_type == S7PDUType.USERDATA:
|
|
666
|
+
# Handle USER_DATA PDU (block info, SZL, clock, etc.)
|
|
667
|
+
return self._handle_userdata(request, client_address)
|
|
668
|
+
|
|
669
|
+
# Handle REQUEST PDU (read/write areas, setup, control)
|
|
670
|
+
# Extract function code from parameters
|
|
671
|
+
if not request.get("parameters"):
|
|
672
|
+
return None
|
|
673
|
+
|
|
674
|
+
params = request["parameters"]
|
|
675
|
+
function_code = params.get("function_code")
|
|
676
|
+
|
|
677
|
+
if function_code == S7Function.SETUP_COMMUNICATION:
|
|
678
|
+
return self._handle_setup_communication(request)
|
|
679
|
+
elif function_code == S7Function.READ_AREA:
|
|
680
|
+
return self._handle_read_area(request, client_address)
|
|
681
|
+
elif function_code == S7Function.WRITE_AREA:
|
|
682
|
+
return self._handle_write_area(request, client_address)
|
|
683
|
+
elif function_code == S7Function.PLC_CONTROL:
|
|
684
|
+
return self._handle_plc_control(request, client_address)
|
|
685
|
+
elif function_code == S7Function.PLC_STOP:
|
|
686
|
+
return self._handle_plc_stop(request, client_address)
|
|
687
|
+
elif function_code == S7Function.START_UPLOAD:
|
|
688
|
+
return self._handle_start_upload(request, client_address)
|
|
689
|
+
elif function_code == S7Function.UPLOAD:
|
|
690
|
+
return self._handle_upload(request, client_address)
|
|
691
|
+
elif function_code == S7Function.END_UPLOAD:
|
|
692
|
+
return self._handle_end_upload(request, client_address)
|
|
693
|
+
elif function_code == S7Function.REQUEST_DOWNLOAD:
|
|
694
|
+
return self._handle_request_download(request, client_address)
|
|
695
|
+
elif function_code == S7Function.DOWNLOAD_BLOCK:
|
|
696
|
+
return self._handle_download_block(request, client_address)
|
|
697
|
+
elif function_code == S7Function.DOWNLOAD_ENDED:
|
|
698
|
+
return self._handle_download_ended(request, client_address)
|
|
699
|
+
else:
|
|
700
|
+
logger.warning(f"Unsupported function code: {function_code}")
|
|
701
|
+
return self._build_error_response(request, 0x8001) # Function not supported
|
|
702
|
+
|
|
703
|
+
except Exception as e:
|
|
704
|
+
logger.error(f"Error processing request: {e}")
|
|
705
|
+
return None
|
|
706
|
+
|
|
707
|
+
def _handle_setup_communication(self, request: Dict[str, Any]) -> bytes:
|
|
708
|
+
"""Handle setup communication request."""
|
|
709
|
+
params = request["parameters"]
|
|
710
|
+
pdu_length = params.get("pdu_length", 480)
|
|
711
|
+
|
|
712
|
+
# Build response with error bytes
|
|
713
|
+
header = struct.pack(
|
|
714
|
+
">BBHHHHBB",
|
|
715
|
+
0x32, # Protocol ID
|
|
716
|
+
S7PDUType.ACK_DATA, # PDU type
|
|
717
|
+
0x0000, # Reserved
|
|
718
|
+
request["sequence"], # Sequence (echo)
|
|
719
|
+
0x0008, # Parameter length
|
|
720
|
+
0x0000, # Data length
|
|
721
|
+
0x00, # Error class (success)
|
|
722
|
+
0x00, # Error code (success)
|
|
723
|
+
)
|
|
724
|
+
|
|
725
|
+
parameters = struct.pack(
|
|
726
|
+
">BBHHH",
|
|
727
|
+
S7Function.SETUP_COMMUNICATION, # Function code
|
|
728
|
+
0x00, # Reserved
|
|
729
|
+
1, # Max AMQ caller
|
|
730
|
+
1, # Max AMQ callee
|
|
731
|
+
min(pdu_length, 480), # PDU length (limited)
|
|
732
|
+
)
|
|
733
|
+
|
|
734
|
+
return header + parameters
|
|
735
|
+
|
|
736
|
+
def _handle_read_area(self, request: Dict[str, Any], client_address: Tuple[str, int]) -> bytes:
|
|
737
|
+
"""Handle read area request."""
|
|
738
|
+
try:
|
|
739
|
+
# Parse address specification from request parameters
|
|
740
|
+
addr_info = self._parse_read_address(request)
|
|
741
|
+
if not addr_info:
|
|
742
|
+
return self._build_error_response(request, 0x8001) # Invalid address
|
|
743
|
+
|
|
744
|
+
area, db_number, start, count = addr_info
|
|
745
|
+
|
|
746
|
+
# Read data from registered memory area
|
|
747
|
+
read_data = self._read_from_memory_area(area, db_number, start, count)
|
|
748
|
+
if read_data is None:
|
|
749
|
+
return self._build_error_response(request, 0x8404) # Area not found
|
|
750
|
+
|
|
751
|
+
# Calculate data length - need to include transport header + data
|
|
752
|
+
data_len = 4 + len(read_data) # Transport header (4 bytes) + data
|
|
753
|
+
|
|
754
|
+
# Build successful response
|
|
755
|
+
# S7 response header includes error class + error code
|
|
756
|
+
header = struct.pack(
|
|
757
|
+
">BBHHHHBB",
|
|
758
|
+
0x32, # Protocol ID
|
|
759
|
+
S7PDUType.ACK_DATA, # PDU type
|
|
760
|
+
0x0000, # Reserved
|
|
761
|
+
request["sequence"], # Sequence (echo)
|
|
762
|
+
0x0002, # Parameter length
|
|
763
|
+
data_len, # Data length
|
|
764
|
+
0x00, # Error class (success)
|
|
765
|
+
0x00, # Error code (success)
|
|
766
|
+
)
|
|
767
|
+
|
|
768
|
+
# Parameters
|
|
769
|
+
parameters = struct.pack(
|
|
770
|
+
">BB",
|
|
771
|
+
S7Function.READ_AREA, # Function code
|
|
772
|
+
0x01, # Item count
|
|
773
|
+
)
|
|
774
|
+
|
|
775
|
+
# Data section
|
|
776
|
+
data_section = (
|
|
777
|
+
struct.pack(
|
|
778
|
+
">BBH",
|
|
779
|
+
0xFF, # Return code (success)
|
|
780
|
+
0x04, # Transport size (04 = byte data)
|
|
781
|
+
len(read_data) * 8, # Data length in bits
|
|
782
|
+
)
|
|
783
|
+
+ read_data
|
|
784
|
+
)
|
|
785
|
+
|
|
786
|
+
# Trigger read event callback
|
|
787
|
+
if self.read_callback:
|
|
788
|
+
event = SrvEvent()
|
|
789
|
+
event.EvtTime = int(time.time())
|
|
790
|
+
event.EvtSender = 0
|
|
791
|
+
event.EvtCode = 0x00004000 # Read event
|
|
792
|
+
event.EvtRetCode = 0
|
|
793
|
+
event.EvtParam1 = 1 # Area
|
|
794
|
+
event.EvtParam2 = 0 # Offset
|
|
795
|
+
event.EvtParam3 = len(read_data) # Size
|
|
796
|
+
event.EvtParam4 = 0
|
|
797
|
+
try:
|
|
798
|
+
self.read_callback(event)
|
|
799
|
+
except Exception as e:
|
|
800
|
+
logger.error(f"Error in read callback: {e}")
|
|
801
|
+
|
|
802
|
+
return header + parameters + data_section
|
|
803
|
+
|
|
804
|
+
except Exception as e:
|
|
805
|
+
logger.error(f"Error handling read request: {e}")
|
|
806
|
+
return self._build_error_response(request, 0x8000)
|
|
807
|
+
|
|
808
|
+
def _parse_read_address(self, request: Dict[str, Any]) -> Optional[Tuple[S7Area, int, int, int]]:
|
|
809
|
+
"""
|
|
810
|
+
Parse read address from request parameters.
|
|
811
|
+
|
|
812
|
+
Returns:
|
|
813
|
+
Tuple of (area, db_number, start, byte_count) or None if invalid
|
|
814
|
+
"""
|
|
815
|
+
try:
|
|
816
|
+
params = request.get("parameters", {})
|
|
817
|
+
if params.get("function_code") != S7Function.READ_AREA:
|
|
818
|
+
return None
|
|
819
|
+
|
|
820
|
+
# Check if we have parsed address specification
|
|
821
|
+
addr_spec = params.get("address_spec", {})
|
|
822
|
+
if addr_spec:
|
|
823
|
+
area = addr_spec.get("area", S7Area.DB)
|
|
824
|
+
db_number = addr_spec.get("db_number", 1)
|
|
825
|
+
start = addr_spec.get("start", 0)
|
|
826
|
+
count = addr_spec.get("count", 4)
|
|
827
|
+
word_len = addr_spec.get("word_len", S7WordLen.BYTE)
|
|
828
|
+
|
|
829
|
+
# Convert count to bytes based on word length
|
|
830
|
+
if word_len in [S7WordLen.TIMER, S7WordLen.COUNTER, S7WordLen.WORD]:
|
|
831
|
+
byte_count = count * 2 # 16-bit items
|
|
832
|
+
elif word_len in [S7WordLen.DWORD, S7WordLen.REAL]:
|
|
833
|
+
byte_count = count * 4 # 32-bit items
|
|
834
|
+
elif word_len == S7WordLen.BIT:
|
|
835
|
+
byte_count = 1 # Single bit needs at least 1 byte
|
|
836
|
+
else:
|
|
837
|
+
byte_count = count # Bytes
|
|
838
|
+
|
|
839
|
+
logger.debug(
|
|
840
|
+
f"Parsed address: area={area}, db={db_number}, start={start}, count={count}, word_len={word_len}, byte_count={byte_count}"
|
|
841
|
+
)
|
|
842
|
+
return (area, db_number, start, byte_count)
|
|
843
|
+
|
|
844
|
+
# Fallback to defaults if parsing failed
|
|
845
|
+
logger.warning("Using default address values - address parsing may have failed")
|
|
846
|
+
return (S7Area.DB, 1, 0, 4)
|
|
847
|
+
|
|
848
|
+
except Exception as e:
|
|
849
|
+
logger.error(f"Error parsing read address: {e}")
|
|
850
|
+
return None
|
|
851
|
+
|
|
852
|
+
def _read_from_memory_area(self, area: S7Area, db_number: int, start: int, count: int) -> Optional[bytearray]:
|
|
853
|
+
"""
|
|
854
|
+
Read data from registered memory area.
|
|
855
|
+
|
|
856
|
+
Args:
|
|
857
|
+
area: Memory area to read from
|
|
858
|
+
db_number: DB number (for DB areas)
|
|
859
|
+
start: Start offset
|
|
860
|
+
count: Number of bytes to read
|
|
861
|
+
|
|
862
|
+
Returns:
|
|
863
|
+
Data read from memory area or None if area not found
|
|
864
|
+
"""
|
|
865
|
+
try:
|
|
866
|
+
area_key = (area, db_number)
|
|
867
|
+
|
|
868
|
+
if area_key not in self.memory_areas:
|
|
869
|
+
logger.warning(f"Memory area {area}#{db_number} not registered")
|
|
870
|
+
# Return dummy data if area not found (for compatibility)
|
|
871
|
+
return bytearray([0x42, 0xFF, 0x12, 0x34])[:count]
|
|
872
|
+
|
|
873
|
+
# Get area data with thread safety
|
|
874
|
+
with self.area_locks[area_key]:
|
|
875
|
+
area_data = self.memory_areas[area_key]
|
|
876
|
+
|
|
877
|
+
# Check bounds
|
|
878
|
+
if start >= len(area_data):
|
|
879
|
+
logger.warning(f"Start address {start} beyond area size {len(area_data)}")
|
|
880
|
+
return bytearray([0x00] * count)
|
|
881
|
+
|
|
882
|
+
# Read requested data, padding with zeros if needed
|
|
883
|
+
end = min(start + count, len(area_data))
|
|
884
|
+
read_data = bytearray(area_data[start:end])
|
|
885
|
+
|
|
886
|
+
# Pad with zeros if we didn't read enough
|
|
887
|
+
if len(read_data) < count:
|
|
888
|
+
read_data.extend([0x00] * (count - len(read_data)))
|
|
889
|
+
|
|
890
|
+
logger.debug(f"Read {len(read_data)} bytes from {area}#{db_number} at offset {start}")
|
|
891
|
+
return read_data
|
|
892
|
+
|
|
893
|
+
except Exception as e:
|
|
894
|
+
logger.error(f"Error reading from memory area: {e}")
|
|
895
|
+
return bytearray([0x00] * count)
|
|
896
|
+
|
|
897
|
+
def _handle_write_area(self, request: Dict[str, Any], client_address: Tuple[str, int]) -> bytes:
|
|
898
|
+
"""Handle write area request."""
|
|
899
|
+
try:
|
|
900
|
+
# Parse address specification from request parameters
|
|
901
|
+
addr_info = self._parse_write_address(request)
|
|
902
|
+
if not addr_info:
|
|
903
|
+
return self._build_error_response(request, 0x8001) # Invalid address
|
|
904
|
+
|
|
905
|
+
area, db_number, start, count, write_data = addr_info
|
|
906
|
+
|
|
907
|
+
# Write data to registered memory area
|
|
908
|
+
success = self._write_to_memory_area(area, db_number, start, write_data)
|
|
909
|
+
if not success:
|
|
910
|
+
return self._build_error_response(request, 0x8404) # Area not found or write error
|
|
911
|
+
|
|
912
|
+
# Build successful response with error bytes
|
|
913
|
+
header = struct.pack(
|
|
914
|
+
">BBHHHHBB",
|
|
915
|
+
0x32, # Protocol ID
|
|
916
|
+
S7PDUType.ACK_DATA, # PDU type
|
|
917
|
+
0x0000, # Reserved
|
|
918
|
+
request["sequence"], # Sequence (echo)
|
|
919
|
+
0x0002, # Parameter length
|
|
920
|
+
0x0001, # Data length
|
|
921
|
+
0x00, # Error class (success)
|
|
922
|
+
0x00, # Error code (success)
|
|
923
|
+
)
|
|
924
|
+
|
|
925
|
+
# Parameters
|
|
926
|
+
parameters = struct.pack(
|
|
927
|
+
">BB",
|
|
928
|
+
S7Function.WRITE_AREA, # Function code
|
|
929
|
+
0x01, # Item count
|
|
930
|
+
)
|
|
931
|
+
|
|
932
|
+
# Data section (write response)
|
|
933
|
+
data_section = b"\xff" # Success return code
|
|
934
|
+
|
|
935
|
+
return header + parameters + data_section
|
|
936
|
+
|
|
937
|
+
except Exception as e:
|
|
938
|
+
logger.error(f"Error handling write request: {e}")
|
|
939
|
+
return self._build_error_response(request, 0x8000)
|
|
940
|
+
|
|
941
|
+
def _handle_plc_control(self, request: Dict[str, Any], client_address: Tuple[str, int]) -> bytes:
|
|
942
|
+
"""Handle PLC control request (start, compress, copy_ram_to_rom)."""
|
|
943
|
+
try:
|
|
944
|
+
params = request.get("parameters", {})
|
|
945
|
+
pi_service = params.get("pi_service", b"")
|
|
946
|
+
|
|
947
|
+
# Check for PI service operations
|
|
948
|
+
if pi_service == b"_MSZL":
|
|
949
|
+
file_id = params.get("file_id", b"")
|
|
950
|
+
if file_id == b"P":
|
|
951
|
+
# Copy RAM to ROM
|
|
952
|
+
logger.info(f"Copy RAM to ROM requested from {client_address}")
|
|
953
|
+
else:
|
|
954
|
+
# Compress memory
|
|
955
|
+
logger.info(f"Compress memory requested from {client_address}")
|
|
956
|
+
elif len(params) >= 2:
|
|
957
|
+
# Has restart type parameter - start operation
|
|
958
|
+
restart_type = params.get("restart_type", 1)
|
|
959
|
+
if restart_type == 1:
|
|
960
|
+
logger.info("PLC Hot Start requested")
|
|
961
|
+
else:
|
|
962
|
+
logger.info("PLC Cold Start requested")
|
|
963
|
+
# Set CPU to running state
|
|
964
|
+
self.cpu_state = CPUState.RUN
|
|
965
|
+
else:
|
|
966
|
+
logger.info("PLC Start requested")
|
|
967
|
+
self.cpu_state = CPUState.RUN
|
|
968
|
+
|
|
969
|
+
# Build successful response
|
|
970
|
+
header = struct.pack(
|
|
971
|
+
">BBHHHHBB",
|
|
972
|
+
0x32, # Protocol ID
|
|
973
|
+
S7PDUType.ACK_DATA, # PDU type
|
|
974
|
+
0x0000, # Reserved
|
|
975
|
+
request["sequence"], # Sequence (echo)
|
|
976
|
+
0x0001, # Parameter length
|
|
977
|
+
0x0000, # Data length
|
|
978
|
+
0x00, # Error class (success)
|
|
979
|
+
0x00, # Error code (success)
|
|
980
|
+
)
|
|
981
|
+
|
|
982
|
+
parameters = struct.pack(">B", S7Function.PLC_CONTROL)
|
|
983
|
+
|
|
984
|
+
return header + parameters
|
|
985
|
+
|
|
986
|
+
except Exception as e:
|
|
987
|
+
logger.error(f"Error handling PLC control request: {e}")
|
|
988
|
+
return self._build_error_response(request, 0x8000)
|
|
989
|
+
|
|
990
|
+
def _handle_plc_stop(self, request: Dict[str, Any], client_address: Tuple[str, int]) -> bytes:
|
|
991
|
+
"""Handle PLC stop request."""
|
|
992
|
+
try:
|
|
993
|
+
logger.info("PLC Stop requested")
|
|
994
|
+
|
|
995
|
+
# Set CPU to stopped state
|
|
996
|
+
self.cpu_state = CPUState.STOP
|
|
997
|
+
|
|
998
|
+
# Build successful response with error bytes
|
|
999
|
+
header = struct.pack(
|
|
1000
|
+
">BBHHHHBB",
|
|
1001
|
+
0x32, # Protocol ID
|
|
1002
|
+
S7PDUType.ACK_DATA, # PDU type
|
|
1003
|
+
0x0000, # Reserved
|
|
1004
|
+
request["sequence"], # Sequence (echo)
|
|
1005
|
+
0x0001, # Parameter length
|
|
1006
|
+
0x0000, # Data length
|
|
1007
|
+
0x00, # Error class (success)
|
|
1008
|
+
0x00, # Error code (success)
|
|
1009
|
+
)
|
|
1010
|
+
|
|
1011
|
+
parameters = struct.pack(">B", S7Function.PLC_STOP)
|
|
1012
|
+
|
|
1013
|
+
return header + parameters
|
|
1014
|
+
|
|
1015
|
+
except Exception as e:
|
|
1016
|
+
logger.error(f"Error handling PLC stop request: {e}")
|
|
1017
|
+
return self._build_error_response(request, 0x8000)
|
|
1018
|
+
|
|
1019
|
+
def _parse_write_address(self, request: Dict[str, Any]) -> Optional[Tuple[S7Area, int, int, int, bytearray]]:
|
|
1020
|
+
"""
|
|
1021
|
+
Parse write address from request parameters and data.
|
|
1022
|
+
|
|
1023
|
+
Returns:
|
|
1024
|
+
Tuple of (area, db_number, start, count, write_data) or None if invalid
|
|
1025
|
+
"""
|
|
1026
|
+
try:
|
|
1027
|
+
params = request.get("parameters", {})
|
|
1028
|
+
if params.get("function_code") != S7Function.WRITE_AREA:
|
|
1029
|
+
return None
|
|
1030
|
+
|
|
1031
|
+
# Check if we have parsed address specification
|
|
1032
|
+
addr_spec = params.get("address_spec", {})
|
|
1033
|
+
if not addr_spec:
|
|
1034
|
+
logger.warning("No address specification in write request")
|
|
1035
|
+
return None
|
|
1036
|
+
|
|
1037
|
+
area = addr_spec.get("area", S7Area.DB)
|
|
1038
|
+
db_number = addr_spec.get("db_number", 1)
|
|
1039
|
+
start = addr_spec.get("start", 0)
|
|
1040
|
+
count = addr_spec.get("count", 0)
|
|
1041
|
+
|
|
1042
|
+
# Extract write data from request data section
|
|
1043
|
+
data_info = request.get("data", {})
|
|
1044
|
+
write_data = data_info.get("data", b"")
|
|
1045
|
+
|
|
1046
|
+
if not write_data:
|
|
1047
|
+
logger.warning("No write data in request")
|
|
1048
|
+
return None
|
|
1049
|
+
|
|
1050
|
+
logger.debug(
|
|
1051
|
+
f"Parsed write address: area={area}, db={db_number}, start={start}, count={count}, data_len={len(write_data)}"
|
|
1052
|
+
)
|
|
1053
|
+
return (area, db_number, start, count, bytearray(write_data))
|
|
1054
|
+
|
|
1055
|
+
except Exception as e:
|
|
1056
|
+
logger.error(f"Error parsing write address: {e}")
|
|
1057
|
+
return None
|
|
1058
|
+
|
|
1059
|
+
def _write_to_memory_area(self, area: S7Area, db_number: int, start: int, write_data: bytearray) -> bool:
|
|
1060
|
+
"""
|
|
1061
|
+
Write data to registered memory area.
|
|
1062
|
+
|
|
1063
|
+
Args:
|
|
1064
|
+
area: Memory area to write to
|
|
1065
|
+
db_number: DB number (for DB areas)
|
|
1066
|
+
start: Start offset
|
|
1067
|
+
write_data: Data to write
|
|
1068
|
+
|
|
1069
|
+
Returns:
|
|
1070
|
+
True if write succeeded, False otherwise
|
|
1071
|
+
"""
|
|
1072
|
+
try:
|
|
1073
|
+
area_key = (area, db_number)
|
|
1074
|
+
|
|
1075
|
+
if area_key not in self.memory_areas:
|
|
1076
|
+
logger.warning(f"Memory area {area}#{db_number} not registered for write")
|
|
1077
|
+
return False
|
|
1078
|
+
|
|
1079
|
+
# Write to area data with thread safety
|
|
1080
|
+
with self.area_locks[area_key]:
|
|
1081
|
+
area_data = self.memory_areas[area_key]
|
|
1082
|
+
|
|
1083
|
+
# Check bounds
|
|
1084
|
+
if start >= len(area_data):
|
|
1085
|
+
logger.warning(f"Write start address {start} beyond area size {len(area_data)}")
|
|
1086
|
+
return False
|
|
1087
|
+
|
|
1088
|
+
# Calculate write range
|
|
1089
|
+
end = min(start + len(write_data), len(area_data))
|
|
1090
|
+
actual_write_len = end - start
|
|
1091
|
+
|
|
1092
|
+
# Write the data
|
|
1093
|
+
area_data[start:end] = write_data[:actual_write_len]
|
|
1094
|
+
|
|
1095
|
+
logger.debug(f"Wrote {actual_write_len} bytes to {area}#{db_number} at offset {start}")
|
|
1096
|
+
|
|
1097
|
+
# If we didn't write all data due to bounds, return error
|
|
1098
|
+
if actual_write_len < len(write_data):
|
|
1099
|
+
logger.warning(f"Only wrote {actual_write_len} of {len(write_data)} bytes due to area bounds")
|
|
1100
|
+
return False
|
|
1101
|
+
|
|
1102
|
+
return True
|
|
1103
|
+
|
|
1104
|
+
except Exception as e:
|
|
1105
|
+
logger.error(f"Error writing to memory area: {e}")
|
|
1106
|
+
return False
|
|
1107
|
+
|
|
1108
|
+
def _parse_request(self, pdu: bytes) -> Dict[str, Any]:
|
|
1109
|
+
"""
|
|
1110
|
+
Parse S7 request PDU.
|
|
1111
|
+
|
|
1112
|
+
Args:
|
|
1113
|
+
pdu: Complete S7 PDU
|
|
1114
|
+
|
|
1115
|
+
Returns:
|
|
1116
|
+
Parsed request data
|
|
1117
|
+
"""
|
|
1118
|
+
if len(pdu) < 10:
|
|
1119
|
+
raise S7ProtocolError("PDU too short for S7 header")
|
|
1120
|
+
|
|
1121
|
+
# Parse S7 header
|
|
1122
|
+
header = struct.unpack(">BBHHHH", pdu[:10])
|
|
1123
|
+
protocol_id, pdu_type, reserved, sequence, param_len, data_len = header
|
|
1124
|
+
|
|
1125
|
+
if protocol_id != 0x32:
|
|
1126
|
+
raise S7ProtocolError(f"Invalid protocol ID: {protocol_id:#02x}")
|
|
1127
|
+
|
|
1128
|
+
request: Dict[str, Any] = {
|
|
1129
|
+
"pdu_type": pdu_type,
|
|
1130
|
+
"sequence": sequence,
|
|
1131
|
+
"param_length": param_len,
|
|
1132
|
+
"data_length": data_len,
|
|
1133
|
+
"parameters": None,
|
|
1134
|
+
"data": None,
|
|
1135
|
+
"error_code": 0,
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
offset = 10
|
|
1139
|
+
|
|
1140
|
+
# Parse parameters if present
|
|
1141
|
+
if param_len > 0:
|
|
1142
|
+
if offset + param_len > len(pdu):
|
|
1143
|
+
raise S7ProtocolError("Parameter section extends beyond PDU")
|
|
1144
|
+
|
|
1145
|
+
param_data = pdu[offset : offset + param_len]
|
|
1146
|
+
|
|
1147
|
+
# Store raw parameters for all request types (needed for upload/download parsing)
|
|
1148
|
+
request["raw_parameters"] = param_data
|
|
1149
|
+
|
|
1150
|
+
if pdu_type == S7PDUType.USERDATA:
|
|
1151
|
+
request["parameters"] = self._parse_userdata_request_parameters(param_data)
|
|
1152
|
+
else:
|
|
1153
|
+
request["parameters"] = self._parse_request_parameters(param_data)
|
|
1154
|
+
offset += param_len
|
|
1155
|
+
|
|
1156
|
+
# Parse data if present
|
|
1157
|
+
if data_len > 0:
|
|
1158
|
+
if offset + data_len > len(pdu):
|
|
1159
|
+
raise S7ProtocolError("Data section extends beyond PDU")
|
|
1160
|
+
|
|
1161
|
+
data_section = pdu[offset : offset + data_len]
|
|
1162
|
+
request["data"] = self._parse_data_section(data_section)
|
|
1163
|
+
|
|
1164
|
+
return request
|
|
1165
|
+
|
|
1166
|
+
def _parse_request_parameters(self, param_data: bytes) -> Dict[str, Any]:
|
|
1167
|
+
"""Parse S7 request parameter section."""
|
|
1168
|
+
if len(param_data) < 1:
|
|
1169
|
+
return {}
|
|
1170
|
+
|
|
1171
|
+
function_code = param_data[0]
|
|
1172
|
+
|
|
1173
|
+
if function_code == S7Function.SETUP_COMMUNICATION:
|
|
1174
|
+
if len(param_data) >= 8:
|
|
1175
|
+
function_code, reserved, max_amq_caller, max_amq_callee, pdu_length = struct.unpack(">BBHHH", param_data[:8])
|
|
1176
|
+
return {
|
|
1177
|
+
"function_code": function_code,
|
|
1178
|
+
"max_amq_caller": max_amq_caller,
|
|
1179
|
+
"max_amq_callee": max_amq_callee,
|
|
1180
|
+
"pdu_length": pdu_length,
|
|
1181
|
+
}
|
|
1182
|
+
elif function_code == S7Function.READ_AREA:
|
|
1183
|
+
# Parse read area parameters
|
|
1184
|
+
if len(param_data) >= 14: # Minimum for read area request
|
|
1185
|
+
# Function code (1) + item count (1) + address spec (12)
|
|
1186
|
+
item_count = param_data[1]
|
|
1187
|
+
|
|
1188
|
+
# Parse address specification starting at byte 2
|
|
1189
|
+
if len(param_data) >= 14:
|
|
1190
|
+
addr_spec = param_data[2:14] # 12 bytes of address specification
|
|
1191
|
+
logger.debug(f"Extracted address spec from params: {addr_spec.hex()}")
|
|
1192
|
+
parsed_addr = self._parse_address_specification(addr_spec)
|
|
1193
|
+
|
|
1194
|
+
return {"function_code": function_code, "item_count": item_count, "address_spec": parsed_addr}
|
|
1195
|
+
elif function_code == S7Function.WRITE_AREA:
|
|
1196
|
+
# Parse write area parameters (same format as read)
|
|
1197
|
+
if len(param_data) >= 14: # Minimum for write area request
|
|
1198
|
+
# Function code (1) + item count (1) + address spec (12)
|
|
1199
|
+
item_count = param_data[1]
|
|
1200
|
+
|
|
1201
|
+
# Parse address specification starting at byte 2
|
|
1202
|
+
if len(param_data) >= 14:
|
|
1203
|
+
addr_spec = param_data[2:14] # 12 bytes of address specification
|
|
1204
|
+
logger.debug(f"Extracted write address spec from params: {addr_spec.hex()}")
|
|
1205
|
+
parsed_addr = self._parse_address_specification(addr_spec)
|
|
1206
|
+
|
|
1207
|
+
return {"function_code": function_code, "item_count": item_count, "address_spec": parsed_addr}
|
|
1208
|
+
elif function_code == S7Function.PLC_CONTROL:
|
|
1209
|
+
# Parse PLC control parameters
|
|
1210
|
+
# Format varies: simple start or PI service (compress/copy_ram_to_rom)
|
|
1211
|
+
if len(param_data) >= 2:
|
|
1212
|
+
# Check for restart type (simple start)
|
|
1213
|
+
restart_type = param_data[1]
|
|
1214
|
+
if restart_type in (1, 2):
|
|
1215
|
+
return {"function_code": function_code, "restart_type": restart_type}
|
|
1216
|
+
|
|
1217
|
+
# Check for PI service (compress/copy_ram_to_rom)
|
|
1218
|
+
# Format: func(1) + reserved(7) + pi_len(1) + pi_service
|
|
1219
|
+
# Or: func(1) + reserved(6) + file_id_len(1) + pi_len(1) + file_id + pi_service
|
|
1220
|
+
if len(param_data) >= 10:
|
|
1221
|
+
# Look for PI service
|
|
1222
|
+
pi_len = param_data[8]
|
|
1223
|
+
if pi_len > 0 and len(param_data) >= 9 + pi_len:
|
|
1224
|
+
pi_service = param_data[9 : 9 + pi_len]
|
|
1225
|
+
# Check for file_id (copy_ram_to_rom)
|
|
1226
|
+
file_id_len = param_data[7]
|
|
1227
|
+
file_id = b""
|
|
1228
|
+
if file_id_len > 0 and len(param_data) >= 9 + file_id_len + pi_len:
|
|
1229
|
+
# Reparse with file_id
|
|
1230
|
+
file_id = param_data[9 : 9 + file_id_len]
|
|
1231
|
+
pi_service = param_data[9 + file_id_len : 9 + file_id_len + pi_len]
|
|
1232
|
+
return {"function_code": function_code, "pi_service": pi_service, "file_id": file_id}
|
|
1233
|
+
|
|
1234
|
+
return {"function_code": function_code}
|
|
1235
|
+
|
|
1236
|
+
def _parse_userdata_request_parameters(self, param_data: bytes) -> Dict[str, Any]:
|
|
1237
|
+
"""
|
|
1238
|
+
Parse USER_DATA request parameters.
|
|
1239
|
+
|
|
1240
|
+
USER_DATA parameter format (from C s7_types.h TReqFunTypedParams):
|
|
1241
|
+
- Byte 0: Reserved (0x00)
|
|
1242
|
+
- Byte 1: Parameter count (usually 0x01)
|
|
1243
|
+
- Byte 2: Type/length header (0x12)
|
|
1244
|
+
- Byte 3: Length (0x04 or 0x08)
|
|
1245
|
+
- Byte 4: Method (0x11 = request, 0x12 = response)
|
|
1246
|
+
- Byte 5: Type (high nibble 0x4=req, 0x8=resp) | Group (low nibble)
|
|
1247
|
+
- Byte 6: Subfunction
|
|
1248
|
+
- Byte 7: Sequence number
|
|
1249
|
+
|
|
1250
|
+
Args:
|
|
1251
|
+
param_data: Raw parameter bytes
|
|
1252
|
+
|
|
1253
|
+
Returns:
|
|
1254
|
+
Dictionary with parsed USER_DATA parameters
|
|
1255
|
+
"""
|
|
1256
|
+
if len(param_data) < 8:
|
|
1257
|
+
logger.debug(f"USER_DATA parameters too short: {len(param_data)} bytes")
|
|
1258
|
+
return {}
|
|
1259
|
+
|
|
1260
|
+
try:
|
|
1261
|
+
# Parse USER_DATA header
|
|
1262
|
+
# Bytes 0-3 are header (reserved, param_count, type_len_header, length)
|
|
1263
|
+
method = param_data[4]
|
|
1264
|
+
type_group = param_data[5]
|
|
1265
|
+
subfunction = param_data[6]
|
|
1266
|
+
sequence = param_data[7]
|
|
1267
|
+
|
|
1268
|
+
# Extract type (high nibble) and group (low nibble)
|
|
1269
|
+
req_type = (type_group >> 4) & 0x0F
|
|
1270
|
+
group = type_group & 0x0F
|
|
1271
|
+
|
|
1272
|
+
logger.debug(
|
|
1273
|
+
f"USER_DATA params: method={method:#02x}, type={req_type}, group={group}, subfunc={subfunction}, seq={sequence}"
|
|
1274
|
+
)
|
|
1275
|
+
|
|
1276
|
+
return {
|
|
1277
|
+
"method": method,
|
|
1278
|
+
"type": req_type,
|
|
1279
|
+
"group": group,
|
|
1280
|
+
"subfunction": subfunction,
|
|
1281
|
+
"sequence": sequence,
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
except Exception as e:
|
|
1285
|
+
logger.error(f"Error parsing USER_DATA parameters: {e}")
|
|
1286
|
+
return {}
|
|
1287
|
+
|
|
1288
|
+
def _parse_address_specification(self, addr_spec: bytes) -> Dict[str, Any]:
|
|
1289
|
+
"""
|
|
1290
|
+
Parse S7 address specification.
|
|
1291
|
+
|
|
1292
|
+
Args:
|
|
1293
|
+
addr_spec: 12-byte address specification from client request
|
|
1294
|
+
|
|
1295
|
+
Returns:
|
|
1296
|
+
Dictionary with parsed address information
|
|
1297
|
+
"""
|
|
1298
|
+
try:
|
|
1299
|
+
if len(addr_spec) < 12:
|
|
1300
|
+
logger.error(f"Address spec too short: {len(addr_spec)} bytes, need 12")
|
|
1301
|
+
return {}
|
|
1302
|
+
|
|
1303
|
+
logger.debug(f"Parsing address spec: {addr_spec.hex()} (length: {len(addr_spec)})")
|
|
1304
|
+
|
|
1305
|
+
# Address specification format:
|
|
1306
|
+
# Byte 0: Specification type (0x12)
|
|
1307
|
+
# Byte 1: Length of following address specification (0x0A = 10 bytes)
|
|
1308
|
+
# Byte 2: Syntax ID (0x10 = S7-Any)
|
|
1309
|
+
# Byte 3: Transport size (word length)
|
|
1310
|
+
# Bytes 4-5: Count (number of items)
|
|
1311
|
+
# Bytes 6-7: DB number (for DB area) or 0
|
|
1312
|
+
# Byte 8: Area code
|
|
1313
|
+
# Bytes 9-11: Start address (3 bytes, big-endian)
|
|
1314
|
+
|
|
1315
|
+
spec_type, length, syntax_id, word_len, count, db_number, area_code, address_bytes = struct.unpack(
|
|
1316
|
+
">BBBBHHB3s", addr_spec
|
|
1317
|
+
)
|
|
1318
|
+
|
|
1319
|
+
# Extract 3-byte address (big-endian)
|
|
1320
|
+
address = struct.unpack(">I", b"\x00" + address_bytes)[0] # Pad to 4 bytes
|
|
1321
|
+
|
|
1322
|
+
# Convert bit address to byte address
|
|
1323
|
+
if word_len == S7WordLen.BIT:
|
|
1324
|
+
byte_addr = address // 8
|
|
1325
|
+
start_address = byte_addr
|
|
1326
|
+
else:
|
|
1327
|
+
start_address = address // 8 # Convert bit address to byte address
|
|
1328
|
+
|
|
1329
|
+
return {
|
|
1330
|
+
"area": S7Area(area_code),
|
|
1331
|
+
"db_number": db_number,
|
|
1332
|
+
"start": start_address,
|
|
1333
|
+
"count": count,
|
|
1334
|
+
"word_len": word_len,
|
|
1335
|
+
"spec_type": spec_type,
|
|
1336
|
+
"syntax_id": syntax_id,
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
except Exception as e:
|
|
1340
|
+
logger.error(f"Error parsing address specification: {e}")
|
|
1341
|
+
return {}
|
|
1342
|
+
|
|
1343
|
+
def _parse_data_section(self, data_section: bytes) -> Dict[str, Any]:
|
|
1344
|
+
"""Parse S7 data section."""
|
|
1345
|
+
if len(data_section) == 1:
|
|
1346
|
+
# Simple return code (for write responses)
|
|
1347
|
+
return {"return_code": data_section[0], "transport_size": 0, "data_length": 0, "data": b""}
|
|
1348
|
+
elif len(data_section) >= 4:
|
|
1349
|
+
# Full data header (for read responses)
|
|
1350
|
+
return_code = data_section[0]
|
|
1351
|
+
transport_size = data_section[1]
|
|
1352
|
+
data_length = struct.unpack(">H", data_section[2:4])[0]
|
|
1353
|
+
|
|
1354
|
+
# Extract actual data - length interpretation depends on transport_size
|
|
1355
|
+
# Transport size 0x09 (octet string): byte length (USERDATA responses)
|
|
1356
|
+
# Transport size 0x00: byte length (USERDATA requests)
|
|
1357
|
+
# Transport size 0x04 (byte): bit length (READ_AREA responses)
|
|
1358
|
+
if transport_size in (0x00, 0x09):
|
|
1359
|
+
# USERDATA uses byte length directly
|
|
1360
|
+
actual_data = data_section[4 : 4 + data_length]
|
|
1361
|
+
else:
|
|
1362
|
+
# READ_AREA responses use bit length
|
|
1363
|
+
actual_data = data_section[4 : 4 + (data_length // 8)]
|
|
1364
|
+
|
|
1365
|
+
return {"return_code": return_code, "transport_size": transport_size, "data_length": data_length, "data": actual_data}
|
|
1366
|
+
else:
|
|
1367
|
+
return {"raw_data": data_section}
|
|
1368
|
+
|
|
1369
|
+
def _build_error_response(self, request: Dict[str, Any], error_code: int) -> bytes:
|
|
1370
|
+
"""Build an error response PDU.
|
|
1371
|
+
|
|
1372
|
+
Uses PDU type ACK (0x02) for error responses without data,
|
|
1373
|
+
matching real S7-1200/1500 PLC behavior.
|
|
1374
|
+
"""
|
|
1375
|
+
error_class = (error_code >> 8) & 0xFF
|
|
1376
|
+
error_byte = error_code & 0xFF
|
|
1377
|
+
header = struct.pack(
|
|
1378
|
+
">BBHHHHBB",
|
|
1379
|
+
0x32, # Protocol ID
|
|
1380
|
+
S7PDUType.ACK, # PDU type (ACK for errors without data)
|
|
1381
|
+
0x0000, # Reserved
|
|
1382
|
+
request.get("sequence", 0), # Sequence (echo)
|
|
1383
|
+
0x0000, # Parameter length
|
|
1384
|
+
0x0000, # Data length
|
|
1385
|
+
error_class, # Error class
|
|
1386
|
+
error_byte, # Error code
|
|
1387
|
+
)
|
|
1388
|
+
|
|
1389
|
+
return header
|
|
1390
|
+
|
|
1391
|
+
# ========================================================================
|
|
1392
|
+
# USER_DATA PDU Handlers (Chunk 1 of protocol implementation)
|
|
1393
|
+
# ========================================================================
|
|
1394
|
+
|
|
1395
|
+
def _handle_userdata(self, request: Dict[str, Any], client_address: Tuple[str, int]) -> bytes:
|
|
1396
|
+
"""
|
|
1397
|
+
Handle USER_DATA PDU requests.
|
|
1398
|
+
|
|
1399
|
+
USER_DATA PDUs are used for:
|
|
1400
|
+
- Block operations (list, info)
|
|
1401
|
+
- SZL (System Status List) requests
|
|
1402
|
+
- Clock operations (get/set time)
|
|
1403
|
+
- Security operations (password)
|
|
1404
|
+
|
|
1405
|
+
Args:
|
|
1406
|
+
request: Parsed S7 request
|
|
1407
|
+
client_address: Client address for logging
|
|
1408
|
+
|
|
1409
|
+
Returns:
|
|
1410
|
+
Response PDU data
|
|
1411
|
+
"""
|
|
1412
|
+
try:
|
|
1413
|
+
# Parse USER_DATA specific parameters
|
|
1414
|
+
userdata_params = self._parse_userdata_parameters(request)
|
|
1415
|
+
if not userdata_params:
|
|
1416
|
+
logger.warning(f"Failed to parse USER_DATA parameters from {client_address}")
|
|
1417
|
+
return self._build_userdata_error_response(request, 0x8104) # Object does not exist
|
|
1418
|
+
|
|
1419
|
+
group = userdata_params.get("group", 0)
|
|
1420
|
+
subfunction = userdata_params.get("subfunction", 0)
|
|
1421
|
+
|
|
1422
|
+
logger.debug(f"USER_DATA request: group={group:#04x}, subfunction={subfunction:#02x}")
|
|
1423
|
+
|
|
1424
|
+
# Route to appropriate handler based on group
|
|
1425
|
+
if group == S7UserDataGroup.BLOCK_INFO:
|
|
1426
|
+
return self._handle_block_info(request, userdata_params, client_address)
|
|
1427
|
+
elif group == S7UserDataGroup.SZL:
|
|
1428
|
+
return self._handle_szl(request, userdata_params, client_address)
|
|
1429
|
+
elif group == S7UserDataGroup.TIME:
|
|
1430
|
+
return self._handle_clock(request, userdata_params, client_address)
|
|
1431
|
+
elif group == S7UserDataGroup.SECURITY:
|
|
1432
|
+
return self._handle_security(request, userdata_params, client_address)
|
|
1433
|
+
else:
|
|
1434
|
+
logger.warning(f"Unsupported USER_DATA group: {group:#04x}")
|
|
1435
|
+
return self._build_userdata_error_response(request, 0x8104)
|
|
1436
|
+
|
|
1437
|
+
except Exception as e:
|
|
1438
|
+
logger.error(f"Error handling USER_DATA request: {e}")
|
|
1439
|
+
return self._build_userdata_error_response(request, 0x8000)
|
|
1440
|
+
|
|
1441
|
+
def _parse_userdata_parameters(self, request: Dict[str, Any]) -> Dict[str, Any]:
|
|
1442
|
+
"""
|
|
1443
|
+
Parse USER_DATA specific parameters.
|
|
1444
|
+
|
|
1445
|
+
USER_DATA parameter format (from C s7_types.h):
|
|
1446
|
+
- Byte 0-2: Parameter header
|
|
1447
|
+
- Byte 3: Parameter length
|
|
1448
|
+
- Byte 4: Method (0x11 = request, 0x12 = response)
|
|
1449
|
+
- Byte 5 (high nibble): Type (0x4 = request, 0x8 = response)
|
|
1450
|
+
- Byte 5 (low nibble): Function group
|
|
1451
|
+
- Byte 6: Subfunction
|
|
1452
|
+
- Byte 7: Sequence number
|
|
1453
|
+
|
|
1454
|
+
Args:
|
|
1455
|
+
request: Parsed S7 request
|
|
1456
|
+
|
|
1457
|
+
Returns:
|
|
1458
|
+
Dictionary with parsed USER_DATA parameters
|
|
1459
|
+
"""
|
|
1460
|
+
try:
|
|
1461
|
+
params = request.get("parameters")
|
|
1462
|
+
if not params:
|
|
1463
|
+
# Try to get raw parameter data from request
|
|
1464
|
+
return {}
|
|
1465
|
+
|
|
1466
|
+
# If we have raw parameter data in the request, parse it
|
|
1467
|
+
raw_params = request.get("raw_parameters", b"")
|
|
1468
|
+
if not raw_params and isinstance(params, dict):
|
|
1469
|
+
# Already parsed - check if it has userdata fields
|
|
1470
|
+
if "group" in params:
|
|
1471
|
+
return params
|
|
1472
|
+
return {}
|
|
1473
|
+
|
|
1474
|
+
if len(raw_params) < 8:
|
|
1475
|
+
logger.debug(f"USER_DATA parameters too short: {len(raw_params)} bytes")
|
|
1476
|
+
return {}
|
|
1477
|
+
|
|
1478
|
+
# Parse USER_DATA parameter format
|
|
1479
|
+
# Skip first 4 bytes (header), then:
|
|
1480
|
+
method = raw_params[4]
|
|
1481
|
+
type_group = raw_params[5]
|
|
1482
|
+
subfunction = raw_params[6]
|
|
1483
|
+
sequence = raw_params[7]
|
|
1484
|
+
|
|
1485
|
+
# Extract type (high nibble) and group (low nibble)
|
|
1486
|
+
req_type = (type_group >> 4) & 0x0F
|
|
1487
|
+
group = type_group & 0x0F
|
|
1488
|
+
|
|
1489
|
+
return {
|
|
1490
|
+
"method": method,
|
|
1491
|
+
"type": req_type,
|
|
1492
|
+
"group": group,
|
|
1493
|
+
"subfunction": subfunction,
|
|
1494
|
+
"sequence": sequence,
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
except Exception as e:
|
|
1498
|
+
logger.error(f"Error parsing USER_DATA parameters: {e}")
|
|
1499
|
+
return {}
|
|
1500
|
+
|
|
1501
|
+
def _handle_block_info(
|
|
1502
|
+
self, request: Dict[str, Any], userdata_params: Dict[str, Any], client_address: Tuple[str, int]
|
|
1503
|
+
) -> bytes:
|
|
1504
|
+
"""
|
|
1505
|
+
Handle block info group requests (grBlocksInfo).
|
|
1506
|
+
|
|
1507
|
+
Subfunctions:
|
|
1508
|
+
- SFun_ListAll (0x01): List all block counts
|
|
1509
|
+
- SFun_ListBoT (0x02): List blocks of type
|
|
1510
|
+
- SFun_BlkInfo (0x03): Get block info
|
|
1511
|
+
|
|
1512
|
+
Args:
|
|
1513
|
+
request: Parsed S7 request
|
|
1514
|
+
userdata_params: Parsed USER_DATA parameters
|
|
1515
|
+
client_address: Client address
|
|
1516
|
+
|
|
1517
|
+
Returns:
|
|
1518
|
+
Response PDU
|
|
1519
|
+
"""
|
|
1520
|
+
subfunction = userdata_params.get("subfunction", 0)
|
|
1521
|
+
|
|
1522
|
+
if subfunction == S7UserDataSubfunction.LIST_ALL:
|
|
1523
|
+
return self._handle_list_all_blocks(request, userdata_params, client_address)
|
|
1524
|
+
elif subfunction == S7UserDataSubfunction.LIST_BLOCKS_OF_TYPE:
|
|
1525
|
+
return self._handle_list_blocks_of_type(request, userdata_params, client_address)
|
|
1526
|
+
elif subfunction == S7UserDataSubfunction.BLOCK_INFO:
|
|
1527
|
+
return self._handle_get_block_info(request, userdata_params, client_address)
|
|
1528
|
+
else:
|
|
1529
|
+
logger.warning(f"Unsupported block info subfunction: {subfunction:#02x}")
|
|
1530
|
+
return self._build_userdata_error_response(request, 0x8104)
|
|
1531
|
+
|
|
1532
|
+
def _handle_szl(self, request: Dict[str, Any], userdata_params: Dict[str, Any], client_address: Tuple[str, int]) -> bytes:
|
|
1533
|
+
"""
|
|
1534
|
+
Handle SZL (System Status List) requests.
|
|
1535
|
+
|
|
1536
|
+
SZL provides system status information about the PLC.
|
|
1537
|
+
Common SZL IDs:
|
|
1538
|
+
- 0x001C: Component identification (for get_cpu_info)
|
|
1539
|
+
- 0x0011: Module identification (for get_order_code)
|
|
1540
|
+
- 0x0131: Communication parameters (for get_cp_info)
|
|
1541
|
+
- 0x0232: Protection level (for get_protection)
|
|
1542
|
+
|
|
1543
|
+
Args:
|
|
1544
|
+
request: Parsed S7 request
|
|
1545
|
+
userdata_params: Parsed USER_DATA parameters
|
|
1546
|
+
client_address: Client address
|
|
1547
|
+
|
|
1548
|
+
Returns:
|
|
1549
|
+
Response PDU with SZL data
|
|
1550
|
+
"""
|
|
1551
|
+
# Extract SZL ID and index from request data
|
|
1552
|
+
data_section = request.get("data", {})
|
|
1553
|
+
raw_data = data_section.get("data", b"")
|
|
1554
|
+
|
|
1555
|
+
# SZL request data: return_code (1) + transport (1) + length (2) + SZL_ID (2) + Index (2)
|
|
1556
|
+
if len(raw_data) >= 4:
|
|
1557
|
+
szl_id = struct.unpack(">H", raw_data[0:2])[0]
|
|
1558
|
+
szl_index = struct.unpack(">H", raw_data[2:4])[0]
|
|
1559
|
+
else:
|
|
1560
|
+
szl_id = 0
|
|
1561
|
+
szl_index = 0
|
|
1562
|
+
|
|
1563
|
+
logger.debug(f"SZL request from {client_address}: ID={szl_id:#06x}, Index={szl_index:#06x}")
|
|
1564
|
+
|
|
1565
|
+
# Get SZL data for the requested ID
|
|
1566
|
+
szl_data = self._get_szl_data(szl_id, szl_index)
|
|
1567
|
+
|
|
1568
|
+
if szl_data is None:
|
|
1569
|
+
logger.debug(f"SZL ID {szl_id:#06x} not available")
|
|
1570
|
+
return self._build_userdata_error_response(request, 0x8104)
|
|
1571
|
+
|
|
1572
|
+
# Build response with SZL header: SZL_ID (2) + Index (2) + data
|
|
1573
|
+
response_data = struct.pack(">HH", szl_id, szl_index) + szl_data
|
|
1574
|
+
|
|
1575
|
+
return self._build_userdata_success_response(request, userdata_params, response_data)
|
|
1576
|
+
|
|
1577
|
+
def _get_szl_data(self, szl_id: int, szl_index: int) -> Optional[bytes]:
|
|
1578
|
+
"""
|
|
1579
|
+
Get SZL data for a specific ID and index.
|
|
1580
|
+
|
|
1581
|
+
Args:
|
|
1582
|
+
szl_id: SZL identifier
|
|
1583
|
+
szl_index: SZL index
|
|
1584
|
+
|
|
1585
|
+
Returns:
|
|
1586
|
+
SZL data bytes or None if not available
|
|
1587
|
+
"""
|
|
1588
|
+
# SZL 0x001C: Component identification (S7CpuInfo)
|
|
1589
|
+
if szl_id == 0x001C:
|
|
1590
|
+
# S7CpuInfo structure fields (each is a null-terminated string)
|
|
1591
|
+
module_type = b"CPU 315-2 PN/DP\x00"
|
|
1592
|
+
serial_number = b"S C-C2UR28922012\x00"
|
|
1593
|
+
as_name = b"SNAP7-SERVER\x00"
|
|
1594
|
+
copyright_info = b"Original Siemens Equipment\x00"
|
|
1595
|
+
module_name = b"CPU 315-2 PN/DP\x00"
|
|
1596
|
+
|
|
1597
|
+
# Pad to fixed sizes (from C structure)
|
|
1598
|
+
module_type = module_type.ljust(32, b"\x00")[:32]
|
|
1599
|
+
serial_number = serial_number.ljust(24, b"\x00")[:24]
|
|
1600
|
+
as_name = as_name.ljust(24, b"\x00")[:24]
|
|
1601
|
+
copyright_info = copyright_info.ljust(26, b"\x00")[:26]
|
|
1602
|
+
module_name = module_name.ljust(24, b"\x00")[:24]
|
|
1603
|
+
|
|
1604
|
+
return module_type + serial_number + as_name + copyright_info + module_name
|
|
1605
|
+
|
|
1606
|
+
# SZL 0x0011: Module identification (S7OrderCode)
|
|
1607
|
+
elif szl_id == 0x0011:
|
|
1608
|
+
order_code = b"6ES7 315-2EH14-0AB0\x00"
|
|
1609
|
+
version = b"V3.3\x00"
|
|
1610
|
+
|
|
1611
|
+
order_code = order_code.ljust(20, b"\x00")[:20]
|
|
1612
|
+
version = version.ljust(4, b"\x00")[:4]
|
|
1613
|
+
|
|
1614
|
+
return order_code + version
|
|
1615
|
+
|
|
1616
|
+
# SZL 0x0131: Communication parameters (S7CpInfo)
|
|
1617
|
+
elif szl_id == 0x0131:
|
|
1618
|
+
# S7CpInfo structure
|
|
1619
|
+
max_pdu = 480
|
|
1620
|
+
max_connections = 32
|
|
1621
|
+
max_mpi = 12
|
|
1622
|
+
max_bus = 12
|
|
1623
|
+
|
|
1624
|
+
return struct.pack(">HHHH", max_pdu, max_connections, max_mpi, max_bus)
|
|
1625
|
+
|
|
1626
|
+
# SZL 0x0232: Protection level (S7Protection)
|
|
1627
|
+
elif szl_id == 0x0232:
|
|
1628
|
+
# S7Protection structure
|
|
1629
|
+
# sch_schal: 1=no password, 2=password level 1, 3=password level 2
|
|
1630
|
+
# sch_par: protection level during runtime
|
|
1631
|
+
# sch_rel: protection level during download
|
|
1632
|
+
# bart_sch: startup protection level
|
|
1633
|
+
# anl_sch: factory setting protection
|
|
1634
|
+
return struct.pack(">HHHHH", 1, 0, 0, 0, 0) # No protection
|
|
1635
|
+
|
|
1636
|
+
# SZL 0x0000: SZL list
|
|
1637
|
+
elif szl_id == 0x0000:
|
|
1638
|
+
# Return list of available SZL IDs
|
|
1639
|
+
available_ids = [0x0000, 0x0011, 0x001C, 0x0131, 0x0232]
|
|
1640
|
+
data = b""
|
|
1641
|
+
for id_val in available_ids:
|
|
1642
|
+
data += struct.pack(">H", id_val)
|
|
1643
|
+
return data
|
|
1644
|
+
|
|
1645
|
+
return None
|
|
1646
|
+
|
|
1647
|
+
def _handle_clock(self, request: Dict[str, Any], userdata_params: Dict[str, Any], client_address: Tuple[str, int]) -> bytes:
|
|
1648
|
+
"""
|
|
1649
|
+
Handle clock requests (get/set time).
|
|
1650
|
+
|
|
1651
|
+
Supports:
|
|
1652
|
+
- GET_CLOCK (0x01): Returns current server time in BCD format
|
|
1653
|
+
- SET_CLOCK (0x02): Accepts time setting (logs but doesn't persist)
|
|
1654
|
+
|
|
1655
|
+
Args:
|
|
1656
|
+
request: Parsed S7 request
|
|
1657
|
+
userdata_params: Parsed USER_DATA parameters
|
|
1658
|
+
client_address: Client address
|
|
1659
|
+
|
|
1660
|
+
Returns:
|
|
1661
|
+
Response PDU with clock data
|
|
1662
|
+
"""
|
|
1663
|
+
subfunction = userdata_params.get("subfunction", 0)
|
|
1664
|
+
|
|
1665
|
+
if subfunction == 0x01: # GET_CLOCK
|
|
1666
|
+
return self._handle_get_clock(request, userdata_params, client_address)
|
|
1667
|
+
elif subfunction == 0x02: # SET_CLOCK
|
|
1668
|
+
return self._handle_set_clock(request, userdata_params, client_address)
|
|
1669
|
+
else:
|
|
1670
|
+
logger.warning(f"Unknown clock subfunction: {subfunction:#04x}")
|
|
1671
|
+
return self._build_userdata_error_response(request, 0x8104)
|
|
1672
|
+
|
|
1673
|
+
def _handle_get_clock(
|
|
1674
|
+
self, request: Dict[str, Any], userdata_params: Dict[str, Any], client_address: Tuple[str, int]
|
|
1675
|
+
) -> bytes:
|
|
1676
|
+
"""
|
|
1677
|
+
Handle get clock request - returns current server time.
|
|
1678
|
+
|
|
1679
|
+
Returns time in BCD format (8 bytes):
|
|
1680
|
+
- Byte 0: Reserved (0x00)
|
|
1681
|
+
- Byte 1: Year (BCD, 0-99)
|
|
1682
|
+
- Byte 2: Month (BCD, 1-12)
|
|
1683
|
+
- Byte 3: Day (BCD, 1-31)
|
|
1684
|
+
- Byte 4: Hour (BCD, 0-23)
|
|
1685
|
+
- Byte 5: Minute (BCD, 0-59)
|
|
1686
|
+
- Byte 6: Second (BCD, 0-59)
|
|
1687
|
+
- Byte 7: Day of week (1=Monday)
|
|
1688
|
+
"""
|
|
1689
|
+
from datetime import datetime
|
|
1690
|
+
|
|
1691
|
+
now = datetime.now()
|
|
1692
|
+
|
|
1693
|
+
def to_bcd(value: int) -> int:
|
|
1694
|
+
return ((value // 10) << 4) | (value % 10)
|
|
1695
|
+
|
|
1696
|
+
year = now.year % 100
|
|
1697
|
+
bcd_time = struct.pack(
|
|
1698
|
+
">BBBBBBBB",
|
|
1699
|
+
0x00, # Reserved
|
|
1700
|
+
to_bcd(year), # Year (BCD)
|
|
1701
|
+
to_bcd(now.month), # Month (BCD)
|
|
1702
|
+
to_bcd(now.day), # Day (BCD)
|
|
1703
|
+
to_bcd(now.hour), # Hour (BCD)
|
|
1704
|
+
to_bcd(now.minute), # Minute (BCD)
|
|
1705
|
+
to_bcd(now.second), # Second (BCD)
|
|
1706
|
+
(now.weekday() + 1) & 0x0F, # Day of week (1=Monday)
|
|
1707
|
+
)
|
|
1708
|
+
|
|
1709
|
+
logger.debug(f"Get clock from {client_address}: returning {now}")
|
|
1710
|
+
return self._build_userdata_success_response(request, userdata_params, bcd_time)
|
|
1711
|
+
|
|
1712
|
+
def _handle_set_clock(
|
|
1713
|
+
self, request: Dict[str, Any], userdata_params: Dict[str, Any], client_address: Tuple[str, int]
|
|
1714
|
+
) -> bytes:
|
|
1715
|
+
"""
|
|
1716
|
+
Handle set clock request - accepts time setting.
|
|
1717
|
+
|
|
1718
|
+
The emulator logs the time but doesn't persist it (always returns current time on get).
|
|
1719
|
+
"""
|
|
1720
|
+
data_section = request.get("data", {})
|
|
1721
|
+
raw_data = data_section.get("data", b"")
|
|
1722
|
+
|
|
1723
|
+
if len(raw_data) >= 8:
|
|
1724
|
+
|
|
1725
|
+
def from_bcd(value: int) -> int:
|
|
1726
|
+
return ((value >> 4) * 10) + (value & 0x0F)
|
|
1727
|
+
|
|
1728
|
+
year = from_bcd(raw_data[1])
|
|
1729
|
+
month = from_bcd(raw_data[2])
|
|
1730
|
+
day = from_bcd(raw_data[3])
|
|
1731
|
+
hour = from_bcd(raw_data[4])
|
|
1732
|
+
minute = from_bcd(raw_data[5])
|
|
1733
|
+
second = from_bcd(raw_data[6])
|
|
1734
|
+
|
|
1735
|
+
logger.info(
|
|
1736
|
+
f"Set clock from {client_address}: 20{year:02d}-{month:02d}-{day:02d} {hour:02d}:{minute:02d}:{second:02d}"
|
|
1737
|
+
)
|
|
1738
|
+
else:
|
|
1739
|
+
logger.debug(f"Set clock from {client_address}: no time data provided")
|
|
1740
|
+
|
|
1741
|
+
# Return success (empty response data)
|
|
1742
|
+
return self._build_userdata_success_response(request, userdata_params, b"")
|
|
1743
|
+
|
|
1744
|
+
def _handle_security(
|
|
1745
|
+
self, request: Dict[str, Any], userdata_params: Dict[str, Any], client_address: Tuple[str, int]
|
|
1746
|
+
) -> bytes:
|
|
1747
|
+
"""
|
|
1748
|
+
Handle security requests (password operations).
|
|
1749
|
+
|
|
1750
|
+
Stub implementation - returns success (no password required).
|
|
1751
|
+
"""
|
|
1752
|
+
logger.debug(f"Security request from {client_address} (returning success)")
|
|
1753
|
+
# Return success - emulator doesn't require password
|
|
1754
|
+
return self._build_userdata_success_response(request, userdata_params, b"")
|
|
1755
|
+
|
|
1756
|
+
def _handle_list_all_blocks(
|
|
1757
|
+
self, request: Dict[str, Any], userdata_params: Dict[str, Any], client_address: Tuple[str, int]
|
|
1758
|
+
) -> bytes:
|
|
1759
|
+
"""
|
|
1760
|
+
Handle list all blocks request (SFun_ListAll).
|
|
1761
|
+
|
|
1762
|
+
Returns count of each block type (OB, FB, FC, DB, SDB, SFC, SFB).
|
|
1763
|
+
|
|
1764
|
+
Response data format (TDataFunListAll):
|
|
1765
|
+
For each block type (7 types):
|
|
1766
|
+
- Byte 0: 0x30 (indicator)
|
|
1767
|
+
- Byte 1: Block type code
|
|
1768
|
+
- Bytes 2-3: Block count (big-endian)
|
|
1769
|
+
|
|
1770
|
+
Args:
|
|
1771
|
+
request: Parsed S7 request
|
|
1772
|
+
userdata_params: Parsed USER_DATA parameters
|
|
1773
|
+
client_address: Client address
|
|
1774
|
+
|
|
1775
|
+
Returns:
|
|
1776
|
+
Response PDU with block counts
|
|
1777
|
+
"""
|
|
1778
|
+
logger.debug(f"List all blocks request from {client_address}")
|
|
1779
|
+
|
|
1780
|
+
# Count registered DB areas
|
|
1781
|
+
db_count = sum(1 for (area, _) in self.memory_areas.keys() if area == S7Area.DB)
|
|
1782
|
+
|
|
1783
|
+
# Block type codes (from C s7_types.h)
|
|
1784
|
+
BLOCK_OB = 0x38 # Organization Block
|
|
1785
|
+
BLOCK_DB = 0x41 # Data Block
|
|
1786
|
+
BLOCK_SDB = 0x42 # System Data Block
|
|
1787
|
+
BLOCK_FC = 0x43 # Function
|
|
1788
|
+
BLOCK_SFC = 0x44 # System Function
|
|
1789
|
+
BLOCK_FB = 0x45 # Function Block
|
|
1790
|
+
BLOCK_SFB = 0x46 # System Function Block
|
|
1791
|
+
|
|
1792
|
+
# Build response data - 4 bytes per block type, 7 block types
|
|
1793
|
+
# Format: 0x30 | block_type | count (2 bytes big-endian)
|
|
1794
|
+
data = b""
|
|
1795
|
+
for block_type, count in [
|
|
1796
|
+
(BLOCK_OB, 0), # No OBs in emulator
|
|
1797
|
+
(BLOCK_FB, 0), # No FBs
|
|
1798
|
+
(BLOCK_FC, 0), # No FCs
|
|
1799
|
+
(BLOCK_DB, db_count), # Registered DBs
|
|
1800
|
+
(BLOCK_SDB, 0), # No SDBs
|
|
1801
|
+
(BLOCK_SFC, 0), # No SFCs
|
|
1802
|
+
(BLOCK_SFB, 0), # No SFBs
|
|
1803
|
+
]:
|
|
1804
|
+
data += struct.pack(">BBH", 0x30, block_type, count)
|
|
1805
|
+
|
|
1806
|
+
logger.debug(f"List all blocks: DB count = {db_count}")
|
|
1807
|
+
return self._build_userdata_success_response(request, userdata_params, data)
|
|
1808
|
+
|
|
1809
|
+
def _handle_list_blocks_of_type(
|
|
1810
|
+
self, request: Dict[str, Any], userdata_params: Dict[str, Any], client_address: Tuple[str, int]
|
|
1811
|
+
) -> bytes:
|
|
1812
|
+
"""
|
|
1813
|
+
Handle list blocks of type request (SFun_ListBoT).
|
|
1814
|
+
|
|
1815
|
+
Returns list of block numbers for a specific block type.
|
|
1816
|
+
|
|
1817
|
+
Request data contains:
|
|
1818
|
+
- Block type code to query
|
|
1819
|
+
|
|
1820
|
+
Response data format:
|
|
1821
|
+
- 2 bytes per block: block number (big-endian)
|
|
1822
|
+
|
|
1823
|
+
Args:
|
|
1824
|
+
request: Parsed S7 request
|
|
1825
|
+
userdata_params: Parsed USER_DATA parameters
|
|
1826
|
+
client_address: Client address
|
|
1827
|
+
|
|
1828
|
+
Returns:
|
|
1829
|
+
Response PDU with block numbers
|
|
1830
|
+
"""
|
|
1831
|
+
logger.debug(f"List blocks of type request from {client_address}")
|
|
1832
|
+
|
|
1833
|
+
# Get requested block type from request data section
|
|
1834
|
+
data_section = request.get("data", {})
|
|
1835
|
+
raw_data = data_section.get("data", b"")
|
|
1836
|
+
|
|
1837
|
+
# Block type code constants
|
|
1838
|
+
block_db = 0x41 # Data Block
|
|
1839
|
+
|
|
1840
|
+
# Handle both new format [0x30, type] and old format [type]
|
|
1841
|
+
if len(raw_data) >= 2 and raw_data[0] == 0x30:
|
|
1842
|
+
requested_type = raw_data[1]
|
|
1843
|
+
elif len(raw_data) > 0:
|
|
1844
|
+
requested_type = raw_data[0]
|
|
1845
|
+
else:
|
|
1846
|
+
requested_type = block_db
|
|
1847
|
+
|
|
1848
|
+
# Currently only support DB type (others not implemented in emulator)
|
|
1849
|
+
if requested_type == block_db:
|
|
1850
|
+
# Get all registered DB numbers
|
|
1851
|
+
db_numbers = sorted([idx for (area, idx) in self.memory_areas.keys() if area == S7Area.DB])
|
|
1852
|
+
|
|
1853
|
+
# Build response data - 4 bytes per block (TDataFunGetBotItem:
|
|
1854
|
+
# BlockNum(2) + Unknown(1) + BlockLang(1))
|
|
1855
|
+
data = b""
|
|
1856
|
+
for db_num in db_numbers:
|
|
1857
|
+
data += struct.pack(">HBB", db_num, 0, 0)
|
|
1858
|
+
|
|
1859
|
+
logger.debug(f"List blocks of type DB: {db_numbers}")
|
|
1860
|
+
return self._build_userdata_success_response(request, userdata_params, data)
|
|
1861
|
+
else:
|
|
1862
|
+
# Other block types not available in emulator
|
|
1863
|
+
logger.debug(f"Block type {requested_type:#02x} not available")
|
|
1864
|
+
return self._build_userdata_success_response(request, userdata_params, b"")
|
|
1865
|
+
|
|
1866
|
+
def _handle_get_block_info(
|
|
1867
|
+
self, request: Dict[str, Any], userdata_params: Dict[str, Any], client_address: Tuple[str, int]
|
|
1868
|
+
) -> bytes:
|
|
1869
|
+
"""
|
|
1870
|
+
Handle get block info request (SFun_BlkInfo).
|
|
1871
|
+
|
|
1872
|
+
Returns information about a specific block.
|
|
1873
|
+
|
|
1874
|
+
Request data contains:
|
|
1875
|
+
- Block type code
|
|
1876
|
+
- Block number
|
|
1877
|
+
- Block language (optional)
|
|
1878
|
+
|
|
1879
|
+
Response data format (TS7BlockInfo):
|
|
1880
|
+
- Various block metadata fields
|
|
1881
|
+
|
|
1882
|
+
Args:
|
|
1883
|
+
request: Parsed S7 request
|
|
1884
|
+
userdata_params: Parsed USER_DATA parameters
|
|
1885
|
+
client_address: Client address
|
|
1886
|
+
|
|
1887
|
+
Returns:
|
|
1888
|
+
Response PDU with block info
|
|
1889
|
+
"""
|
|
1890
|
+
logger.debug(f"Get block info request from {client_address}")
|
|
1891
|
+
|
|
1892
|
+
# Get requested block from request data section
|
|
1893
|
+
data_section = request.get("data", {})
|
|
1894
|
+
raw_data = data_section.get("data", b"")
|
|
1895
|
+
|
|
1896
|
+
# Block type code constants
|
|
1897
|
+
block_db = 0x41 # Data Block
|
|
1898
|
+
|
|
1899
|
+
# Parse request: handle new format [0x30, type, 'A', ASCII_num(5)]
|
|
1900
|
+
# and old format [type, num(2), 0x41]
|
|
1901
|
+
if len(raw_data) >= 8 and raw_data[0] == 0x30:
|
|
1902
|
+
# New format: 0x30 + type + 'A' + 5-digit ASCII number
|
|
1903
|
+
requested_type = raw_data[1]
|
|
1904
|
+
try:
|
|
1905
|
+
block_number = int(raw_data[3:8].decode("ascii"))
|
|
1906
|
+
except (ValueError, UnicodeDecodeError):
|
|
1907
|
+
block_number = 1
|
|
1908
|
+
elif len(raw_data) >= 3:
|
|
1909
|
+
# Old format: type(1) + number(2) + filesystem(1)
|
|
1910
|
+
requested_type = raw_data[0]
|
|
1911
|
+
block_number = struct.unpack(">H", raw_data[1:3])[0]
|
|
1912
|
+
else:
|
|
1913
|
+
# Default values
|
|
1914
|
+
requested_type = block_db
|
|
1915
|
+
block_number = 1
|
|
1916
|
+
|
|
1917
|
+
# Check if block exists
|
|
1918
|
+
if requested_type == block_db:
|
|
1919
|
+
area_key = (S7Area.DB, block_number)
|
|
1920
|
+
if area_key in self.memory_areas:
|
|
1921
|
+
block_size = len(self.memory_areas[area_key])
|
|
1922
|
+
|
|
1923
|
+
# Build TResDataBlockInfo structure (78 bytes)
|
|
1924
|
+
# Layout per Snap7 C s7_types.h
|
|
1925
|
+
data = bytearray(78)
|
|
1926
|
+
data[0] = 0x30 # Const
|
|
1927
|
+
data[1] = requested_type # BlkType
|
|
1928
|
+
data[9] = 0 # BlkFlags
|
|
1929
|
+
data[10] = 0 # BlkLang
|
|
1930
|
+
data[11] = requested_type # SubBlkType
|
|
1931
|
+
struct.pack_into(">H", data, 12, block_number) # BlkNumber
|
|
1932
|
+
struct.pack_into(">I", data, 14, block_size) # LoadSize
|
|
1933
|
+
struct.pack_into(">H", data, 34, 0) # SBBLength
|
|
1934
|
+
struct.pack_into(">H", data, 38, 0) # LocalData
|
|
1935
|
+
struct.pack_into(">H", data, 40, block_size) # MC7Size
|
|
1936
|
+
# Author (8 bytes at offset 42)
|
|
1937
|
+
data[42:50] = b"SNAP7EMU"
|
|
1938
|
+
# Family (8 bytes at offset 50)
|
|
1939
|
+
data[50:58] = b"EMULATOR"
|
|
1940
|
+
# Header (8 bytes at offset 58)
|
|
1941
|
+
data[58:60] = b"DB"
|
|
1942
|
+
data[66] = 1 # Version
|
|
1943
|
+
|
|
1944
|
+
logger.debug(f"Get block info for DB{block_number}: size={block_size}")
|
|
1945
|
+
return self._build_userdata_success_response(request, userdata_params, bytes(data))
|
|
1946
|
+
else:
|
|
1947
|
+
logger.debug(f"Block DB{block_number} not found")
|
|
1948
|
+
return self._build_userdata_error_response(request, 0x8104) # Object not found
|
|
1949
|
+
else:
|
|
1950
|
+
# Other block types not available
|
|
1951
|
+
logger.debug(f"Block type {requested_type:#02x} not available")
|
|
1952
|
+
return self._build_userdata_error_response(request, 0x8104)
|
|
1953
|
+
|
|
1954
|
+
def _build_userdata_error_response(self, request: Dict[str, Any], error_code: int) -> bytes:
|
|
1955
|
+
"""
|
|
1956
|
+
Build USER_DATA error response PDU.
|
|
1957
|
+
|
|
1958
|
+
Args:
|
|
1959
|
+
request: Original request
|
|
1960
|
+
error_code: S7 error code
|
|
1961
|
+
|
|
1962
|
+
Returns:
|
|
1963
|
+
Error response PDU
|
|
1964
|
+
"""
|
|
1965
|
+
# USER_DATA response format is different from standard response
|
|
1966
|
+
# Parameter section (12-byte format per TS7Params7)
|
|
1967
|
+
param_data = struct.pack(
|
|
1968
|
+
">BBBBBBBBBBBB",
|
|
1969
|
+
0x00, # Reserved
|
|
1970
|
+
0x01, # Parameter count
|
|
1971
|
+
0x12, # Type/length header
|
|
1972
|
+
0x08, # Length (8 bytes following)
|
|
1973
|
+
0x12, # Method (response)
|
|
1974
|
+
0x84, # Type (8=response) | Group (4=SZL, but used for error)
|
|
1975
|
+
0x01, # Subfunction
|
|
1976
|
+
0x00, # Sequence
|
|
1977
|
+
0x00, # Data unit reference
|
|
1978
|
+
0x00, # Last data unit
|
|
1979
|
+
0x00, # Error code high
|
|
1980
|
+
0x00, # Error code low
|
|
1981
|
+
)
|
|
1982
|
+
|
|
1983
|
+
# Data section: return code only (error code in transport format)
|
|
1984
|
+
data_section = struct.pack(">BBH", (error_code >> 8) & 0xFF, 0x00, 0)
|
|
1985
|
+
|
|
1986
|
+
# Build S7 header for USERDATA (10 bytes, no error_class/error_code in header)
|
|
1987
|
+
header = struct.pack(
|
|
1988
|
+
">BBHHHH",
|
|
1989
|
+
0x32, # Protocol ID
|
|
1990
|
+
S7PDUType.USERDATA, # PDU type
|
|
1991
|
+
0x0000, # Reserved
|
|
1992
|
+
request.get("sequence", 0), # Sequence
|
|
1993
|
+
len(param_data), # Parameter length
|
|
1994
|
+
len(data_section), # Data length
|
|
1995
|
+
)
|
|
1996
|
+
|
|
1997
|
+
return header + param_data + data_section
|
|
1998
|
+
|
|
1999
|
+
def _build_userdata_success_response(self, request: Dict[str, Any], userdata_params: Dict[str, Any], data: bytes) -> bytes:
|
|
2000
|
+
"""
|
|
2001
|
+
Build USER_DATA success response PDU.
|
|
2002
|
+
|
|
2003
|
+
Args:
|
|
2004
|
+
request: Original request
|
|
2005
|
+
userdata_params: Parsed USER_DATA parameters
|
|
2006
|
+
data: Response data
|
|
2007
|
+
|
|
2008
|
+
Returns:
|
|
2009
|
+
Success response PDU
|
|
2010
|
+
"""
|
|
2011
|
+
group = userdata_params.get("group", 0)
|
|
2012
|
+
subfunction = userdata_params.get("subfunction", 0)
|
|
2013
|
+
seq = userdata_params.get("sequence", 0)
|
|
2014
|
+
|
|
2015
|
+
# Parameter section for success response (12-byte format per TS7Params7)
|
|
2016
|
+
param_data = struct.pack(
|
|
2017
|
+
">BBBBBBBBBBBB",
|
|
2018
|
+
0x00, # Reserved
|
|
2019
|
+
0x01, # Parameter count
|
|
2020
|
+
0x12, # Type/length header
|
|
2021
|
+
0x08, # Length (8 bytes following)
|
|
2022
|
+
0x12, # Method (response)
|
|
2023
|
+
0x80 | group, # Type (8=response) | Group
|
|
2024
|
+
subfunction, # Subfunction
|
|
2025
|
+
seq, # Sequence
|
|
2026
|
+
0x00, # Data unit reference
|
|
2027
|
+
0x00, # Last data unit
|
|
2028
|
+
0x00, # Error code high
|
|
2029
|
+
0x00, # Error code low
|
|
2030
|
+
)
|
|
2031
|
+
|
|
2032
|
+
# Data section: return code (0xFF = success) + data
|
|
2033
|
+
data_section = struct.pack(">BBH", 0xFF, 0x09, len(data)) + data
|
|
2034
|
+
|
|
2035
|
+
# Build S7 header for USERDATA (10 bytes, no error_class/error_code in header)
|
|
2036
|
+
header = struct.pack(
|
|
2037
|
+
">BBHHHH",
|
|
2038
|
+
0x32, # Protocol ID
|
|
2039
|
+
S7PDUType.USERDATA, # PDU type
|
|
2040
|
+
0x0000, # Reserved
|
|
2041
|
+
request.get("sequence", 0), # Sequence
|
|
2042
|
+
len(param_data), # Parameter length
|
|
2043
|
+
len(data_section), # Data length
|
|
2044
|
+
)
|
|
2045
|
+
|
|
2046
|
+
return header + param_data + data_section
|
|
2047
|
+
|
|
2048
|
+
# ========================================================================
|
|
2049
|
+
# Block Transfer Handlers (Upload/Download/Delete)
|
|
2050
|
+
# ========================================================================
|
|
2051
|
+
|
|
2052
|
+
def _handle_start_upload(self, request: Dict[str, Any], client_address: Tuple[str, int]) -> bytes:
|
|
2053
|
+
"""
|
|
2054
|
+
Handle start upload request.
|
|
2055
|
+
|
|
2056
|
+
Parses the block address and returns upload ID and block length.
|
|
2057
|
+
|
|
2058
|
+
Args:
|
|
2059
|
+
request: Parsed S7 request
|
|
2060
|
+
client_address: Client address for logging
|
|
2061
|
+
|
|
2062
|
+
Returns:
|
|
2063
|
+
Response PDU with upload ID and block length
|
|
2064
|
+
"""
|
|
2065
|
+
try:
|
|
2066
|
+
raw_params = request.get("raw_parameters", b"")
|
|
2067
|
+
|
|
2068
|
+
# Parse block address from parameters
|
|
2069
|
+
# Format: function + status + reserved + upload_id + block_addr_len + block_addr
|
|
2070
|
+
block_type = 0x41 # Default to DB
|
|
2071
|
+
block_num = 1
|
|
2072
|
+
|
|
2073
|
+
if len(raw_params) >= 10:
|
|
2074
|
+
addr_len = raw_params[9]
|
|
2075
|
+
if len(raw_params) >= 10 + addr_len:
|
|
2076
|
+
block_addr = raw_params[10 : 10 + addr_len]
|
|
2077
|
+
# Parse block address: type (2 hex) + num (5 digits) + filesystem
|
|
2078
|
+
try:
|
|
2079
|
+
block_type = int(block_addr[0:2], 16)
|
|
2080
|
+
block_num = int(block_addr[2:7])
|
|
2081
|
+
except (ValueError, IndexError):
|
|
2082
|
+
pass
|
|
2083
|
+
|
|
2084
|
+
logger.info(f"Start upload request from {client_address}: type={block_type:#02x}, num={block_num}")
|
|
2085
|
+
|
|
2086
|
+
# Generate upload ID and get block length
|
|
2087
|
+
upload_id = 1 # Simple upload ID
|
|
2088
|
+
block_length = 0
|
|
2089
|
+
|
|
2090
|
+
# Check if block exists
|
|
2091
|
+
if block_type == 0x41: # DB
|
|
2092
|
+
area_key = (S7Area.DB, block_num)
|
|
2093
|
+
if area_key in self.memory_areas:
|
|
2094
|
+
block_length = len(self.memory_areas[area_key])
|
|
2095
|
+
|
|
2096
|
+
# Store upload context for this client
|
|
2097
|
+
if not hasattr(self, "_upload_contexts"):
|
|
2098
|
+
self._upload_contexts: Dict[Tuple[str, int], Dict[str, Any]] = {}
|
|
2099
|
+
self._upload_contexts[client_address] = {
|
|
2100
|
+
"upload_id": upload_id,
|
|
2101
|
+
"block_type": block_type,
|
|
2102
|
+
"block_num": block_num,
|
|
2103
|
+
"offset": 0,
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
# Build response: function + status + reserved + upload_id + block_len_string_len + block_len_string
|
|
2107
|
+
block_len_str = f"{block_length:06d}".encode("ascii")
|
|
2108
|
+
param_data = (
|
|
2109
|
+
struct.pack(
|
|
2110
|
+
">BBBIB",
|
|
2111
|
+
S7Function.START_UPLOAD,
|
|
2112
|
+
0x00, # Status
|
|
2113
|
+
0x00, # Reserved
|
|
2114
|
+
upload_id,
|
|
2115
|
+
len(block_len_str),
|
|
2116
|
+
)
|
|
2117
|
+
+ block_len_str
|
|
2118
|
+
)
|
|
2119
|
+
|
|
2120
|
+
header = struct.pack(
|
|
2121
|
+
">BBHHHHBB",
|
|
2122
|
+
0x32, # Protocol ID
|
|
2123
|
+
S7PDUType.ACK_DATA, # PDU type
|
|
2124
|
+
0x0000, # Reserved
|
|
2125
|
+
request["sequence"], # Sequence
|
|
2126
|
+
len(param_data), # Parameter length
|
|
2127
|
+
0x0000, # Data length
|
|
2128
|
+
0x00, # Error class (success)
|
|
2129
|
+
0x00, # Error code (success)
|
|
2130
|
+
)
|
|
2131
|
+
|
|
2132
|
+
return header + param_data
|
|
2133
|
+
|
|
2134
|
+
except Exception as e:
|
|
2135
|
+
logger.error(f"Error handling start upload: {e}")
|
|
2136
|
+
return self._build_error_response(request, 0x8000)
|
|
2137
|
+
|
|
2138
|
+
def _handle_upload(self, request: Dict[str, Any], client_address: Tuple[str, int]) -> bytes:
|
|
2139
|
+
"""
|
|
2140
|
+
Handle upload request - return block data.
|
|
2141
|
+
|
|
2142
|
+
Args:
|
|
2143
|
+
request: Parsed S7 request
|
|
2144
|
+
client_address: Client address for logging
|
|
2145
|
+
|
|
2146
|
+
Returns:
|
|
2147
|
+
Response PDU with block data
|
|
2148
|
+
"""
|
|
2149
|
+
try:
|
|
2150
|
+
# Get upload context for this client
|
|
2151
|
+
if not hasattr(self, "_upload_contexts") or client_address not in self._upload_contexts:
|
|
2152
|
+
logger.warning(f"Upload request without start_upload from {client_address}")
|
|
2153
|
+
return self._build_error_response(request, 0x8104)
|
|
2154
|
+
|
|
2155
|
+
ctx = self._upload_contexts[client_address]
|
|
2156
|
+
block_type = ctx["block_type"]
|
|
2157
|
+
block_num = ctx["block_num"]
|
|
2158
|
+
|
|
2159
|
+
# Get block data
|
|
2160
|
+
block_data = b""
|
|
2161
|
+
if block_type == 0x41: # DB
|
|
2162
|
+
area_key = (S7Area.DB, block_num)
|
|
2163
|
+
if area_key in self.memory_areas:
|
|
2164
|
+
with self.area_locks[area_key]:
|
|
2165
|
+
block_data = bytes(self.memory_areas[area_key])
|
|
2166
|
+
|
|
2167
|
+
logger.info(f"Upload request from {client_address}: sending {len(block_data)} bytes")
|
|
2168
|
+
|
|
2169
|
+
# Build response with data
|
|
2170
|
+
# Status: 0x00 = more data, 0x01 = last packet
|
|
2171
|
+
param_data = struct.pack(
|
|
2172
|
+
">BBBI",
|
|
2173
|
+
S7Function.UPLOAD,
|
|
2174
|
+
0x01, # Status: last packet
|
|
2175
|
+
0x00, # Reserved
|
|
2176
|
+
ctx["upload_id"],
|
|
2177
|
+
)
|
|
2178
|
+
|
|
2179
|
+
# Data section: length (2 bytes) + unknown (2 bytes) + data
|
|
2180
|
+
data_section = struct.pack(">HH", len(block_data), 0x00FB) + block_data
|
|
2181
|
+
|
|
2182
|
+
header = struct.pack(
|
|
2183
|
+
">BBHHHHBB",
|
|
2184
|
+
0x32, # Protocol ID
|
|
2185
|
+
S7PDUType.ACK_DATA, # PDU type
|
|
2186
|
+
0x0000, # Reserved
|
|
2187
|
+
request["sequence"], # Sequence
|
|
2188
|
+
len(param_data), # Parameter length
|
|
2189
|
+
len(data_section), # Data length
|
|
2190
|
+
0x00, # Error class (success)
|
|
2191
|
+
0x00, # Error code (success)
|
|
2192
|
+
)
|
|
2193
|
+
|
|
2194
|
+
return header + param_data + data_section
|
|
2195
|
+
|
|
2196
|
+
except Exception as e:
|
|
2197
|
+
logger.error(f"Error handling upload: {e}")
|
|
2198
|
+
return self._build_error_response(request, 0x8000)
|
|
2199
|
+
|
|
2200
|
+
def _handle_end_upload(self, request: Dict[str, Any], client_address: Tuple[str, int]) -> bytes:
|
|
2201
|
+
"""
|
|
2202
|
+
Handle end upload request.
|
|
2203
|
+
|
|
2204
|
+
Args:
|
|
2205
|
+
request: Parsed S7 request
|
|
2206
|
+
client_address: Client address for logging
|
|
2207
|
+
|
|
2208
|
+
Returns:
|
|
2209
|
+
Response PDU acknowledging end of upload
|
|
2210
|
+
"""
|
|
2211
|
+
try:
|
|
2212
|
+
# Clean up upload context
|
|
2213
|
+
if hasattr(self, "_upload_contexts") and client_address in self._upload_contexts:
|
|
2214
|
+
del self._upload_contexts[client_address]
|
|
2215
|
+
|
|
2216
|
+
logger.info(f"End upload from {client_address}")
|
|
2217
|
+
|
|
2218
|
+
# Build simple response
|
|
2219
|
+
param_data = struct.pack(">B", S7Function.END_UPLOAD)
|
|
2220
|
+
|
|
2221
|
+
header = struct.pack(
|
|
2222
|
+
">BBHHHHBB",
|
|
2223
|
+
0x32, # Protocol ID
|
|
2224
|
+
S7PDUType.ACK_DATA, # PDU type
|
|
2225
|
+
0x0000, # Reserved
|
|
2226
|
+
request["sequence"], # Sequence
|
|
2227
|
+
len(param_data), # Parameter length
|
|
2228
|
+
0x0000, # Data length
|
|
2229
|
+
0x00, # Error class (success)
|
|
2230
|
+
0x00, # Error code (success)
|
|
2231
|
+
)
|
|
2232
|
+
|
|
2233
|
+
return header + param_data
|
|
2234
|
+
|
|
2235
|
+
except Exception as e:
|
|
2236
|
+
logger.error(f"Error handling end upload: {e}")
|
|
2237
|
+
return self._build_error_response(request, 0x8000)
|
|
2238
|
+
|
|
2239
|
+
def _handle_request_download(self, request: Dict[str, Any], client_address: Tuple[str, int]) -> bytes:
|
|
2240
|
+
"""
|
|
2241
|
+
Handle request download - acknowledge download request.
|
|
2242
|
+
|
|
2243
|
+
Args:
|
|
2244
|
+
request: Parsed S7 request
|
|
2245
|
+
client_address: Client address for logging
|
|
2246
|
+
|
|
2247
|
+
Returns:
|
|
2248
|
+
Response PDU acknowledging download request
|
|
2249
|
+
"""
|
|
2250
|
+
try:
|
|
2251
|
+
raw_params = request.get("raw_parameters", b"")
|
|
2252
|
+
|
|
2253
|
+
# Parse block address from parameters
|
|
2254
|
+
block_type = 0x41 # Default to DB
|
|
2255
|
+
block_num = 1
|
|
2256
|
+
|
|
2257
|
+
if len(raw_params) >= 6:
|
|
2258
|
+
addr_len = raw_params[5]
|
|
2259
|
+
if len(raw_params) >= 6 + addr_len:
|
|
2260
|
+
block_addr = raw_params[6 : 6 + addr_len]
|
|
2261
|
+
try:
|
|
2262
|
+
block_type = int(block_addr[0:2], 16)
|
|
2263
|
+
block_num = int(block_addr[2:7])
|
|
2264
|
+
except (ValueError, IndexError):
|
|
2265
|
+
pass
|
|
2266
|
+
|
|
2267
|
+
logger.info(f"Request download from {client_address}: type={block_type:#02x}, num={block_num}")
|
|
2268
|
+
|
|
2269
|
+
# Store download context
|
|
2270
|
+
if not hasattr(self, "_download_contexts"):
|
|
2271
|
+
self._download_contexts: Dict[Tuple[str, int], Dict[str, Any]] = {}
|
|
2272
|
+
self._download_contexts[client_address] = {
|
|
2273
|
+
"block_type": block_type,
|
|
2274
|
+
"block_num": block_num,
|
|
2275
|
+
"data": bytearray(),
|
|
2276
|
+
}
|
|
2277
|
+
|
|
2278
|
+
# Build response acknowledging download
|
|
2279
|
+
param_data = struct.pack(">B", S7Function.REQUEST_DOWNLOAD)
|
|
2280
|
+
|
|
2281
|
+
header = struct.pack(
|
|
2282
|
+
">BBHHHHBB",
|
|
2283
|
+
0x32, # Protocol ID
|
|
2284
|
+
S7PDUType.ACK_DATA, # PDU type
|
|
2285
|
+
0x0000, # Reserved
|
|
2286
|
+
request["sequence"], # Sequence
|
|
2287
|
+
len(param_data), # Parameter length
|
|
2288
|
+
0x0000, # Data length
|
|
2289
|
+
0x00, # Error class (success)
|
|
2290
|
+
0x00, # Error code (success)
|
|
2291
|
+
)
|
|
2292
|
+
|
|
2293
|
+
return header + param_data
|
|
2294
|
+
|
|
2295
|
+
except Exception as e:
|
|
2296
|
+
logger.error(f"Error handling request download: {e}")
|
|
2297
|
+
return self._build_error_response(request, 0x8000)
|
|
2298
|
+
|
|
2299
|
+
def _handle_download_block(self, request: Dict[str, Any], client_address: Tuple[str, int]) -> bytes:
|
|
2300
|
+
"""
|
|
2301
|
+
Handle download block - receive block data.
|
|
2302
|
+
|
|
2303
|
+
Args:
|
|
2304
|
+
request: Parsed S7 request
|
|
2305
|
+
client_address: Client address for logging
|
|
2306
|
+
|
|
2307
|
+
Returns:
|
|
2308
|
+
Response PDU acknowledging data receipt
|
|
2309
|
+
"""
|
|
2310
|
+
try:
|
|
2311
|
+
# Get download context
|
|
2312
|
+
if not hasattr(self, "_download_contexts") or client_address not in self._download_contexts:
|
|
2313
|
+
logger.warning(f"Download block without request_download from {client_address}")
|
|
2314
|
+
return self._build_error_response(request, 0x8104)
|
|
2315
|
+
|
|
2316
|
+
ctx = self._download_contexts[client_address]
|
|
2317
|
+
|
|
2318
|
+
# Extract data from request
|
|
2319
|
+
data_info = request.get("data", {})
|
|
2320
|
+
block_data = data_info.get("data", b"")
|
|
2321
|
+
|
|
2322
|
+
# Append data to context
|
|
2323
|
+
ctx["data"].extend(block_data)
|
|
2324
|
+
|
|
2325
|
+
logger.info(f"Download block from {client_address}: received {len(block_data)} bytes")
|
|
2326
|
+
|
|
2327
|
+
# Build response
|
|
2328
|
+
param_data = struct.pack(">B", S7Function.DOWNLOAD_BLOCK)
|
|
2329
|
+
|
|
2330
|
+
header = struct.pack(
|
|
2331
|
+
">BBHHHHBB",
|
|
2332
|
+
0x32, # Protocol ID
|
|
2333
|
+
S7PDUType.ACK_DATA, # PDU type
|
|
2334
|
+
0x0000, # Reserved
|
|
2335
|
+
request["sequence"], # Sequence
|
|
2336
|
+
len(param_data), # Parameter length
|
|
2337
|
+
0x0000, # Data length
|
|
2338
|
+
0x00, # Error class (success)
|
|
2339
|
+
0x00, # Error code (success)
|
|
2340
|
+
)
|
|
2341
|
+
|
|
2342
|
+
return header + param_data
|
|
2343
|
+
|
|
2344
|
+
except Exception as e:
|
|
2345
|
+
logger.error(f"Error handling download block: {e}")
|
|
2346
|
+
return self._build_error_response(request, 0x8000)
|
|
2347
|
+
|
|
2348
|
+
def _handle_download_ended(self, request: Dict[str, Any], client_address: Tuple[str, int]) -> bytes:
|
|
2349
|
+
"""
|
|
2350
|
+
Handle download ended - finalize block storage.
|
|
2351
|
+
|
|
2352
|
+
Args:
|
|
2353
|
+
request: Parsed S7 request
|
|
2354
|
+
client_address: Client address for logging
|
|
2355
|
+
|
|
2356
|
+
Returns:
|
|
2357
|
+
Response PDU confirming download complete
|
|
2358
|
+
"""
|
|
2359
|
+
try:
|
|
2360
|
+
# Get download context
|
|
2361
|
+
if not hasattr(self, "_download_contexts") or client_address not in self._download_contexts:
|
|
2362
|
+
logger.warning(f"Download ended without download_block from {client_address}")
|
|
2363
|
+
return self._build_error_response(request, 0x8104)
|
|
2364
|
+
|
|
2365
|
+
ctx = self._download_contexts[client_address]
|
|
2366
|
+
block_type = ctx["block_type"]
|
|
2367
|
+
block_num = ctx["block_num"]
|
|
2368
|
+
block_data = ctx["data"]
|
|
2369
|
+
|
|
2370
|
+
# Store block data
|
|
2371
|
+
if block_type == 0x41: # DB
|
|
2372
|
+
area_key = (S7Area.DB, block_num)
|
|
2373
|
+
if area_key in self.memory_areas:
|
|
2374
|
+
# Update existing area - copy data into existing area without resizing
|
|
2375
|
+
with self.area_locks[area_key]:
|
|
2376
|
+
existing_area = self.memory_areas[area_key]
|
|
2377
|
+
copy_len = min(len(block_data), len(existing_area))
|
|
2378
|
+
existing_area[0:copy_len] = block_data[0:copy_len]
|
|
2379
|
+
else:
|
|
2380
|
+
# Create new area
|
|
2381
|
+
self.memory_areas[area_key] = bytearray(block_data)
|
|
2382
|
+
self.area_locks[area_key] = threading.Lock()
|
|
2383
|
+
|
|
2384
|
+
logger.info(f"Download ended from {client_address}: stored {len(block_data)} bytes to {block_type:#02x}:{block_num}")
|
|
2385
|
+
|
|
2386
|
+
# Clean up context
|
|
2387
|
+
del self._download_contexts[client_address]
|
|
2388
|
+
|
|
2389
|
+
# Build response
|
|
2390
|
+
param_data = struct.pack(">B", S7Function.DOWNLOAD_ENDED)
|
|
2391
|
+
|
|
2392
|
+
header = struct.pack(
|
|
2393
|
+
">BBHHHHBB",
|
|
2394
|
+
0x32, # Protocol ID
|
|
2395
|
+
S7PDUType.ACK_DATA, # PDU type
|
|
2396
|
+
0x0000, # Reserved
|
|
2397
|
+
request["sequence"], # Sequence
|
|
2398
|
+
len(param_data), # Parameter length
|
|
2399
|
+
0x0000, # Data length
|
|
2400
|
+
0x00, # Error class (success)
|
|
2401
|
+
0x00, # Error code (success)
|
|
2402
|
+
)
|
|
2403
|
+
|
|
2404
|
+
return header + param_data
|
|
2405
|
+
|
|
2406
|
+
except Exception as e:
|
|
2407
|
+
logger.error(f"Error handling download ended: {e}")
|
|
2408
|
+
return self._build_error_response(request, 0x8000)
|
|
2409
|
+
|
|
2410
|
+
def __enter__(self) -> "Server":
|
|
2411
|
+
"""Context manager entry."""
|
|
2412
|
+
return self
|
|
2413
|
+
|
|
2414
|
+
def __exit__(
|
|
2415
|
+
self,
|
|
2416
|
+
exc_type: Optional[Type[BaseException]],
|
|
2417
|
+
exc_val: Optional[BaseException],
|
|
2418
|
+
exc_tb: Optional[TracebackType],
|
|
2419
|
+
) -> None:
|
|
2420
|
+
"""Context manager exit."""
|
|
2421
|
+
self.destroy()
|
|
2422
|
+
|
|
2423
|
+
|
|
2424
|
+
class ServerISOConnection:
|
|
2425
|
+
"""ISO connection wrapper for server-side communication."""
|
|
2426
|
+
|
|
2427
|
+
# COTP PDU types
|
|
2428
|
+
COTP_CR = 0xE0 # Connection Request
|
|
2429
|
+
COTP_CC = 0xD0 # Connection Confirm
|
|
2430
|
+
COTP_DR = 0x80 # Disconnect Request
|
|
2431
|
+
COTP_DC = 0xC0 # Disconnect Confirm
|
|
2432
|
+
COTP_DT = 0xF0 # Data Transfer
|
|
2433
|
+
|
|
2434
|
+
def __init__(self, client_socket: socket.socket):
|
|
2435
|
+
"""Initialize server ISO connection."""
|
|
2436
|
+
self.socket = client_socket
|
|
2437
|
+
self.socket.settimeout(5.0)
|
|
2438
|
+
self.connected = False
|
|
2439
|
+
self.src_ref = 0x0001 # Server reference
|
|
2440
|
+
self.dst_ref = 0x0000 # Client reference (assigned during handshake)
|
|
2441
|
+
|
|
2442
|
+
def accept_connection(self) -> bool:
|
|
2443
|
+
"""Accept ISO connection from client."""
|
|
2444
|
+
try:
|
|
2445
|
+
# Receive COTP Connection Request
|
|
2446
|
+
tpkt_header = self._recv_exact(4)
|
|
2447
|
+
version, reserved, length = struct.unpack(">BBH", tpkt_header)
|
|
2448
|
+
|
|
2449
|
+
if version != 3:
|
|
2450
|
+
logger.error(f"Invalid TPKT version: {version}")
|
|
2451
|
+
return False
|
|
2452
|
+
|
|
2453
|
+
payload = self._recv_exact(length - 4)
|
|
2454
|
+
|
|
2455
|
+
# Parse COTP Connection Request
|
|
2456
|
+
if not self._parse_cotp_cr(payload):
|
|
2457
|
+
return False
|
|
2458
|
+
|
|
2459
|
+
# Send COTP Connection Confirm
|
|
2460
|
+
cc_pdu = self._build_cotp_cc()
|
|
2461
|
+
tpkt_frame = self._build_tpkt(cc_pdu)
|
|
2462
|
+
self.socket.sendall(tpkt_frame)
|
|
2463
|
+
|
|
2464
|
+
self.connected = True
|
|
2465
|
+
logger.debug("ISO connection established")
|
|
2466
|
+
return True
|
|
2467
|
+
|
|
2468
|
+
except Exception as e:
|
|
2469
|
+
logger.error(f"Error accepting ISO connection: {e}")
|
|
2470
|
+
return False
|
|
2471
|
+
|
|
2472
|
+
def receive_data(self) -> bytes:
|
|
2473
|
+
"""Receive data from client."""
|
|
2474
|
+
# Receive TPKT header (4 bytes)
|
|
2475
|
+
tpkt_header = self._recv_exact(4)
|
|
2476
|
+
|
|
2477
|
+
# Parse TPKT header
|
|
2478
|
+
version, reserved, length = struct.unpack(">BBH", tpkt_header)
|
|
2479
|
+
|
|
2480
|
+
if version != 3:
|
|
2481
|
+
raise S7ConnectionError(f"Invalid TPKT version: {version}")
|
|
2482
|
+
|
|
2483
|
+
# Receive remaining data
|
|
2484
|
+
remaining = length - 4
|
|
2485
|
+
if remaining <= 0:
|
|
2486
|
+
raise S7ConnectionError("Invalid TPKT length")
|
|
2487
|
+
|
|
2488
|
+
payload = self._recv_exact(remaining)
|
|
2489
|
+
|
|
2490
|
+
# Parse COTP header and extract data
|
|
2491
|
+
return self._parse_cotp_data(payload)
|
|
2492
|
+
|
|
2493
|
+
def send_data(self, data: bytes) -> None:
|
|
2494
|
+
"""Send data to client."""
|
|
2495
|
+
# Wrap data in COTP Data Transfer PDU
|
|
2496
|
+
cotp_data = self._build_cotp_dt(data)
|
|
2497
|
+
|
|
2498
|
+
# Wrap in TPKT frame
|
|
2499
|
+
tpkt_frame = self._build_tpkt(cotp_data)
|
|
2500
|
+
|
|
2501
|
+
# Send over TCP
|
|
2502
|
+
self.socket.sendall(tpkt_frame)
|
|
2503
|
+
|
|
2504
|
+
def _parse_cotp_cr(self, data: bytes) -> bool:
|
|
2505
|
+
"""Parse COTP Connection Request."""
|
|
2506
|
+
if len(data) < 7:
|
|
2507
|
+
logger.error("COTP CR too short")
|
|
2508
|
+
return False
|
|
2509
|
+
|
|
2510
|
+
pdu_len, pdu_type, dst_ref, src_ref, class_opt = struct.unpack(">BBHHB", data[:7])
|
|
2511
|
+
|
|
2512
|
+
if pdu_type != self.COTP_CR:
|
|
2513
|
+
logger.error(f"Expected COTP CR, got {pdu_type:#02x}")
|
|
2514
|
+
return False
|
|
2515
|
+
|
|
2516
|
+
# Store client reference
|
|
2517
|
+
self.dst_ref = src_ref
|
|
2518
|
+
|
|
2519
|
+
logger.debug(f"Received COTP CR from client ref {src_ref}")
|
|
2520
|
+
return True
|
|
2521
|
+
|
|
2522
|
+
def _build_cotp_cc(self) -> bytes:
|
|
2523
|
+
"""Build COTP Connection Confirm."""
|
|
2524
|
+
# Basic COTP CC
|
|
2525
|
+
base_pdu = struct.pack(
|
|
2526
|
+
">BBHHB",
|
|
2527
|
+
6, # PDU length
|
|
2528
|
+
self.COTP_CC, # PDU type
|
|
2529
|
+
self.dst_ref, # Destination reference (client's source ref)
|
|
2530
|
+
self.src_ref, # Source reference (our ref)
|
|
2531
|
+
0x00, # Class/option
|
|
2532
|
+
)
|
|
2533
|
+
|
|
2534
|
+
return struct.pack(">B", 6) + base_pdu[1:]
|
|
2535
|
+
|
|
2536
|
+
def _recv_exact(self, size: int) -> bytes:
|
|
2537
|
+
"""Receive exactly the specified number of bytes."""
|
|
2538
|
+
data = bytearray()
|
|
2539
|
+
|
|
2540
|
+
while len(data) < size:
|
|
2541
|
+
chunk = self.socket.recv(size - len(data))
|
|
2542
|
+
if not chunk:
|
|
2543
|
+
raise ConnectionResetError("Connection closed by peer")
|
|
2544
|
+
data.extend(chunk)
|
|
2545
|
+
|
|
2546
|
+
return bytes(data)
|
|
2547
|
+
|
|
2548
|
+
def _build_tpkt(self, payload: bytes) -> bytes:
|
|
2549
|
+
"""Build TPKT frame."""
|
|
2550
|
+
length = len(payload) + 4
|
|
2551
|
+
return struct.pack(">BBH", 3, 0, length) + payload
|
|
2552
|
+
|
|
2553
|
+
def _build_cotp_dt(self, data: bytes) -> bytes:
|
|
2554
|
+
"""Build COTP Data Transfer PDU."""
|
|
2555
|
+
header = struct.pack(">BBB", 2, self.COTP_DT, 0x80)
|
|
2556
|
+
return header + data
|
|
2557
|
+
|
|
2558
|
+
def _parse_cotp_data(self, cotp_pdu: bytes) -> bytes:
|
|
2559
|
+
"""Parse COTP Data Transfer PDU and extract S7 data."""
|
|
2560
|
+
if len(cotp_pdu) < 3:
|
|
2561
|
+
raise S7ConnectionError("Invalid COTP DT: too short")
|
|
2562
|
+
|
|
2563
|
+
pdu_len, pdu_type, eot_num = struct.unpack(">BBB", cotp_pdu[:3])
|
|
2564
|
+
|
|
2565
|
+
if pdu_type != self.COTP_DT:
|
|
2566
|
+
raise S7ConnectionError(f"Expected COTP DT, got {pdu_type:#02x}")
|
|
2567
|
+
|
|
2568
|
+
return cotp_pdu[3:] # Return data portion
|
|
2569
|
+
|
|
2570
|
+
|
|
2571
|
+
def mainloop(tcp_port: int = 1102, init_standard_values: bool = False) -> None:
|
|
2572
|
+
"""
|
|
2573
|
+
Initialize a pure Python S7 server with default values.
|
|
2574
|
+
|
|
2575
|
+
Args:
|
|
2576
|
+
tcp_port: Port that the server will listen on
|
|
2577
|
+
init_standard_values: If True, initialize some default values
|
|
2578
|
+
"""
|
|
2579
|
+
server = Server()
|
|
2580
|
+
|
|
2581
|
+
# Create standard memory areas - need at least 600 bytes for test data
|
|
2582
|
+
db_size = 600
|
|
2583
|
+
db_data = bytearray(db_size)
|
|
2584
|
+
pa_data = bytearray(100)
|
|
2585
|
+
pe_data = bytearray(100)
|
|
2586
|
+
mk_data = bytearray(100)
|
|
2587
|
+
tm_data = bytearray(100)
|
|
2588
|
+
ct_data = bytearray(100)
|
|
2589
|
+
|
|
2590
|
+
# Register memory areas
|
|
2591
|
+
# DB 0 for test_mainloop.py, DB 1 for other tests
|
|
2592
|
+
server.register_area(SrvArea.DB, 0, db_data)
|
|
2593
|
+
server.register_area(SrvArea.DB, 1, bytearray(db_size))
|
|
2594
|
+
# Register at index 0 (used by most tests) and index 1
|
|
2595
|
+
server.register_area(SrvArea.PA, 0, pa_data)
|
|
2596
|
+
server.register_area(SrvArea.PA, 1, bytearray(100))
|
|
2597
|
+
server.register_area(SrvArea.PE, 0, pe_data)
|
|
2598
|
+
server.register_area(SrvArea.PE, 1, bytearray(100))
|
|
2599
|
+
server.register_area(SrvArea.MK, 0, mk_data)
|
|
2600
|
+
server.register_area(SrvArea.MK, 1, bytearray(100))
|
|
2601
|
+
server.register_area(SrvArea.TM, 0, tm_data)
|
|
2602
|
+
server.register_area(SrvArea.TM, 1, bytearray(100))
|
|
2603
|
+
server.register_area(SrvArea.CT, 0, ct_data)
|
|
2604
|
+
server.register_area(SrvArea.CT, 1, bytearray(100))
|
|
2605
|
+
|
|
2606
|
+
if init_standard_values:
|
|
2607
|
+
logger.info("Initializing with standard values for tests")
|
|
2608
|
+
|
|
2609
|
+
# test_read_booleans: offset 0, expects 0xAA (alternating False/True: 0,1,0,1,0,1,0,1)
|
|
2610
|
+
db_data[0] = 0xAA # Binary: 10101010
|
|
2611
|
+
|
|
2612
|
+
# test_read_small_int: offset 10, expects -128, 0, 100, 127 (signed bytes)
|
|
2613
|
+
db_data[10] = 0x80 # -128 as signed byte
|
|
2614
|
+
db_data[11] = 0x00 # 0
|
|
2615
|
+
db_data[12] = 100 # 100
|
|
2616
|
+
db_data[13] = 127 # 127
|
|
2617
|
+
|
|
2618
|
+
# test_read_unsigned_small_int: offset 20, expects 0, 255
|
|
2619
|
+
db_data[20] = 0 # 0
|
|
2620
|
+
db_data[21] = 255 # 255
|
|
2621
|
+
|
|
2622
|
+
# test_read_int: offset 30, expects -32768, -1234, 0, 1234, 32767 (signed 16-bit, big-endian)
|
|
2623
|
+
struct.pack_into(">h", db_data, 30, -32768)
|
|
2624
|
+
struct.pack_into(">h", db_data, 32, -1234)
|
|
2625
|
+
struct.pack_into(">h", db_data, 34, 0)
|
|
2626
|
+
struct.pack_into(">h", db_data, 36, 1234)
|
|
2627
|
+
struct.pack_into(">h", db_data, 38, 32767)
|
|
2628
|
+
|
|
2629
|
+
# test_read_double_int: offset 40, expects -2147483648, -32768, 0, 32767, 2147483647 (signed 32-bit)
|
|
2630
|
+
struct.pack_into(">i", db_data, 40, -2147483648)
|
|
2631
|
+
struct.pack_into(">i", db_data, 44, -32768)
|
|
2632
|
+
struct.pack_into(">i", db_data, 48, 0)
|
|
2633
|
+
struct.pack_into(">i", db_data, 52, 32767)
|
|
2634
|
+
struct.pack_into(">i", db_data, 56, 2147483647)
|
|
2635
|
+
|
|
2636
|
+
# test_read_real: offset 60, expects various float values (9 floats = 36 bytes)
|
|
2637
|
+
struct.pack_into(">f", db_data, 60, -3.402823e38)
|
|
2638
|
+
struct.pack_into(">f", db_data, 64, -3.402823e12)
|
|
2639
|
+
struct.pack_into(">f", db_data, 68, -175494351e-38)
|
|
2640
|
+
struct.pack_into(">f", db_data, 72, -1.175494351e-12)
|
|
2641
|
+
struct.pack_into(">f", db_data, 76, 0.0)
|
|
2642
|
+
struct.pack_into(">f", db_data, 80, 1.175494351e-38)
|
|
2643
|
+
struct.pack_into(">f", db_data, 84, 1.175494351e-12)
|
|
2644
|
+
struct.pack_into(">f", db_data, 88, 3.402823466e12)
|
|
2645
|
+
struct.pack_into(">f", db_data, 92, 3.402823466e38)
|
|
2646
|
+
|
|
2647
|
+
# test_read_string: offset 100, expects "the brown fox jumps over the lazy dog"
|
|
2648
|
+
# S7 string format: max_len (1 byte), actual_len (1 byte), then string data
|
|
2649
|
+
test_string = "the brown fox jumps over the lazy dog"
|
|
2650
|
+
db_data[100] = 254 # Max length
|
|
2651
|
+
db_data[101] = len(test_string) # Actual length
|
|
2652
|
+
db_data[102 : 102 + len(test_string)] = test_string.encode("ascii")
|
|
2653
|
+
|
|
2654
|
+
# test_read_word: offset 400, expects 0x0000, 0x1234, 0xABCD, 0xFFFF (unsigned 16-bit)
|
|
2655
|
+
struct.pack_into(">H", db_data, 400, 0x0000)
|
|
2656
|
+
struct.pack_into(">H", db_data, 404, 0x1234)
|
|
2657
|
+
struct.pack_into(">H", db_data, 408, 0xABCD)
|
|
2658
|
+
struct.pack_into(">H", db_data, 412, 0xFFFF)
|
|
2659
|
+
|
|
2660
|
+
# test_read_double_word: offset 500, expects 0x00000000, 0x12345678, 0x1234ABCD, 0xFFFFFFFF (unsigned 32-bit)
|
|
2661
|
+
struct.pack_into(">I", db_data, 500, 0x00000000)
|
|
2662
|
+
struct.pack_into(">I", db_data, 508, 0x12345678)
|
|
2663
|
+
struct.pack_into(">I", db_data, 516, 0x1234ABCD)
|
|
2664
|
+
struct.pack_into(">I", db_data, 524, 0xFFFFFFFF)
|
|
2665
|
+
|
|
2666
|
+
# Start server
|
|
2667
|
+
server.start(tcp_port)
|
|
2668
|
+
|
|
2669
|
+
try:
|
|
2670
|
+
logger.info(f"Pure Python S7 server running on port {tcp_port}")
|
|
2671
|
+
logger.info("Press Ctrl+C to stop")
|
|
2672
|
+
|
|
2673
|
+
# Keep server running
|
|
2674
|
+
while True:
|
|
2675
|
+
time.sleep(1)
|
|
2676
|
+
|
|
2677
|
+
except KeyboardInterrupt:
|
|
2678
|
+
logger.info("Stopping server...")
|
|
2679
|
+
finally:
|
|
2680
|
+
server.stop()
|
|
2681
|
+
server.destroy()
|