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/__init__.py +489 -0
- profinet/alarm_listener.py +455 -0
- profinet/alarms.py +522 -0
- profinet/blocks.py +1111 -0
- profinet/cli.py +328 -0
- profinet/cyclic.py +535 -0
- profinet/dcp.py +1510 -0
- profinet/device.py +1044 -0
- profinet/diagnosis.py +635 -0
- profinet/exceptions.py +505 -0
- profinet/indices.py +778 -0
- profinet/protocol.py +665 -0
- profinet/py.typed +2 -0
- profinet/rpc.py +2633 -0
- profinet/rt.py +446 -0
- profinet/util.py +1333 -0
- profinet/vendors.py +2220 -0
- profinet_py-0.4.0.dist-info/METADATA +131 -0
- profinet_py-0.4.0.dist-info/RECORD +23 -0
- profinet_py-0.4.0.dist-info/WHEEL +5 -0
- profinet_py-0.4.0.dist-info/entry_points.txt +2 -0
- profinet_py-0.4.0.dist-info/licenses/LICENSE +674 -0
- profinet_py-0.4.0.dist-info/top_level.txt +1 -0
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
|