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/outstation.py ADDED
@@ -0,0 +1,400 @@
1
+ """
2
+ Outstation (server) implementation for testing purposes.
3
+
4
+ This allows creating a DNP3 outstation that can be used as a test target.
5
+ """
6
+
7
+ from typing import Optional
8
+
9
+ from ._ffi import ffi, lib, check_error
10
+ from .types import (
11
+ LinkErrorMode,
12
+ DecodeLevel,
13
+ )
14
+ from .handler import ConnectionStateListener
15
+
16
+
17
+ class EventBufferConfig:
18
+ """Configuration for event buffer sizes."""
19
+
20
+ def __init__(
21
+ self,
22
+ binary: int = 10,
23
+ double_bit_binary: int = 10,
24
+ binary_output_status: int = 10,
25
+ counter: int = 5,
26
+ frozen_counter: int = 5,
27
+ analog: int = 5,
28
+ analog_output_status: int = 5,
29
+ octet_string: int = 3,
30
+ ):
31
+ self.binary = binary
32
+ self.double_bit_binary = double_bit_binary
33
+ self.binary_output_status = binary_output_status
34
+ self.counter = counter
35
+ self.frozen_counter = frozen_counter
36
+ self.analog = analog
37
+ self.analog_output_status = analog_output_status
38
+ self.octet_string = octet_string
39
+
40
+ def to_ffi(self):
41
+ return ffi.new("dnp3_event_buffer_config_t*", {
42
+ "max_binary": self.binary,
43
+ "max_double_bit_binary": self.double_bit_binary,
44
+ "max_binary_output_status": self.binary_output_status,
45
+ "max_counter": self.counter,
46
+ "max_frozen_counter": self.frozen_counter,
47
+ "max_analog": self.analog,
48
+ "max_analog_output_status": self.analog_output_status,
49
+ "max_octet_string": self.octet_string,
50
+ })[0]
51
+
52
+
53
+ class OutstationConfig:
54
+ """Configuration for an outstation."""
55
+
56
+ def __init__(
57
+ self,
58
+ outstation_address: int = 1024,
59
+ master_address: int = 1,
60
+ event_buffer: Optional[EventBufferConfig] = None,
61
+ decode_level: Optional[DecodeLevel] = None,
62
+ ):
63
+ self.outstation_address = outstation_address
64
+ self.master_address = master_address
65
+ self.event_buffer = event_buffer or EventBufferConfig()
66
+ self.decode_level = decode_level or DecodeLevel()
67
+
68
+ def to_ffi(self):
69
+ config = ffi.new("dnp3_outstation_config_t*")
70
+ config.outstation_address = self.outstation_address
71
+ config.master_address = self.master_address
72
+ config.event_buffer_config = self.event_buffer.to_ffi()
73
+ config.solicited_buffer_size = 2048
74
+ config.unsolicited_buffer_size = 2048
75
+ config.rx_buffer_size = 2048
76
+ config.decode_level = ffi.new("dnp3_decode_level_t*", self.decode_level.to_ffi())[0]
77
+ config.confirm_timeout = 5000
78
+ config.select_timeout = 5000
79
+ # Features — matches dnp3_outstation_features_init() defaults
80
+ config.features.self_address = False
81
+ config.features.broadcast = True
82
+ config.features.unsolicited = True
83
+ config.features.respond_to_any_master = False
84
+ config.max_unsolicited_retries = 4294967295 # max uint32
85
+ config.unsolicited_retry_delay = 5000
86
+ config.keep_alive_timeout = 60000 # ms
87
+ config.max_read_request_headers = 64
88
+ config.max_controls_per_request = 65535
89
+ # Class zero config — matches dnp3_class_zero_config_init() defaults
90
+ config.class_zero.binary = True
91
+ config.class_zero.double_bit_binary = True
92
+ config.class_zero.binary_output_status = True
93
+ config.class_zero.counter = True
94
+ config.class_zero.frozen_counter = True
95
+ config.class_zero.analog = True
96
+ config.class_zero.analog_output_status = True
97
+ config.class_zero.octet_string = False
98
+ return config[0]
99
+
100
+
101
+ class OutstationApplication:
102
+ """Default outstation application callbacks."""
103
+
104
+ def __init__(self):
105
+ @ffi.callback("uint16_t(void*)")
106
+ def _get_processing_delay_ms(ctx):
107
+ return 0
108
+
109
+ @ffi.callback("dnp3_write_time_result_t(uint64_t, void*)")
110
+ def _write_absolute_time(time, ctx):
111
+ return lib.DNP3_WRITE_TIME_RESULT_OK
112
+
113
+ @ffi.callback("dnp3_application_iin_t(void*)")
114
+ def _get_application_iin(ctx):
115
+ return ffi.new("dnp3_application_iin_t*", {
116
+ "need_time": False,
117
+ "local_control": False,
118
+ "device_trouble": False,
119
+ "config_corrupt": False,
120
+ })[0]
121
+
122
+ @ffi.callback("dnp3_restart_delay_t(void*)")
123
+ def _cold_restart(ctx):
124
+ return ffi.new("dnp3_restart_delay_t*", {
125
+ "restart_type": lib.DNP3_RESTART_DELAY_TYPE_SECONDS,
126
+ "value": 60,
127
+ })[0]
128
+
129
+ @ffi.callback("dnp3_restart_delay_t(void*)")
130
+ def _warm_restart(ctx):
131
+ return ffi.new("dnp3_restart_delay_t*", {
132
+ "restart_type": lib.DNP3_RESTART_DELAY_TYPE_NOT_SUPPORTED,
133
+ "value": 0,
134
+ })[0]
135
+
136
+ @ffi.callback("dnp3_freeze_result_t(dnp3_freeze_type_t, dnp3_database_handle_t*, void*)")
137
+ def _freeze_counters_all(freeze_type, db, ctx):
138
+ return lib.DNP3_FREEZE_RESULT_NOT_SUPPORTED
139
+
140
+ @ffi.callback("dnp3_freeze_result_t(uint16_t, uint16_t, dnp3_freeze_type_t, dnp3_database_handle_t*, void*)")
141
+ def _freeze_counters_range(start, stop, freeze_type, db, ctx):
142
+ return lib.DNP3_FREEZE_RESULT_NOT_SUPPORTED
143
+
144
+ @ffi.callback("bool(uint8_t, uint8_t, dnp3_string_attr_t, const char*, void*)")
145
+ def _write_string_attr(set_id, var, attr, value, ctx):
146
+ return True
147
+
148
+ self._get_processing_delay_ms = _get_processing_delay_ms
149
+ self._write_absolute_time = _write_absolute_time
150
+ self._get_application_iin = _get_application_iin
151
+ self._cold_restart = _cold_restart
152
+ self._warm_restart = _warm_restart
153
+ self._freeze_counters_all = _freeze_counters_all
154
+ self._freeze_counters_range = _freeze_counters_range
155
+ self._write_string_attr = _write_string_attr
156
+
157
+ def as_ffi(self):
158
+ return ffi.new("dnp3_outstation_application_t*", {
159
+ "get_processing_delay_ms": self._get_processing_delay_ms,
160
+ "write_absolute_time": self._write_absolute_time,
161
+ "get_application_iin": self._get_application_iin,
162
+ "cold_restart": self._cold_restart,
163
+ "warm_restart": self._warm_restart,
164
+ "freeze_counters_all": self._freeze_counters_all,
165
+ "freeze_counters_range": self._freeze_counters_range,
166
+ "write_string_attr": self._write_string_attr,
167
+ "on_destroy": ffi.NULL,
168
+ "ctx": ffi.NULL,
169
+ })[0]
170
+
171
+
172
+ class OutstationInformation:
173
+ """Default outstation information callbacks (no-op)."""
174
+
175
+ def __init__(self):
176
+ @ffi.callback("void(dnp3_request_header_t, void*)")
177
+ def _noop_header(h, ctx):
178
+ pass
179
+
180
+ @ffi.callback("void(dnp3_function_code_t, dnp3_broadcast_action_t, void*)")
181
+ def _broadcast_received(fc, action, ctx):
182
+ pass
183
+
184
+ @ffi.callback("void(uint8_t, void*)")
185
+ def _noop_u8(v, ctx):
186
+ pass
187
+
188
+ @ffi.callback("void(uint8_t, uint8_t, void*)")
189
+ def _noop_u8_u8(a, b, ctx):
190
+ pass
191
+
192
+ @ffi.callback("void(bool, uint8_t, void*)")
193
+ def _noop_bool_u8(a, b, ctx):
194
+ pass
195
+
196
+ @ffi.callback("void(uint8_t, bool, void*)")
197
+ def _noop_u8_bool(a, b, ctx):
198
+ pass
199
+
200
+ @ffi.callback("void(void*)")
201
+ def _noop(ctx):
202
+ pass
203
+
204
+ self._noop_header = _noop_header
205
+ self._broadcast_received = _broadcast_received
206
+ self._noop_u8 = _noop_u8
207
+ self._noop_u8_u8 = _noop_u8_u8
208
+ self._noop_bool_u8 = _noop_bool_u8
209
+ self._noop_u8_bool = _noop_u8_bool
210
+ self._noop = _noop
211
+
212
+ def as_ffi(self):
213
+ return ffi.new("dnp3_outstation_information_t*", {
214
+ "process_request_from_idle": self._noop_header,
215
+ "broadcast_received": self._broadcast_received,
216
+ "enter_solicited_confirm_wait": self._noop_u8,
217
+ "solicited_confirm_timeout": self._noop_u8,
218
+ "solicited_confirm_received": self._noop_u8,
219
+ "solicited_confirm_wait_new_request": self._noop,
220
+ "wrong_solicited_confirm_seq": self._noop_u8_u8,
221
+ "unexpected_confirm": self._noop_bool_u8,
222
+ "enter_unsolicited_confirm_wait": self._noop_u8,
223
+ "unsolicited_confirm_timeout": self._noop_u8_bool,
224
+ "unsolicited_confirmed": self._noop_u8,
225
+ "clear_restart_iin": self._noop,
226
+ "on_destroy": ffi.NULL,
227
+ "ctx": ffi.NULL,
228
+ })[0]
229
+
230
+
231
+ class ControlHandler:
232
+ """Default control handler (accepts all controls)."""
233
+
234
+ def __init__(self):
235
+ @ffi.callback("void(void*)")
236
+ def _begin_fragment(ctx):
237
+ pass
238
+
239
+ @ffi.callback("void(dnp3_database_handle_t*, void*)")
240
+ def _end_fragment(db, ctx):
241
+ pass
242
+
243
+ @ffi.callback("dnp3_command_status_t(dnp3_group12_var1_t, uint16_t, dnp3_database_handle_t*, void*)")
244
+ def _select_g12v1(control, index, db, ctx):
245
+ return lib.DNP3_COMMAND_STATUS_SUCCESS
246
+
247
+ @ffi.callback("dnp3_command_status_t(dnp3_group12_var1_t, uint16_t, dnp3_operate_type_t, dnp3_database_handle_t*, void*)")
248
+ def _operate_g12v1(control, index, op_type, db, ctx):
249
+ return lib.DNP3_COMMAND_STATUS_SUCCESS
250
+
251
+ @ffi.callback("dnp3_command_status_t(int32_t, uint16_t, dnp3_database_handle_t*, void*)")
252
+ def _select_g41v1(value, index, db, ctx):
253
+ return lib.DNP3_COMMAND_STATUS_SUCCESS
254
+
255
+ @ffi.callback("dnp3_command_status_t(int32_t, uint16_t, dnp3_operate_type_t, dnp3_database_handle_t*, void*)")
256
+ def _operate_g41v1(value, index, op_type, db, ctx):
257
+ return lib.DNP3_COMMAND_STATUS_SUCCESS
258
+
259
+ @ffi.callback("dnp3_command_status_t(int16_t, uint16_t, dnp3_database_handle_t*, void*)")
260
+ def _select_g41v2(value, index, db, ctx):
261
+ return lib.DNP3_COMMAND_STATUS_SUCCESS
262
+
263
+ @ffi.callback("dnp3_command_status_t(int16_t, uint16_t, dnp3_operate_type_t, dnp3_database_handle_t*, void*)")
264
+ def _operate_g41v2(value, index, op_type, db, ctx):
265
+ return lib.DNP3_COMMAND_STATUS_SUCCESS
266
+
267
+ @ffi.callback("dnp3_command_status_t(float, uint16_t, dnp3_database_handle_t*, void*)")
268
+ def _select_g41v3(value, index, db, ctx):
269
+ return lib.DNP3_COMMAND_STATUS_SUCCESS
270
+
271
+ @ffi.callback("dnp3_command_status_t(float, uint16_t, dnp3_operate_type_t, dnp3_database_handle_t*, void*)")
272
+ def _operate_g41v3(value, index, op_type, db, ctx):
273
+ return lib.DNP3_COMMAND_STATUS_SUCCESS
274
+
275
+ @ffi.callback("dnp3_command_status_t(double, uint16_t, dnp3_database_handle_t*, void*)")
276
+ def _select_g41v4(value, index, db, ctx):
277
+ return lib.DNP3_COMMAND_STATUS_SUCCESS
278
+
279
+ @ffi.callback("dnp3_command_status_t(double, uint16_t, dnp3_operate_type_t, dnp3_database_handle_t*, void*)")
280
+ def _operate_g41v4(value, index, op_type, db, ctx):
281
+ return lib.DNP3_COMMAND_STATUS_SUCCESS
282
+
283
+ self._begin_fragment = _begin_fragment
284
+ self._end_fragment = _end_fragment
285
+ self._select_g12v1 = _select_g12v1
286
+ self._operate_g12v1 = _operate_g12v1
287
+ self._select_g41v1 = _select_g41v1
288
+ self._operate_g41v1 = _operate_g41v1
289
+ self._select_g41v2 = _select_g41v2
290
+ self._operate_g41v2 = _operate_g41v2
291
+ self._select_g41v3 = _select_g41v3
292
+ self._operate_g41v3 = _operate_g41v3
293
+ self._select_g41v4 = _select_g41v4
294
+ self._operate_g41v4 = _operate_g41v4
295
+
296
+ def as_ffi(self):
297
+ return ffi.new("dnp3_control_handler_t*", {
298
+ "begin_fragment": self._begin_fragment,
299
+ "end_fragment": self._end_fragment,
300
+ "select_g12v1": self._select_g12v1,
301
+ "operate_g12v1": self._operate_g12v1,
302
+ "select_g41v1": self._select_g41v1,
303
+ "operate_g41v1": self._operate_g41v1,
304
+ "select_g41v2": self._select_g41v2,
305
+ "operate_g41v2": self._operate_g41v2,
306
+ "select_g41v3": self._select_g41v3,
307
+ "operate_g41v3": self._operate_g41v3,
308
+ "select_g41v4": self._select_g41v4,
309
+ "operate_g41v4": self._operate_g41v4,
310
+ "on_destroy": ffi.NULL,
311
+ "ctx": ffi.NULL,
312
+ })[0]
313
+
314
+
315
+ class OutstationServer:
316
+ """
317
+ TCP outstation server for testing purposes.
318
+
319
+ Creates a server that accepts master connections.
320
+ """
321
+
322
+ def __init__(
323
+ self,
324
+ runtime,
325
+ address: str = "127.0.0.1:20000",
326
+ link_error_mode: LinkErrorMode = LinkErrorMode.CLOSE,
327
+ ):
328
+ self._runtime = runtime
329
+ self._outstations = []
330
+ self._destroyed = False
331
+
332
+ server_ptr = ffi.new("dnp3_outstation_server_t**")
333
+ err = lib.dnp3_outstation_server_create_tcp_server(
334
+ runtime._ptr,
335
+ link_error_mode.value,
336
+ address.encode(),
337
+ server_ptr,
338
+ )
339
+ check_error(err)
340
+ self._ptr = server_ptr[0]
341
+
342
+ def add_outstation(
343
+ self,
344
+ config: Optional[OutstationConfig] = None,
345
+ application: Optional[OutstationApplication] = None,
346
+ information: Optional[OutstationInformation] = None,
347
+ control_handler: Optional[ControlHandler] = None,
348
+ listener: Optional[ConnectionStateListener] = None,
349
+ ):
350
+ """Add an outstation to the server."""
351
+ config = config or OutstationConfig()
352
+ application = application or OutstationApplication()
353
+ information = information or OutstationInformation()
354
+ control_handler = control_handler or ControlHandler()
355
+ listener = listener or ConnectionStateListener()
356
+
357
+ # Keep references
358
+ self._app = application
359
+ self._info = information
360
+ self._ctrl = control_handler
361
+ self._listener = listener
362
+
363
+ address_filter = lib.dnp3_address_filter_any()
364
+ outstation_ptr = ffi.new("dnp3_outstation_t**")
365
+
366
+ err = lib.dnp3_outstation_server_add_outstation(
367
+ self._ptr,
368
+ config.to_ffi(),
369
+ application.as_ffi(),
370
+ information.as_ffi(),
371
+ control_handler.as_ffi(),
372
+ listener.as_ffi(),
373
+ address_filter,
374
+ outstation_ptr,
375
+ )
376
+ lib.dnp3_address_filter_destroy(address_filter)
377
+ check_error(err)
378
+
379
+ self._outstations.append(outstation_ptr[0])
380
+ return outstation_ptr[0]
381
+
382
+ def bind(self):
383
+ """Start accepting connections."""
384
+ check_error(lib.dnp3_outstation_server_bind(self._ptr))
385
+
386
+ def destroy(self):
387
+ """Clean up all outstations and the server."""
388
+ if self._destroyed:
389
+ return
390
+ self._destroyed = True
391
+ for os in self._outstations:
392
+ lib.dnp3_outstation_destroy(os)
393
+ self._outstations.clear()
394
+ lib.dnp3_outstation_server_destroy(self._ptr)
395
+
396
+ def __del__(self):
397
+ try:
398
+ self.destroy()
399
+ except Exception:
400
+ pass
dnp3/runtime.py ADDED
@@ -0,0 +1,57 @@
1
+ """
2
+ DNP3 runtime management.
3
+
4
+ The runtime manages the Tokio async runtime used by the Rust library.
5
+ It must be created before any channels and destroyed after all channels.
6
+ """
7
+
8
+ from ._ffi import ffi, lib, check_error
9
+
10
+
11
+ class Runtime:
12
+ """
13
+ Manages the underlying Tokio async runtime.
14
+
15
+ Usage:
16
+ runtime = Runtime(num_threads=4)
17
+ # ... create channels ...
18
+ runtime.destroy()
19
+
20
+ Or as a context manager:
21
+ with Runtime() as runtime:
22
+ channel = create_tcp_channel(runtime, ...)
23
+ """
24
+
25
+ def __init__(self, num_threads: int = 0):
26
+ """
27
+ Create a new runtime.
28
+
29
+ Args:
30
+ num_threads: Number of worker threads. 0 = auto-detect from CPU count.
31
+ """
32
+ rt_ptr = ffi.new("dnp3_runtime_t**")
33
+ config = ffi.new("dnp3_runtime_config_t*", {"num_core_threads": num_threads})
34
+ err = lib.dnp3_runtime_create(config[0], rt_ptr)
35
+ check_error(err)
36
+ self._ptr = rt_ptr[0]
37
+ self._destroyed = False
38
+
39
+ def set_shutdown_timeout(self, timeout_secs: int):
40
+ """Set maximum time to wait during shutdown."""
41
+ lib.dnp3_runtime_set_shutdown_timeout(self._ptr, timeout_secs)
42
+
43
+ def destroy(self):
44
+ """Destroy the runtime. Blocks until all tasks complete."""
45
+ if not self._destroyed and self._ptr is not None:
46
+ lib.dnp3_runtime_destroy(self._ptr)
47
+ self._destroyed = True
48
+
49
+ def __enter__(self):
50
+ return self
51
+
52
+ def __exit__(self, exc_type, exc_val, exc_tb):
53
+ self.destroy()
54
+ return False
55
+
56
+ def __del__(self):
57
+ self.destroy()