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 +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/__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)
|