pydnp3-stepfunc 1.6.0.3__cp313-cp313-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/handler.py ADDED
@@ -0,0 +1,509 @@
1
+ """
2
+ Callback handler wrappers for DNP3 read/command/event callbacks.
3
+
4
+ The C FFI uses function pointers in structs for async callbacks.
5
+ This module wraps them with cffi @ffi.callback decorators and
6
+ collects data into Python objects.
7
+ """
8
+
9
+ import threading
10
+ from typing import List, Optional, Dict, Any
11
+
12
+ from ._ffi import ffi, lib
13
+ from .types import (
14
+ BinaryInput,
15
+ DoubleBitBinaryInput,
16
+ BinaryOutputStatus,
17
+ Counter,
18
+ FrozenCounter,
19
+ AnalogInput,
20
+ AnalogOutputStatus,
21
+ IIN,
22
+ ReadType,
23
+ )
24
+
25
+
26
+ class ReadHandler:
27
+ """
28
+ Collects data received from DNP3 read operations.
29
+
30
+ Usage:
31
+ handler = ReadHandler()
32
+ # Pass handler.as_ffi() to master_channel_read or add_association
33
+ # After read completes, access handler.binary_inputs, handler.analog_inputs, etc.
34
+ """
35
+
36
+ def __init__(self):
37
+ self.binary_inputs: List[BinaryInput] = []
38
+ self.double_bit_binary_inputs: List[DoubleBitBinaryInput] = []
39
+ self.binary_output_statuses: List[BinaryOutputStatus] = []
40
+ self.counters: List[Counter] = []
41
+ self.frozen_counters: List[FrozenCounter] = []
42
+ self.analog_inputs: List[AnalogInput] = []
43
+ self.analog_output_statuses: List[AnalogOutputStatus] = []
44
+ self.octet_strings: List[Dict[str, Any]] = []
45
+ self.string_attrs: List[Dict[str, Any]] = []
46
+ self.iin: Optional[IIN] = None
47
+ self._lock = threading.Lock()
48
+
49
+ # Create persistent C callbacks - these must be kept alive
50
+ # as long as the handler is in use
51
+
52
+ @ffi.callback("void(dnp3_read_type_t, dnp3_response_header_t, void*)")
53
+ def _begin_fragment(read_type, header, ctx):
54
+ with self._lock:
55
+ self.iin = IIN.from_ffi(header.iin)
56
+
57
+ @ffi.callback("void(dnp3_read_type_t, dnp3_response_header_t, void*)")
58
+ def _end_fragment(read_type, header, ctx):
59
+ pass
60
+
61
+ @ffi.callback("void(dnp3_header_info_t, dnp3_binary_input_iterator_t*, void*)")
62
+ def _handle_binary_input(info, it, ctx):
63
+ with self._lock:
64
+ while True:
65
+ val = lib.dnp3_binary_input_iterator_next(it)
66
+ if val == ffi.NULL:
67
+ break
68
+ self.binary_inputs.append(BinaryInput.from_ffi(val))
69
+
70
+ @ffi.callback("void(dnp3_header_info_t, dnp3_double_bit_binary_input_iterator_t*, void*)")
71
+ def _handle_double_bit(info, it, ctx):
72
+ with self._lock:
73
+ while True:
74
+ val = lib.dnp3_double_bit_binary_input_iterator_next(it)
75
+ if val == ffi.NULL:
76
+ break
77
+ self.double_bit_binary_inputs.append(DoubleBitBinaryInput.from_ffi(val))
78
+
79
+ @ffi.callback("void(dnp3_header_info_t, dnp3_binary_output_status_iterator_t*, void*)")
80
+ def _handle_bos(info, it, ctx):
81
+ with self._lock:
82
+ while True:
83
+ val = lib.dnp3_binary_output_status_iterator_next(it)
84
+ if val == ffi.NULL:
85
+ break
86
+ self.binary_output_statuses.append(BinaryOutputStatus.from_ffi(val))
87
+
88
+ @ffi.callback("void(dnp3_header_info_t, dnp3_counter_iterator_t*, void*)")
89
+ def _handle_counter(info, it, ctx):
90
+ with self._lock:
91
+ while True:
92
+ val = lib.dnp3_counter_iterator_next(it)
93
+ if val == ffi.NULL:
94
+ break
95
+ self.counters.append(Counter.from_ffi(val))
96
+
97
+ @ffi.callback("void(dnp3_header_info_t, dnp3_frozen_counter_iterator_t*, void*)")
98
+ def _handle_frozen_counter(info, it, ctx):
99
+ with self._lock:
100
+ while True:
101
+ val = lib.dnp3_frozen_counter_iterator_next(it)
102
+ if val == ffi.NULL:
103
+ break
104
+ self.frozen_counters.append(FrozenCounter.from_ffi(val))
105
+
106
+ @ffi.callback("void(dnp3_header_info_t, dnp3_analog_input_iterator_t*, void*)")
107
+ def _handle_analog_input(info, it, ctx):
108
+ with self._lock:
109
+ while True:
110
+ val = lib.dnp3_analog_input_iterator_next(it)
111
+ if val == ffi.NULL:
112
+ break
113
+ self.analog_inputs.append(AnalogInput.from_ffi(val))
114
+
115
+ @ffi.callback("void(dnp3_header_info_t, dnp3_analog_output_status_iterator_t*, void*)")
116
+ def _handle_aos(info, it, ctx):
117
+ with self._lock:
118
+ while True:
119
+ val = lib.dnp3_analog_output_status_iterator_next(it)
120
+ if val == ffi.NULL:
121
+ break
122
+ self.analog_output_statuses.append(AnalogOutputStatus.from_ffi(val))
123
+
124
+ @ffi.callback("void(dnp3_header_info_t, dnp3_octet_string_iterator_t*, void*)")
125
+ def _handle_octet_string(info, it, ctx):
126
+ with self._lock:
127
+ while True:
128
+ val = lib.dnp3_octet_string_iterator_next(it)
129
+ if val == ffi.NULL:
130
+ break
131
+ data = []
132
+ while True:
133
+ byte_ptr = lib.dnp3_byte_iterator_next(val.value)
134
+ if byte_ptr == ffi.NULL:
135
+ break
136
+ data.append(byte_ptr[0])
137
+ self.octet_strings.append({"index": val.index, "value": bytes(data)})
138
+
139
+ @ffi.callback("void(dnp3_header_info_t, dnp3_string_attr_t, uint8_t, uint8_t, const char*, void*)")
140
+ def _handle_string_attr(info, attr, set_id, variation, value, ctx):
141
+ with self._lock:
142
+ self.string_attrs.append({
143
+ "attr": int(attr),
144
+ "set": set_id,
145
+ "variation": variation,
146
+ "value": ffi.string(value).decode() if value != ffi.NULL else "",
147
+ })
148
+
149
+ # Store references to prevent GC
150
+ self._begin_fragment = _begin_fragment
151
+ self._end_fragment = _end_fragment
152
+ self._handle_binary_input = _handle_binary_input
153
+ self._handle_double_bit = _handle_double_bit
154
+ self._handle_bos = _handle_bos
155
+ self._handle_counter = _handle_counter
156
+ self._handle_frozen_counter = _handle_frozen_counter
157
+ self._handle_analog_input = _handle_analog_input
158
+ self._handle_aos = _handle_aos
159
+ self._handle_octet_string = _handle_octet_string
160
+ self._handle_string_attr = _handle_string_attr
161
+
162
+ def clear(self):
163
+ """Reset all collected data."""
164
+ with self._lock:
165
+ self.binary_inputs.clear()
166
+ self.double_bit_binary_inputs.clear()
167
+ self.binary_output_statuses.clear()
168
+ self.counters.clear()
169
+ self.frozen_counters.clear()
170
+ self.analog_inputs.clear()
171
+ self.analog_output_statuses.clear()
172
+ self.octet_strings.clear()
173
+ self.string_attrs.clear()
174
+ self.iin = None
175
+
176
+ def as_ffi(self):
177
+ """Return a dnp3_read_handler_t struct for use with the C FFI."""
178
+ return ffi.new("dnp3_read_handler_t*", {
179
+ "begin_fragment": self._begin_fragment,
180
+ "end_fragment": self._end_fragment,
181
+ "handle_binary_input": self._handle_binary_input,
182
+ "handle_double_bit_binary_input": self._handle_double_bit,
183
+ "handle_binary_output_status": self._handle_bos,
184
+ "handle_counter": self._handle_counter,
185
+ "handle_frozen_counter": self._handle_frozen_counter,
186
+ "handle_analog_input": self._handle_analog_input,
187
+ "handle_analog_output_status": self._handle_aos,
188
+ "handle_octet_string": self._handle_octet_string,
189
+ "handle_string_attr": self._handle_string_attr,
190
+ "on_destroy": ffi.NULL,
191
+ "ctx": ffi.NULL,
192
+ })[0]
193
+
194
+
195
+ class ClientStateListener:
196
+ """Tracks master channel connection state changes."""
197
+
198
+ def __init__(self):
199
+ self.state = None
200
+ self._event = threading.Event()
201
+ self._lock = threading.Lock()
202
+
203
+ @ffi.callback("void(dnp3_client_state_t, void*)")
204
+ def _on_change(state, ctx):
205
+ with self._lock:
206
+ self.state = int(state)
207
+ self._event.set()
208
+
209
+ self._on_change = _on_change
210
+
211
+ def wait_for_state(self, expected_state, timeout=10.0) -> bool:
212
+ """Wait for a specific state, returns True if reached."""
213
+ deadline = threading.Event()
214
+ while True:
215
+ with self._lock:
216
+ if self.state == expected_state:
217
+ return True
218
+ if not self._event.wait(timeout):
219
+ return False
220
+ self._event.clear()
221
+
222
+ def as_ffi(self):
223
+ return ffi.new("dnp3_client_state_listener_t*", {
224
+ "on_change": self._on_change,
225
+ "on_destroy": ffi.NULL,
226
+ "ctx": ffi.NULL,
227
+ })[0]
228
+
229
+
230
+ class PortStateListener:
231
+ """Tracks serial port state changes."""
232
+
233
+ def __init__(self):
234
+ self.state = None
235
+
236
+ @ffi.callback("void(dnp3_port_state_t, void*)")
237
+ def _on_change(state, ctx):
238
+ self.state = int(state)
239
+
240
+ self._on_change = _on_change
241
+
242
+ def as_ffi(self):
243
+ return ffi.new("dnp3_port_state_listener_t*", {
244
+ "on_change": self._on_change,
245
+ "on_destroy": ffi.NULL,
246
+ "ctx": ffi.NULL,
247
+ })[0]
248
+
249
+
250
+ class ConnectionStateListener:
251
+ """Tracks outstation server connection state changes."""
252
+
253
+ def __init__(self):
254
+ self.state = None
255
+
256
+ @ffi.callback("void(dnp3_connection_state_t, void*)")
257
+ def _on_change(state, ctx):
258
+ self.state = int(state)
259
+
260
+ self._on_change = _on_change
261
+
262
+ def as_ffi(self):
263
+ return ffi.new("dnp3_connection_state_listener_t*", {
264
+ "on_change": self._on_change,
265
+ "on_destroy": ffi.NULL,
266
+ "ctx": ffi.NULL,
267
+ })[0]
268
+
269
+
270
+ class AssociationHandler:
271
+ """Provides time synchronization for master-outstation association."""
272
+
273
+ def __init__(self):
274
+ @ffi.callback("dnp3_utc_timestamp_t(void*)")
275
+ def _get_current_time(ctx):
276
+ import time
277
+ return ffi.new("dnp3_utc_timestamp_t*", {
278
+ "value": int(time.time() * 1000),
279
+ "is_valid": True,
280
+ })[0]
281
+
282
+ self._get_current_time = _get_current_time
283
+
284
+ def as_ffi(self):
285
+ return ffi.new("dnp3_association_handler_t*", {
286
+ "get_current_time": self._get_current_time,
287
+ "on_destroy": ffi.NULL,
288
+ "ctx": ffi.NULL,
289
+ })[0]
290
+
291
+
292
+ class AssociationInformation:
293
+ """Receives task status notifications."""
294
+
295
+ def __init__(self):
296
+ self.last_task_type = None
297
+ self.last_error = None
298
+
299
+ @ffi.callback("void(dnp3_task_type_t, dnp3_function_code_t, uint8_t, void*)")
300
+ def _task_start(task_type, fc, seq, ctx):
301
+ pass
302
+
303
+ @ffi.callback("void(dnp3_task_type_t, dnp3_function_code_t, uint8_t, void*)")
304
+ def _task_success(task_type, fc, seq, ctx):
305
+ self.last_task_type = int(task_type)
306
+
307
+ @ffi.callback("void(dnp3_task_type_t, dnp3_task_error_t, void*)")
308
+ def _task_fail(task_type, error, ctx):
309
+ self.last_task_type = int(task_type)
310
+ self.last_error = int(error)
311
+
312
+ @ffi.callback("void(bool, uint8_t, void*)")
313
+ def _unsolicited_response(is_duplicate, seq, ctx):
314
+ pass
315
+
316
+ self._task_start = _task_start
317
+ self._task_success = _task_success
318
+ self._task_fail = _task_fail
319
+ self._unsolicited_response = _unsolicited_response
320
+
321
+ def as_ffi(self):
322
+ return ffi.new("dnp3_association_information_t*", {
323
+ "task_start": self._task_start,
324
+ "task_success": self._task_success,
325
+ "task_fail": self._task_fail,
326
+ "unsolicited_response": self._unsolicited_response,
327
+ "on_destroy": ffi.NULL,
328
+ "ctx": ffi.NULL,
329
+ })[0]
330
+
331
+
332
+ class TaskCallback:
333
+ """Generic callback for async operations (read, command, restart, etc.)."""
334
+
335
+ def __init__(self):
336
+ self.success = False
337
+ self.result = None
338
+ self.error = None
339
+ self._event = threading.Event()
340
+
341
+ def wait(self, timeout=30.0) -> bool:
342
+ """Wait for the callback to fire. Returns True if it completed."""
343
+ return self._event.wait(timeout)
344
+
345
+ def _signal_success(self, result=None):
346
+ self.success = True
347
+ self.result = result
348
+ self._event.set()
349
+
350
+ def _signal_failure(self, error):
351
+ self.success = False
352
+ self.error = error
353
+ self._event.set()
354
+
355
+
356
+ class ReadTaskCallback(TaskCallback):
357
+ """Callback for read operations."""
358
+
359
+ def __init__(self):
360
+ super().__init__()
361
+
362
+ @ffi.callback("void(dnp3_nothing_t, void*)")
363
+ def _on_complete(nothing, ctx):
364
+ self._signal_success()
365
+
366
+ @ffi.callback("void(dnp3_read_error_t, void*)")
367
+ def _on_failure(error, ctx):
368
+ self._signal_failure(int(error))
369
+
370
+ self._on_complete = _on_complete
371
+ self._on_failure = _on_failure
372
+
373
+ def as_ffi(self):
374
+ return ffi.new("dnp3_read_task_callback_t*", {
375
+ "on_complete": self._on_complete,
376
+ "on_failure": self._on_failure,
377
+ "on_destroy": ffi.NULL,
378
+ "ctx": ffi.NULL,
379
+ })[0]
380
+
381
+
382
+ class CommandTaskCallback(TaskCallback):
383
+ """Callback for command (operate/select) operations."""
384
+
385
+ def __init__(self):
386
+ super().__init__()
387
+
388
+ @ffi.callback("void(dnp3_nothing_t, void*)")
389
+ def _on_complete(nothing, ctx):
390
+ self._signal_success()
391
+
392
+ @ffi.callback("void(dnp3_command_error_t, void*)")
393
+ def _on_failure(error, ctx):
394
+ self._signal_failure(int(error))
395
+
396
+ self._on_complete = _on_complete
397
+ self._on_failure = _on_failure
398
+
399
+ def as_ffi(self):
400
+ return ffi.new("dnp3_command_task_callback_t*", {
401
+ "on_complete": self._on_complete,
402
+ "on_failure": self._on_failure,
403
+ "on_destroy": ffi.NULL,
404
+ "ctx": ffi.NULL,
405
+ })[0]
406
+
407
+
408
+ class TimeSyncTaskCallback(TaskCallback):
409
+ """Callback for time sync operations."""
410
+
411
+ def __init__(self):
412
+ super().__init__()
413
+
414
+ @ffi.callback("void(dnp3_nothing_t, void*)")
415
+ def _on_complete(nothing, ctx):
416
+ self._signal_success()
417
+
418
+ @ffi.callback("void(dnp3_time_sync_error_t, void*)")
419
+ def _on_failure(error, ctx):
420
+ self._signal_failure(int(error))
421
+
422
+ self._on_complete = _on_complete
423
+ self._on_failure = _on_failure
424
+
425
+ def as_ffi(self):
426
+ return ffi.new("dnp3_time_sync_task_callback_t*", {
427
+ "on_complete": self._on_complete,
428
+ "on_failure": self._on_failure,
429
+ "on_destroy": ffi.NULL,
430
+ "ctx": ffi.NULL,
431
+ })[0]
432
+
433
+
434
+ class RestartTaskCallback(TaskCallback):
435
+ """Callback for restart operations — returns delay in ms."""
436
+
437
+ def __init__(self):
438
+ super().__init__()
439
+
440
+ @ffi.callback("void(uint64_t, void*)")
441
+ def _on_complete(delay, ctx):
442
+ self._signal_success(delay)
443
+
444
+ @ffi.callback("void(dnp3_restart_error_t, void*)")
445
+ def _on_failure(error, ctx):
446
+ self._signal_failure(int(error))
447
+
448
+ self._on_complete = _on_complete
449
+ self._on_failure = _on_failure
450
+
451
+ def as_ffi(self):
452
+ return ffi.new("dnp3_restart_task_callback_t*", {
453
+ "on_complete": self._on_complete,
454
+ "on_failure": self._on_failure,
455
+ "on_destroy": ffi.NULL,
456
+ "ctx": ffi.NULL,
457
+ })[0]
458
+
459
+
460
+ class EmptyResponseCallback(TaskCallback):
461
+ """Callback for operations that expect empty responses."""
462
+
463
+ def __init__(self):
464
+ super().__init__()
465
+
466
+ @ffi.callback("void(dnp3_nothing_t, void*)")
467
+ def _on_complete(nothing, ctx):
468
+ self._signal_success()
469
+
470
+ @ffi.callback("void(dnp3_empty_response_error_t, void*)")
471
+ def _on_failure(error, ctx):
472
+ self._signal_failure(int(error))
473
+
474
+ self._on_complete = _on_complete
475
+ self._on_failure = _on_failure
476
+
477
+ def as_ffi(self):
478
+ return ffi.new("dnp3_empty_response_callback_t*", {
479
+ "on_complete": self._on_complete,
480
+ "on_failure": self._on_failure,
481
+ "on_destroy": ffi.NULL,
482
+ "ctx": ffi.NULL,
483
+ })[0]
484
+
485
+
486
+ class LinkStatusCallback(TaskCallback):
487
+ """Callback for link status check."""
488
+
489
+ def __init__(self):
490
+ super().__init__()
491
+
492
+ @ffi.callback("void(dnp3_nothing_t, void*)")
493
+ def _on_complete(nothing, ctx):
494
+ self._signal_success()
495
+
496
+ @ffi.callback("void(dnp3_link_status_error_t, void*)")
497
+ def _on_failure(error, ctx):
498
+ self._signal_failure(int(error))
499
+
500
+ self._on_complete = _on_complete
501
+ self._on_failure = _on_failure
502
+
503
+ def as_ffi(self):
504
+ return ffi.new("dnp3_link_status_callback_t*", {
505
+ "on_complete": self._on_complete,
506
+ "on_failure": self._on_failure,
507
+ "on_destroy": ffi.NULL,
508
+ "ctx": ffi.NULL,
509
+ })[0]
dnp3/logging.py ADDED
@@ -0,0 +1,93 @@
1
+ """
2
+ DNP3 protocol logging configuration.
3
+
4
+ Wraps the stepfunc/dnp3 tracing/logging subsystem.
5
+ """
6
+
7
+ from enum import IntEnum
8
+ from typing import Optional, Callable
9
+
10
+ from ._ffi import ffi, lib, check_error
11
+
12
+ _logging_configured = False
13
+
14
+
15
+ class LogLevel(IntEnum):
16
+ ERROR = 0
17
+ WARN = 1
18
+ INFO = 2
19
+ DEBUG = 3
20
+ TRACE = 4
21
+
22
+
23
+ class LogOutputFormat(IntEnum):
24
+ TEXT = 0
25
+ JSON = 1
26
+
27
+
28
+ class TimeFormat(IntEnum):
29
+ NONE = 0
30
+ RFC_3339 = 1
31
+ SYSTEM = 2
32
+
33
+
34
+ def configure_logging(
35
+ level: LogLevel = LogLevel.WARN,
36
+ output_format: LogOutputFormat = LogOutputFormat.TEXT,
37
+ print_level: bool = True,
38
+ print_module_info: bool = False,
39
+ time_format: TimeFormat = TimeFormat.SYSTEM,
40
+ callback: Optional[Callable[[LogLevel, str], None]] = None,
41
+ ):
42
+ """
43
+ Configure the DNP3 library logging.
44
+
45
+ Can only be called once. Subsequent calls will be ignored.
46
+
47
+ Args:
48
+ level: Minimum log level
49
+ output_format: Text or JSON
50
+ print_level: Include log level in output
51
+ print_module_info: Include module info
52
+ time_format: Timestamp format
53
+ callback: Optional callback for log messages. If None, logs to stdout.
54
+ """
55
+ global _logging_configured
56
+ if _logging_configured:
57
+ return
58
+ _logging_configured = True
59
+
60
+ config = ffi.new("dnp3_logging_config_t*")
61
+ config.level = level.value
62
+ config.output_format = output_format.value
63
+ config.print_level = print_level
64
+ config.print_module_info = print_module_info
65
+ config.time_format = time_format.value
66
+
67
+ if callback is not None:
68
+ @ffi.callback("void(dnp3_log_level_t, const char*, void*)")
69
+ def _on_message(lvl, msg, ctx):
70
+ callback(LogLevel(lvl), ffi.string(msg).decode())
71
+
72
+ # Must keep reference alive
73
+ configure_logging._callback_ref = _on_message
74
+
75
+ logger = ffi.new("dnp3_logger_t*", {
76
+ "on_message": _on_message,
77
+ "on_destroy": ffi.NULL,
78
+ "ctx": ffi.NULL,
79
+ })[0]
80
+ else:
81
+ @ffi.callback("void(dnp3_log_level_t, const char*, void*)")
82
+ def _on_message(lvl, msg, ctx):
83
+ print(ffi.string(msg).decode(), end="")
84
+
85
+ configure_logging._callback_ref = _on_message
86
+
87
+ logger = ffi.new("dnp3_logger_t*", {
88
+ "on_message": _on_message,
89
+ "on_destroy": ffi.NULL,
90
+ "ctx": ffi.NULL,
91
+ })[0]
92
+
93
+ lib.dnp3_configure_logging(config[0], logger)