pydnp3-stepfunc 1.6.0.3__cp310-cp310-manylinux1_x86_64.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.
- dnp3/__init__.py +138 -0
- dnp3/_build_ffi.py +166 -0
- dnp3/_ffi.py +64 -0
- dnp3/channel.py +240 -0
- dnp3/handler.py +509 -0
- dnp3/logging.py +93 -0
- dnp3/master.py +539 -0
- dnp3/outstation.py +400 -0
- dnp3/runtime.py +57 -0
- dnp3/types.py +517 -0
- dnp3/vendor/dnp3.h +8253 -0
- dnp3/vendor/libdnp3_ffi.so +0 -0
- pydnp3_stepfunc-1.6.0.3.dist-info/METADATA +238 -0
- pydnp3_stepfunc-1.6.0.3.dist-info/RECORD +17 -0
- pydnp3_stepfunc-1.6.0.3.dist-info/WHEEL +5 -0
- pydnp3_stepfunc-1.6.0.3.dist-info/licenses/LICENSE +21 -0
- pydnp3_stepfunc-1.6.0.3.dist-info/top_level.txt +1 -0
dnp3/master.py
ADDED
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Master station implementation wrapping the stepfunc/dnp3 C FFI.
|
|
3
|
+
|
|
4
|
+
Provides a high-level API for DNP3 master operations including:
|
|
5
|
+
- Polling (integrity, event, class-based)
|
|
6
|
+
- Read operations (specific group/variation)
|
|
7
|
+
- Control operations (SBO, direct operate)
|
|
8
|
+
- Time synchronization
|
|
9
|
+
- Restart commands
|
|
10
|
+
- Device attribute reading
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from typing import Optional, List, Dict, Any
|
|
14
|
+
|
|
15
|
+
from ._ffi import ffi, lib, check_error, DNP3Error
|
|
16
|
+
from .types import (
|
|
17
|
+
Variation,
|
|
18
|
+
CommandMode,
|
|
19
|
+
TimeSyncMode,
|
|
20
|
+
FunctionCode,
|
|
21
|
+
AutoTimeSync,
|
|
22
|
+
DecodeLevel,
|
|
23
|
+
Group12Var1,
|
|
24
|
+
ControlCode,
|
|
25
|
+
OpType,
|
|
26
|
+
TripCloseCode,
|
|
27
|
+
)
|
|
28
|
+
from .handler import (
|
|
29
|
+
ReadHandler,
|
|
30
|
+
ReadTaskCallback,
|
|
31
|
+
CommandTaskCallback,
|
|
32
|
+
TimeSyncTaskCallback,
|
|
33
|
+
RestartTaskCallback,
|
|
34
|
+
EmptyResponseCallback,
|
|
35
|
+
LinkStatusCallback,
|
|
36
|
+
AssociationHandler,
|
|
37
|
+
AssociationInformation,
|
|
38
|
+
ClientStateListener,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class AssociationConfig:
|
|
43
|
+
"""Configuration for a master-outstation association."""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
disable_unsol_classes: tuple = (True, True, True), # class 1,2,3
|
|
48
|
+
enable_unsol_classes: tuple = (True, True, True),
|
|
49
|
+
startup_integrity_classes: tuple = (True, True, True, True), # class 0,1,2,3
|
|
50
|
+
event_scan_on_events_available: tuple = (False, False, False),
|
|
51
|
+
auto_time_sync: AutoTimeSync = AutoTimeSync.LAN,
|
|
52
|
+
keep_alive_timeout: int = 60, # seconds
|
|
53
|
+
auto_tasks_retry_strategy_min_delay: int = 1000, # ms
|
|
54
|
+
auto_tasks_retry_strategy_max_delay: int = 30000, # ms
|
|
55
|
+
response_timeout: int = 5000, # ms
|
|
56
|
+
auto_integrity_scan_on_buffer_overflow: bool = True,
|
|
57
|
+
max_queued_user_requests: int = 16,
|
|
58
|
+
):
|
|
59
|
+
self.disable_unsol_classes = disable_unsol_classes
|
|
60
|
+
self.enable_unsol_classes = enable_unsol_classes
|
|
61
|
+
self.startup_integrity_classes = startup_integrity_classes
|
|
62
|
+
self.event_scan_on_events_available = event_scan_on_events_available
|
|
63
|
+
self.auto_time_sync = auto_time_sync
|
|
64
|
+
self.keep_alive_timeout = keep_alive_timeout
|
|
65
|
+
self.auto_tasks_retry_strategy_min_delay = auto_tasks_retry_strategy_min_delay
|
|
66
|
+
self.auto_tasks_retry_strategy_max_delay = auto_tasks_retry_strategy_max_delay
|
|
67
|
+
self.response_timeout = response_timeout
|
|
68
|
+
self.auto_integrity_scan_on_buffer_overflow = auto_integrity_scan_on_buffer_overflow
|
|
69
|
+
self.max_queued_user_requests = max_queued_user_requests
|
|
70
|
+
|
|
71
|
+
def to_ffi(self):
|
|
72
|
+
config = ffi.new("dnp3_association_config_t*")
|
|
73
|
+
|
|
74
|
+
# Response timeout (ms)
|
|
75
|
+
config.response_timeout = self.response_timeout
|
|
76
|
+
|
|
77
|
+
# Disable unsolicited classes
|
|
78
|
+
config.disable_unsol_classes.class1 = self.disable_unsol_classes[0]
|
|
79
|
+
config.disable_unsol_classes.class2 = self.disable_unsol_classes[1]
|
|
80
|
+
config.disable_unsol_classes.class3 = self.disable_unsol_classes[2]
|
|
81
|
+
|
|
82
|
+
# Enable unsolicited classes
|
|
83
|
+
config.enable_unsol_classes.class1 = self.enable_unsol_classes[0]
|
|
84
|
+
config.enable_unsol_classes.class2 = self.enable_unsol_classes[1]
|
|
85
|
+
config.enable_unsol_classes.class3 = self.enable_unsol_classes[2]
|
|
86
|
+
|
|
87
|
+
# Startup integrity poll classes
|
|
88
|
+
config.startup_integrity_classes.class0 = self.startup_integrity_classes[0]
|
|
89
|
+
config.startup_integrity_classes.class1 = self.startup_integrity_classes[1]
|
|
90
|
+
config.startup_integrity_classes.class2 = self.startup_integrity_classes[2]
|
|
91
|
+
config.startup_integrity_classes.class3 = self.startup_integrity_classes[3]
|
|
92
|
+
|
|
93
|
+
config.auto_time_sync = self.auto_time_sync.value
|
|
94
|
+
config.auto_tasks_retry_strategy.min_delay = self.auto_tasks_retry_strategy_min_delay
|
|
95
|
+
config.auto_tasks_retry_strategy.max_delay = self.auto_tasks_retry_strategy_max_delay
|
|
96
|
+
config.keep_alive_timeout = self.keep_alive_timeout
|
|
97
|
+
config.auto_integrity_scan_on_buffer_overflow = self.auto_integrity_scan_on_buffer_overflow
|
|
98
|
+
|
|
99
|
+
# Event scan on events available
|
|
100
|
+
config.event_scan_on_events_available.class1 = self.event_scan_on_events_available[0]
|
|
101
|
+
config.event_scan_on_events_available.class2 = self.event_scan_on_events_available[1]
|
|
102
|
+
config.event_scan_on_events_available.class3 = self.event_scan_on_events_available[2]
|
|
103
|
+
|
|
104
|
+
config.max_queued_user_requests = self.max_queued_user_requests
|
|
105
|
+
|
|
106
|
+
return config[0]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class AssociationId:
|
|
110
|
+
"""Wraps the C association_id struct."""
|
|
111
|
+
|
|
112
|
+
def __init__(self, c_id):
|
|
113
|
+
self._id = c_id
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def address(self):
|
|
117
|
+
return self._id.address
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def raw(self):
|
|
121
|
+
return self._id
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class PollId:
|
|
125
|
+
"""Wraps the C poll_id struct."""
|
|
126
|
+
|
|
127
|
+
def __init__(self, c_id):
|
|
128
|
+
self._id = c_id
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def raw(self):
|
|
132
|
+
return self._id
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class MasterChannel:
|
|
136
|
+
"""
|
|
137
|
+
High-level wrapper around a DNP3 master channel.
|
|
138
|
+
|
|
139
|
+
Manages associations and provides methods for read, control,
|
|
140
|
+
time sync, and restart operations.
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
def __init__(self, ptr, listener=None):
|
|
144
|
+
self._ptr = ptr
|
|
145
|
+
self._listener = listener
|
|
146
|
+
self._destroyed = False
|
|
147
|
+
|
|
148
|
+
def destroy(self):
|
|
149
|
+
"""Destroy the channel and free resources."""
|
|
150
|
+
if not self._destroyed and self._ptr is not None:
|
|
151
|
+
lib.dnp3_master_channel_destroy(self._ptr)
|
|
152
|
+
self._destroyed = True
|
|
153
|
+
|
|
154
|
+
def __del__(self):
|
|
155
|
+
self.destroy()
|
|
156
|
+
|
|
157
|
+
def enable(self):
|
|
158
|
+
"""Enable communications on the channel."""
|
|
159
|
+
check_error(lib.dnp3_master_channel_enable(self._ptr))
|
|
160
|
+
|
|
161
|
+
def disable(self):
|
|
162
|
+
"""Disable communications on the channel."""
|
|
163
|
+
check_error(lib.dnp3_master_channel_disable(self._ptr))
|
|
164
|
+
|
|
165
|
+
def set_decode_level(self, level: DecodeLevel):
|
|
166
|
+
"""Set the decode/logging level."""
|
|
167
|
+
c_level = ffi.new("dnp3_decode_level_t*", level.to_ffi())[0]
|
|
168
|
+
check_error(lib.dnp3_master_channel_set_decode_level(self._ptr, c_level))
|
|
169
|
+
|
|
170
|
+
def add_association(
|
|
171
|
+
self,
|
|
172
|
+
address: int,
|
|
173
|
+
read_handler: Optional[ReadHandler] = None,
|
|
174
|
+
config: Optional[AssociationConfig] = None,
|
|
175
|
+
assoc_handler: Optional[AssociationHandler] = None,
|
|
176
|
+
assoc_info: Optional[AssociationInformation] = None,
|
|
177
|
+
) -> AssociationId:
|
|
178
|
+
"""
|
|
179
|
+
Add a master-outstation association.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
address: Outstation DNP3 address
|
|
183
|
+
read_handler: Handler for received data
|
|
184
|
+
config: Association configuration
|
|
185
|
+
assoc_handler: Time provider
|
|
186
|
+
assoc_info: Task status receiver
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
AssociationId for use in subsequent operations
|
|
190
|
+
"""
|
|
191
|
+
read_handler = read_handler or ReadHandler()
|
|
192
|
+
config = config or AssociationConfig()
|
|
193
|
+
assoc_handler = assoc_handler or AssociationHandler()
|
|
194
|
+
assoc_info = assoc_info or AssociationInformation()
|
|
195
|
+
|
|
196
|
+
# Keep references to handlers to prevent GC
|
|
197
|
+
self._read_handler = read_handler
|
|
198
|
+
self._assoc_handler = assoc_handler
|
|
199
|
+
self._assoc_info = assoc_info
|
|
200
|
+
|
|
201
|
+
assoc_id = ffi.new("dnp3_association_id_t*")
|
|
202
|
+
err = lib.dnp3_master_channel_add_association(
|
|
203
|
+
self._ptr,
|
|
204
|
+
address,
|
|
205
|
+
config.to_ffi(),
|
|
206
|
+
read_handler.as_ffi(),
|
|
207
|
+
assoc_handler.as_ffi(),
|
|
208
|
+
assoc_info.as_ffi(),
|
|
209
|
+
assoc_id,
|
|
210
|
+
)
|
|
211
|
+
check_error(err)
|
|
212
|
+
return AssociationId(assoc_id[0])
|
|
213
|
+
|
|
214
|
+
def remove_association(self, assoc_id: AssociationId):
|
|
215
|
+
"""Remove an association."""
|
|
216
|
+
check_error(lib.dnp3_master_channel_remove_association(self._ptr, assoc_id.raw))
|
|
217
|
+
|
|
218
|
+
def add_poll(
|
|
219
|
+
self,
|
|
220
|
+
assoc_id: AssociationId,
|
|
221
|
+
class0: bool = False,
|
|
222
|
+
class1: bool = True,
|
|
223
|
+
class2: bool = True,
|
|
224
|
+
class3: bool = True,
|
|
225
|
+
period_ms: int = 5000,
|
|
226
|
+
) -> PollId:
|
|
227
|
+
"""
|
|
228
|
+
Add a periodic poll to the association.
|
|
229
|
+
|
|
230
|
+
Args:
|
|
231
|
+
assoc_id: Association to poll on
|
|
232
|
+
class0: Include Class 0 (static data)
|
|
233
|
+
class1: Include Class 1 events
|
|
234
|
+
class2: Include Class 2 events
|
|
235
|
+
class3: Include Class 3 events
|
|
236
|
+
period_ms: Poll period in milliseconds
|
|
237
|
+
|
|
238
|
+
Returns:
|
|
239
|
+
PollId for managing the poll
|
|
240
|
+
"""
|
|
241
|
+
request = lib.dnp3_request_new_class(class0, class1, class2, class3)
|
|
242
|
+
poll_id = ffi.new("dnp3_poll_id_t*")
|
|
243
|
+
err = lib.dnp3_master_channel_add_poll(
|
|
244
|
+
self._ptr, assoc_id.raw, request, period_ms, poll_id
|
|
245
|
+
)
|
|
246
|
+
lib.dnp3_request_destroy(request)
|
|
247
|
+
check_error(err)
|
|
248
|
+
return PollId(poll_id[0])
|
|
249
|
+
|
|
250
|
+
def demand_poll(self, poll_id: PollId):
|
|
251
|
+
"""Trigger an immediate execution of a poll."""
|
|
252
|
+
check_error(lib.dnp3_master_channel_demand_poll(self._ptr, poll_id.raw))
|
|
253
|
+
|
|
254
|
+
def remove_poll(self, poll_id: PollId):
|
|
255
|
+
"""Remove a poll."""
|
|
256
|
+
check_error(lib.dnp3_master_channel_remove_poll(self._ptr, poll_id.raw))
|
|
257
|
+
|
|
258
|
+
def read(
|
|
259
|
+
self,
|
|
260
|
+
assoc_id: AssociationId,
|
|
261
|
+
variation: Optional[Variation] = None,
|
|
262
|
+
class0: bool = True,
|
|
263
|
+
class1: bool = True,
|
|
264
|
+
class2: bool = True,
|
|
265
|
+
class3: bool = True,
|
|
266
|
+
start: Optional[int] = None,
|
|
267
|
+
stop: Optional[int] = None,
|
|
268
|
+
timeout: float = 30.0,
|
|
269
|
+
) -> bool:
|
|
270
|
+
"""
|
|
271
|
+
Perform a one-shot read operation.
|
|
272
|
+
|
|
273
|
+
If variation is specified, reads that specific group/variation.
|
|
274
|
+
Otherwise reads by class.
|
|
275
|
+
|
|
276
|
+
Args:
|
|
277
|
+
assoc_id: Association to read from
|
|
278
|
+
variation: Specific variation to read (e.g., Variation.GROUP30_VAR0)
|
|
279
|
+
class0-3: Classes to include when variation is None
|
|
280
|
+
start: Start index for range reads
|
|
281
|
+
stop: Stop index for range reads
|
|
282
|
+
timeout: Timeout in seconds
|
|
283
|
+
|
|
284
|
+
Returns:
|
|
285
|
+
True if read succeeded, False on timeout/error
|
|
286
|
+
"""
|
|
287
|
+
if variation is not None:
|
|
288
|
+
if start is not None and stop is not None:
|
|
289
|
+
request = lib.dnp3_request_new_two_byte_range(variation.value, start, stop)
|
|
290
|
+
else:
|
|
291
|
+
request = lib.dnp3_request_new_all_objects(variation.value)
|
|
292
|
+
else:
|
|
293
|
+
request = lib.dnp3_request_new_class(class0, class1, class2, class3)
|
|
294
|
+
|
|
295
|
+
cb = ReadTaskCallback()
|
|
296
|
+
err = lib.dnp3_master_channel_read(
|
|
297
|
+
self._ptr, assoc_id.raw, request, cb.as_ffi()
|
|
298
|
+
)
|
|
299
|
+
lib.dnp3_request_destroy(request)
|
|
300
|
+
check_error(err)
|
|
301
|
+
|
|
302
|
+
cb.wait(timeout)
|
|
303
|
+
return cb.success
|
|
304
|
+
|
|
305
|
+
def read_attributes(
|
|
306
|
+
self,
|
|
307
|
+
assoc_id: AssociationId,
|
|
308
|
+
variation: int = 0xFE, # ALL_ATTRIBUTES_REQUEST
|
|
309
|
+
set_id: int = 0,
|
|
310
|
+
timeout: float = 30.0,
|
|
311
|
+
) -> bool:
|
|
312
|
+
"""
|
|
313
|
+
Read device attributes (Group 0).
|
|
314
|
+
|
|
315
|
+
Args:
|
|
316
|
+
assoc_id: Association
|
|
317
|
+
variation: Attribute variation (0xFE = all attributes request)
|
|
318
|
+
set_id: Attribute set (default 0)
|
|
319
|
+
timeout: Timeout in seconds
|
|
320
|
+
|
|
321
|
+
Returns:
|
|
322
|
+
True if read succeeded
|
|
323
|
+
"""
|
|
324
|
+
request = lib.dnp3_request_create()
|
|
325
|
+
lib.dnp3_request_add_specific_attribute(request, variation, set_id)
|
|
326
|
+
|
|
327
|
+
cb = ReadTaskCallback()
|
|
328
|
+
err = lib.dnp3_master_channel_read(
|
|
329
|
+
self._ptr, assoc_id.raw, request, cb.as_ffi()
|
|
330
|
+
)
|
|
331
|
+
lib.dnp3_request_destroy(request)
|
|
332
|
+
check_error(err)
|
|
333
|
+
|
|
334
|
+
cb.wait(timeout)
|
|
335
|
+
return cb.success
|
|
336
|
+
|
|
337
|
+
def operate(
|
|
338
|
+
self,
|
|
339
|
+
assoc_id: AssociationId,
|
|
340
|
+
mode: CommandMode = CommandMode.SELECT_BEFORE_OPERATE,
|
|
341
|
+
crob_commands: Optional[List[tuple]] = None,
|
|
342
|
+
analog_int32_commands: Optional[List[tuple]] = None,
|
|
343
|
+
analog_float_commands: Optional[List[tuple]] = None,
|
|
344
|
+
analog_double_commands: Optional[List[tuple]] = None,
|
|
345
|
+
timeout: float = 30.0,
|
|
346
|
+
) -> bool:
|
|
347
|
+
"""
|
|
348
|
+
Perform a control operation.
|
|
349
|
+
|
|
350
|
+
Args:
|
|
351
|
+
assoc_id: Association
|
|
352
|
+
mode: SELECT_BEFORE_OPERATE or DIRECT_OPERATE
|
|
353
|
+
crob_commands: List of (index, Group12Var1) tuples for binary output control
|
|
354
|
+
analog_int32_commands: List of (index, value) for G41V1
|
|
355
|
+
analog_float_commands: List of (index, value) for G41V3
|
|
356
|
+
analog_double_commands: List of (index, value) for G41V4
|
|
357
|
+
timeout: Timeout in seconds
|
|
358
|
+
|
|
359
|
+
Returns:
|
|
360
|
+
True if operation succeeded
|
|
361
|
+
"""
|
|
362
|
+
commands = lib.dnp3_command_set_create()
|
|
363
|
+
|
|
364
|
+
if crob_commands:
|
|
365
|
+
for idx, g12v1 in crob_commands:
|
|
366
|
+
c_g12v1 = ffi.new("dnp3_group12_var1_t*", g12v1.to_ffi())[0]
|
|
367
|
+
lib.dnp3_command_set_add_g12_v1_u16(commands, idx, c_g12v1)
|
|
368
|
+
|
|
369
|
+
if analog_int32_commands:
|
|
370
|
+
for idx, value in analog_int32_commands:
|
|
371
|
+
lib.dnp3_command_set_add_g41_v1_u16(commands, idx, value)
|
|
372
|
+
|
|
373
|
+
if analog_float_commands:
|
|
374
|
+
for idx, value in analog_float_commands:
|
|
375
|
+
lib.dnp3_command_set_add_g41_v3_u16(commands, idx, value)
|
|
376
|
+
|
|
377
|
+
if analog_double_commands:
|
|
378
|
+
for idx, value in analog_double_commands:
|
|
379
|
+
lib.dnp3_command_set_add_g41_v4_u16(commands, idx, value)
|
|
380
|
+
|
|
381
|
+
cb = CommandTaskCallback()
|
|
382
|
+
err = lib.dnp3_master_channel_operate(
|
|
383
|
+
self._ptr, assoc_id.raw, mode.value, commands, cb.as_ffi()
|
|
384
|
+
)
|
|
385
|
+
lib.dnp3_command_set_destroy(commands)
|
|
386
|
+
check_error(err)
|
|
387
|
+
|
|
388
|
+
cb.wait(timeout)
|
|
389
|
+
return cb.success
|
|
390
|
+
|
|
391
|
+
def select_before_operate(
|
|
392
|
+
self,
|
|
393
|
+
assoc_id: AssociationId,
|
|
394
|
+
index: int,
|
|
395
|
+
op_type: OpType = OpType.LATCH_ON,
|
|
396
|
+
tcc: TripCloseCode = TripCloseCode.NUL,
|
|
397
|
+
timeout: float = 30.0,
|
|
398
|
+
) -> bool:
|
|
399
|
+
"""
|
|
400
|
+
Convenience: Perform a Select-Before-Operate binary output control.
|
|
401
|
+
|
|
402
|
+
Args:
|
|
403
|
+
assoc_id: Association
|
|
404
|
+
index: Binary output index
|
|
405
|
+
op_type: Operation type (LATCH_ON, LATCH_OFF, PULSE_ON, etc.)
|
|
406
|
+
tcc: Trip-Close code
|
|
407
|
+
timeout: Timeout
|
|
408
|
+
|
|
409
|
+
Returns:
|
|
410
|
+
True if successful
|
|
411
|
+
"""
|
|
412
|
+
g12v1 = Group12Var1(
|
|
413
|
+
code=ControlCode(tcc=tcc, op_type=op_type),
|
|
414
|
+
)
|
|
415
|
+
return self.operate(
|
|
416
|
+
assoc_id,
|
|
417
|
+
mode=CommandMode.SELECT_BEFORE_OPERATE,
|
|
418
|
+
crob_commands=[(index, g12v1)],
|
|
419
|
+
timeout=timeout,
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
def direct_operate(
|
|
423
|
+
self,
|
|
424
|
+
assoc_id: AssociationId,
|
|
425
|
+
index: int,
|
|
426
|
+
op_type: OpType = OpType.LATCH_ON,
|
|
427
|
+
tcc: TripCloseCode = TripCloseCode.NUL,
|
|
428
|
+
timeout: float = 30.0,
|
|
429
|
+
) -> bool:
|
|
430
|
+
"""Convenience: Perform a Direct Operate binary output control."""
|
|
431
|
+
g12v1 = Group12Var1(
|
|
432
|
+
code=ControlCode(tcc=tcc, op_type=op_type),
|
|
433
|
+
)
|
|
434
|
+
return self.operate(
|
|
435
|
+
assoc_id,
|
|
436
|
+
mode=CommandMode.DIRECT_OPERATE,
|
|
437
|
+
crob_commands=[(index, g12v1)],
|
|
438
|
+
timeout=timeout,
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
def synchronize_time(
|
|
442
|
+
self,
|
|
443
|
+
assoc_id: AssociationId,
|
|
444
|
+
mode: TimeSyncMode = TimeSyncMode.LAN,
|
|
445
|
+
timeout: float = 30.0,
|
|
446
|
+
) -> bool:
|
|
447
|
+
"""Synchronize time with the outstation."""
|
|
448
|
+
cb = TimeSyncTaskCallback()
|
|
449
|
+
err = lib.dnp3_master_channel_synchronize_time(
|
|
450
|
+
self._ptr, assoc_id.raw, mode.value, cb.as_ffi()
|
|
451
|
+
)
|
|
452
|
+
check_error(err)
|
|
453
|
+
cb.wait(timeout)
|
|
454
|
+
return cb.success
|
|
455
|
+
|
|
456
|
+
def cold_restart(
|
|
457
|
+
self,
|
|
458
|
+
assoc_id: AssociationId,
|
|
459
|
+
timeout: float = 30.0,
|
|
460
|
+
) -> Optional[int]:
|
|
461
|
+
"""
|
|
462
|
+
Send a cold restart command.
|
|
463
|
+
|
|
464
|
+
Returns:
|
|
465
|
+
Delay in ms before restart, or None on failure
|
|
466
|
+
"""
|
|
467
|
+
cb = RestartTaskCallback()
|
|
468
|
+
err = lib.dnp3_master_channel_cold_restart(
|
|
469
|
+
self._ptr, assoc_id.raw, cb.as_ffi()
|
|
470
|
+
)
|
|
471
|
+
check_error(err)
|
|
472
|
+
cb.wait(timeout)
|
|
473
|
+
return cb.result if cb.success else None
|
|
474
|
+
|
|
475
|
+
def warm_restart(
|
|
476
|
+
self,
|
|
477
|
+
assoc_id: AssociationId,
|
|
478
|
+
timeout: float = 30.0,
|
|
479
|
+
) -> Optional[int]:
|
|
480
|
+
"""
|
|
481
|
+
Send a warm restart command.
|
|
482
|
+
|
|
483
|
+
Returns:
|
|
484
|
+
Delay in ms before restart, or None on failure
|
|
485
|
+
"""
|
|
486
|
+
cb = RestartTaskCallback()
|
|
487
|
+
err = lib.dnp3_master_channel_warm_restart(
|
|
488
|
+
self._ptr, assoc_id.raw, cb.as_ffi()
|
|
489
|
+
)
|
|
490
|
+
check_error(err)
|
|
491
|
+
cb.wait(timeout)
|
|
492
|
+
return cb.result if cb.success else None
|
|
493
|
+
|
|
494
|
+
def check_link_status(
|
|
495
|
+
self,
|
|
496
|
+
assoc_id: AssociationId,
|
|
497
|
+
timeout: float = 10.0,
|
|
498
|
+
) -> bool:
|
|
499
|
+
"""Check link status with the outstation."""
|
|
500
|
+
cb = LinkStatusCallback()
|
|
501
|
+
err = lib.dnp3_master_channel_check_link_status(
|
|
502
|
+
self._ptr, assoc_id.raw, cb.as_ffi()
|
|
503
|
+
)
|
|
504
|
+
check_error(err)
|
|
505
|
+
cb.wait(timeout)
|
|
506
|
+
return cb.success
|
|
507
|
+
|
|
508
|
+
def send_and_expect_empty_response(
|
|
509
|
+
self,
|
|
510
|
+
assoc_id: AssociationId,
|
|
511
|
+
function_code: FunctionCode,
|
|
512
|
+
variation: Optional[Variation] = None,
|
|
513
|
+
timeout: float = 30.0,
|
|
514
|
+
) -> bool:
|
|
515
|
+
"""
|
|
516
|
+
Send a request and expect an empty response (e.g., enable/disable unsolicited).
|
|
517
|
+
|
|
518
|
+
Args:
|
|
519
|
+
assoc_id: Association
|
|
520
|
+
function_code: DNP3 function code
|
|
521
|
+
variation: Optional variation to include in request
|
|
522
|
+
timeout: Timeout
|
|
523
|
+
|
|
524
|
+
Returns:
|
|
525
|
+
True if successful
|
|
526
|
+
"""
|
|
527
|
+
request = lib.dnp3_request_create()
|
|
528
|
+
if variation is not None:
|
|
529
|
+
lib.dnp3_request_add_all_objects_header(request, variation.value)
|
|
530
|
+
|
|
531
|
+
cb = EmptyResponseCallback()
|
|
532
|
+
err = lib.dnp3_master_channel_send_and_expect_empty_response(
|
|
533
|
+
self._ptr, assoc_id.raw, function_code.value, request, cb.as_ffi()
|
|
534
|
+
)
|
|
535
|
+
lib.dnp3_request_destroy(request)
|
|
536
|
+
check_error(err)
|
|
537
|
+
|
|
538
|
+
cb.wait(timeout)
|
|
539
|
+
return cb.success
|