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/__init__.py ADDED
@@ -0,0 +1,138 @@
1
+ """
2
+ dnp3-python: Python bindings for the stepfunc/dnp3 Rust library.
3
+
4
+ Provides a Pythonic API for DNP3 master and outstation operations,
5
+ built on the production-quality stepfunc/dnp3 implementation with
6
+ full Secure Authentication support.
7
+
8
+ License: stepfunc/dnp3 uses a non-commercial license
9
+ (evaluation/research/training only).
10
+
11
+ Basic usage::
12
+
13
+ from dnp3 import Runtime, MasterChannelConfig, ReadHandler
14
+ from dnp3.channel import create_tcp_channel
15
+ from dnp3.logging import configure_logging, LogLevel
16
+
17
+ configure_logging(LogLevel.INFO)
18
+
19
+ with Runtime() as runtime:
20
+ handler = ReadHandler()
21
+ channel = create_tcp_channel(
22
+ runtime,
23
+ endpoints=["127.0.0.1:20000"],
24
+ config=MasterChannelConfig(address=1),
25
+ )
26
+ assoc = channel.add_association(address=1024, read_handler=handler)
27
+ channel.enable()
28
+
29
+ # Read all data
30
+ channel.read(assoc)
31
+
32
+ # Access collected data
33
+ for bi in handler.binary_inputs:
34
+ print(f"BI {bi.index}: {bi.value}")
35
+ for ai in handler.analog_inputs:
36
+ print(f"AI {ai.index}: {ai.value}")
37
+
38
+ channel.destroy()
39
+ """
40
+
41
+ __version__ = "0.1.0"
42
+
43
+ from ._ffi import DNP3Error
44
+
45
+ from .runtime import Runtime
46
+
47
+ from .types import (
48
+ # Enums
49
+ ParamError,
50
+ CommandStatus,
51
+ TripCloseCode,
52
+ OpType,
53
+ DoubleBit,
54
+ TimeQuality,
55
+ CommandMode,
56
+ TimeSyncMode,
57
+ LinkErrorMode,
58
+ AppDecodeLevel,
59
+ TransportDecodeLevel,
60
+ LinkDecodeLevel,
61
+ PhysDecodeLevel,
62
+ FunctionCode,
63
+ ClientState,
64
+ ReadType,
65
+ TaskType,
66
+ AutoTimeSync,
67
+ Variation,
68
+ # Convenience group/variation aliases
69
+ BINARY_INPUT,
70
+ BINARY_OUTPUT,
71
+ ANALOG_INPUT,
72
+ ANALOG_OUTPUT,
73
+ COUNTER,
74
+ FROZEN_COUNTER,
75
+ # Data classes
76
+ Flags,
77
+ Timestamp,
78
+ BinaryInput,
79
+ DoubleBitBinaryInput,
80
+ BinaryOutputStatus,
81
+ Counter,
82
+ FrozenCounter,
83
+ AnalogInput,
84
+ AnalogOutputStatus,
85
+ ControlCode,
86
+ Group12Var1,
87
+ DecodeLevel,
88
+ ConnectStrategy,
89
+ IIN,
90
+ IIN1,
91
+ IIN2,
92
+ )
93
+
94
+ from .handler import (
95
+ ReadHandler,
96
+ ClientStateListener,
97
+ PortStateListener,
98
+ ConnectionStateListener,
99
+ AssociationHandler,
100
+ AssociationInformation,
101
+ ReadTaskCallback,
102
+ CommandTaskCallback,
103
+ TimeSyncTaskCallback,
104
+ RestartTaskCallback,
105
+ EmptyResponseCallback,
106
+ LinkStatusCallback,
107
+ )
108
+
109
+ from .channel import (
110
+ MasterChannelConfig,
111
+ create_tcp_channel,
112
+ create_tls_channel,
113
+ create_serial_channel,
114
+ create_udp_channel,
115
+ )
116
+
117
+ from .master import (
118
+ MasterChannel,
119
+ AssociationConfig,
120
+ AssociationId,
121
+ PollId,
122
+ )
123
+
124
+ from .outstation import (
125
+ OutstationServer,
126
+ OutstationConfig,
127
+ EventBufferConfig,
128
+ OutstationApplication,
129
+ OutstationInformation,
130
+ ControlHandler,
131
+ )
132
+
133
+ from .logging import (
134
+ configure_logging,
135
+ LogLevel,
136
+ LogOutputFormat,
137
+ TimeFormat,
138
+ )
dnp3/_build_ffi.py ADDED
@@ -0,0 +1,166 @@
1
+ """
2
+ Build helpers for cffi bindings from the stepfunc/dnp3 C headers.
3
+
4
+ Preprocesses dnp3.h to extract what cffi can parse (enums, structs,
5
+ function declarations, numeric #define constants).
6
+ """
7
+
8
+ import os
9
+ import re
10
+ from pathlib import Path
11
+
12
+
13
+ def _project_root():
14
+ """Return the project root (two levels above src/dnp3/)."""
15
+ return Path(__file__).resolve().parent.parents[1]
16
+
17
+
18
+ def find_header():
19
+ """Locate the dnp3.h header file from the stepfunc/dnp3 build."""
20
+ root = _project_root()
21
+ pkg_dir = Path(__file__).resolve().parent
22
+ candidates = [
23
+ # Bundled inside installed package (wheel distribution)
24
+ pkg_dir / "vendor" / "dnp3.h",
25
+ # Sibling directory (common development layout)
26
+ root.parent / "stepfunc-dnp3" / "ffi" / "bindings" / "c" / "generated" / "include" / "dnp3.h",
27
+ # Vendored inside project
28
+ root / "vendor" / "dnp3" / "include" / "dnp3.h",
29
+ # Environment override
30
+ Path(os.environ.get("DNP3_HEADER_PATH", "/nonexistent")),
31
+ ]
32
+ for candidate in candidates:
33
+ if candidate.exists():
34
+ return candidate
35
+ raise FileNotFoundError(
36
+ "Cannot find dnp3.h. Build stepfunc/dnp3 first or set DNP3_HEADER_PATH.\n"
37
+ f"Searched: {[str(c) for c in candidates]}"
38
+ )
39
+
40
+
41
+ def find_shared_lib():
42
+ """Locate the compiled libdnp3_ffi shared library."""
43
+ root = _project_root()
44
+ pkg_dir = Path(__file__).resolve().parent
45
+ candidates = [
46
+ # Bundled inside installed package (wheel distribution)
47
+ pkg_dir / "vendor" / "libdnp3_ffi.so",
48
+ # Sibling directory — pre-built artifacts
49
+ root.parent / "stepfunc-dnp3" / "ffi" / "bindings" / "c" / "generated" / "lib" / "x86_64-unknown-linux-gnu" / "libdnp3_ffi.so",
50
+ # Sibling directory — cargo build output
51
+ root.parent / "stepfunc-dnp3" / "target" / "release" / "libdnp3_ffi.so",
52
+ # Vendored inside project
53
+ root / "vendor" / "dnp3" / "lib" / "libdnp3_ffi.so",
54
+ # Environment override
55
+ Path(os.environ.get("DNP3_LIB_PATH", "/nonexistent")),
56
+ ]
57
+ for candidate in candidates:
58
+ if candidate.exists():
59
+ return candidate
60
+ raise FileNotFoundError(
61
+ "Cannot find libdnp3_ffi.so. Build stepfunc/dnp3 first or set DNP3_LIB_PATH.\n"
62
+ f"Searched: {[str(c) for c in candidates]}"
63
+ )
64
+
65
+
66
+ def preprocess_header(header_path: Path) -> str:
67
+ """
68
+ Preprocess dnp3.h for cffi cdef consumption.
69
+
70
+ Strips:
71
+ - Static inline functions (with their full bodies)
72
+ - #include, #pragma, #ifdef/#endif, extern "C"
73
+ - C comments (// and /* */)
74
+
75
+ Keeps:
76
+ - typedef enum { ... } name;
77
+ - typedef struct name { ... } name;
78
+ - typedef struct name name; (opaque forward declarations)
79
+ - Non-static function declarations
80
+ - #define DNP3_* constants
81
+ """
82
+ text = header_path.read_text()
83
+
84
+ # Remove multi-line comments
85
+ text = re.sub(r"/\*.*?\*/", "", text, flags=re.DOTALL)
86
+
87
+ # Remove single-line comments
88
+ text = re.sub(r"//[^\n]*", "", text)
89
+
90
+ # Remove static functions with their bodies using brace-depth tracking
91
+ result = []
92
+ i = 0
93
+ while i < len(text):
94
+ if text[i:i+7] == "static ":
95
+ j = i + 7
96
+ while j < len(text) and text[j] != "{" and text[j] != ";":
97
+ j += 1
98
+ if j < len(text) and text[j] == "{":
99
+ depth = 1
100
+ j += 1
101
+ while j < len(text) and depth > 0:
102
+ if text[j] == "{":
103
+ depth += 1
104
+ elif text[j] == "}":
105
+ depth -= 1
106
+ j += 1
107
+ i = j
108
+ continue
109
+ elif j < len(text) and text[j] == ";":
110
+ i = j + 1
111
+ continue
112
+ result.append(text[i])
113
+ i += 1
114
+
115
+ text = "".join(result)
116
+
117
+ # Filter preprocessor directives
118
+ lines = text.split("\n")
119
+ output_lines = []
120
+ for line in lines:
121
+ stripped = line.strip()
122
+
123
+ if stripped.startswith("#pragma"):
124
+ continue
125
+ if stripped.startswith(("#ifdef", "#ifndef", "#endif")):
126
+ continue
127
+ if stripped.startswith("#include"):
128
+ continue
129
+ if 'extern "C"' in stripped:
130
+ continue
131
+
132
+ # Keep numeric DNP3_* constants only (cffi can't parse string defines)
133
+ if stripped.startswith("#define DNP3_"):
134
+ if '"' in stripped:
135
+ continue
136
+ output_lines.append(stripped)
137
+ continue
138
+
139
+ if stripped.startswith("#define"):
140
+ continue
141
+
142
+ output_lines.append(line)
143
+
144
+ text = "\n".join(output_lines)
145
+
146
+ # Remove lone closing braces left over from extern "C" block
147
+ lines2 = text.split("\n")
148
+ final_lines = [line for line in lines2 if line.strip() != "}"]
149
+ text = "\n".join(final_lines)
150
+
151
+ # Clean up excessive blank lines
152
+ text = re.sub(r"\n{3,}", "\n\n", text)
153
+ text = re.sub(r"[ \t]+\n", "\n", text)
154
+
155
+ return text.strip() + "\n"
156
+
157
+
158
+ def get_cdef():
159
+ """Get the preprocessed cdef string for cffi."""
160
+ header_path = find_header()
161
+ return preprocess_header(header_path)
162
+
163
+
164
+ if __name__ == "__main__":
165
+ cdef = get_cdef()
166
+ print(cdef)
dnp3/_ffi.py ADDED
@@ -0,0 +1,64 @@
1
+ """
2
+ Raw cffi bindings for the stepfunc/dnp3 C FFI library.
3
+
4
+ This module provides the low-level FFI object and loaded library.
5
+ Higher-level modules should import `ffi` and `lib` from here.
6
+ """
7
+
8
+ import cffi
9
+
10
+ from ._build_ffi import get_cdef, find_shared_lib
11
+
12
+ # Create the FFI instance
13
+ ffi = cffi.FFI()
14
+
15
+ # Parse the C header definitions
16
+ _cdef = get_cdef()
17
+ ffi.cdef(_cdef)
18
+
19
+ # Load the shared library
20
+ _lib_path = str(find_shared_lib())
21
+ lib = ffi.dlopen(_lib_path)
22
+
23
+ # Error code names (the to_string functions are static inline in the header,
24
+ # not exported from the .so, so we replicate them here)
25
+ _PARAM_ERROR_NAMES = {
26
+ 0: "ok",
27
+ 1: "invalid_timeout",
28
+ 2: "null_parameter",
29
+ 3: "string_not_utf8",
30
+ 4: "no_support",
31
+ 5: "association_does_not_exist",
32
+ 6: "association_duplicate_address",
33
+ 7: "invalid_socket_address",
34
+ 8: "invalid_dnp3_address",
35
+ 9: "invalid_buffer_size",
36
+ 10: "address_filter_conflict",
37
+ 11: "server_already_started",
38
+ 12: "server_bind_error",
39
+ 13: "master_already_shutdown",
40
+ 14: "runtime_creation_failure",
41
+ 15: "runtime_destroyed",
42
+ 16: "runtime_cannot_block_within_async",
43
+ 17: "logging_already_configured",
44
+ 18: "point_does_not_exist",
45
+ 19: "invalid_peer_certificate",
46
+ 20: "invalid_local_certificate",
47
+ 21: "invalid_private_key",
48
+ 22: "invalid_dns_name",
49
+ 23: "other_tls_error",
50
+ 24: "wrong_channel_type",
51
+ 25: "consumed",
52
+ }
53
+
54
+
55
+ class DNP3Error(Exception):
56
+ """Base exception for DNP3 library errors."""
57
+ pass
58
+
59
+
60
+ def check_error(err):
61
+ """Check a dnp3_param_error_t return value and raise on failure."""
62
+ if err != lib.DNP3_PARAM_ERROR_OK:
63
+ name = _PARAM_ERROR_NAMES.get(err, f"unknown({err})")
64
+ raise DNP3Error(f"DNP3 error: {name}")
dnp3/channel.py ADDED
@@ -0,0 +1,240 @@
1
+ """
2
+ TCP/TLS/Serial/UDP channel creation for DNP3 master stations.
3
+ """
4
+
5
+ from typing import Optional, List
6
+
7
+ from ._ffi import ffi, lib, check_error
8
+ from .types import (
9
+ LinkErrorMode,
10
+ DecodeLevel,
11
+ ConnectStrategy,
12
+ )
13
+ from .handler import ClientStateListener, PortStateListener
14
+
15
+
16
+ class MasterChannelConfig:
17
+ """Configuration for a master channel."""
18
+
19
+ def __init__(
20
+ self,
21
+ address: int = 1,
22
+ decode_level: Optional[DecodeLevel] = None,
23
+ tx_buffer_size: int = 2048,
24
+ rx_buffer_size: int = 2048,
25
+ ):
26
+ self.address = address
27
+ self.decode_level = decode_level or DecodeLevel()
28
+ self.tx_buffer_size = tx_buffer_size
29
+ self.rx_buffer_size = rx_buffer_size
30
+
31
+ def to_ffi(self):
32
+ """Create the C config struct."""
33
+ config = ffi.new("dnp3_master_channel_config_t*")
34
+ config.address = self.address
35
+ config.decode_level = ffi.new("dnp3_decode_level_t*", self.decode_level.to_ffi())[0]
36
+ config.tx_buffer_size = self.tx_buffer_size
37
+ config.rx_buffer_size = self.rx_buffer_size
38
+ return config[0]
39
+
40
+
41
+ def create_tcp_channel(
42
+ runtime,
43
+ endpoints: List[str],
44
+ config: Optional[MasterChannelConfig] = None,
45
+ link_error_mode: LinkErrorMode = LinkErrorMode.CLOSE,
46
+ connect_strategy: Optional[ConnectStrategy] = None,
47
+ listener: Optional[ClientStateListener] = None,
48
+ ):
49
+ """
50
+ Create a TCP master channel.
51
+
52
+ Args:
53
+ runtime: Runtime instance (from Runtime class)
54
+ endpoints: List of "host:port" strings. First is primary.
55
+ config: Master channel configuration
56
+ link_error_mode: How to handle link errors
57
+ connect_strategy: Reconnection strategy
58
+ listener: Connection state listener
59
+
60
+ Returns:
61
+ A MasterChannel wrapping the C pointer
62
+ """
63
+ from .master import MasterChannel
64
+
65
+ config = config or MasterChannelConfig()
66
+ connect_strategy = connect_strategy or ConnectStrategy()
67
+ listener = listener or ClientStateListener()
68
+
69
+ # Create endpoint list
70
+ ep_list = lib.dnp3_endpoint_list_create(endpoints[0].encode())
71
+ for ep in endpoints[1:]:
72
+ lib.dnp3_endpoint_list_add(ep_list, ep.encode())
73
+
74
+ # Create channel
75
+ channel_ptr = ffi.new("dnp3_master_channel_t**")
76
+ strategy_ffi = ffi.new("dnp3_connect_strategy_t*", connect_strategy.to_ffi())[0]
77
+
78
+ err = lib.dnp3_master_channel_create_tcp(
79
+ runtime._ptr,
80
+ link_error_mode.value,
81
+ config.to_ffi(),
82
+ ep_list,
83
+ strategy_ffi,
84
+ listener.as_ffi(),
85
+ channel_ptr,
86
+ )
87
+ lib.dnp3_endpoint_list_destroy(ep_list)
88
+ check_error(err)
89
+
90
+ return MasterChannel(channel_ptr[0], listener)
91
+
92
+
93
+ def create_tls_channel(
94
+ runtime,
95
+ endpoints: List[str],
96
+ tls_config: dict,
97
+ config: Optional[MasterChannelConfig] = None,
98
+ link_error_mode: LinkErrorMode = LinkErrorMode.CLOSE,
99
+ connect_strategy: Optional[ConnectStrategy] = None,
100
+ listener: Optional[ClientStateListener] = None,
101
+ ):
102
+ """
103
+ Create a TLS master channel.
104
+
105
+ Args:
106
+ runtime: Runtime instance
107
+ endpoints: List of "host:port" strings
108
+ tls_config: dict with keys: dns_name, peer_cert_path, local_cert_path,
109
+ private_key_path, password (optional), certificate_mode (optional)
110
+ config: Master channel configuration
111
+ link_error_mode: How to handle link errors
112
+ connect_strategy: Reconnection strategy
113
+ listener: Connection state listener
114
+
115
+ Returns:
116
+ A MasterChannel wrapping the C pointer
117
+ """
118
+ from .master import MasterChannel
119
+
120
+ config = config or MasterChannelConfig()
121
+ connect_strategy = connect_strategy or ConnectStrategy()
122
+ listener = listener or ClientStateListener()
123
+
124
+ ep_list = lib.dnp3_endpoint_list_create(endpoints[0].encode())
125
+ for ep in endpoints[1:]:
126
+ lib.dnp3_endpoint_list_add(ep_list, ep.encode())
127
+
128
+ # Build TLS config
129
+ tls_c = ffi.new("dnp3_tls_client_config_t*")
130
+ tls_c.dns_name = ffi.new("char[]", tls_config["dns_name"].encode())
131
+ tls_c.peer_cert_path = ffi.new("char[]", tls_config["peer_cert_path"].encode())
132
+ tls_c.local_cert_path = ffi.new("char[]", tls_config["local_cert_path"].encode())
133
+ tls_c.private_key_path = ffi.new("char[]", tls_config["private_key_path"].encode())
134
+ tls_c.password = ffi.new("char[]", tls_config.get("password", "").encode())
135
+ if "certificate_mode" in tls_config:
136
+ tls_c.certificate_mode = tls_config["certificate_mode"]
137
+
138
+ channel_ptr = ffi.new("dnp3_master_channel_t**")
139
+ strategy_ffi = ffi.new("dnp3_connect_strategy_t*", connect_strategy.to_ffi())[0]
140
+
141
+ err = lib.dnp3_master_channel_create_tls(
142
+ runtime._ptr,
143
+ link_error_mode.value,
144
+ config.to_ffi(),
145
+ ep_list,
146
+ strategy_ffi,
147
+ listener.as_ffi(),
148
+ tls_c[0],
149
+ channel_ptr,
150
+ )
151
+ lib.dnp3_endpoint_list_destroy(ep_list)
152
+ check_error(err)
153
+
154
+ return MasterChannel(channel_ptr[0], listener)
155
+
156
+
157
+ def create_serial_channel(
158
+ runtime,
159
+ path: str,
160
+ config: Optional[MasterChannelConfig] = None,
161
+ open_retry_delay: int = 5000,
162
+ listener: Optional[PortStateListener] = None,
163
+ ):
164
+ """
165
+ Create a serial master channel.
166
+
167
+ Args:
168
+ runtime: Runtime instance
169
+ path: Serial port path (e.g., "/dev/ttyS0")
170
+ config: Master channel configuration
171
+ open_retry_delay: Retry delay in ms when port open fails
172
+ listener: Port state listener
173
+
174
+ Returns:
175
+ A MasterChannel wrapping the C pointer
176
+ """
177
+ from .master import MasterChannel
178
+
179
+ config = config or MasterChannelConfig()
180
+ listener = listener or PortStateListener()
181
+
182
+ serial_settings = ffi.new("dnp3_serial_settings_t*")
183
+ # Use defaults (9600 baud, 8N1)
184
+ serial_settings.baud_rate = 9600
185
+ serial_settings.data_bits = lib.DNP3_DATA_BITS_EIGHT
186
+ serial_settings.flow_control = lib.DNP3_FLOW_CONTROL_NONE
187
+ serial_settings.parity = lib.DNP3_PARITY_NONE
188
+ serial_settings.stop_bits = lib.DNP3_STOP_BITS_ONE
189
+
190
+ channel_ptr = ffi.new("dnp3_master_channel_t**")
191
+
192
+ err = lib.dnp3_master_channel_create_serial(
193
+ runtime._ptr,
194
+ config.to_ffi(),
195
+ path.encode(),
196
+ serial_settings[0],
197
+ open_retry_delay,
198
+ listener.as_ffi(),
199
+ channel_ptr,
200
+ )
201
+ check_error(err)
202
+
203
+ return MasterChannel(channel_ptr[0], None)
204
+
205
+
206
+ def create_udp_channel(
207
+ runtime,
208
+ local_endpoint: str,
209
+ config: Optional[MasterChannelConfig] = None,
210
+ retry_delay: int = 5000,
211
+ ):
212
+ """
213
+ Create a UDP master channel.
214
+
215
+ Args:
216
+ runtime: Runtime instance
217
+ local_endpoint: Local bind address "host:port"
218
+ config: Master channel configuration
219
+ retry_delay: Retry delay in ms
220
+
221
+ Returns:
222
+ A MasterChannel wrapping the C pointer
223
+ """
224
+ from .master import MasterChannel
225
+
226
+ config = config or MasterChannelConfig()
227
+
228
+ channel_ptr = ffi.new("dnp3_master_channel_t**")
229
+
230
+ err = lib.dnp3_master_channel_create_udp(
231
+ runtime._ptr,
232
+ config.to_ffi(),
233
+ local_endpoint.encode(),
234
+ 0, # link_read_mode
235
+ retry_delay,
236
+ channel_ptr,
237
+ )
238
+ check_error(err)
239
+
240
+ return MasterChannel(channel_ptr[0], None)