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/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