profinet-py 0.4.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.
profinet/dcp.py ADDED
@@ -0,0 +1,1510 @@
1
+ """
2
+ PROFINET DCP (Discovery and Configuration Protocol) implementation.
3
+
4
+ Provides device discovery and basic configuration operations:
5
+ - send_discover(): Multicast discovery request
6
+ - read_response(): Collect and parse discovery responses
7
+ - get_param(): Read device parameter (name, IP)
8
+ - set_param(): Write device parameter
9
+
10
+ Credits:
11
+ Original implementation by Alfred Krohmer (2015)
12
+ https://github.com/alfredkrohmer/profinet
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+ import random
19
+ import struct
20
+ import time
21
+ from collections.abc import Callable
22
+ from dataclasses import dataclass
23
+ from socket import socket
24
+ from struct import unpack
25
+ from typing import Any, Dict, List, Optional, Tuple
26
+
27
+ from .exceptions import DCPError
28
+ from .protocol import (
29
+ EthernetHeader,
30
+ PNDCPBlock,
31
+ PNDCPBlockRequest,
32
+ PNDCPHeader,
33
+ )
34
+ from .util import (
35
+ MAX_ETHERNET_FRAME,
36
+ PROFINET_ETHERTYPE,
37
+ VLAN_ETHERTYPE,
38
+ ip2s,
39
+ mac2s,
40
+ max_timeout,
41
+ s2ip,
42
+ s2mac,
43
+ )
44
+ from .vendors import get_vendor_name
45
+
46
+ logger = logging.getLogger(__name__)
47
+
48
+ # =============================================================================
49
+ # Constants
50
+ # =============================================================================
51
+
52
+ # DCP multicast address
53
+ DCP_MULTICAST_MAC = "01:0e:cf:00:00:00"
54
+
55
+ # DCP Frame IDs
56
+ DCP_IDENTIFY_FRAME_ID = 0xFEFE
57
+ DCP_GET_SET_FRAME_ID = 0xFEFD
58
+ DCP_HELLO_FRAME_ID = 0xFEFC
59
+
60
+ # DCP Service IDs
61
+ DCP_SERVICE_ID_GET = 0x03
62
+ DCP_SERVICE_ID_SET = 0x04
63
+ DCP_SERVICE_ID_IDENTIFY = 0x05
64
+ DCP_SERVICE_ID_HELLO = 0x06
65
+
66
+ # DCP Service Types
67
+ DCP_SERVICE_TYPE_REQUEST = 0x00
68
+ DCP_SERVICE_TYPE_RESPONSE_SUCCESS = 0x01
69
+ DCP_SERVICE_TYPE_RESPONSE_UNSUPPORTED = 0x05
70
+
71
+ # DCP maximum name length (IEC 61158-6-10)
72
+ DCP_MAX_NAME_LENGTH = 240
73
+
74
+ # DCP Options
75
+ DCP_OPTION_IP = 0x01
76
+ DCP_OPTION_DEVICE = 0x02
77
+ DCP_OPTION_DHCP = 0x03
78
+ DCP_OPTION_LLDP = 0x04
79
+ DCP_OPTION_CONTROL = 0x05
80
+ DCP_OPTION_DEVICE_INITIATIVE = 0x06
81
+ DCP_OPTION_NME = 0x07
82
+ DCP_OPTION_ALL = 0xFF
83
+
84
+ # DCP SubOptions for IP (Option 1)
85
+ DCP_SUBOPTION_IP_MAC = 0x01
86
+ DCP_SUBOPTION_IP_PARAMETER = 0x02
87
+ DCP_SUBOPTION_IP_FULL_SUITE = 0x03
88
+
89
+ # DCP SubOptions for Device (Option 2)
90
+ DCP_SUBOPTION_DEVICE_TYPE = 0x01 # Type of Station / Manufacturer specific
91
+ DCP_SUBOPTION_DEVICE_NAME = 0x02 # Name of Station
92
+ DCP_SUBOPTION_DEVICE_ID = 0x03 # Device ID (Vendor + Device)
93
+ DCP_SUBOPTION_DEVICE_ROLE = 0x04 # Device Role
94
+ DCP_SUBOPTION_DEVICE_OPTIONS = 0x05 # Supported options list
95
+ DCP_SUBOPTION_DEVICE_ALIAS = 0x06 # Alias Name
96
+ DCP_SUBOPTION_DEVICE_INSTANCE = 0x07 # Device Instance
97
+ DCP_SUBOPTION_DEVICE_OEM_ID = 0x08 # OEM Device ID
98
+ DCP_SUBOPTION_DEVICE_RSI = 0x0A # RSI Properties
99
+
100
+ # DCP SubOptions for Control (Option 5)
101
+ DCP_SUBOPTION_CONTROL_START = 0x01
102
+ DCP_SUBOPTION_CONTROL_STOP = 0x02
103
+ DCP_SUBOPTION_CONTROL_SIGNAL = 0x03
104
+ DCP_SUBOPTION_CONTROL_RESPONSE = 0x04
105
+ DCP_SUBOPTION_CONTROL_RESET_FACTORY = 0x05
106
+ DCP_SUBOPTION_CONTROL_RESET_TO_FACTORY = 0x06
107
+
108
+ # DCP SubOptions for DHCP (Option 3) - Standard DHCP option codes
109
+ DCP_SUBOPTION_DHCP_HOSTNAME = 0x0C # 12 - Host name
110
+ DCP_SUBOPTION_DHCP_VENDOR_SPEC = 0x2B # 43 - Vendor specific
111
+ DCP_SUBOPTION_DHCP_SERVER_ID = 0x36 # 54 - Server identifier
112
+ DCP_SUBOPTION_DHCP_PARAM_REQ = 0x37 # 55 - Parameter request list
113
+ DCP_SUBOPTION_DHCP_CLASS_ID = 0x3C # 60 - Class identifier
114
+ DCP_SUBOPTION_DHCP_CLIENT_ID = 0x3D # 61 - DHCP client identifier
115
+ DCP_SUBOPTION_DHCP_FQDN = 0x51 # 81 - FQDN
116
+ DCP_SUBOPTION_DHCP_UUID = 0x61 # 97 - UUID/GUID-based Client
117
+ DCP_SUBOPTION_DHCP_CONTROL = 0xFF # 255 - Control DHCP
118
+
119
+ # DCP SubOptions for LLDP (Option 4)
120
+ DCP_SUBOPTION_LLDP_PORT_ID = 0x01
121
+ DCP_SUBOPTION_LLDP_CHASSIS_ID = 0x02
122
+ DCP_SUBOPTION_LLDP_TTL = 0x03
123
+ DCP_SUBOPTION_LLDP_PORT_DESC = 0x04
124
+ DCP_SUBOPTION_LLDP_SYSTEM_NAME = 0x05
125
+ DCP_SUBOPTION_LLDP_SYSTEM_DESC = 0x06
126
+ DCP_SUBOPTION_LLDP_SYSTEM_CAP = 0x07
127
+ DCP_SUBOPTION_LLDP_MGMT_ADDR = 0x08
128
+
129
+ # DCP SubOptions for DeviceInitiative (Option 6)
130
+ DCP_SUBOPTION_DEVICE_INITIATIVE = 0x01
131
+
132
+ # Manufacturer-specific options (0x80-0xFE per IEC 61158-6-10)
133
+ DCP_OPTION_MANUF_MIN = 0x80
134
+ DCP_OPTION_MANUF_MAX = 0xFE
135
+ DCP_OPTION_MANUF_X80 = 0x80
136
+ DCP_OPTION_MANUF_X81 = 0x81
137
+ DCP_OPTION_MANUF_X82 = 0x82
138
+ DCP_OPTION_MANUF_X83 = 0x83
139
+ DCP_OPTION_MANUF_X84 = 0x84
140
+ DCP_OPTION_MANUF_X85 = 0x85
141
+ DCP_OPTION_MANUF_X86 = 0x86
142
+
143
+ # Device Role bit masks
144
+ DEVICE_ROLE_IO_DEVICE = 0x01
145
+ DEVICE_ROLE_IO_CONTROLLER = 0x02
146
+ DEVICE_ROLE_IO_MULTIDEVICE = 0x04
147
+ DEVICE_ROLE_PN_SUPERVISOR = 0x08
148
+
149
+ # Device Role names
150
+ DEVICE_ROLE_NAMES = {
151
+ DEVICE_ROLE_IO_DEVICE: "IO-Device",
152
+ DEVICE_ROLE_IO_CONTROLLER: "IO-Controller",
153
+ DEVICE_ROLE_IO_MULTIDEVICE: "IO-Multidevice",
154
+ DEVICE_ROLE_PN_SUPERVISOR: "PN-Supervisor",
155
+ }
156
+
157
+
158
+ def decode_device_role(role_byte: int) -> List[str]:
159
+ """Decode device role bitmask to list of role names."""
160
+ roles = []
161
+ for mask, name in DEVICE_ROLE_NAMES.items():
162
+ if role_byte & mask:
163
+ roles.append(name)
164
+ return roles if roles else ["Unknown"]
165
+
166
+
167
+ # Option names for display
168
+ DCP_OPTION_NAMES = {
169
+ 0x01: "IP",
170
+ 0x02: "Device",
171
+ 0x03: "DHCP",
172
+ 0x04: "LLDP",
173
+ 0x05: "Control",
174
+ 0x06: "DeviceInitiative",
175
+ 0x07: "NME",
176
+ 0xFF: "All",
177
+ }
178
+
179
+ # Suboption names per option
180
+ DCP_SUBOPTION_NAMES = {
181
+ 0x01: { # IP
182
+ 0x01: "MAC",
183
+ 0x02: "IP",
184
+ 0x03: "FullIPSuite",
185
+ },
186
+ 0x02: { # Device
187
+ 0x01: "Type",
188
+ 0x02: "Name",
189
+ 0x03: "DeviceID",
190
+ 0x04: "Role",
191
+ 0x05: "Options",
192
+ 0x06: "Alias",
193
+ 0x07: "Instance",
194
+ 0x08: "OEM-ID",
195
+ 0x09: "StandardGateway",
196
+ 0x0A: "RSI",
197
+ },
198
+ 0x03: { # DHCP
199
+ 0x0C: "Hostname",
200
+ 0x2B: "VendorSpec",
201
+ 0x36: "ServerID",
202
+ 0x37: "ParamReq",
203
+ 0x3C: "ClassID",
204
+ 0x3D: "ClientID",
205
+ 0x51: "FQDN",
206
+ 0x61: "UUID",
207
+ 0xFF: "Control",
208
+ },
209
+ 0x04: { # LLDP
210
+ 0x01: "PortID",
211
+ 0x02: "ChassisID",
212
+ 0x03: "TTL",
213
+ 0x04: "PortDescription",
214
+ 0x05: "SystemName",
215
+ 0x06: "SystemDescription",
216
+ 0x07: "SystemCapabilities",
217
+ 0x08: "ManagementAddress",
218
+ },
219
+ 0x05: { # Control
220
+ 0x01: "Start",
221
+ 0x02: "Stop",
222
+ 0x03: "Signal",
223
+ 0x04: "Response",
224
+ 0x05: "FactoryReset",
225
+ 0x06: "ResetToFactory",
226
+ },
227
+ 0x06: { # DeviceInitiative
228
+ 0x01: "Initiative",
229
+ },
230
+ }
231
+
232
+
233
+ def get_block_name(option: int, suboption: int) -> str:
234
+ """Get human-readable name for a DCP block."""
235
+ opt_name = DCP_OPTION_NAMES.get(option)
236
+ if opt_name is None:
237
+ if 0x80 <= option <= 0xFE:
238
+ opt_name = f"Vendor-0x{option:02X}"
239
+ else:
240
+ opt_name = f"Opt-0x{option:02X}"
241
+
242
+ subopt_names = DCP_SUBOPTION_NAMES.get(option, {})
243
+ subopt_name = subopt_names.get(suboption)
244
+ if subopt_name is None:
245
+ subopt_name = f"0x{suboption:02X}"
246
+
247
+ return f"{opt_name}/{subopt_name}"
248
+
249
+ # Legacy reset mode constants (kept for compatibility)
250
+ RESET_MODE_COMMUNICATION = 0x0002
251
+ RESET_MODE_APPLICATION = 0x0004
252
+ RESET_MODE_ENGINEERING = 0x0008
253
+ RESET_MODE_ALL_DATA = 0x0010
254
+ RESET_MODE_DEVICE = 0x0020
255
+ RESET_MODE_FACTORY = 0x0040
256
+
257
+
258
+ class IPBlockInfo:
259
+ """IP Block Info values (IEC 61158-6-10).
260
+
261
+ These values indicate the IP configuration status of a device.
262
+ """
263
+
264
+ IP_NOT_SET = 0x0000
265
+ IP_SET = 0x0001
266
+ IP_SET_BY_DHCP = 0x0002
267
+ IP_NOT_SET_CONFLICT = 0x0080
268
+ IP_SET_CONFLICT = 0x0081
269
+ IP_SET_BY_DHCP_CONFLICT = 0x0082
270
+
271
+ NAMES = {
272
+ 0x0000: "IP not set",
273
+ 0x0001: "IP set",
274
+ 0x0002: "IP set by DHCP",
275
+ 0x0080: "IP not set (address conflict detected)",
276
+ 0x0081: "IP set (address conflict detected)",
277
+ 0x0082: "IP set by DHCP (address conflict detected)",
278
+ }
279
+
280
+ @classmethod
281
+ def get_name(cls, info: int) -> str:
282
+ """Get human-readable name for IP block info."""
283
+ return cls.NAMES.get(info, f"Unknown (0x{info:04X})")
284
+
285
+ @classmethod
286
+ def has_conflict(cls, info: int) -> bool:
287
+ """Check if IP block info indicates address conflict."""
288
+ return (info & 0x0080) != 0
289
+
290
+ @classmethod
291
+ def is_dhcp(cls, info: int) -> bool:
292
+ """Check if IP was set by DHCP."""
293
+ return (info & 0x0002) != 0
294
+
295
+
296
+ class BlockQualifier:
297
+ """Block Qualifier values for SET operations (IEC 61158-6-10)."""
298
+
299
+ TEMPORARY = 0x0000
300
+ PERMANENT = 0x0001
301
+
302
+ NAMES = {
303
+ 0x0000: "Temporary",
304
+ 0x0001: "Permanent",
305
+ }
306
+
307
+ @classmethod
308
+ def get_name(cls, qualifier: int) -> str:
309
+ """Get human-readable name for block qualifier."""
310
+ return cls.NAMES.get(qualifier, f"Unknown (0x{qualifier:04X})")
311
+
312
+
313
+ class ResetQualifier:
314
+ """Reset to Factory qualifier values (IEC 61158-6-10)."""
315
+
316
+ # Mode 1: Reset application data
317
+ RESET_APPLICATION_DATA = 0x0002
318
+ RESET_APPLICATION_DATA_ALT = 0x0003
319
+
320
+ # Mode 2: Reset communication parameters
321
+ RESET_COMMUNICATION_PARAM = 0x0004
322
+ RESET_COMMUNICATION_PARAM_ALT = 0x0005
323
+
324
+ # Mode 3: Reset engineering parameters
325
+ RESET_ENGINEERING_PARAM = 0x0006
326
+ RESET_ENGINEERING_PARAM_ALT = 0x0007
327
+
328
+ # Mode 4: Reset all stored data
329
+ RESET_ALL_STORED_DATA = 0x0008
330
+ RESET_ALL_STORED_DATA_ALT = 0x0009
331
+
332
+ # Mode 5: Reset engineering parameter (alternate)
333
+ RESET_ENGINEERING_PARAM_2 = 0x000A
334
+ RESET_ENGINEERING_PARAM_2_ALT = 0x000B
335
+
336
+ # Mode 8: Reset to factory values
337
+ RESET_TO_FACTORY = 0x0010
338
+ RESET_TO_FACTORY_ALT = 0x0011
339
+
340
+ # Mode 9: Reset and restore data
341
+ RESET_AND_RESTORE = 0x0012
342
+ RESET_AND_RESTORE_ALT = 0x0013
343
+
344
+ NAMES = {
345
+ 0x0002: "Reset application data",
346
+ 0x0003: "Reset application data",
347
+ 0x0004: "Reset communication parameter",
348
+ 0x0005: "Reset communication parameter",
349
+ 0x0006: "Reset engineering parameter",
350
+ 0x0007: "Reset engineering parameter",
351
+ 0x0008: "Reset all stored data",
352
+ 0x0009: "Reset all stored data",
353
+ 0x000A: "Reset engineering parameter",
354
+ 0x000B: "Reset engineering parameter",
355
+ 0x0010: "Reset to factory values",
356
+ 0x0011: "Reset to factory values",
357
+ 0x0012: "Reset and restore data",
358
+ 0x0013: "Reset and restore data",
359
+ }
360
+
361
+ @classmethod
362
+ def get_name(cls, qualifier: int) -> str:
363
+ """Get human-readable name for reset qualifier."""
364
+ return cls.NAMES.get(qualifier, f"Unknown (0x{qualifier:04X})")
365
+
366
+
367
+ class DeviceInitiative:
368
+ """DeviceInitiative values (IEC 61158-6-10)."""
369
+
370
+ NO_HELLO = 0x0000
371
+ ISSUE_HELLO = 0x0001
372
+
373
+ NAMES = {
374
+ 0x0000: "Device does not issue DCP-Hello after power on",
375
+ 0x0001: "Device issues DCP-Hello after power on",
376
+ }
377
+
378
+ @classmethod
379
+ def get_name(cls, value: int) -> str:
380
+ """Get human-readable name for device initiative value."""
381
+ return cls.NAMES.get(value, f"Unknown (0x{value:04X})")
382
+
383
+
384
+ class DCPResponseCode:
385
+ """DCP response/error codes (IEC 61158-6-10).
386
+
387
+ These codes are returned in DCP Set responses to indicate success or failure.
388
+ """
389
+
390
+ NO_ERROR = 0x00
391
+ OPTION_NOT_SUPPORTED = 0x01
392
+ SUBOPTION_NOT_SUPPORTED = 0x02
393
+ SUBOPTION_NOT_SET = 0x03
394
+ RESOURCE_ERROR = 0x04
395
+ SET_NOT_POSSIBLE = 0x05
396
+ IN_OPERATION_SET_NOT_POSSIBLE = 0x06
397
+
398
+ NAMES = {
399
+ 0x00: "No error",
400
+ 0x01: "Option not supported",
401
+ 0x02: "Suboption not supported or no DataSet available",
402
+ 0x03: "Suboption not set",
403
+ 0x04: "Resource error",
404
+ 0x05: "Set not possible",
405
+ 0x06: "In operation, SET not possible",
406
+ }
407
+
408
+ @classmethod
409
+ def get_name(cls, code: int) -> str:
410
+ """Get human-readable name for response code."""
411
+ return cls.NAMES.get(code, f"Unknown (0x{code:02X})")
412
+
413
+
414
+ @dataclass
415
+ class DCPDHCPBlock:
416
+ """Parsed DHCP block from DCP response."""
417
+
418
+ suboption: int
419
+ suboption_name: str
420
+ raw_data: bytes
421
+ hostname: Optional[str] = None
422
+ client_id: Optional[bytes] = None
423
+ vendor_specific: Optional[bytes] = None
424
+ fqdn: Optional[str] = None
425
+ uuid: Optional[str] = None
426
+
427
+ @classmethod
428
+ def parse(cls, suboption: int, data: bytes) -> DCPDHCPBlock:
429
+ """Parse DHCP block data based on suboption type."""
430
+ suboption_name = DCP_SUBOPTION_NAMES.get(0x03, {}).get(
431
+ suboption, f"0x{suboption:02X}"
432
+ )
433
+ block = cls(suboption=suboption, suboption_name=suboption_name, raw_data=data)
434
+
435
+ if suboption == DCP_SUBOPTION_DHCP_HOSTNAME:
436
+ block.hostname = data.rstrip(b"\x00").decode("utf-8", errors="replace")
437
+ elif suboption == DCP_SUBOPTION_DHCP_CLIENT_ID:
438
+ block.client_id = data
439
+ elif suboption == DCP_SUBOPTION_DHCP_VENDOR_SPEC:
440
+ block.vendor_specific = data
441
+ elif suboption == DCP_SUBOPTION_DHCP_FQDN:
442
+ block.fqdn = data.rstrip(b"\x00").decode("utf-8", errors="replace")
443
+ elif suboption == DCP_SUBOPTION_DHCP_UUID:
444
+ if len(data) >= 16:
445
+ block.uuid = "-".join(
446
+ [
447
+ data[0:4].hex(),
448
+ data[4:6].hex(),
449
+ data[6:8].hex(),
450
+ data[8:10].hex(),
451
+ data[10:16].hex(),
452
+ ]
453
+ )
454
+
455
+ return block
456
+
457
+
458
+ @dataclass
459
+ class DCPLLDPBlock:
460
+ """Parsed LLDP block from DCP response."""
461
+
462
+ suboption: int
463
+ suboption_name: str
464
+ raw_data: bytes
465
+ port_id: Optional[str] = None
466
+ chassis_id: Optional[str] = None
467
+ ttl: Optional[int] = None
468
+ port_description: Optional[str] = None
469
+ system_name: Optional[str] = None
470
+ system_description: Optional[str] = None
471
+ system_capabilities: Optional[int] = None
472
+ management_address: Optional[str] = None
473
+
474
+ @classmethod
475
+ def parse(cls, suboption: int, data: bytes) -> DCPLLDPBlock:
476
+ """Parse LLDP block data based on suboption type."""
477
+ suboption_name = DCP_SUBOPTION_NAMES.get(0x04, {}).get(
478
+ suboption, f"0x{suboption:02X}"
479
+ )
480
+ block = cls(suboption=suboption, suboption_name=suboption_name, raw_data=data)
481
+
482
+ if suboption == DCP_SUBOPTION_LLDP_PORT_ID:
483
+ block.port_id = data.rstrip(b"\x00").decode("utf-8", errors="replace")
484
+ elif suboption == DCP_SUBOPTION_LLDP_CHASSIS_ID:
485
+ block.chassis_id = data.rstrip(b"\x00").decode("utf-8", errors="replace")
486
+ elif suboption == DCP_SUBOPTION_LLDP_TTL:
487
+ if len(data) >= 2:
488
+ block.ttl = struct.unpack(">H", data[:2])[0]
489
+ elif suboption == DCP_SUBOPTION_LLDP_PORT_DESC:
490
+ block.port_description = data.rstrip(b"\x00").decode(
491
+ "utf-8", errors="replace"
492
+ )
493
+ elif suboption == DCP_SUBOPTION_LLDP_SYSTEM_NAME:
494
+ block.system_name = data.rstrip(b"\x00").decode("utf-8", errors="replace")
495
+ elif suboption == DCP_SUBOPTION_LLDP_SYSTEM_DESC:
496
+ block.system_description = data.rstrip(b"\x00").decode(
497
+ "utf-8", errors="replace"
498
+ )
499
+ elif suboption == DCP_SUBOPTION_LLDP_SYSTEM_CAP:
500
+ if len(data) >= 2:
501
+ block.system_capabilities = struct.unpack(">H", data[:2])[0]
502
+ elif suboption == DCP_SUBOPTION_LLDP_MGMT_ADDR:
503
+ block.management_address = data.hex()
504
+
505
+ return block
506
+
507
+
508
+ # Parameter name mappings
509
+ PARAMS: Dict[str, Tuple[int, int]] = {
510
+ "name": PNDCPBlock.NAME_OF_STATION,
511
+ "ip": PNDCPBlock.IP_ADDRESS,
512
+ }
513
+
514
+
515
+ def _generate_xid() -> int:
516
+ """Generate random transaction ID for DCP requests."""
517
+ return random.randint(0, 0xFFFFFFFF)
518
+
519
+
520
+ # =============================================================================
521
+ # Device Description
522
+ # =============================================================================
523
+
524
+
525
+ class DCPDeviceDescription:
526
+ """Parsed PROFINET device information from DCP response.
527
+
528
+ Attributes:
529
+ mac: MAC address string
530
+ name: Station name
531
+ device_type: Device type/family (e.g., "S7-1200")
532
+ ip: IP address string
533
+ netmask: Network mask string
534
+ gateway: Gateway address string
535
+ ip_block_info: IP block info value (IPBlockInfo constants)
536
+ ip_conflict: True if IP address conflict detected
537
+ ip_set_by_dhcp: True if IP was set by DHCP
538
+ vendor_high: High byte of vendor ID
539
+ vendor_low: Low byte of vendor ID
540
+ device_high: High byte of device ID
541
+ device_low: Low byte of device ID
542
+ device_role: Device role bitmask
543
+ device_roles: List of role names (e.g., ["IO-Device"])
544
+ device_instance: Device instance (high, low)
545
+ alias_name: Alias name if set
546
+ supported_options: List of supported (option, suboption) tuples
547
+ dhcp_blocks: List of parsed DHCP blocks
548
+ lldp_blocks: List of parsed LLDP blocks
549
+ device_initiative: Device initiative value
550
+ issues_hello: True if device issues DCP-Hello after power on
551
+ """
552
+
553
+ def __init__(self, mac: bytes, blocks: Dict[Tuple[int, int], bytes]) -> None:
554
+ """Initialize device description from DCP blocks.
555
+
556
+ Args:
557
+ mac: Device MAC address (6 bytes)
558
+ blocks: Dictionary of (option, suboption) -> payload mappings
559
+
560
+ Raises:
561
+ DCPError: If required blocks are missing
562
+ """
563
+ self.mac = mac2s(mac)
564
+
565
+ # Handle device type/family (e.g., "S7-1200")
566
+ type_block = blocks.get(PNDCPBlock.DEVICE_TYPE)
567
+ if type_block is not None:
568
+ self.device_type = type_block.rstrip(b"\x00").decode("utf-8", errors="replace")
569
+ else:
570
+ self.device_type = ""
571
+
572
+ # Handle station name (required)
573
+ name_block = blocks.get(PNDCPBlock.NAME_OF_STATION)
574
+ if name_block is not None:
575
+ self.name = name_block.decode("utf-8", errors="replace")
576
+ else:
577
+ self.name = ""
578
+ logger.warning(f"Device {self.mac} has no station name")
579
+
580
+ # Handle IP configuration (required)
581
+ ip_block = blocks.get(PNDCPBlock.IP_ADDRESS)
582
+ self.ip_block_info = 0
583
+ self.ip_conflict = False
584
+ self.ip_set_by_dhcp = False
585
+ if ip_block is not None and len(ip_block) >= 12:
586
+ self.ip = s2ip(ip_block[0:4])
587
+ self.netmask = s2ip(ip_block[4:8])
588
+ self.gateway = s2ip(ip_block[8:12])
589
+ # Check for BlockInfo prefix (14 bytes = 2 byte info + 12 byte IP data)
590
+ if len(ip_block) >= 14:
591
+ self.ip_block_info = struct.unpack(">H", ip_block[0:2])[0]
592
+ self.ip_conflict = IPBlockInfo.has_conflict(self.ip_block_info)
593
+ self.ip_set_by_dhcp = IPBlockInfo.is_dhcp(self.ip_block_info)
594
+ self.ip = s2ip(ip_block[2:6])
595
+ self.netmask = s2ip(ip_block[6:10])
596
+ self.gateway = s2ip(ip_block[10:14])
597
+ else:
598
+ self.ip = "0.0.0.0"
599
+ self.netmask = "0.0.0.0"
600
+ self.gateway = "0.0.0.0"
601
+ logger.warning(f"Device {self.mac} has no IP configuration")
602
+
603
+ # Handle device ID (optional)
604
+ device_id = blocks.get(PNDCPBlock.DEVICE_ID, b"\x00\x00\x00\x00")
605
+ if len(device_id) >= 4:
606
+ self.vendor_high, self.vendor_low, self.device_high, self.device_low = unpack(
607
+ ">BBBB", device_id[0:4]
608
+ )
609
+ else:
610
+ self.vendor_high = 0
611
+ self.vendor_low = 0
612
+ self.device_high = 0
613
+ self.device_low = 0
614
+
615
+ # Handle device role (optional) - (2, 4)
616
+ role_block = blocks.get(PNDCPBlock.DEVICE_ROLE)
617
+ if role_block is not None and len(role_block) >= 1:
618
+ self.device_role = role_block[0]
619
+ self.device_roles = decode_device_role(self.device_role)
620
+ else:
621
+ self.device_role = 0
622
+ self.device_roles = []
623
+
624
+ # Handle device instance (optional) - (2, 7)
625
+ instance_block = blocks.get((DCP_OPTION_DEVICE, DCP_SUBOPTION_DEVICE_INSTANCE))
626
+ if instance_block is not None and len(instance_block) >= 2:
627
+ self.device_instance = (instance_block[0], instance_block[1])
628
+ else:
629
+ self.device_instance = (0, 0)
630
+
631
+ # Handle alias name (optional) - (2, 6)
632
+ alias_block = blocks.get((DCP_OPTION_DEVICE, DCP_SUBOPTION_DEVICE_ALIAS))
633
+ if alias_block is not None:
634
+ self.alias_name = alias_block.rstrip(b"\x00").decode("utf-8", errors="replace")
635
+ else:
636
+ self.alias_name = ""
637
+
638
+ # Handle device options (optional) - (2, 5)
639
+ # Each option is 2 bytes: option + suboption
640
+ options_block = blocks.get(PNDCPBlock.DEVICE_OPTIONS)
641
+ self.supported_options: List[Tuple[int, int]] = []
642
+ if options_block is not None and len(options_block) >= 2:
643
+ for i in range(0, len(options_block) - 1, 2):
644
+ opt = options_block[i]
645
+ subopt = options_block[i + 1]
646
+ self.supported_options.append((opt, subopt))
647
+
648
+ # Parse DHCP blocks
649
+ self.dhcp_blocks: List[DCPDHCPBlock] = []
650
+ for key, data in blocks.items():
651
+ if isinstance(key, tuple) and len(key) == 2:
652
+ opt, subopt = key
653
+ if opt == DCP_OPTION_DHCP:
654
+ self.dhcp_blocks.append(DCPDHCPBlock.parse(subopt, data))
655
+
656
+ # Parse LLDP blocks
657
+ self.lldp_blocks: List[DCPLLDPBlock] = []
658
+ for key, data in blocks.items():
659
+ if isinstance(key, tuple) and len(key) == 2:
660
+ opt, subopt = key
661
+ if opt == DCP_OPTION_LLDP:
662
+ self.lldp_blocks.append(DCPLLDPBlock.parse(subopt, data))
663
+
664
+ # Handle DeviceInitiative (optional) - (6, 1)
665
+ initiative_block = blocks.get(
666
+ (DCP_OPTION_DEVICE_INITIATIVE, DCP_SUBOPTION_DEVICE_INITIATIVE)
667
+ )
668
+ self.device_initiative = 0
669
+ self.issues_hello = False
670
+ if initiative_block is not None and len(initiative_block) >= 2:
671
+ self.device_initiative = struct.unpack(">H", initiative_block[0:2])[0]
672
+ self.issues_hello = self.device_initiative == DeviceInitiative.ISSUE_HELLO
673
+
674
+ # Store all raw blocks for unknown/vendor-specific options
675
+ self.raw_blocks: Dict[Tuple[int, int], bytes] = {}
676
+ known_blocks = {
677
+ PNDCPBlock.IP_ADDRESS, PNDCPBlock.DEVICE_TYPE, PNDCPBlock.NAME_OF_STATION,
678
+ PNDCPBlock.DEVICE_ID, PNDCPBlock.DEVICE_ROLE, PNDCPBlock.DEVICE_OPTIONS,
679
+ PNDCPBlock.DEVICE_ALIAS, PNDCPBlock.DEVICE_INSTANCE,
680
+ (DCP_OPTION_DEVICE, DCP_SUBOPTION_DEVICE_INSTANCE),
681
+ (DCP_OPTION_DEVICE, DCP_SUBOPTION_DEVICE_ALIAS),
682
+ (DCP_OPTION_DEVICE_INITIATIVE, DCP_SUBOPTION_DEVICE_INITIATIVE),
683
+ }
684
+ # Exclude DHCP and LLDP blocks from raw_blocks
685
+ for key, value in blocks.items():
686
+ if isinstance(key, tuple) and key not in known_blocks:
687
+ opt, _ = key
688
+ if opt not in (DCP_OPTION_DHCP, DCP_OPTION_LLDP):
689
+ self.raw_blocks[key] = value
690
+
691
+ @property
692
+ def vendor_id(self) -> int:
693
+ """Get 16-bit vendor ID."""
694
+ return (self.vendor_high << 8) | self.vendor_low
695
+
696
+ @property
697
+ def device_id(self) -> int:
698
+ """Get 16-bit device ID."""
699
+ return (self.device_high << 8) | self.device_low
700
+
701
+ @property
702
+ def vendor_name(self) -> str:
703
+ """Get vendor name from ID lookup."""
704
+ return get_vendor_name(self.vendor_id)
705
+
706
+ def __repr__(self) -> str:
707
+ return (
708
+ f"DCPDeviceDescription(name={self.name!r}, type={self.device_type!r}, "
709
+ f"ip={self.ip}, mac={self.mac}, vendor={self.vendor_name!r})"
710
+ )
711
+
712
+ def __str__(self) -> str:
713
+ lines = [
714
+ f"PROFINET Device: {self.name}",
715
+ f" MAC: {self.mac}",
716
+ ]
717
+ if self.device_type:
718
+ lines.append(f" Type: {self.device_type}")
719
+ lines.extend([
720
+ f" IP: {self.ip}",
721
+ f" Netmask: {self.netmask}",
722
+ f" Gateway: {self.gateway}",
723
+ ])
724
+ # IP Block Info
725
+ if self.ip_block_info:
726
+ lines.append(f" IP Info: {IPBlockInfo.get_name(self.ip_block_info)}")
727
+ if self.ip_conflict:
728
+ lines.append(" Warning: IP address conflict detected")
729
+ if self.ip_set_by_dhcp:
730
+ lines.append(" IP Source: DHCP")
731
+ lines.extend([
732
+ f" Vendor: {self.vendor_name} (0x{self.vendor_id:04X})",
733
+ f" Device: 0x{self.device_id:04X}",
734
+ ])
735
+ if self.device_roles:
736
+ lines.append(f" Role: {', '.join(self.device_roles)}")
737
+ if self.device_instance != (0, 0):
738
+ lines.append(f" Instance: {self.device_instance[0]}.{self.device_instance[1]}")
739
+ if self.alias_name:
740
+ lines.append(f" Alias: {self.alias_name}")
741
+ # Device Initiative
742
+ if self.device_initiative:
743
+ lines.append(f" Initiative: {DeviceInitiative.get_name(self.device_initiative)}")
744
+ if self.supported_options:
745
+ opts = [get_block_name(o, s) for o, s in self.supported_options]
746
+ lines.append(f" Supports: {', '.join(opts)}")
747
+ # DHCP blocks
748
+ if self.dhcp_blocks:
749
+ lines.append(" DHCP:")
750
+ for block in self.dhcp_blocks:
751
+ if block.hostname:
752
+ lines.append(f" Hostname: {block.hostname}")
753
+ elif block.fqdn:
754
+ lines.append(f" FQDN: {block.fqdn}")
755
+ elif block.uuid:
756
+ lines.append(f" UUID: {block.uuid}")
757
+ else:
758
+ lines.append(f" {block.suboption_name}: {block.raw_data.hex()}")
759
+ # LLDP blocks
760
+ if self.lldp_blocks:
761
+ lines.append(" LLDP:")
762
+ for block in self.lldp_blocks:
763
+ if block.system_name:
764
+ lines.append(f" SystemName: {block.system_name}")
765
+ elif block.port_id:
766
+ lines.append(f" PortID: {block.port_id}")
767
+ elif block.chassis_id:
768
+ lines.append(f" ChassisID: {block.chassis_id}")
769
+ else:
770
+ lines.append(f" {block.suboption_name}: {block.raw_data.hex()}")
771
+ if self.raw_blocks:
772
+ for (opt, subopt), data in self.raw_blocks.items():
773
+ lines.append(f" Unknown ({opt},{subopt}): {data.hex()}")
774
+ return "\n".join(lines)
775
+
776
+
777
+ # =============================================================================
778
+ # DCP Operations
779
+ # =============================================================================
780
+
781
+
782
+ def get_param(
783
+ sock: socket,
784
+ src: bytes,
785
+ target: str,
786
+ param: str,
787
+ timeout_sec: int = 5,
788
+ ) -> Optional[bytes]:
789
+ """Read a parameter from a PROFINET device.
790
+
791
+ Args:
792
+ sock: Raw Ethernet socket
793
+ src: Source MAC address (6 bytes)
794
+ target: Target device MAC address string
795
+ param: Parameter name ("name" or "ip")
796
+ timeout_sec: Timeout in seconds
797
+
798
+ Returns:
799
+ Parameter value as bytes, or None if not found
800
+
801
+ Raises:
802
+ DCPError: If parameter name is unknown
803
+ """
804
+ if param not in PARAMS:
805
+ raise DCPError(f"Unknown parameter: {param!r}. Valid: {list(PARAMS.keys())}")
806
+
807
+ dst = s2mac(target)
808
+ param_tuple = PARAMS[param]
809
+ xid = _generate_xid()
810
+
811
+ block = PNDCPBlockRequest(param_tuple[0], param_tuple[1], 0, payload=b"")
812
+ dcp = PNDCPHeader(
813
+ DCP_GET_SET_FRAME_ID,
814
+ PNDCPHeader.GET,
815
+ PNDCPHeader.REQUEST,
816
+ xid,
817
+ 0,
818
+ 2,
819
+ payload=block,
820
+ )
821
+ eth = EthernetHeader(dst, src, PROFINET_ETHERTYPE, payload=dcp)
822
+
823
+ sock.send(bytes(eth))
824
+
825
+ responses = read_response(sock, src, timeout_sec=timeout_sec, once=True)
826
+ if responses:
827
+ first_response = list(responses.values())[0]
828
+ return first_response.get(param_tuple)
829
+ return None
830
+
831
+
832
+ def set_param(
833
+ sock: socket,
834
+ src: bytes,
835
+ target: str,
836
+ param: str,
837
+ value: str,
838
+ timeout_sec: int = 5,
839
+ ) -> bool:
840
+ """Write a parameter to a PROFINET device.
841
+
842
+ Args:
843
+ sock: Raw Ethernet socket
844
+ src: Source MAC address (6 bytes)
845
+ target: Target device MAC address string
846
+ param: Parameter name ("name" or "ip")
847
+ value: New parameter value
848
+ timeout_sec: Timeout in seconds
849
+
850
+ Returns:
851
+ True if response received, False if timeout
852
+
853
+ Raises:
854
+ DCPError: If parameter name is unknown
855
+ ValueError: If name exceeds DCP_MAX_NAME_LENGTH (240 chars)
856
+ """
857
+ if param not in PARAMS:
858
+ raise DCPError(f"Unknown parameter: {param!r}. Valid: {list(PARAMS.keys())}")
859
+
860
+ # Validate name length per IEC 61158-6-10
861
+ if param == "name" and len(value) > DCP_MAX_NAME_LENGTH:
862
+ raise ValueError(
863
+ f"Station name exceeds maximum length: {len(value)} > {DCP_MAX_NAME_LENGTH}"
864
+ )
865
+
866
+ dst = s2mac(target)
867
+ param_tuple = PARAMS[param]
868
+ value_bytes = bytes(value, encoding="ascii")
869
+ xid = _generate_xid()
870
+
871
+ # Add padding for block qualifier (2 bytes)
872
+ block = PNDCPBlockRequest(
873
+ param_tuple[0],
874
+ param_tuple[1],
875
+ len(value_bytes) + 2,
876
+ payload=bytes([0x00, 0x00]) + value_bytes,
877
+ )
878
+
879
+ # Calculate length with padding
880
+ padding = 1 if len(value_bytes) % 2 == 1 else 0
881
+ dcp = PNDCPHeader(
882
+ DCP_GET_SET_FRAME_ID,
883
+ PNDCPHeader.SET,
884
+ PNDCPHeader.REQUEST,
885
+ xid,
886
+ 0,
887
+ len(value_bytes) + 6 + padding,
888
+ payload=block,
889
+ )
890
+ eth = EthernetHeader(dst, src, PROFINET_ETHERTYPE, payload=dcp)
891
+
892
+ sock.send(bytes(eth))
893
+
894
+ # Wait for response
895
+ # NOTE: SET response is not validated (status/error codes are not checked)
896
+ sock.settimeout(float(timeout_sec))
897
+ try:
898
+ sock.recv(MAX_ETHERNET_FRAME)
899
+ # Wait for device to process
900
+ time.sleep(2)
901
+ return True
902
+ except TimeoutError:
903
+ logger.warning(f"No response from {target} for set_param")
904
+ return False
905
+
906
+
907
+ def set_ip(
908
+ sock: socket,
909
+ src: bytes,
910
+ target: str,
911
+ ip: str,
912
+ netmask: str,
913
+ gateway: str,
914
+ permanent: bool = False,
915
+ timeout_sec: int = 5,
916
+ ) -> bool:
917
+ """Set IP configuration on a PROFINET device via DCP.
918
+
919
+ Args:
920
+ sock: Raw Ethernet socket
921
+ src: Source MAC address (6 bytes)
922
+ target: Target device MAC address string
923
+ ip: New IP address (e.g., "192.168.10.3")
924
+ netmask: Subnet mask (e.g., "255.255.255.0")
925
+ gateway: Gateway address (e.g., "192.168.10.1")
926
+ permanent: If True, save IP permanently; if False, temporary (default)
927
+ timeout_sec: Timeout in seconds
928
+
929
+ Returns:
930
+ True if response received, False if timeout
931
+ """
932
+ dst = s2mac(target)
933
+ xid = _generate_xid()
934
+
935
+ # Convert IP strings to bytes (4 bytes each)
936
+ def ip_to_bytes(ip_str: str) -> bytes:
937
+ parts = ip_str.split(".")
938
+ return bytes([int(p) for p in parts])
939
+
940
+ ip_bytes = ip_to_bytes(ip)
941
+ netmask_bytes = ip_to_bytes(netmask)
942
+ gateway_bytes = ip_to_bytes(gateway)
943
+
944
+ # IP block payload: 2 bytes qualifier + 4 IP + 4 netmask + 4 gateway = 14 bytes
945
+ value_bytes = ip_bytes + netmask_bytes + gateway_bytes
946
+
947
+ # Use appropriate qualifier based on permanent flag
948
+ qualifier = BlockQualifier.PERMANENT if permanent else BlockQualifier.TEMPORARY
949
+ qualifier_bytes = struct.pack(">H", qualifier)
950
+
951
+ block = PNDCPBlockRequest(
952
+ PNDCPBlock.IP_ADDRESS[0], # Option (0x01 = IP)
953
+ PNDCPBlock.IP_ADDRESS[1], # Suboption (0x02 = IP Suite)
954
+ len(value_bytes) + 2, # Length includes 2-byte qualifier
955
+ payload=qualifier_bytes + value_bytes,
956
+ )
957
+
958
+ # Calculate length with padding (blocks are 2-byte aligned)
959
+ padding = 0 if len(value_bytes) % 2 == 0 else 1
960
+ dcp = PNDCPHeader(
961
+ DCP_GET_SET_FRAME_ID,
962
+ PNDCPHeader.SET,
963
+ PNDCPHeader.REQUEST,
964
+ xid,
965
+ 0,
966
+ len(value_bytes) + 6 + padding,
967
+ payload=block,
968
+ )
969
+ eth = EthernetHeader(dst, src, PROFINET_ETHERTYPE, payload=dcp)
970
+
971
+ sock.send(bytes(eth))
972
+
973
+ # Wait for response
974
+ # NOTE: SET response is not validated (status/error codes are not checked)
975
+ sock.settimeout(float(timeout_sec))
976
+ try:
977
+ sock.recv(MAX_ETHERNET_FRAME)
978
+ # Wait for device to process
979
+ time.sleep(2)
980
+ return True
981
+ except TimeoutError:
982
+ logger.warning(f"No response from {target} for set_ip")
983
+ return False
984
+
985
+
986
+ def send_discover(sock: socket, src: bytes, response_delay: int = 0x0080) -> None:
987
+ """Send DCP Identify multicast request.
988
+
989
+ Sends an Identify request to the PROFINET multicast address
990
+ to discover all devices on the network.
991
+
992
+ Args:
993
+ sock: Raw Ethernet socket
994
+ src: Source MAC address (6 bytes)
995
+ response_delay: Max response delay in 10ms units (default: 0x0080 = 1.28s)
996
+ """
997
+ xid = _generate_xid()
998
+
999
+ block = PNDCPBlockRequest(0xFF, 0xFF, 0, payload=b"")
1000
+ dcp = PNDCPHeader(
1001
+ DCP_IDENTIFY_FRAME_ID,
1002
+ PNDCPHeader.IDENTIFY,
1003
+ PNDCPHeader.REQUEST,
1004
+ xid,
1005
+ response_delay,
1006
+ len(block),
1007
+ payload=block,
1008
+ )
1009
+ eth = EthernetHeader(
1010
+ s2mac(DCP_MULTICAST_MAC),
1011
+ src,
1012
+ PROFINET_ETHERTYPE,
1013
+ payload=dcp,
1014
+ )
1015
+
1016
+ sock.send(bytes(eth))
1017
+ logger.debug(f"Sent DCP Identify request (xid=0x{xid:08X})")
1018
+
1019
+
1020
+ def send_request(
1021
+ sock: socket,
1022
+ src: bytes,
1023
+ block_type: Tuple[int, int],
1024
+ value: bytes,
1025
+ ) -> None:
1026
+ """Send DCP Identify request with specific filter.
1027
+
1028
+ Args:
1029
+ sock: Raw Ethernet socket
1030
+ src: Source MAC address (6 bytes)
1031
+ block_type: (option, suboption) tuple to filter
1032
+ value: Filter value bytes
1033
+ """
1034
+ xid = _generate_xid()
1035
+
1036
+ block = PNDCPBlockRequest(block_type[0], block_type[1], len(value), payload=value)
1037
+ dcp = PNDCPHeader(
1038
+ DCP_IDENTIFY_FRAME_ID,
1039
+ PNDCPHeader.IDENTIFY,
1040
+ PNDCPHeader.REQUEST,
1041
+ xid,
1042
+ 0,
1043
+ len(block),
1044
+ payload=block,
1045
+ )
1046
+ eth = EthernetHeader(
1047
+ s2mac(DCP_MULTICAST_MAC),
1048
+ src,
1049
+ PROFINET_ETHERTYPE,
1050
+ payload=dcp,
1051
+ )
1052
+
1053
+ sock.send(bytes(eth))
1054
+ logger.debug(f"Sent DCP request for {block_type} (xid=0x{xid:08X})")
1055
+
1056
+
1057
+ def read_response(
1058
+ sock: socket,
1059
+ my_mac: bytes,
1060
+ timeout_sec: int = 20,
1061
+ once: bool = False,
1062
+ debug: bool = False,
1063
+ ) -> Dict[bytes, Dict[Any, Any]]:
1064
+ """Read and parse DCP responses.
1065
+
1066
+ Args:
1067
+ sock: Raw Ethernet socket
1068
+ my_mac: Our MAC address (6 bytes) for filtering
1069
+ timeout_sec: Maximum time to wait for responses
1070
+ once: If True, return after first response
1071
+ debug: If True, log debug information
1072
+
1073
+ Returns:
1074
+ Dictionary mapping MAC addresses to parsed block data
1075
+ """
1076
+ result: Dict[bytes, Dict[Any, Any]] = {}
1077
+ sock.settimeout(2.0)
1078
+
1079
+ try:
1080
+ with max_timeout(timeout_sec) as timer:
1081
+ while not timer.timed_out:
1082
+ try:
1083
+ data = sock.recv(MAX_ETHERNET_FRAME)
1084
+ except TimeoutError:
1085
+ continue
1086
+ except OSError as e:
1087
+ logger.debug(f"Socket error during receive: {e}")
1088
+ continue
1089
+
1090
+ if len(data) < 14: # Minimum Ethernet header
1091
+ continue
1092
+
1093
+ # Parse Ethernet header
1094
+ try:
1095
+ eth = EthernetHeader(data)
1096
+ except ValueError as e:
1097
+ logger.debug(f"Failed to parse Ethernet header: {e}")
1098
+ continue
1099
+
1100
+ # Filter: only packets to us
1101
+ if eth.dst != my_mac:
1102
+ continue
1103
+
1104
+ # Handle both VLAN-tagged and non-VLAN frames
1105
+ # Some devices (e.g., Siemens S7-1200) respond with VLAN tags
1106
+ payload = eth.payload
1107
+ if eth.type == VLAN_ETHERTYPE:
1108
+ # VLAN header: 2 bytes TCI + 2 bytes inner ethertype
1109
+ if len(payload) < 4:
1110
+ continue
1111
+ inner_type = (payload[2] << 8) | payload[3]
1112
+ if inner_type != PROFINET_ETHERTYPE:
1113
+ continue
1114
+ payload = payload[4:] # Skip VLAN header
1115
+ elif eth.type != PROFINET_ETHERTYPE:
1116
+ continue
1117
+
1118
+ if debug:
1119
+ logger.info(f"DCP response from {mac2s(eth.src)}")
1120
+
1121
+ # Parse DCP header
1122
+ try:
1123
+ dcp = PNDCPHeader(payload)
1124
+ except ValueError as e:
1125
+ logger.debug(f"Failed to parse DCP header: {e}")
1126
+ continue
1127
+
1128
+ # Filter: only DCP responses
1129
+ if dcp.service_type != PNDCPHeader.RESPONSE:
1130
+ continue
1131
+
1132
+ # Parse DCP blocks
1133
+ blocks = dcp.payload
1134
+ length = dcp.length
1135
+ parsed: Dict[Any, Any] = {}
1136
+
1137
+ while length > 6:
1138
+ try:
1139
+ block = PNDCPBlock(blocks)
1140
+ except ValueError as e:
1141
+ logger.debug(f"Failed to parse DCP block: {e}")
1142
+ break
1143
+
1144
+ block_option = (block.option, block.suboption)
1145
+ parsed[block_option] = block.payload
1146
+
1147
+ if block_option == PNDCPBlock.NAME_OF_STATION:
1148
+ if debug:
1149
+ logger.info(f" Name: {block.payload.decode('utf-8', errors='replace')}")
1150
+ parsed["name"] = block.payload
1151
+
1152
+ elif block_option == PNDCPBlock.IP_ADDRESS:
1153
+ if debug:
1154
+ logger.info(f" IP: {s2ip(block.payload[0:4])}")
1155
+ parsed["ip"] = s2ip(block.payload[0:4])
1156
+
1157
+ elif block_option == PNDCPBlock.DEVICE_ID:
1158
+ parsed["devId"] = block.payload
1159
+
1160
+ # Handle padding (blocks are 2-byte aligned)
1161
+ block_len = block.length
1162
+ if block_len % 2 == 1:
1163
+ block_len += 1
1164
+
1165
+ # Move to next block (4 bytes header + payload + padding)
1166
+ blocks = blocks[block_len + 4:]
1167
+ length -= 4 + block_len
1168
+
1169
+ result[eth.src] = parsed
1170
+
1171
+ if once:
1172
+ break
1173
+
1174
+ except TimeoutError:
1175
+ pass
1176
+
1177
+ logger.debug(f"DCP discovery found {len(result)} devices")
1178
+ return result
1179
+
1180
+
1181
+ def send_hello(
1182
+ sock: socket,
1183
+ src: bytes,
1184
+ station_name: str,
1185
+ ip: str = "0.0.0.0",
1186
+ netmask: str = "0.0.0.0",
1187
+ gateway: str = "0.0.0.0",
1188
+ device_id: Tuple[int, int] = (0, 0),
1189
+ device_role: int = DEVICE_ROLE_IO_DEVICE,
1190
+ ) -> None:
1191
+ """Send DCP Hello multicast announcement.
1192
+
1193
+ This allows a device to announce its presence after power-on.
1194
+ Used by devices with DeviceInitiative = 0x0001.
1195
+
1196
+ Args:
1197
+ sock: Raw Ethernet socket
1198
+ src: Source MAC address (6 bytes)
1199
+ station_name: Device station name
1200
+ ip: IP address (default: "0.0.0.0")
1201
+ netmask: Subnet mask (default: "0.0.0.0")
1202
+ gateway: Gateway address (default: "0.0.0.0")
1203
+ device_id: (vendor_id, device_id) tuple (default: (0, 0))
1204
+ device_role: Device role bitmask (default: IO-Device)
1205
+ """
1206
+ xid = _generate_xid()
1207
+
1208
+ # Build blocks for Hello PDU
1209
+ blocks_data = b""
1210
+
1211
+ # Name of Station block
1212
+ name_bytes = station_name.encode("ascii")
1213
+ name_block = PNDCPBlockRequest(
1214
+ DCP_OPTION_DEVICE,
1215
+ DCP_SUBOPTION_DEVICE_NAME,
1216
+ len(name_bytes),
1217
+ payload=name_bytes,
1218
+ )
1219
+ blocks_data += bytes(name_block)
1220
+ if len(name_bytes) % 2 == 1:
1221
+ blocks_data += b"\x00" # Padding
1222
+
1223
+ # IP block
1224
+ ip_bytes = ip2s(ip) + ip2s(netmask) + ip2s(gateway)
1225
+ ip_block = PNDCPBlockRequest(
1226
+ DCP_OPTION_IP,
1227
+ DCP_SUBOPTION_IP_PARAMETER,
1228
+ len(ip_bytes),
1229
+ payload=ip_bytes,
1230
+ )
1231
+ blocks_data += bytes(ip_block)
1232
+
1233
+ # Device ID block
1234
+ device_id_bytes = struct.pack(
1235
+ ">HH", device_id[0], device_id[1]
1236
+ )
1237
+ device_id_block = PNDCPBlockRequest(
1238
+ DCP_OPTION_DEVICE,
1239
+ DCP_SUBOPTION_DEVICE_ID,
1240
+ len(device_id_bytes),
1241
+ payload=device_id_bytes,
1242
+ )
1243
+ blocks_data += bytes(device_id_block)
1244
+
1245
+ # Device Role block
1246
+ role_bytes = bytes([device_role, 0x00])
1247
+ role_block = PNDCPBlockRequest(
1248
+ DCP_OPTION_DEVICE,
1249
+ DCP_SUBOPTION_DEVICE_ROLE,
1250
+ len(role_bytes),
1251
+ payload=role_bytes,
1252
+ )
1253
+ blocks_data += bytes(role_block)
1254
+
1255
+ # Device Initiative block
1256
+ initiative_bytes = struct.pack(">H", DeviceInitiative.ISSUE_HELLO)
1257
+ initiative_block = PNDCPBlockRequest(
1258
+ DCP_OPTION_DEVICE_INITIATIVE,
1259
+ DCP_SUBOPTION_DEVICE_INITIATIVE,
1260
+ len(initiative_bytes),
1261
+ payload=initiative_bytes,
1262
+ )
1263
+ blocks_data += bytes(initiative_block)
1264
+
1265
+ # Build DCP header
1266
+ dcp = PNDCPHeader(
1267
+ DCP_HELLO_FRAME_ID,
1268
+ PNDCPHeader.HELLO,
1269
+ PNDCPHeader.REQUEST,
1270
+ xid,
1271
+ 0, # No response delay for Hello
1272
+ len(blocks_data),
1273
+ payload=blocks_data,
1274
+ )
1275
+
1276
+ # Send to multicast address
1277
+ eth = EthernetHeader(
1278
+ s2mac(DCP_MULTICAST_MAC),
1279
+ src,
1280
+ PROFINET_ETHERTYPE,
1281
+ payload=dcp,
1282
+ )
1283
+
1284
+ sock.send(bytes(eth))
1285
+ logger.debug(f"Sent DCP Hello (station={station_name}, xid=0x{xid:08X})")
1286
+
1287
+
1288
+ def receive_hello(
1289
+ sock: socket,
1290
+ my_mac: bytes,
1291
+ timeout_sec: int = 10,
1292
+ callback: Optional[Callable[[DCPDeviceDescription], None]] = None,
1293
+ ) -> List[DCPDeviceDescription]:
1294
+ """Listen for DCP Hello announcements.
1295
+
1296
+ Args:
1297
+ sock: Raw Ethernet socket
1298
+ my_mac: Our MAC address (6 bytes) for filtering
1299
+ timeout_sec: Maximum time to wait
1300
+ callback: Optional callback for each Hello received
1301
+
1302
+ Returns:
1303
+ List of DCPDeviceDescription objects from Hello PDUs
1304
+ """
1305
+ devices: List[DCPDeviceDescription] = []
1306
+ sock.settimeout(2.0)
1307
+
1308
+ try:
1309
+ with max_timeout(timeout_sec) as timer:
1310
+ while not timer.timed_out:
1311
+ try:
1312
+ data = sock.recv(MAX_ETHERNET_FRAME)
1313
+ except TimeoutError:
1314
+ continue
1315
+
1316
+ if len(data) < 14:
1317
+ continue
1318
+
1319
+ eth = EthernetHeader(data)
1320
+
1321
+ # Handle VLAN
1322
+ payload = eth.payload
1323
+ if eth.type == VLAN_ETHERTYPE:
1324
+ if len(payload) < 4:
1325
+ continue
1326
+ inner_type = (payload[2] << 8) | payload[3]
1327
+ if inner_type != PROFINET_ETHERTYPE:
1328
+ continue
1329
+ payload = payload[4:]
1330
+ elif eth.type != PROFINET_ETHERTYPE:
1331
+ continue
1332
+
1333
+ if len(payload) < 10:
1334
+ continue
1335
+
1336
+ try:
1337
+ dcp = PNDCPHeader(payload)
1338
+ except (ValueError, struct.error):
1339
+ continue
1340
+
1341
+ # Filter for Hello requests only
1342
+ if dcp.service_id != PNDCPHeader.HELLO:
1343
+ continue
1344
+ if dcp.service_type != PNDCPHeader.REQUEST:
1345
+ continue
1346
+
1347
+ # Parse blocks
1348
+ blocks: Dict[Tuple[int, int], bytes] = {}
1349
+ offset = 0
1350
+ dcp_payload = dcp.payload
1351
+ while offset < len(dcp_payload) - 4:
1352
+ try:
1353
+ opt = dcp_payload[offset]
1354
+ subopt = dcp_payload[offset + 1]
1355
+ length = (dcp_payload[offset + 2] << 8) | dcp_payload[offset + 3]
1356
+ block_data = dcp_payload[offset + 4 : offset + 4 + length]
1357
+ blocks[(opt, subopt)] = block_data
1358
+ # Move to next block (2-byte aligned)
1359
+ block_len = 4 + length
1360
+ if length % 2 == 1:
1361
+ block_len += 1
1362
+ offset += block_len
1363
+ except (IndexError, struct.error):
1364
+ break
1365
+
1366
+ try:
1367
+ device = DCPDeviceDescription(eth.src, blocks)
1368
+ devices.append(device)
1369
+
1370
+ if callback:
1371
+ callback(device)
1372
+
1373
+ logger.debug(f"Received DCP Hello from {device.name}")
1374
+ except Exception as e:
1375
+ logger.warning(f"Failed to parse Hello from {mac2s(eth.src)}: {e}")
1376
+
1377
+ except TimeoutError:
1378
+ pass
1379
+
1380
+ return devices
1381
+
1382
+
1383
+ def signal_device(
1384
+ sock: socket,
1385
+ src: bytes,
1386
+ target: str,
1387
+ duration_ms: int = 3000,
1388
+ timeout_sec: int = 5,
1389
+ ) -> bool:
1390
+ """Send DCP Signal command to flash device LEDs.
1391
+
1392
+ This sends a Control/Signal request that causes the device to
1393
+ flash its identification LEDs for the specified duration.
1394
+
1395
+ Args:
1396
+ sock: Raw Ethernet socket
1397
+ src: Source MAC address (6 bytes)
1398
+ target: Target device MAC address string
1399
+ duration_ms: Flash duration in milliseconds (default: 3000)
1400
+ timeout_sec: Response timeout in seconds
1401
+
1402
+ Returns:
1403
+ True if response received, False if timeout
1404
+ """
1405
+ dst = s2mac(target)
1406
+ xid = _generate_xid()
1407
+
1408
+ # Signal block data: BlockInfo (2 bytes) + SignalValue (2 bytes)
1409
+ # BlockInfo: 0x0001 = temporary signal
1410
+ # SignalValue: duration in 100ms units
1411
+ duration_units = max(1, duration_ms // 100) # Convert to 100ms units
1412
+ block_info = bytes([0x00, 0x01]) # Temporary signal
1413
+ signal_value = duration_units.to_bytes(2, 'big')
1414
+ block_data = block_info + signal_value
1415
+
1416
+ block = PNDCPBlockRequest(
1417
+ DCP_OPTION_CONTROL,
1418
+ DCP_SUBOPTION_CONTROL_SIGNAL,
1419
+ len(block_data),
1420
+ payload=block_data,
1421
+ )
1422
+
1423
+ dcp = PNDCPHeader(
1424
+ DCP_GET_SET_FRAME_ID,
1425
+ PNDCPHeader.SET,
1426
+ PNDCPHeader.REQUEST,
1427
+ xid,
1428
+ 0,
1429
+ len(block_data) + 4, # block header (4) + data
1430
+ payload=block,
1431
+ )
1432
+ eth = EthernetHeader(dst, src, PROFINET_ETHERTYPE, payload=dcp)
1433
+
1434
+ sock.send(bytes(eth))
1435
+ logger.debug(f"Sent DCP Signal request to {target} (duration={duration_ms}ms)")
1436
+
1437
+ # Wait for response
1438
+ sock.settimeout(float(timeout_sec))
1439
+ try:
1440
+ sock.recv(MAX_ETHERNET_FRAME)
1441
+ return True
1442
+ except TimeoutError:
1443
+ logger.warning(f"No response from {target} for signal command")
1444
+ return False
1445
+
1446
+
1447
+ def reset_to_factory(
1448
+ sock: socket,
1449
+ src: bytes,
1450
+ target: str,
1451
+ mode: int = RESET_MODE_COMMUNICATION,
1452
+ timeout_sec: int = 5,
1453
+ ) -> bool:
1454
+ """Send DCP Reset to Factory command.
1455
+
1456
+ WARNING: This will reset device configuration! Use with caution.
1457
+
1458
+ Args:
1459
+ sock: Raw Ethernet socket
1460
+ src: Source MAC address (6 bytes)
1461
+ target: Target device MAC address string
1462
+ mode: Reset mode bitmask (default: RESET_MODE_COMMUNICATION)
1463
+ - RESET_MODE_COMMUNICATION (0x0002): Reset comm params (mandatory)
1464
+ - RESET_MODE_APPLICATION (0x0004): Reset application data
1465
+ - RESET_MODE_ENGINEERING (0x0008): Reset engineering data
1466
+ - RESET_MODE_ALL_DATA (0x0010): Reset all data
1467
+ - RESET_MODE_DEVICE (0x0020): Reset device
1468
+ - RESET_MODE_FACTORY (0x0040): Reset to factory image
1469
+ timeout_sec: Response timeout in seconds
1470
+
1471
+ Returns:
1472
+ True if response received, False if timeout
1473
+ """
1474
+ dst = s2mac(target)
1475
+ xid = _generate_xid()
1476
+
1477
+ # Reset block data: BlockQualifier (2 bytes) with reset mode
1478
+ block_qualifier = mode.to_bytes(2, 'big')
1479
+
1480
+ block = PNDCPBlockRequest(
1481
+ DCP_OPTION_CONTROL,
1482
+ DCP_SUBOPTION_CONTROL_RESET_TO_FACTORY,
1483
+ len(block_qualifier),
1484
+ payload=block_qualifier,
1485
+ )
1486
+
1487
+ dcp = PNDCPHeader(
1488
+ DCP_GET_SET_FRAME_ID,
1489
+ PNDCPHeader.SET,
1490
+ PNDCPHeader.REQUEST,
1491
+ xid,
1492
+ 0,
1493
+ len(block_qualifier) + 4, # block header (4) + data
1494
+ payload=block,
1495
+ )
1496
+ eth = EthernetHeader(dst, src, PROFINET_ETHERTYPE, payload=dcp)
1497
+
1498
+ sock.send(bytes(eth))
1499
+ logger.debug(f"Sent DCP Reset to Factory request to {target} (mode=0x{mode:04X})")
1500
+
1501
+ # Wait for response
1502
+ sock.settimeout(float(timeout_sec))
1503
+ try:
1504
+ sock.recv(MAX_ETHERNET_FRAME)
1505
+ # Device needs time to reset
1506
+ time.sleep(2)
1507
+ return True
1508
+ except TimeoutError:
1509
+ logger.warning(f"No response from {target} for reset command")
1510
+ return False