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.
@@ -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()