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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,9 @@
1
+ """exceptions raised by modscanne"""
2
+
3
+
4
+ class ModScannerError(Exception):
5
+ """base exception fpr all modscanner errors"""
6
+
7
+
8
+ class ModScannerConnectionError(Exception):
9
+ """raised when a Modbus connection cannot be establised"""
@@ -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,2 @@
1
+ [console_scripts]
2
+ modscanner = modscanner.cli:app
@@ -0,0 +1,3 @@
1
+ pymodbus<3.15,>=3.14
2
+ rich<15,>=13
3
+ typer<1,>=0.12
@@ -0,0 +1 @@
1
+ modscanner