modscanner 0.1.0__tar.gz
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.
- modscanner-0.1.0/LICENSE +21 -0
- modscanner-0.1.0/PKG-INFO +159 -0
- modscanner-0.1.0/README.md +130 -0
- modscanner-0.1.0/pyproject.toml +73 -0
- modscanner-0.1.0/setup.cfg +4 -0
- modscanner-0.1.0/src/modscanner/__init__.py +30 -0
- modscanner-0.1.0/src/modscanner/cli.py +118 -0
- modscanner-0.1.0/src/modscanner/exceptions.py +9 -0
- modscanner-0.1.0/src/modscanner/models.py +100 -0
- modscanner-0.1.0/src/modscanner/py.typed +0 -0
- modscanner-0.1.0/src/modscanner/reporters/__init__.py +0 -0
- modscanner-0.1.0/src/modscanner/reporters/console.py +40 -0
- modscanner-0.1.0/src/modscanner/scanner.py +151 -0
- modscanner-0.1.0/src/modscanner/transports/__init__.py +0 -0
- modscanner-0.1.0/src/modscanner/transports/base.py +62 -0
- modscanner-0.1.0/src/modscanner/transports/pymodbus_transport.py +89 -0
- modscanner-0.1.0/src/modscanner.egg-info/PKG-INFO +159 -0
- modscanner-0.1.0/src/modscanner.egg-info/SOURCES.txt +20 -0
- modscanner-0.1.0/src/modscanner.egg-info/dependency_links.txt +1 -0
- modscanner-0.1.0/src/modscanner.egg-info/entry_points.txt +2 -0
- modscanner-0.1.0/src/modscanner.egg-info/requires.txt +3 -0
- modscanner-0.1.0/src/modscanner.egg-info/top_level.txt +1 -0
modscanner-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Davis Onyeoguzoro
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: modscanner
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A safety-first Modbus discovery and register inspection toolkit.
|
|
5
|
+
Author: Davis Onyeoguzoro
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/davisonyeas/modscanner
|
|
8
|
+
Project-URL: Documentation, https://davisonyeas.github.io/modscanner/
|
|
9
|
+
Project-URL: Repository, https://github.com/davisonyeas/modscanner
|
|
10
|
+
Project-URL: Issues, https://github.com/davisonyeas/modscanner/issues
|
|
11
|
+
Project-URL: Changelog, https://github.com/davisonyeas/modscanner/blob/master/CHANGELOG.md
|
|
12
|
+
Keywords: modbus,modbus-tcp,modbus-rtu,industrial-automation,plc,scada
|
|
13
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Intended Audience :: Manufacturing
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
22
|
+
Requires-Python: >=3.11
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Requires-Dist: pymodbus<3.15,>=3.14
|
|
26
|
+
Requires-Dist: rich<15,>=13
|
|
27
|
+
Requires-Dist: typer<1,>=0.12
|
|
28
|
+
Dynamic: license-file
|
|
29
|
+
|
|
30
|
+
# ModScanner
|
|
31
|
+
|
|
32
|
+
A safety-first Modbus scanning toolkit for Python.
|
|
33
|
+
|
|
34
|
+
ModScanner helps engineers inspect Modbus devices without immediately falling back to slow, one-register-at-a-time scanning. It reads registers in blocks and adaptively splits rejected blocks to identify valid and invalid addresses.
|
|
35
|
+
|
|
36
|
+
> **Project status:**
|
|
37
|
+
Early development. Version 0.1.0 supports only
|
|
38
|
+
> **Modbus TCP holding register scanning.
|
|
39
|
+
|
|
40
|
+
## Features
|
|
41
|
+
|
|
42
|
+
- Read-only Modbus TCP scanning
|
|
43
|
+
- Holding register reads using function code 03
|
|
44
|
+
- Configurable device ID, port, timeout, range, and block size
|
|
45
|
+
- Adaptive block splitting when a device rejects a register range
|
|
46
|
+
- Seperation of protocol errors, transport errors, and invalid responses
|
|
47
|
+
- Terminal output using Rich
|
|
48
|
+
- Typed Python data module
|
|
49
|
+
- Transport abstraction that is built untop of PyModbus
|
|
50
|
+
- Unit tested scanning behavior
|
|
51
|
+
|
|
52
|
+
## Installation
|
|
53
|
+
|
|
54
|
+
Install ModScanner from PyPI using either of two(2) methods the below:
|
|
55
|
+
|
|
56
|
+
1. Install ModScanner from PyPI:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
pip install modscanner
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
2. Using uv:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
uv add modscanner
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Verify the installation using:
|
|
69
|
+
```bash
|
|
70
|
+
modscanner --help
|
|
71
|
+
or
|
|
72
|
+
modscanner --h
|
|
73
|
+
or
|
|
74
|
+
modscanner version
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
> **The above help shows the commands available, while the version shows the current version you have installd on your machine**
|
|
78
|
+
|
|
79
|
+
## Quick start
|
|
80
|
+
|
|
81
|
+
There are different ways to use the library;
|
|
82
|
+
|
|
83
|
+
1. Scan holding registers `0` through `19` on a Modbus TCP device using the below:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
modscanner scan-tcp 192.168.1.10 --device-id 1 --start 0 --count 20
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
> **The above expects you to use the IP Address of the Modbus device, so replace the 192.168.1.10 with your Modbus device**
|
|
90
|
+
|
|
91
|
+
2. Specify the TCP port, timeout, and request block size using:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
modscanner scan-tcp 192.168.1.10 --port 502 --device-id 1 --start 0 --count 100 --block-size 50 --timeout 2
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Run:
|
|
98
|
+
```bash
|
|
99
|
+
modscanner scan-tcp --help
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
to see all available options.
|
|
103
|
+
|
|
104
|
+
## Addressing
|
|
105
|
+
|
|
106
|
+
ModScanner uses zero-based Modbus protocol addresses.
|
|
107
|
+
|
|
108
|
+
For example, a vendor document may label the first holding register as `40001`, while its protocol address is commonly `0`.
|
|
109
|
+
|
|
110
|
+
Always confirm how the device manufacturer represents register addresses before scanning.
|
|
111
|
+
|
|
112
|
+
## Adaptive scanning
|
|
113
|
+
|
|
114
|
+
Some Modbus devices reject an entire request when only one address is within the requested block is invalid.
|
|
115
|
+
|
|
116
|
+
ModScanner handles this by:
|
|
117
|
+
1. Reading a block of registers.
|
|
118
|
+
2. Accepting the block when the response is valid.
|
|
119
|
+
3. Splitting a block when the device return a protocol-level error.
|
|
120
|
+
4. Continuing until individual valid and invalid addresses are identified.
|
|
121
|
+
5. Avoiding recursive requests after transport failure such as timeouts.
|
|
122
|
+
|
|
123
|
+
This reduces unnecessary requests while stil supporting sparse register maps.
|
|
124
|
+
|
|
125
|
+
## Safety
|
|
126
|
+
|
|
127
|
+
ModScanner 0.1.0 is read-only.
|
|
128
|
+
|
|
129
|
+
It does not write coils or registers. Future write-validation functionality will require explicit register allowlists, value limits, confirmation, readback, and audit logging.
|
|
130
|
+
|
|
131
|
+
Read-only registers should also be used carefully on production systems. Confirm the target IP address, port, device ID, and permitted register range before scanning industrial equipments.
|
|
132
|
+
|
|
133
|
+
## Current limitation
|
|
134
|
+
|
|
135
|
+
Version 0.1.0 does not yet support:
|
|
136
|
+
|
|
137
|
+
- Modbus RTU
|
|
138
|
+
- Coils
|
|
139
|
+
- Discrete inputs
|
|
140
|
+
- Input registers
|
|
141
|
+
- Automatic device ID discovery
|
|
142
|
+
- JSON, CSV, or YAML exports
|
|
143
|
+
- Data type decoding
|
|
144
|
+
- Byte-order or word-order conversion
|
|
145
|
+
- Device profiles
|
|
146
|
+
- Register monitoring
|
|
147
|
+
- Register writes
|
|
148
|
+
- Writable-register validation
|
|
149
|
+
|
|
150
|
+
> **All the above are planned for future releasesa**
|
|
151
|
+
|
|
152
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution guidelines.
|
|
153
|
+
|
|
154
|
+
## Security
|
|
155
|
+
|
|
156
|
+
Do not use ModScanner to perform unauthorized scanning or testing.
|
|
157
|
+
|
|
158
|
+
## Author
|
|
159
|
+
Developed by [Davis Onyeoguzoro](https://github.com/davisonyeas)
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# ModScanner
|
|
2
|
+
|
|
3
|
+
A safety-first Modbus scanning toolkit for Python.
|
|
4
|
+
|
|
5
|
+
ModScanner helps engineers inspect Modbus devices without immediately falling back to slow, one-register-at-a-time scanning. It reads registers in blocks and adaptively splits rejected blocks to identify valid and invalid addresses.
|
|
6
|
+
|
|
7
|
+
> **Project status:**
|
|
8
|
+
Early development. Version 0.1.0 supports only
|
|
9
|
+
> **Modbus TCP holding register scanning.
|
|
10
|
+
|
|
11
|
+
## Features
|
|
12
|
+
|
|
13
|
+
- Read-only Modbus TCP scanning
|
|
14
|
+
- Holding register reads using function code 03
|
|
15
|
+
- Configurable device ID, port, timeout, range, and block size
|
|
16
|
+
- Adaptive block splitting when a device rejects a register range
|
|
17
|
+
- Seperation of protocol errors, transport errors, and invalid responses
|
|
18
|
+
- Terminal output using Rich
|
|
19
|
+
- Typed Python data module
|
|
20
|
+
- Transport abstraction that is built untop of PyModbus
|
|
21
|
+
- Unit tested scanning behavior
|
|
22
|
+
|
|
23
|
+
## Installation
|
|
24
|
+
|
|
25
|
+
Install ModScanner from PyPI using either of two(2) methods the below:
|
|
26
|
+
|
|
27
|
+
1. Install ModScanner from PyPI:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install modscanner
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
2. Using uv:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
uv add modscanner
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Verify the installation using:
|
|
40
|
+
```bash
|
|
41
|
+
modscanner --help
|
|
42
|
+
or
|
|
43
|
+
modscanner --h
|
|
44
|
+
or
|
|
45
|
+
modscanner version
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
> **The above help shows the commands available, while the version shows the current version you have installd on your machine**
|
|
49
|
+
|
|
50
|
+
## Quick start
|
|
51
|
+
|
|
52
|
+
There are different ways to use the library;
|
|
53
|
+
|
|
54
|
+
1. Scan holding registers `0` through `19` on a Modbus TCP device using the below:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
modscanner scan-tcp 192.168.1.10 --device-id 1 --start 0 --count 20
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
> **The above expects you to use the IP Address of the Modbus device, so replace the 192.168.1.10 with your Modbus device**
|
|
61
|
+
|
|
62
|
+
2. Specify the TCP port, timeout, and request block size using:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
modscanner scan-tcp 192.168.1.10 --port 502 --device-id 1 --start 0 --count 100 --block-size 50 --timeout 2
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Run:
|
|
69
|
+
```bash
|
|
70
|
+
modscanner scan-tcp --help
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
to see all available options.
|
|
74
|
+
|
|
75
|
+
## Addressing
|
|
76
|
+
|
|
77
|
+
ModScanner uses zero-based Modbus protocol addresses.
|
|
78
|
+
|
|
79
|
+
For example, a vendor document may label the first holding register as `40001`, while its protocol address is commonly `0`.
|
|
80
|
+
|
|
81
|
+
Always confirm how the device manufacturer represents register addresses before scanning.
|
|
82
|
+
|
|
83
|
+
## Adaptive scanning
|
|
84
|
+
|
|
85
|
+
Some Modbus devices reject an entire request when only one address is within the requested block is invalid.
|
|
86
|
+
|
|
87
|
+
ModScanner handles this by:
|
|
88
|
+
1. Reading a block of registers.
|
|
89
|
+
2. Accepting the block when the response is valid.
|
|
90
|
+
3. Splitting a block when the device return a protocol-level error.
|
|
91
|
+
4. Continuing until individual valid and invalid addresses are identified.
|
|
92
|
+
5. Avoiding recursive requests after transport failure such as timeouts.
|
|
93
|
+
|
|
94
|
+
This reduces unnecessary requests while stil supporting sparse register maps.
|
|
95
|
+
|
|
96
|
+
## Safety
|
|
97
|
+
|
|
98
|
+
ModScanner 0.1.0 is read-only.
|
|
99
|
+
|
|
100
|
+
It does not write coils or registers. Future write-validation functionality will require explicit register allowlists, value limits, confirmation, readback, and audit logging.
|
|
101
|
+
|
|
102
|
+
Read-only registers should also be used carefully on production systems. Confirm the target IP address, port, device ID, and permitted register range before scanning industrial equipments.
|
|
103
|
+
|
|
104
|
+
## Current limitation
|
|
105
|
+
|
|
106
|
+
Version 0.1.0 does not yet support:
|
|
107
|
+
|
|
108
|
+
- Modbus RTU
|
|
109
|
+
- Coils
|
|
110
|
+
- Discrete inputs
|
|
111
|
+
- Input registers
|
|
112
|
+
- Automatic device ID discovery
|
|
113
|
+
- JSON, CSV, or YAML exports
|
|
114
|
+
- Data type decoding
|
|
115
|
+
- Byte-order or word-order conversion
|
|
116
|
+
- Device profiles
|
|
117
|
+
- Register monitoring
|
|
118
|
+
- Register writes
|
|
119
|
+
- Writable-register validation
|
|
120
|
+
|
|
121
|
+
> **All the above are planned for future releasesa**
|
|
122
|
+
|
|
123
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution guidelines.
|
|
124
|
+
|
|
125
|
+
## Security
|
|
126
|
+
|
|
127
|
+
Do not use ModScanner to perform unauthorized scanning or testing.
|
|
128
|
+
|
|
129
|
+
## Author
|
|
130
|
+
Developed by [Davis Onyeoguzoro](https://github.com/davisonyeas)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
[project]
|
|
7
|
+
name = "modscanner"
|
|
8
|
+
version = "0.1.0"
|
|
9
|
+
description = "A safety-first Modbus discovery and register inspection toolkit."
|
|
10
|
+
readme = "README.md"
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
|
+
license = "MIT"
|
|
13
|
+
license-files = ["LICENSE"]
|
|
14
|
+
|
|
15
|
+
authors = [
|
|
16
|
+
{ name = "Davis Onyeoguzoro" },
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
keywords = [
|
|
20
|
+
"modbus",
|
|
21
|
+
"modbus-tcp",
|
|
22
|
+
"modbus-rtu",
|
|
23
|
+
"industrial-automation",
|
|
24
|
+
"plc",
|
|
25
|
+
"scada",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
classifiers = [
|
|
29
|
+
"Development Status :: 2 - Pre-Alpha",
|
|
30
|
+
"Intended Audience :: Developers",
|
|
31
|
+
"Intended Audience :: Manufacturing",
|
|
32
|
+
"Operating System :: OS Independent",
|
|
33
|
+
"Programming Language :: Python :: 3",
|
|
34
|
+
"Programming Language :: Python :: 3.11",
|
|
35
|
+
"Programming Language :: Python :: 3.12",
|
|
36
|
+
"Programming Language :: Python :: 3.13",
|
|
37
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
dependencies = [
|
|
41
|
+
"pymodbus>=3.14,<3.15",
|
|
42
|
+
"rich>=13,<15",
|
|
43
|
+
"typer>=0.12,<1",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
[project.scripts]
|
|
48
|
+
modscanner = "modscanner.cli:app"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
[project.urls]
|
|
52
|
+
Homepage = "https://github.com/davisonyeas/modscanner"
|
|
53
|
+
Documentation = "https://davisonyeas.github.io/modscanner/"
|
|
54
|
+
Repository = "https://github.com/davisonyeas/modscanner"
|
|
55
|
+
Issues = "https://github.com/davisonyeas/modscanner/issues"
|
|
56
|
+
Changelog = "https://github.com/davisonyeas/modscanner/blob/master/CHANGELOG.md"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
[tool.setuptools.packages.find]
|
|
60
|
+
where = ["src"]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
[tool.setuptools.package-data]
|
|
64
|
+
modscanner = ["py.typed"]
|
|
65
|
+
|
|
66
|
+
[dependency-groups]
|
|
67
|
+
dev = [
|
|
68
|
+
"mypy>=2.3.0",
|
|
69
|
+
"pytest>=9.1.1",
|
|
70
|
+
"pytest-cov>=7.1.0",
|
|
71
|
+
"ruff>=0.15.22",
|
|
72
|
+
"twine>=6.2.0",
|
|
73
|
+
]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""modScanner public package interface"""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError
|
|
4
|
+
from importlib.metadata import version
|
|
5
|
+
|
|
6
|
+
from modscanner.models import (
|
|
7
|
+
RegisterArea,
|
|
8
|
+
RegisterResult,
|
|
9
|
+
ResultStatus,
|
|
10
|
+
ScanPlan,
|
|
11
|
+
ScanReport,
|
|
12
|
+
TcpTarget,
|
|
13
|
+
)
|
|
14
|
+
from modscanner.scanner import Scanner
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
__version__ = version("modscanner")
|
|
18
|
+
except PackageNotFoundError:
|
|
19
|
+
__version__ = "0.0.0"
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"RegisterArea",
|
|
23
|
+
"RegisterResult",
|
|
24
|
+
"ResultStatus",
|
|
25
|
+
"ScanPlan",
|
|
26
|
+
"ScanReport",
|
|
27
|
+
"Scanner",
|
|
28
|
+
"TcpTarget",
|
|
29
|
+
"__version__",
|
|
30
|
+
]
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""command-line interface for ModScanner"""
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from modscanner.exceptions import ModScannerConnectionError
|
|
6
|
+
from modscanner.models import ScanPlan, TcpTarget
|
|
7
|
+
from modscanner.reporters.console import render_report
|
|
8
|
+
from modscanner.scanner import Scanner
|
|
9
|
+
from modscanner.transports.pymodbus_transport import (
|
|
10
|
+
PymodbusTcpTransport,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
app = typer.Typer(
|
|
14
|
+
name="modscanner",
|
|
15
|
+
help="safely discover and inspect Modbus devices via RTU and TCP.",
|
|
16
|
+
no_args_is_help=True,
|
|
17
|
+
context_settings={"help_option_names": ["--help", "--h", "-help", "--h"]},
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
console = Console()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@app.command("scan-tcp")
|
|
24
|
+
def scan_tcp(
|
|
25
|
+
host: str = typer.Argument(
|
|
26
|
+
...,
|
|
27
|
+
help="hostname or IP address of the Modbus TCP device.",
|
|
28
|
+
),
|
|
29
|
+
start: int = typer.Option(
|
|
30
|
+
0,
|
|
31
|
+
"--start",
|
|
32
|
+
"-s",
|
|
33
|
+
min=0,
|
|
34
|
+
max=65_535,
|
|
35
|
+
help="zero-based starting register address.",
|
|
36
|
+
),
|
|
37
|
+
count: int = typer.Option(
|
|
38
|
+
10,
|
|
39
|
+
"--count",
|
|
40
|
+
"-c",
|
|
41
|
+
min=1,
|
|
42
|
+
max=65_536,
|
|
43
|
+
help="number of registers to scan.",
|
|
44
|
+
),
|
|
45
|
+
device_id: int = typer.Option(
|
|
46
|
+
1,
|
|
47
|
+
"--device-id",
|
|
48
|
+
"-d",
|
|
49
|
+
min=1,
|
|
50
|
+
max=247,
|
|
51
|
+
help="Modbus device identifier.",
|
|
52
|
+
),
|
|
53
|
+
port: int = typer.Option(
|
|
54
|
+
502,
|
|
55
|
+
"--port",
|
|
56
|
+
"-p",
|
|
57
|
+
min=1,
|
|
58
|
+
max=65_535,
|
|
59
|
+
help="Modbus TCP port.",
|
|
60
|
+
),
|
|
61
|
+
timeout: float = typer.Option(
|
|
62
|
+
3.0,
|
|
63
|
+
"--timeout",
|
|
64
|
+
min=0.1,
|
|
65
|
+
help="Request timeout in seconds.",
|
|
66
|
+
),
|
|
67
|
+
block_size: int = typer.Option(
|
|
68
|
+
125,
|
|
69
|
+
"--block-size",
|
|
70
|
+
min=1,
|
|
71
|
+
max=125,
|
|
72
|
+
help="maximum registers requested per block.",
|
|
73
|
+
),
|
|
74
|
+
) -> None:
|
|
75
|
+
"""scan a range of Modbus TCP holding registers."""
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
target = TcpTarget(
|
|
79
|
+
host=host,
|
|
80
|
+
port=port,
|
|
81
|
+
device_id=device_id,
|
|
82
|
+
timeout=timeout,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
plan = ScanPlan(
|
|
86
|
+
start=start,
|
|
87
|
+
count=count,
|
|
88
|
+
block_size=block_size,
|
|
89
|
+
)
|
|
90
|
+
except ValueError as exc:
|
|
91
|
+
raise typer.BadParameter(str(exc)) from exc
|
|
92
|
+
|
|
93
|
+
transport = PymodbusTcpTransport(target)
|
|
94
|
+
scanner = Scanner(transport, target)
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
transport.connect()
|
|
98
|
+
report = scanner.scan_holding_registers(plan)
|
|
99
|
+
except ModScannerConnectionError as exc:
|
|
100
|
+
console.print(f"Connection failed: {exc}")
|
|
101
|
+
raise typer.Exit(code=2) from exc
|
|
102
|
+
finally:
|
|
103
|
+
transport.close()
|
|
104
|
+
|
|
105
|
+
render_report(report, console)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@app.command()
|
|
109
|
+
def version() -> None:
|
|
110
|
+
"""display the installed ModScanner version"""
|
|
111
|
+
|
|
112
|
+
from modscanner import __version__
|
|
113
|
+
|
|
114
|
+
console.print(f"modscanner {__version__}")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
if __name__ == "__main__":
|
|
118
|
+
app()
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""core data models used by modscanner"""
|
|
2
|
+
|
|
3
|
+
from enum import StrEnum
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class RegisterArea(StrEnum):
|
|
8
|
+
"""supported modbus data areas"""
|
|
9
|
+
|
|
10
|
+
COIL = "coil"
|
|
11
|
+
DISCRETE_INPUT = "discrete_input"
|
|
12
|
+
INPUT_REGISTER = "input_register"
|
|
13
|
+
HOLDING_REGISTER = "holding_register"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ResultStatus(StrEnum):
|
|
17
|
+
"""outcome of reading individual modbus addresss"""
|
|
18
|
+
|
|
19
|
+
OK = "ok"
|
|
20
|
+
PROTOCOL_ERROR = "protocol_error"
|
|
21
|
+
TRANSPORT_ERROR = "transport_error"
|
|
22
|
+
INVALID_RESPONSE = "invalid_response"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True, slots=True)
|
|
26
|
+
class TcpTarget:
|
|
27
|
+
"""commection information for a modbus tcp device"""
|
|
28
|
+
|
|
29
|
+
host: str
|
|
30
|
+
port: int = 502
|
|
31
|
+
device_id: int = 1
|
|
32
|
+
timeout: float = 3.0
|
|
33
|
+
|
|
34
|
+
def post_init__(self) -> None:
|
|
35
|
+
if not self.host.strip():
|
|
36
|
+
raise ValueError("host cannot be empty")
|
|
37
|
+
|
|
38
|
+
if not 1 <= self.port <= 65_535:
|
|
39
|
+
raise ValueError("port must be between 1 and 65535")
|
|
40
|
+
|
|
41
|
+
if not 1 <= self.device_id <= 247:
|
|
42
|
+
raise ValueError("device_id must be between 1 and 247")
|
|
43
|
+
|
|
44
|
+
if self.timeout <= 0:
|
|
45
|
+
raise ValueError("timeout must be greater than zero")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True, slots=True)
|
|
49
|
+
class ScanPlan:
|
|
50
|
+
"""description og contiguous holding register scan"""
|
|
51
|
+
|
|
52
|
+
start: int
|
|
53
|
+
count: int
|
|
54
|
+
block_size: int = 125
|
|
55
|
+
area: RegisterArea = RegisterArea.HOLDING_REGISTER
|
|
56
|
+
|
|
57
|
+
def __post_init__(self) -> None:
|
|
58
|
+
if not 0 <= self.start <= 65_535:
|
|
59
|
+
raise ValueError("start must be between 0 and 65535")
|
|
60
|
+
|
|
61
|
+
if self.count < 1:
|
|
62
|
+
raise ValueError("count must be atleast 1")
|
|
63
|
+
|
|
64
|
+
if self.start + self.count > 65_536:
|
|
65
|
+
raise ValueError("the requested address range exceeeds 65535")
|
|
66
|
+
|
|
67
|
+
if not 1 <= self.block_size <= 125:
|
|
68
|
+
raise ValueError("block size must be between 1 and 125")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True, slots=True)
|
|
72
|
+
class RegisterResult:
|
|
73
|
+
"""result for one scanned Modbus register"""
|
|
74
|
+
|
|
75
|
+
address: int
|
|
76
|
+
area: RegisterArea
|
|
77
|
+
status: ResultStatus
|
|
78
|
+
value: int | None = None
|
|
79
|
+
error: str | None = None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True, slots=True)
|
|
83
|
+
class ScanReport:
|
|
84
|
+
"""complete result of a Modbus register scan"""
|
|
85
|
+
|
|
86
|
+
target: TcpTarget
|
|
87
|
+
plan: ScanPlan
|
|
88
|
+
results: tuple[RegisterResult, ...]
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def successful_count(self) -> int:
|
|
92
|
+
"""return the number of successfully read registers"""
|
|
93
|
+
|
|
94
|
+
return sum(result.status is ResultStatus.OK for result in self.results)
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def failed_count(self) -> int:
|
|
98
|
+
"""return the number of registers that could not be read"""
|
|
99
|
+
|
|
100
|
+
return len(self.results) - self.successful_count
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""rich terminal output for ModScanner reports"""
|
|
2
|
+
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
from rich.table import Table
|
|
5
|
+
from modscanner.models import ScanReport
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def render_report(
|
|
9
|
+
report: ScanReport,
|
|
10
|
+
console: Console | None = None,
|
|
11
|
+
) -> None:
|
|
12
|
+
"""render a scan report as a terminal table"""
|
|
13
|
+
|
|
14
|
+
output = console or Console()
|
|
15
|
+
|
|
16
|
+
table = Table(
|
|
17
|
+
title=(
|
|
18
|
+
f"Holding registers: "
|
|
19
|
+
f"{report.target.host}:{report.target.port} "
|
|
20
|
+
f"(device {report.target.device_id})"
|
|
21
|
+
)
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
table.add_column("Address", justify="right")
|
|
25
|
+
table.add_column("Value", justify="right")
|
|
26
|
+
table.add_column("Status")
|
|
27
|
+
table.add_column("Error")
|
|
28
|
+
|
|
29
|
+
for result in report.results:
|
|
30
|
+
table.add_row(
|
|
31
|
+
str(result.address),
|
|
32
|
+
"" if result.value is None else str(result.value),
|
|
33
|
+
result.status.value,
|
|
34
|
+
result.error or "",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
output.print(table)
|
|
38
|
+
output.print(
|
|
39
|
+
f"Successful: {report.successful_count} | Failed: {report.failed_count}"
|
|
40
|
+
)
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""read-only Modbus register scanning logic"""
|
|
2
|
+
|
|
3
|
+
from modscanner.models import (
|
|
4
|
+
RegisterArea,
|
|
5
|
+
RegisterResult,
|
|
6
|
+
ResultStatus,
|
|
7
|
+
ScanPlan,
|
|
8
|
+
ScanReport,
|
|
9
|
+
TcpTarget,
|
|
10
|
+
)
|
|
11
|
+
from modscanner.transports.base import (
|
|
12
|
+
ModbusTransport,
|
|
13
|
+
ReadErrorKind,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Scanner:
|
|
18
|
+
"""read-only Modbus scanner"""
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
transport: ModbusTransport,
|
|
23
|
+
target: TcpTarget,
|
|
24
|
+
) -> None:
|
|
25
|
+
self._transport = transport
|
|
26
|
+
self._target = target
|
|
27
|
+
|
|
28
|
+
def scan_holding_registers(self, plan: ScanPlan) -> ScanReport:
|
|
29
|
+
"""scan holding registers using adaptive block splitting"""
|
|
30
|
+
|
|
31
|
+
results: list[RegisterResult] = []
|
|
32
|
+
|
|
33
|
+
range_end = plan.start + plan.count
|
|
34
|
+
current_address = plan.start
|
|
35
|
+
|
|
36
|
+
while current_address < range_end:
|
|
37
|
+
block_count = min(
|
|
38
|
+
plan.block_size,
|
|
39
|
+
range_end - current_address,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
self._scan_block(
|
|
43
|
+
address=current_address,
|
|
44
|
+
count=block_count,
|
|
45
|
+
results=results,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
current_address += block_count
|
|
49
|
+
|
|
50
|
+
results.sort(key=lambda result: result.address)
|
|
51
|
+
|
|
52
|
+
return ScanReport(
|
|
53
|
+
target=self._target,
|
|
54
|
+
plan=plan,
|
|
55
|
+
results=tuple(results),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
def _scan_block(
|
|
59
|
+
self,
|
|
60
|
+
address: int,
|
|
61
|
+
count: int,
|
|
62
|
+
results: list[RegisterResult],
|
|
63
|
+
) -> None:
|
|
64
|
+
block = self._transport.read_holding_registers(
|
|
65
|
+
address=address,
|
|
66
|
+
count=count,
|
|
67
|
+
device_id=self._target.device_id,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
if block.ok:
|
|
71
|
+
values = block.values
|
|
72
|
+
|
|
73
|
+
if values is None or len(values) != count:
|
|
74
|
+
self._append_failures(
|
|
75
|
+
address=address,
|
|
76
|
+
count=count,
|
|
77
|
+
status=ResultStatus.INVALID_RESPONSE,
|
|
78
|
+
error="transport returned an invalid successful result",
|
|
79
|
+
results=results,
|
|
80
|
+
)
|
|
81
|
+
return
|
|
82
|
+
|
|
83
|
+
for offset, value in enumerate(values):
|
|
84
|
+
results.append(
|
|
85
|
+
RegisterResult(
|
|
86
|
+
address=address + offset,
|
|
87
|
+
area=RegisterArea.HOLDING_REGISTER,
|
|
88
|
+
status=ResultStatus.OK,
|
|
89
|
+
value=value,
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
return
|
|
94
|
+
|
|
95
|
+
if block.error_kind is ReadErrorKind.PROTOCOL and count > 1:
|
|
96
|
+
left_count = count // 2
|
|
97
|
+
right_count = count - left_count
|
|
98
|
+
|
|
99
|
+
self._scan_block(
|
|
100
|
+
address=address,
|
|
101
|
+
count=left_count,
|
|
102
|
+
results=results,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
self._scan_block(
|
|
106
|
+
address=address + left_count,
|
|
107
|
+
count=right_count,
|
|
108
|
+
results=results,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
return
|
|
112
|
+
|
|
113
|
+
status = self._status_from_error_kind(block.error_kind)
|
|
114
|
+
|
|
115
|
+
self._append_failures(
|
|
116
|
+
address=address,
|
|
117
|
+
count=count,
|
|
118
|
+
status=status,
|
|
119
|
+
error=block.error or "unknown Modbus error",
|
|
120
|
+
results=results,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
@staticmethod
|
|
124
|
+
def _status_from_error_kind(
|
|
125
|
+
error_kind: ReadErrorKind | None,
|
|
126
|
+
) -> ResultStatus:
|
|
127
|
+
if error_kind is ReadErrorKind.PROTOCOL:
|
|
128
|
+
return ResultStatus.PROTOCOL_ERROR
|
|
129
|
+
|
|
130
|
+
if error_kind is ReadErrorKind.TRANSPORT:
|
|
131
|
+
return ResultStatus.TRANSPORT_ERROR
|
|
132
|
+
|
|
133
|
+
return ResultStatus.INVALID_RESPONSE
|
|
134
|
+
|
|
135
|
+
@staticmethod
|
|
136
|
+
def _append_failures(
|
|
137
|
+
address: int,
|
|
138
|
+
count: int,
|
|
139
|
+
status: ResultStatus,
|
|
140
|
+
error: str,
|
|
141
|
+
results: list[RegisterResult],
|
|
142
|
+
) -> None:
|
|
143
|
+
for offset in range(count):
|
|
144
|
+
results.append(
|
|
145
|
+
RegisterResult(
|
|
146
|
+
address=address + offset,
|
|
147
|
+
area=RegisterArea.HOLDING_REGISTER,
|
|
148
|
+
status=status,
|
|
149
|
+
error=error,
|
|
150
|
+
)
|
|
151
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""transport abstraction used by the ModScanner core"""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from enum import StrEnum
|
|
5
|
+
from typing import Protocol
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ReadErrorKind(StrEnum):
|
|
9
|
+
"""categories of errors returned by a transport"""
|
|
10
|
+
|
|
11
|
+
PROTOCOL = "protocol"
|
|
12
|
+
TRANSPORT = "transport"
|
|
13
|
+
INVALID_RESPONSE = "invalid_response"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True, slots=True)
|
|
17
|
+
class BlockRead:
|
|
18
|
+
"""result of reading one contiguous block of rgisters"""
|
|
19
|
+
|
|
20
|
+
values: tuple[int, ...] | None = None
|
|
21
|
+
error_kind: ReadErrorKind | None = None
|
|
22
|
+
error: str | None = None
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def ok(self) -> bool:
|
|
26
|
+
"""return whether the operation wass a success"""
|
|
27
|
+
|
|
28
|
+
return self.error_kind is None
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
def success(cls, values: tuple[int, ...]) -> "BlockRead":
|
|
32
|
+
"""create a successful block result"""
|
|
33
|
+
|
|
34
|
+
return cls(values=values)
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def failure(
|
|
38
|
+
cls,
|
|
39
|
+
kind: ReadErrorKind,
|
|
40
|
+
error: str,
|
|
41
|
+
) -> "BlockRead":
|
|
42
|
+
"""create a failed block result"""
|
|
43
|
+
|
|
44
|
+
return cls(error_kind=kind, error=error)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ModbusTransport(Protocol):
|
|
48
|
+
"""interface implemented by concret Modbus transports"""
|
|
49
|
+
|
|
50
|
+
def connect(self) -> None:
|
|
51
|
+
"""connect to the Modbus device"""
|
|
52
|
+
|
|
53
|
+
def close(self) -> None:
|
|
54
|
+
"""close the connection"""
|
|
55
|
+
|
|
56
|
+
def read_holding_registers(
|
|
57
|
+
self,
|
|
58
|
+
address: int,
|
|
59
|
+
count: int,
|
|
60
|
+
device_id: int,
|
|
61
|
+
) -> BlockRead:
|
|
62
|
+
"""read a contiguoous block of holding registers"""
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""PyModbus-backed Modbus TCP transport"""
|
|
2
|
+
|
|
3
|
+
from pymodbus.client import ModbusTcpClient
|
|
4
|
+
from pymodbus.exceptions import ModbusException
|
|
5
|
+
from modscanner.exceptions import ModScannerConnectionError
|
|
6
|
+
from modscanner.models import TcpTarget
|
|
7
|
+
from modscanner.transports.base import BlockRead, ReadErrorKind
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PymodbusTcpTransport:
|
|
11
|
+
"""synchronous Modbus TCP transport using PyModbus"""
|
|
12
|
+
|
|
13
|
+
def __init__(self, target: TcpTarget) -> None:
|
|
14
|
+
self._target = target
|
|
15
|
+
self._client = ModbusTcpClient(
|
|
16
|
+
host=target.host,
|
|
17
|
+
port=target.port,
|
|
18
|
+
timeout=target.timeout,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
def connect(self) -> None:
|
|
22
|
+
"""connect to the configured Modbus TCP device"""
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
connected = self._client.connect()
|
|
26
|
+
except (ModbusException, OSError, TimeoutError) as exc:
|
|
27
|
+
raise ModScannerConnectionError(
|
|
28
|
+
f"could not connect to {self._target.host}:{self._target.port}: {exc}"
|
|
29
|
+
) from exc
|
|
30
|
+
|
|
31
|
+
if not connected:
|
|
32
|
+
raise ModScannerConnectionError(
|
|
33
|
+
f"could not connect to {self._target.host}:{self._target.port}"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
def close(self) -> None:
|
|
37
|
+
"""close the PyModbus TCP connection"""
|
|
38
|
+
|
|
39
|
+
self._client.close()
|
|
40
|
+
|
|
41
|
+
def read_holding_registers(
|
|
42
|
+
self,
|
|
43
|
+
address: int,
|
|
44
|
+
count: int,
|
|
45
|
+
device_id: int,
|
|
46
|
+
) -> BlockRead:
|
|
47
|
+
"""read holding registers using function code 03"""
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
response = self._client.read_holding_registers(
|
|
51
|
+
address,
|
|
52
|
+
count=count,
|
|
53
|
+
device_id=device_id,
|
|
54
|
+
)
|
|
55
|
+
except (ModbusException, OSError, TimeoutError) as exc:
|
|
56
|
+
return BlockRead.failure(
|
|
57
|
+
ReadErrorKind.TRANSPORT,
|
|
58
|
+
str(exc),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
if response is None:
|
|
62
|
+
return BlockRead.failure(
|
|
63
|
+
ReadErrorKind.INVALID_RESPONSE,
|
|
64
|
+
"the device returned no response object",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
if response.isError():
|
|
68
|
+
return BlockRead.failure(
|
|
69
|
+
ReadErrorKind.PROTOCOL,
|
|
70
|
+
str(response),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
registers = getattr(response, "registers", None)
|
|
74
|
+
|
|
75
|
+
if registers is None:
|
|
76
|
+
return BlockRead.failure(
|
|
77
|
+
ReadErrorKind.INVALID_RESPONSE,
|
|
78
|
+
"the response did not contain registers",
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
values = tuple(int(value) for value in registers)
|
|
82
|
+
|
|
83
|
+
if len(values) != count:
|
|
84
|
+
return BlockRead.failure(
|
|
85
|
+
ReadErrorKind.INVALID_RESPONSE,
|
|
86
|
+
f"expected {count} registers but received {len(values)}",
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
return BlockRead.success(values)
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: modscanner
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A safety-first Modbus discovery and register inspection toolkit.
|
|
5
|
+
Author: Davis Onyeoguzoro
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/davisonyeas/modscanner
|
|
8
|
+
Project-URL: Documentation, https://davisonyeas.github.io/modscanner/
|
|
9
|
+
Project-URL: Repository, https://github.com/davisonyeas/modscanner
|
|
10
|
+
Project-URL: Issues, https://github.com/davisonyeas/modscanner/issues
|
|
11
|
+
Project-URL: Changelog, https://github.com/davisonyeas/modscanner/blob/master/CHANGELOG.md
|
|
12
|
+
Keywords: modbus,modbus-tcp,modbus-rtu,industrial-automation,plc,scada
|
|
13
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Intended Audience :: Manufacturing
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
22
|
+
Requires-Python: >=3.11
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Requires-Dist: pymodbus<3.15,>=3.14
|
|
26
|
+
Requires-Dist: rich<15,>=13
|
|
27
|
+
Requires-Dist: typer<1,>=0.12
|
|
28
|
+
Dynamic: license-file
|
|
29
|
+
|
|
30
|
+
# ModScanner
|
|
31
|
+
|
|
32
|
+
A safety-first Modbus scanning toolkit for Python.
|
|
33
|
+
|
|
34
|
+
ModScanner helps engineers inspect Modbus devices without immediately falling back to slow, one-register-at-a-time scanning. It reads registers in blocks and adaptively splits rejected blocks to identify valid and invalid addresses.
|
|
35
|
+
|
|
36
|
+
> **Project status:**
|
|
37
|
+
Early development. Version 0.1.0 supports only
|
|
38
|
+
> **Modbus TCP holding register scanning.
|
|
39
|
+
|
|
40
|
+
## Features
|
|
41
|
+
|
|
42
|
+
- Read-only Modbus TCP scanning
|
|
43
|
+
- Holding register reads using function code 03
|
|
44
|
+
- Configurable device ID, port, timeout, range, and block size
|
|
45
|
+
- Adaptive block splitting when a device rejects a register range
|
|
46
|
+
- Seperation of protocol errors, transport errors, and invalid responses
|
|
47
|
+
- Terminal output using Rich
|
|
48
|
+
- Typed Python data module
|
|
49
|
+
- Transport abstraction that is built untop of PyModbus
|
|
50
|
+
- Unit tested scanning behavior
|
|
51
|
+
|
|
52
|
+
## Installation
|
|
53
|
+
|
|
54
|
+
Install ModScanner from PyPI using either of two(2) methods the below:
|
|
55
|
+
|
|
56
|
+
1. Install ModScanner from PyPI:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
pip install modscanner
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
2. Using uv:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
uv add modscanner
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Verify the installation using:
|
|
69
|
+
```bash
|
|
70
|
+
modscanner --help
|
|
71
|
+
or
|
|
72
|
+
modscanner --h
|
|
73
|
+
or
|
|
74
|
+
modscanner version
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
> **The above help shows the commands available, while the version shows the current version you have installd on your machine**
|
|
78
|
+
|
|
79
|
+
## Quick start
|
|
80
|
+
|
|
81
|
+
There are different ways to use the library;
|
|
82
|
+
|
|
83
|
+
1. Scan holding registers `0` through `19` on a Modbus TCP device using the below:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
modscanner scan-tcp 192.168.1.10 --device-id 1 --start 0 --count 20
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
> **The above expects you to use the IP Address of the Modbus device, so replace the 192.168.1.10 with your Modbus device**
|
|
90
|
+
|
|
91
|
+
2. Specify the TCP port, timeout, and request block size using:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
modscanner scan-tcp 192.168.1.10 --port 502 --device-id 1 --start 0 --count 100 --block-size 50 --timeout 2
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Run:
|
|
98
|
+
```bash
|
|
99
|
+
modscanner scan-tcp --help
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
to see all available options.
|
|
103
|
+
|
|
104
|
+
## Addressing
|
|
105
|
+
|
|
106
|
+
ModScanner uses zero-based Modbus protocol addresses.
|
|
107
|
+
|
|
108
|
+
For example, a vendor document may label the first holding register as `40001`, while its protocol address is commonly `0`.
|
|
109
|
+
|
|
110
|
+
Always confirm how the device manufacturer represents register addresses before scanning.
|
|
111
|
+
|
|
112
|
+
## Adaptive scanning
|
|
113
|
+
|
|
114
|
+
Some Modbus devices reject an entire request when only one address is within the requested block is invalid.
|
|
115
|
+
|
|
116
|
+
ModScanner handles this by:
|
|
117
|
+
1. Reading a block of registers.
|
|
118
|
+
2. Accepting the block when the response is valid.
|
|
119
|
+
3. Splitting a block when the device return a protocol-level error.
|
|
120
|
+
4. Continuing until individual valid and invalid addresses are identified.
|
|
121
|
+
5. Avoiding recursive requests after transport failure such as timeouts.
|
|
122
|
+
|
|
123
|
+
This reduces unnecessary requests while stil supporting sparse register maps.
|
|
124
|
+
|
|
125
|
+
## Safety
|
|
126
|
+
|
|
127
|
+
ModScanner 0.1.0 is read-only.
|
|
128
|
+
|
|
129
|
+
It does not write coils or registers. Future write-validation functionality will require explicit register allowlists, value limits, confirmation, readback, and audit logging.
|
|
130
|
+
|
|
131
|
+
Read-only registers should also be used carefully on production systems. Confirm the target IP address, port, device ID, and permitted register range before scanning industrial equipments.
|
|
132
|
+
|
|
133
|
+
## Current limitation
|
|
134
|
+
|
|
135
|
+
Version 0.1.0 does not yet support:
|
|
136
|
+
|
|
137
|
+
- Modbus RTU
|
|
138
|
+
- Coils
|
|
139
|
+
- Discrete inputs
|
|
140
|
+
- Input registers
|
|
141
|
+
- Automatic device ID discovery
|
|
142
|
+
- JSON, CSV, or YAML exports
|
|
143
|
+
- Data type decoding
|
|
144
|
+
- Byte-order or word-order conversion
|
|
145
|
+
- Device profiles
|
|
146
|
+
- Register monitoring
|
|
147
|
+
- Register writes
|
|
148
|
+
- Writable-register validation
|
|
149
|
+
|
|
150
|
+
> **All the above are planned for future releasesa**
|
|
151
|
+
|
|
152
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution guidelines.
|
|
153
|
+
|
|
154
|
+
## Security
|
|
155
|
+
|
|
156
|
+
Do not use ModScanner to perform unauthorized scanning or testing.
|
|
157
|
+
|
|
158
|
+
## Author
|
|
159
|
+
Developed by [Davis Onyeoguzoro](https://github.com/davisonyeas)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/modscanner/__init__.py
|
|
5
|
+
src/modscanner/cli.py
|
|
6
|
+
src/modscanner/exceptions.py
|
|
7
|
+
src/modscanner/models.py
|
|
8
|
+
src/modscanner/py.typed
|
|
9
|
+
src/modscanner/scanner.py
|
|
10
|
+
src/modscanner.egg-info/PKG-INFO
|
|
11
|
+
src/modscanner.egg-info/SOURCES.txt
|
|
12
|
+
src/modscanner.egg-info/dependency_links.txt
|
|
13
|
+
src/modscanner.egg-info/entry_points.txt
|
|
14
|
+
src/modscanner.egg-info/requires.txt
|
|
15
|
+
src/modscanner.egg-info/top_level.txt
|
|
16
|
+
src/modscanner/reporters/__init__.py
|
|
17
|
+
src/modscanner/reporters/console.py
|
|
18
|
+
src/modscanner/transports/__init__.py
|
|
19
|
+
src/modscanner/transports/base.py
|
|
20
|
+
src/modscanner/transports/pymodbus_transport.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
modscanner
|