Skip to content

Commit 4ce2277

Browse files
committed
Add DNP3 read/recon tool
--- + Add documentation for new tool
1 parent 8742ddc commit 4ce2277

3 files changed

Lines changed: 340 additions & 0 deletions

File tree

docs/source/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ and MacOS. Any other Python version is not officially supported::
4040
:caption: DNP3 / IEEE 1815
4141
:hidden:
4242

43+
protocols/dnp3/dnp3read
4344
protocols/dnp3/dnp3dump
4445
protocols/dnp3/api
4546

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
2+
.. _dnp3_example_read:
3+
4+
Reading Objects
5+
===============
6+
7+
The ``dnp3read.py`` script can be used to connect to a DNP3 outstation and request
8+
data objects based on specified classes. The script offers several connection
9+
and request options that allow you to customize your interaction with the
10+
outstation.
11+
12+
Target Format
13+
-------------
14+
15+
16+
The format for specifyinf a remote address to connect to is a little bit
17+
different this time::
18+
19+
<link_addr>@<host>[:<port>]
20+
21+
22+
The ``<link_addr>`` is the DNP3 link address, and the ``<host>`` is the IP address or hostname of the outstation. The default port is ``20000``.
23+
24+
25+
Requesting Object Groups
26+
------------------------
27+
28+
The ``-G`` option specifies the DNP3 object group number to request. This is an
29+
integer representing the group of data objects you want to retrieve. For
30+
instance:
31+
32+
.. code-block::
33+
:caption: Reading all octet strings (group 110)
34+
35+
$ dnp3read.py -t [email protected] -G 110
36+
[I] Connecting to outstation (1024) at 127.0.0.1:20000...
37+
Data Objects:
38+
├── Object(s): Octet string (Obj: 110, Var: 1) [Range: 8-10]
39+
│ ├── Octet string [0]:
40+
│ │ 00000000: 00 .
41+
│ │
42+
│ ├── Octet string [1]:
43+
│ │ 00000000: 00 .
44+
│ │
45+
...
46+
47+
└── Object(s): Octet string (Obj: 110, Var: 5) [Range: 7-8]
48+
└── Octet string [7]:
49+
00000000: 48 65 6c 6c 6f Hello
50+
51+
52+
Requesting Objects from Data Classes
53+
------------------------------------
54+
55+
To read all objects from specific data classes, just specify the ``-class*``
56+
option:
57+
58+
.. code-block::
59+
:caption: Reading all class1 object groups
60+
61+
dnp3read.py -t [email protected] -class1
62+
[I] Connecting to outstation (1024) at 127.0.0.1:20000...
63+
Data Objects:
64+
├── Object(s): Analog output event (Obj: 42, Var: 1) [Count: 1]
65+
│ └── 32-bit without time [0]: (prefix: 7)
66+
│ ├── comm_lost: False
67+
│ ├── local_forced: False
68+
│ ├── online: True
69+
│ ├── over_range: False
70+
│ ├── reference_err: False
71+
│ ├── remote_forced: False
72+
│ ├── reserved0: False
73+
│ ├── restart: False
74+
│ └── value: 1
75+
└── Object(s): Octet string event (Obj: 111, Var: 5) [Count: 1]
76+
└── Octet string event [0]: (prefix: 7)
77+
00000000: 48 65 6c 6c 6f Hello
78+
79+
80+
81+
.. option:: -class0
82+
83+
Requests **class 0** objects. The response will include objects from the following groups:
84+
85+
* 1: Binary Input
86+
* 3: Double-bit Binary Input
87+
* 10: Binary Output Status
88+
* 20: Counter
89+
* 21: Frozen Counter
90+
* 30: Analog Input
91+
* 31: Frozen Analog Input
92+
* 40: Analog Output Status
93+
* 87: Data Set
94+
* 101: Binary-Coded Decimal Integer
95+
* 102: Unsigned Integer—8-bit
96+
* 110: Octet String
97+
* 121: Security Statistics
98+
99+
.. option:: -class1
100+
101+
Requests **class 1** objects. The response may include objects from the following groups:
102+
103+
* 2: Binary Input Event
104+
* 4: Double-bit Binary Input Event
105+
* 11: Binary Output Event
106+
* 13: Binary Output Command Event
107+
* 22: Counter Event
108+
* 23: Frozen Counter Event
109+
* 32: Analog Input Event
110+
* 33: Frozen Analog Input Event
111+
* 42: Analog Output Event
112+
* 43: Analog Output Command Event
113+
* 70: File Transfer
114+
* 88: Data Set Event
115+
* 111: Octet String Event
116+
* 113: Virtual Terminal Event
117+
* 120: Authentication
118+
* 122: Security Statistics Event
119+
120+
.. note::
121+
``-class2`` and ``-class3`` request the same object groups as ``-class1``
122+

src/icspacket/examples/dnp3read.py

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
# This file is part of icspacket.
2+
# Copyright (C) 2025-present MatrixEditor @ github
3+
#
4+
# This program is free software: you can redistribute it and/or modify
5+
# it under the terms of the GNU General Public License as published by
6+
# the Free Software Foundation, either version 3 of the License, or
7+
# (at your option) any later version.
8+
#
9+
# This program is distributed in the hope that it will be useful,
10+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
# GNU General Public License for more details.
13+
#
14+
# You should have received a copy of the GNU General Public License
15+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
16+
# pyright: reportUnusedCallResult=false, reportGeneralTypeIssues=false
17+
#
18+
# Description:
19+
# - Reads values from an outstation
20+
import logging
21+
import sys
22+
import argparse
23+
import textwrap
24+
25+
from rich.console import Console
26+
27+
from icspacket.core import logger
28+
from icspacket.proto.dnp3.const import FunctionCode
29+
from icspacket.proto.dnp3.master import DNP3_Master
30+
from icspacket.proto.dnp3.objects.coding import unpack_objects
31+
from icspacket.proto.dnp3.objects.util import new_class_data_request, as_variation0
32+
33+
from icspacket.examples.util import add_logging_options
34+
from icspacket.examples.dnp3dump import dump_objects
35+
from icspacket.proto.dnp3.objects.variations import get_group_name
36+
37+
38+
def parse_target(target_spec: str) -> tuple[int, str, int] | None:
39+
# format: <link_addr>@<host>[:<port>]
40+
if "@" not in target_spec:
41+
return logging.error(
42+
"Invalid target specification: %s, expected <link_addr>@<host>[:<port>]",
43+
target_spec,
44+
)
45+
46+
link_addr, target_spec = target_spec.split("@", 1)
47+
if ":" in target_spec:
48+
host, port = target_spec.split(":", 1)
49+
else:
50+
host = target_spec
51+
port = 20000
52+
return int(link_addr), host, int(port)
53+
54+
55+
class DNP3Reader:
56+
def __init__(self, master: DNP3_Master) -> None:
57+
self.master = master
58+
self.console = Console()
59+
60+
def run(self, args):
61+
classes = []
62+
if args.class0 or args.all_classes:
63+
classes.append(0)
64+
if args.class1 or args.all_classes:
65+
classes.append(1)
66+
if args.class2 or args.all_classes:
67+
classes.append(2)
68+
if args.class3 or args.all_classes:
69+
classes.append(3)
70+
71+
if args.group is not None and len(classes) > 0:
72+
logging.warning(
73+
"Group and class options are mutually exclusive, ignoring class options"
74+
)
75+
76+
if args.group is not None:
77+
group = get_group_name(args.group)
78+
group = (
79+
f"[b]{group}[/] ({args.group})"
80+
if group is not None
81+
else str(args.group)
82+
)
83+
84+
objects = as_variation0(args.group)
85+
status = f"Reading data objects from group {group}..."
86+
else:
87+
if len(classes) == 0:
88+
logging.error("No classes specified")
89+
return
90+
91+
objects = new_class_data_request(*classes)
92+
status = f"Reading data objects from classes {tuple(classes)}..."
93+
94+
with self.console.status(status):
95+
apdu = self.master.request(FunctionCode.READ, objects)
96+
97+
if apdu is None:
98+
logging.error("No response from outstation")
99+
return
100+
101+
logging.debug(
102+
"Received response from outstation with code %s", apdu.function.name
103+
)
104+
if apdu.iin is not None:
105+
if apdu.iin.no_func_code_support:
106+
return logging.error(
107+
"Outstation does not support function code %s",
108+
FunctionCode.READ,
109+
)
110+
111+
raw_objects = apdu.objects
112+
if not raw_objects:
113+
logging.info("No data returned from outstation")
114+
return
115+
116+
try:
117+
objects = unpack_objects(apdu.objects)
118+
tree = dump_objects(objects)
119+
self.console.print(tree)
120+
except Exception as e:
121+
return logging.error("Failed to unpack objects: %s", e)
122+
123+
124+
def cli_main():
125+
from icspacket import __version__
126+
127+
parser = argparse.ArgumentParser(formatter_class=argparse.RawTextHelpFormatter)
128+
# fmt: off
129+
group = parser.add_argument_group("Connection Options")
130+
group.add_argument("-t", "--target", type=str, help="Target host (IP address or hostname) to establish the connection (default port is 20000)", metavar="<link_addr>@<host>[:<port>]", required=True)
131+
group.add_argument("-l", "--listen", type=int, help="Local link address to use, default is 1", metavar="LINK_ADDR", default=1)
132+
group.add_argument("--timeout", type=float, metavar="SEC", help="Timeout in seconds for link-level operations (default: None)", default=None)
133+
134+
group = parser.add_argument_group("Request Options")
135+
group.add_argument("-G", "--group", type=int, help="DNP3 object group number", metavar="ID")
136+
class0_groups = """\
137+
Group Description
138+
----- -----------
139+
1 Binary Input
140+
3 Double-bit Binary Input
141+
10 Binary Output Status
142+
20 Counter
143+
21 Frozen Counter
144+
30 Analog Input
145+
31 Frozen Analog Input
146+
40 Analog Output Status
147+
87 Data Set
148+
101 Binary-Coded Dec imal Integer
149+
102 Unsigned Integer—8-bit
150+
110 Octet String
151+
121 Security Statistics
152+
"""
153+
group.add_argument("-class0", action="store_true", help=f"Request class 0 objects. The outstation will include some or all of the \nfollowing objects in its response:\n{textwrap.dedent(class0_groups)}\n", default=False)
154+
class1_groups = """\
155+
Group Description
156+
----- -----------
157+
2 Binary Input Event
158+
4 Double-bit Binary Input Event
159+
11 Binary Output Event
160+
13 Binary Output Command Event
161+
22 Counter Event
162+
23 Frozen Counter Event
163+
32 Analog Input Event
164+
33 Frozen Analog Input Event
165+
42 Analog Output Event
166+
43 Analog Output Command Event
167+
70 File Transfer
168+
88 Data Set Event
169+
111 Octet String Event
170+
113 Virtual Terminal Event
171+
120 Authentication
172+
122 Security Statistics Event
173+
"""
174+
group.add_argument("-class1", action="store_true", help=f"Request class 1 objects. The response will include none (null response), some, \nor all of the following objects:\n{textwrap.dedent(class1_groups)}\n", default=False)
175+
class2_groups = """-- same as class 1 --"""
176+
group.add_argument("-class2", action="store_true", help=f"Request class 2 objects. The response will include none (null response), \nsome, or all of the following objects:\n{textwrap.dedent(class2_groups)}", default=False)
177+
class3_groups = """-- same as class 1 --"""
178+
group.add_argument("-class3", action="store_true", help=f"Request class 3 objects. The response will include none (null response), \nsome, or all of the following objects:\n{textwrap.dedent(class3_groups)}", default=False)
179+
group.add_argument("--all-classes", action="store_true", help="Request objects from all classes", default=False)
180+
# fmt: on
181+
add_logging_options(parser)
182+
183+
args = parser.parse_args()
184+
185+
logger.init_from_args(args.verbosity, args.quiet, args.ts)
186+
if args.verbosity > 0:
187+
print(f"icspacket v{__version__}\n")
188+
189+
master = DNP3_Master(link_addr=args.listen)
190+
remote_addr = parse_target(args.target)
191+
if remote_addr is None:
192+
sys.exit(1)
193+
194+
try:
195+
logging.info("Connecting to outstation (%04d) at %s:%d...", *remote_addr)
196+
master.associate(remote_addr)
197+
master.transport.link.sock.settimeout(args.timeout)
198+
except ConnectionError as e:
199+
logging.error("Could not connect to outstation: %s", e)
200+
sys.exit(1)
201+
else:
202+
logging.debug("Successfully connected to outstation (%04d)", remote_addr[0])
203+
204+
try:
205+
reader = DNP3Reader(master)
206+
reader.run(args)
207+
except KeyboardInterrupt:
208+
logging.warning("Operation cancelled by user")
209+
except Exception as e:
210+
logging.exception("Encountered an unexpected exception:", e)
211+
finally:
212+
logging.debug("Disconnecting from outstation...")
213+
master.release(0.1)
214+
215+
216+
if __name__ == "__main__":
217+
cli_main()

0 commit comments

Comments
 (0)