├── .gitignore ├── .pre-commit-config.yaml ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── modbus_sniffer ├── __init__.py └── modbus_sniffer.py ├── pyproject.toml ├── requirements.txt ├── setup.py ├── tests └── send_client_requests.py └── tools ├── async_twisted_client_serial.py ├── process_slave_reply_all.py ├── slave_reply_all.py ├── slave_reply_all_read_all.py └── synchronous_client.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | exclude: '^docs/|/migrations/|devcontainer.json' 2 | default_stages: [commit] 3 | 4 | default_language_version: 5 | python: python3.11 6 | 7 | repos: 8 | - repo: https://github.com/pre-commit/pre-commit-hooks 9 | rev: v4.5.0 10 | hooks: 11 | - id: trailing-whitespace 12 | - id: end-of-file-fixer 13 | - id: check-json 14 | - id: check-toml 15 | - id: check-xml 16 | - id: check-yaml 17 | - id: debug-statements 18 | - id: check-builtin-literals 19 | - id: check-case-conflict 20 | - id: check-docstring-first 21 | - id: detect-private-key 22 | 23 | - repo: https://github.com/adamchainz/django-upgrade 24 | rev: '1.16.0' 25 | hooks: 26 | - id: django-upgrade 27 | args: ['--target-version', '4.2'] 28 | 29 | # Run the Ruff linter. 30 | - repo: https://github.com/astral-sh/ruff-pre-commit 31 | rev: v0.3.2 32 | hooks: 33 | # Linter 34 | - id: ruff 35 | args: [--fix, --exit-non-zero-on-fix] 36 | # Formatter 37 | - id: ruff-format 38 | 39 | - repo: https://github.com/Riverside-Healthcare/djLint 40 | rev: v1.34.1 41 | hooks: 42 | - id: djlint-reformat-django 43 | - id: djlint-django 44 | 45 | # sets up .pre-commit-ci.yaml to ensure pre-commit dependencies stay up to date 46 | ci: 47 | autoupdate_schedule: weekly 48 | skip: [] 49 | submodules: false 50 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Contributions welcome! 2 | Please first make an issue and then reference the issue in the pull request. 3 | Try to make granular commits. 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Simon Hobbs 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ModbusSniffer 2 | Modbus RTU packet sniffer 3 | 4 | **ModbusSniffer** is a simple Modbus RTU packet sniffer for serial buses. 5 | 6 | It wraps `pymodbus` to decode and print all packets observed on the wire, 7 | from either the master or slave perspective. This is especially useful 8 | for debugging communication between Modbus devices, verifying protocol behavior, 9 | or reverse-engineering device interactions. 10 | 11 | ## Features 12 | - Pure Python, built on [pymodbus](https://github.com/pymodbus-dev/pymodbus) 13 | - Captures and decodes Modbus RTU packets from both master and slave devices 14 | - Great for protocol debugging, development, and test benches 15 | -️ Simple command-line interface with readable log output 16 | - Easily extensible for custom logging or automation 17 | 18 | ## Installation 19 | ```bash 20 | git clone https://github.com/snhobbs/ModbusSniffer 21 | cd ModbusSniffer 22 | pip install . 23 | ``` 24 | 25 | ## Usage 26 | **Terminal output only** 27 | ```bash 28 | modbus_sniffer --port /dev/ttyUSB0 --baud 9600 29 | python3 modbus_sniffer.py --port /dev/ttyUSB0 --baud 9600 30 | ``` 31 | 32 | **With logfile output** 33 | ```bash 34 | modbus_sniffer --port /dev/ttyUSB0 --baud 19200 --logfile modbus.log 35 | python3 modbus_sniffer.py --port /dev/ttyUSB0 --baud 19200 --logfile modbus.log 36 | ``` 37 | 38 | | Option | Description | 39 | | ------------ | --------------------------------------------- | 40 | | `--port, -p` | Serial port to open (default: `/dev/ttyUSB0`) | 41 | | `--baud, -b` | Baud rate (default: `9600`) | 42 | | `--timeout` | Set Modbus read timeout manually | 43 | | `--debug` | Enable verbose debug logging | 44 | 45 | 46 | ## Testing with socat 47 | 1. Setup a simulated serial link 48 | 49 | ```bash 50 | socat -d -d pty,raw,echo=0,link=/tmp/ttyS0 pty,raw,echo=0,link=/tmp/ttyS1 51 | ``` 52 | 53 | 2. Start modbus_sniffer 54 | ```bash 55 | python modbus_sniffer.py --port /tmp/ttyS0 --baud 9600 56 | ``` 57 | 58 | 3. Run test script 59 | ```bash 60 | python tests/send_client_requests.py /tmp/ttyS1 --baud 9600 61 | ``` 62 | -------------------------------------------------------------------------------- /modbus_sniffer/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snhobbs/ModbusSniffer/180ae4f504e968ab8b40afbcb382d7f015469dca/modbus_sniffer/__init__.py -------------------------------------------------------------------------------- /modbus_sniffer/modbus_sniffer.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import logging 3 | 4 | import click 5 | import serial 6 | from pymodbus.factory import ClientDecoder 7 | from pymodbus.factory import ServerDecoder 8 | from pymodbus.transaction import ModbusRtuFramer 9 | 10 | FORMAT = ( 11 | "%(asctime)-15s %(threadName)-15s" 12 | " %(levelname)-8s %(module)-15s:%(lineno)-8s %(message)s" 13 | ) 14 | log = logging.getLogger("modbus_sniffer") 15 | 16 | kByteLength_8N1 = 10 # 8N1 is 10 bits/byte over the wire 17 | 18 | class SerialSnooper: 19 | """ 20 | Modbus RTU serial snooper that listens for and decodes Modbus messages. 21 | 22 | Attributes: 23 | kMaxReadSize (int): Maximum number of bytes to read in one call. 24 | """ 25 | kMaxReadSize = 128 26 | 27 | def __init__(self, port, baud=9600, timeout=None, byte_length=kByteLength_8N1): 28 | """ 29 | Args: 30 | port (str): Serial port path (e.g., /dev/ttyUSB0 or COM3). 31 | baud (int): Baud rate of the serial communication. 32 | timeout (float or None): Timeout in seconds. If None, it is computed based on baud rate. 33 | byte_length (int): Clocks per byte, default is 10 for standard 8N1 34 | """ 35 | self.port = port 36 | self.baud = baud 37 | if timeout is None: 38 | timeout = float(9 * byte_length) / baud 39 | self.timeout = timeout 40 | self.connection = serial.Serial(port, baud, timeout=timeout) 41 | self.client_framer = ModbusRtuFramer(decoder=ClientDecoder()) 42 | self.server_framer = ModbusRtuFramer(decoder=ServerDecoder()) 43 | 44 | def __enter__(self): 45 | return self 46 | 47 | def __exit__(self, exc_type, exc_val, exc_tb): 48 | self.close() 49 | 50 | def open(self): 51 | self.connection.open() 52 | 53 | def close(self): 54 | self.connection.close() 55 | 56 | def server_packet_callback(self, *args, **kwargs): 57 | for msg in args: 58 | (str(type(msg)).split(".")[-1].strip("'><").replace("Request", "")) 59 | with contextlib.suppress(AttributeError): 60 | pass 61 | with contextlib.suppress(AttributeError): 62 | pass 63 | with contextlib.suppress(AttributeError): 64 | pass 65 | 66 | def client_packet_callback(self, *args, **kwargs): 67 | for msg in args: 68 | (str(type(msg)).split(".")[-1].strip("'><").replace("Request", "")) 69 | with contextlib.suppress(AttributeError): 70 | pass 71 | with contextlib.suppress(AttributeError): 72 | pass 73 | with contextlib.suppress(AttributeError): 74 | pass 75 | 76 | def read_raw(self, n=16): 77 | return self.connection.read(n) 78 | 79 | def process(self, data): 80 | if len(data) <= 0: 81 | return 82 | with contextlib.suppress(IndexError, TypeError, KeyError): 83 | self.client_framer.processIncomingPacket( 84 | data, self.client_packet_callback, unit=None, single=True 85 | ) 86 | with contextlib.suppress(IndexError, TypeError, KeyError): 87 | self.server_framer.processIncomingPacket( 88 | data, self.server_packet_callback, unit=None, single=True 89 | ) 90 | 91 | def read(self): 92 | self.process(self.read_raw()) 93 | 94 | 95 | @click.command() 96 | @click.option("--port", "-p", default="/dev/ttyUSB0", help="Serial device path (e.g., /dev/ttyUSB0 or COM1)") 97 | @click.option("--baud", "-b", type=int, default=9600, help="Baud rate for serial communication") 98 | @click.option("--debug", is_flag=True, help="Enable debug-level logging") 99 | @click.option("--timeout", type=float, help="Set a custom Modbus timeout in seconds") 100 | @click.option("--logfile", type=click.Path(), help="Optional log file path") 101 | def main(port, baud, debug, timeout, logfile): 102 | log_level = logging.DEBUG if debug else logging.INFO 103 | 104 | # Configure root logger 105 | logging.getLogger().handlers.clear() # Remove existing handlers 106 | handlers = [logging.StreamHandler()] 107 | if logfile: 108 | handlers.append(logging.FileHandler(logfile)) 109 | 110 | logging.basicConfig( 111 | level=log_level, 112 | format=FORMAT, 113 | #handlers=handlers 114 | ) 115 | 116 | log.info(f"Starting ModbusSniffer on {port} @ {baud} baud") 117 | 118 | with SerialSnooper(port, baud, timeout) as ss: 119 | while True: 120 | data = ss.read_raw() 121 | if len(data): 122 | pass 123 | _ = ss.process(data) 124 | 125 | 126 | if __name__ == "__main__": 127 | main() 128 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name="modbus_sniffer" 3 | version="1.0.3" 4 | #dynamic = ["dependencies"] 5 | authors=[ 6 | {name="Simon Hobbs", email='simon.hobbs@electrooptical.net'}] 7 | requires-python='>=3.6' 8 | classifiers=[ 9 | 'Development Status :: 2 - Pre-Alpha', 10 | 'Intended Audience :: Developers', 11 | 'Natural Language :: English', 12 | 'Programming Language :: Python :: 3', 13 | 'Programming Language :: Python :: 3.6', 14 | 'Programming Language :: Python :: 3.7', 15 | 'Programming Language :: Python :: 3.8', 16 | ] 17 | description="Pymodbus based modbus rtu packet sniffer" 18 | keywords=['modbus_sniffer'] 19 | readme="README.md" 20 | dynamic = ["dependencies"] 21 | 22 | [project.scripts] 23 | modbus_sniffer='modbus_sniffer.modbus_sniffer:main' 24 | 25 | [project.urls] 26 | github='https://github.com/snhobbs/ModbusSniffer' 27 | 28 | [tool.setuptools.dynamic] 29 | dependencies = {file = ["requirements.txt"]} 30 | 31 | [tool.hatch.metadata] 32 | allow-direct-references = true 33 | 34 | [tool.setuptools] 35 | packages=['modbus_sniffer'] 36 | 37 | # ==== pytest ==== 38 | [tool.pytest.ini_options] 39 | minversion = "6.0" 40 | addopts = "--ds=config.settings.test --reuse-db" 41 | python_files = [ 42 | "tests.py", 43 | "test_*.py", 44 | ] 45 | 46 | # ==== Coverage ==== 47 | [tool.coverage.run] 48 | include = ["modbus_sniffer/**"] 49 | omit = ["*/migrations/*", "*/tests/*"] 50 | plugins = ["django_coverage_plugin"] 51 | 52 | # ==== mypy ==== 53 | [tool.mypy] 54 | python_version = "3.11" 55 | check_untyped_defs = true 56 | ignore_missing_imports = true 57 | warn_unused_ignores = true 58 | warn_redundant_casts = true 59 | warn_unused_configs = true 60 | plugins = [ 61 | "mypy_django_plugin.main", 62 | ] 63 | 64 | [[tool.mypy.overrides]] 65 | # Django migrations should not produce any errors: 66 | module = "*.migrations.*" 67 | ignore_errors = true 68 | 69 | [tool.django-stubs] 70 | django_settings_module = "config.settings.test" 71 | 72 | # ==== djLint ==== 73 | [tool.djlint] 74 | blank_line_after_tag = "load,extends" 75 | close_void_tags = true 76 | format_css = true 77 | format_js = true 78 | # TODO: remove T002 when fixed https://github.com/Riverside-Healthcare/djLint/issues/687 79 | ignore = "H006,H030,H031,T002" 80 | include = "H017,H035" 81 | indent = 2 82 | max_line_length = 119 83 | profile = "django" 84 | 85 | [tool.djlint.css] 86 | indent_size = 2 87 | 88 | [tool.djlint.js] 89 | indent_size = 2 90 | 91 | [tool.ruff] 92 | # Exclude a variety of commonly ignored directories. 93 | exclude = [ 94 | ".bzr", 95 | ".direnv", 96 | ".eggs", 97 | ".git", 98 | ".git-rewrite", 99 | ".hg", 100 | ".mypy_cache", 101 | ".nox", 102 | ".pants.d", 103 | ".pytype", 104 | ".ruff_cache", 105 | ".svn", 106 | ".tox", 107 | ".venv", 108 | "__pypackages__", 109 | "_build", 110 | "buck-out", 111 | "build", 112 | "dist", 113 | "node_modules", 114 | "venv", 115 | "*/migrations/*.py", 116 | "staticfiles/*" 117 | ] 118 | # Same as Django: https://github.com/cookiecutter/cookiecutter-django/issues/4792. 119 | line-length = 88 120 | indent-width = 4 121 | target-version = "py311" 122 | 123 | [tool.ruff.lint] 124 | select = [ 125 | "F", 126 | "E", 127 | "W", 128 | "C90", 129 | "I", 130 | "N", 131 | "UP", 132 | "YTT", 133 | # "ANN", # flake8-annotations: we should support this in the future but 100+ errors atm 134 | "ASYNC", 135 | "S", 136 | "BLE", 137 | "FBT", 138 | "B", 139 | "A", 140 | "COM", 141 | "C4", 142 | "DTZ", 143 | "T10", 144 | "DJ", 145 | "EM", 146 | "EXE", 147 | "FA", 148 | 'ISC', 149 | "ICN", 150 | "G", 151 | 'INP', 152 | 'PIE', 153 | "T20", 154 | 'PYI', 155 | 'PT', 156 | "Q", 157 | "RSE", 158 | "RET", 159 | "SLF", 160 | "SLOT", 161 | "SIM", 162 | "TID", 163 | "TCH", 164 | "INT", 165 | # "ARG", # Unused function argument 166 | "PTH", 167 | "ERA", 168 | "PD", 169 | "PGH", 170 | "PL", 171 | "TRY", 172 | "FLY", 173 | # "NPY", 174 | # "AIR", 175 | "PERF", 176 | # "FURB", 177 | # "LOG", 178 | "RUF" 179 | ] 180 | ignore = [ 181 | "S101", # Use of assert detected https://docs.astral.sh/ruff/rules/assert/ 182 | "RUF012", # Mutable class attributes should be annotated with `typing.ClassVar` 183 | "SIM102", # sometimes it's better to nest 184 | "COM812", # missing-trailing-comma recommended to remove 185 | "ISC001", # single-line-implicit-string-concatenation recommended to remove 186 | "E501", # line too long 187 | "PD901", # generic names 188 | "ERA001", # commented out code 189 | ] 190 | # Allow fix for all enabled rules (when `--fix`) is provided. 191 | fixable = ["ALL"] 192 | unfixable = [] 193 | # Allow unused variables when underscore-prefixed. 194 | dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" 195 | 196 | [tool.ruff.format] 197 | quote-style = "double" 198 | indent-style = "space" 199 | skip-magic-trailing-comma = false 200 | line-ending = "auto" 201 | 202 | [tool.ruff.lint.isort] 203 | force-single-line = true 204 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | click>=7.0 2 | pyserial 3 | pymodbus<=3.0.2,>=2.3.0 4 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | from setuptools import setup 3 | 4 | setup() 5 | -------------------------------------------------------------------------------- /tests/send_client_requests.py: -------------------------------------------------------------------------------- 1 | import time 2 | import serial 3 | import struct 4 | import sys 5 | 6 | # Example Modbus RTU function codes 7 | READ_COILS = 0x01 8 | READ_HOLDING_REGISTERS = 0x03 9 | 10 | # CRC16 Modbus implementation 11 | def crc16(data: bytes) -> bytes: 12 | crc = 0xFFFF 13 | for pos in data: 14 | crc ^= pos 15 | for _ in range(8): 16 | if (crc & 0x0001) != 0: 17 | crc >>= 1 18 | crc ^= 0xA001 19 | else: 20 | crc >>= 1 21 | return struct.pack(" bytes: 25 | packet = struct.pack("B", slave_addr) + struct.pack("B", function_code) + data 26 | return packet + crc16(packet) 27 | 28 | def main(port='/dev/ttyUSB0', baud=9600): 29 | # Replace with your test port or use a loopback USB adapter (e.g., ttyUSB0 connected to ttyUSB1) 30 | 31 | with serial.Serial(port, baud, timeout=1) as ser: 32 | print(f"Sending fake Modbus RTU responses on {port}...") 33 | 34 | # Send a few fake responses 35 | for i in range(10): 36 | # Simulate a response to READ_COILS: 1 byte of data (e.g., 0b00001101) 37 | data_bytes = struct.pack("B", 1) + struct.pack("B", 0b00001101) 38 | frame = make_response(slave_addr=1, function_code=READ_COILS, data=data_bytes) 39 | 40 | ser.write(frame) 41 | print(f"Sent [{i}]: {frame.hex(' ')}") 42 | 43 | time.sleep(0.5) 44 | 45 | if __name__ == "__main__": 46 | main(*sys.argv[1:]) 47 | -------------------------------------------------------------------------------- /tools/async_twisted_client_serial.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | Pymodbus Asynchronous Client Examples 4 | -------------------------------------------------------------------------- 5 | 6 | The following is an example of how to use the asynchronous serial modbus 7 | client implementation from pymodbus with twisted. 8 | """ 9 | 10 | import logging 11 | import sys 12 | 13 | from pymodbus.client.asynchronous import schedulers 14 | from pymodbus.client.asynchronous.serial import AsyncModbusSerialClient 15 | from pymodbus.client.asynchronous.twisted import ModbusClientProtocol 16 | from twisted.internet import reactor 17 | 18 | logging.basicConfig() 19 | log = logging.getLogger("pymodbus") 20 | log.setLevel(logging.DEBUG) 21 | 22 | # ---------------------------------------------------------------------------# 23 | # state a few constants 24 | # ---------------------------------------------------------------------------# 25 | 26 | STATUS_REGS = (1, 2) 27 | STATUS_COILS = (1, 3) 28 | CLIENT_DELAY = 1 29 | UNIT = 0x03 30 | 31 | 32 | class ExampleProtocol(ModbusClientProtocol): 33 | def __init__(self, framer): 34 | """Initializes our custom protocol 35 | 36 | :param framer: The decoder to use to process messages 37 | :param endpoint: The endpoint to send results to 38 | """ 39 | ModbusClientProtocol.__init__(self, framer) 40 | log.debug("Beginning the processing loop") 41 | reactor.callLater(CLIENT_DELAY, self.fetch_holding_registers) 42 | 43 | def fetch_holding_registers(self): 44 | """Defer fetching holding registers""" 45 | log.debug("Starting the next cycle") 46 | d = self.read_holding_registers(*STATUS_REGS, unit=UNIT) 47 | d.addCallbacks(self.send_holding_registers, self.error_handler) 48 | 49 | def send_holding_registers(self, response): 50 | """Write values of holding registers, defer fetching coils 51 | 52 | :param response: The response to process 53 | """ 54 | log.info(response.getRegister(0)) 55 | log.info(response.getRegister(1)) 56 | d = self.read_coils(*STATUS_COILS, unit=UNIT) 57 | d.addCallbacks(self.start_next_cycle, self.error_handler) 58 | 59 | def start_next_cycle(self, response): 60 | """Write values of coils, trigger next cycle 61 | 62 | :param response: The response to process 63 | """ 64 | log.info(response.getBit(0)) 65 | log.info(response.getBit(1)) 66 | log.info(response.getBit(2)) 67 | reactor.callLater(CLIENT_DELAY, self.fetch_holding_registers) 68 | 69 | def error_handler(self, failure): 70 | """Handle any twisted errors 71 | 72 | :param failure: The error to handle 73 | """ 74 | log.error(failure) 75 | 76 | 77 | if __name__ == "__main__": 78 | proto, client = AsyncModbusSerialClient( 79 | schedulers.REACTOR, 80 | method="rtu", 81 | baudrate=sys.argv[2], 82 | port=sys.argv[1], 83 | timeout=2, 84 | proto_cls=ExampleProtocol, 85 | ) 86 | proto.start() 87 | # proto.stop() 88 | -------------------------------------------------------------------------------- /tools/process_slave_reply_all.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import copy 3 | 4 | # from custom_message import CustomModbusRequest 5 | # import socat_test as socat 6 | import logging 7 | 8 | # import pymodbus 9 | # from pymodbus.transaction import ModbusRtuFramer 10 | # from pymodbus.utilities import hexlify_packets 11 | # from binascii import b2a_hex 12 | import sys 13 | from multiprocessing import Process 14 | from multiprocessing import Queue 15 | from queue import Empty 16 | 17 | import serial 18 | from pymodbus.datastore import ModbusSequentialDataBlock 19 | from pymodbus.datastore import ModbusServerContext 20 | from pymodbus.datastore import ModbusSlaveContext 21 | from pymodbus.server.sync import StartSerialServer 22 | from pymodbus.transaction import ModbusRtuFramer 23 | 24 | from modbus_sniffer import SerialSnooper 25 | 26 | # asynchronous import StartSerialServer 27 | FORMAT = ( 28 | "%(asctime)-15s %(threadName)-15s" 29 | " %(levelname)-8s %(module)-15s:%(lineno)-8s %(message)s" 30 | ) 31 | logging.basicConfig(format=FORMAT) 32 | log = logging.getLogger() 33 | # log.setLevel(logging.DEBUG) 34 | # log.setLevel(logging.WARNING) 35 | log.setLevel(logging.INFO) 36 | 37 | 38 | def run_server(device, baud=9600): 39 | store = ModbusSlaveContext( 40 | di=ModbusSequentialDataBlock(0, [17] * 100), 41 | co=ModbusSequentialDataBlock(0, [17] * 100), 42 | hr=ModbusSequentialDataBlock(0, [25185] * 1024), 43 | ir=ModbusSequentialDataBlock(0, [25185] * 1024), 44 | ) 45 | slaves = { 46 | 1: copy.deepcopy(store), 47 | 2: copy.deepcopy(store), 48 | 3: copy.deepcopy(store), 49 | 4: copy.deepcopy(store), 50 | 5: copy.deepcopy(store), 51 | 6: copy.deepcopy(store), 52 | 246: copy.deepcopy(store), 53 | } 54 | context = ModbusServerContext( 55 | slaves=slaves, 56 | single=False, 57 | ) 58 | 59 | # RTU Server 60 | StartSerialServer( 61 | context, 62 | identity=None, 63 | port=device, 64 | framer=ModbusRtuFramer, 65 | stopbits=1, 66 | bytesize=8, 67 | parity=serial.PARITY_NONE, 68 | baudrate=baud, 69 | ) 70 | 71 | 72 | def read_to_queue(s, q): 73 | while True: 74 | q.put(s.read(1)) 75 | 76 | 77 | if __name__ == "__main__": 78 | baud = 9600 79 | try: 80 | port = sys.argv[1] 81 | except IndexError: 82 | sys.exit(-1) 83 | with contextlib.suppress(IndexError, ValueError): 84 | baud = int(sys.argv[2]) 85 | 86 | server = Process(target=run_server, args=("/tmp/ttyp0", baud)) 87 | # run_server(device=port, baud=baud) 88 | server.start() 89 | try: 90 | master_sniffer = SerialSnooper(port, baud) 91 | slave_sniffer = SerialSnooper("/tmp/ptyp0", baud) 92 | mq = Queue() 93 | sq = Queue() 94 | master_thread = Process( 95 | target=read_to_queue, args=(master_sniffer.connection, mq) 96 | ) 97 | slave_thread = Process( 98 | target=read_to_queue, args=(slave_sniffer.connection, sq) 99 | ) 100 | master_thread.start() 101 | slave_thread.start() 102 | 103 | while True: 104 | master_data = b"" 105 | slave_data = b"" 106 | try: 107 | master_data = mq.get() # read data from usb 108 | slave_sniffer.connection.write(master_data) # connect data to slave 109 | except Empty: 110 | break 111 | 112 | try: 113 | slave_data = sq.get() # read response from slave server 114 | except Empty: 115 | break 116 | master_sniffer.connection.write(slave_data) # send slave response to master 117 | slave_sniffer.process(slave_data) # read slave packet 118 | master_sniffer.process(master_data) # read master packet 119 | finally: 120 | master_sniffer.close() 121 | slave_sniffer.close() 122 | -------------------------------------------------------------------------------- /tools/slave_reply_all.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import copy 3 | 4 | # asynchronous import StartSerialServer 5 | import logging 6 | 7 | # import pymodbus 8 | # from pymodbus.transaction import ModbusRtuFramer 9 | # from pymodbus.utilities import hexlify_packets 10 | # from binascii import b2a_hex 11 | import sys 12 | 13 | import serial 14 | from pymodbus.datastore import ModbusSequentialDataBlock 15 | from pymodbus.datastore import ModbusServerContext 16 | from pymodbus.datastore import ModbusSlaveContext 17 | 18 | # from custom_message import CustomModbusRequest 19 | # import socat_test as socat 20 | from pymodbus.server.sync import StartSerialServer 21 | from pymodbus.transaction import ModbusRtuFramer 22 | 23 | FORMAT = ( 24 | "%(asctime)-15s %(threadName)-15s" 25 | " %(levelname)-8s %(module)-15s:%(lineno)-8s %(message)s" 26 | ) 27 | logging.basicConfig(format=FORMAT) 28 | log = logging.getLogger() 29 | log.setLevel(logging.DEBUG) 30 | # log.setLevel(logging.INFO) 31 | # log.setLevel(logging.WARNING) 32 | 33 | 34 | def write_to_reg(start, arr, string): 35 | i = 0 36 | while i <= len(string) // 2: 37 | try: 38 | reg = ord(string[2 * i]) << 8 39 | reg += ord(string[2 * i + 1]) 40 | except IndexError: 41 | break 42 | finally: 43 | arr[start + i] = reg 44 | assert reg < (1 << 16) 45 | i += 1 46 | return arr 47 | 48 | 49 | version = "MSDS Rev B." 50 | fv = "2.0.1" 51 | input_reg = [0] * 1024 52 | input_reg = write_to_reg(33, input_reg, fv) 53 | input_reg = write_to_reg(1, input_reg, version) 54 | 55 | 56 | def run_server(device, baud=9600): 57 | store = ModbusSlaveContext( 58 | di=ModbusSequentialDataBlock(0, [17] * 100), 59 | co=ModbusSequentialDataBlock(0, [17] * 100), 60 | hr=ModbusSequentialDataBlock(0, [25185] * 1024), 61 | ir=ModbusSequentialDataBlock(0, copy.deepcopy(input_reg)), 62 | ) 63 | slaves = { 64 | 1: copy.deepcopy(store), 65 | 2: copy.deepcopy(store), 66 | 3: copy.deepcopy(store), 67 | 4: copy.deepcopy(store), 68 | 5: copy.deepcopy(store), 69 | 6: copy.deepcopy(store), 70 | 246: copy.deepcopy(store), 71 | } 72 | context = ModbusServerContext( 73 | slaves=slaves, 74 | single=False, 75 | ) 76 | 77 | # RTU Server 78 | StartSerialServer( 79 | context, 80 | identity=None, 81 | port=device, 82 | timeout=3.5 * 10 / baud, 83 | framer=ModbusRtuFramer, 84 | stopbits=1, 85 | bytesize=8, 86 | parity=serial.PARITY_NONE, 87 | baudrate=baud, 88 | ) 89 | 90 | 91 | if __name__ == "__main__": 92 | baud = 9600 93 | try: 94 | port = sys.argv[1] 95 | except IndexError: 96 | sys.exit(-1) 97 | with contextlib.suppress(IndexError, ValueError): 98 | baud = int(sys.argv[2]) 99 | 100 | run_server(device=port, baud=baud) 101 | -------------------------------------------------------------------------------- /tools/slave_reply_all_read_all.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import copy 3 | 4 | # from custom_message import CustomModbusRequest 5 | # import socat_test as socat 6 | import logging 7 | 8 | # import pymodbus 9 | # from pymodbus.transaction import ModbusRtuFramer 10 | # from pymodbus.utilities import hexlify_packets 11 | # from binascii import b2a_hex 12 | import sys 13 | from multiprocessing import Process 14 | 15 | import serial 16 | from pymodbus.datastore import ModbusSequentialDataBlock 17 | from pymodbus.datastore import ModbusServerContext 18 | from pymodbus.datastore import ModbusSlaveContext 19 | from pymodbus.server.sync import StartSerialServer 20 | from pymodbus.transaction import ModbusRtuFramer 21 | 22 | from modbus_sniffer import SerialSnooper 23 | 24 | # asynchronous import StartSerialServer 25 | FORMAT = ( 26 | "%(asctime)-15s %(threadName)-15s" 27 | " %(levelname)-8s %(module)-15s:%(lineno)-8s %(message)s" 28 | ) 29 | logging.basicConfig(format=FORMAT) 30 | log = logging.getLogger() 31 | log.setLevel(logging.DEBUG) 32 | # log.setLevel(logging.INFO) 33 | # log.setLevel(logging.WARNING) 34 | 35 | 36 | def run_server(device, baud=9600): 37 | store = ModbusSlaveContext( 38 | di=ModbusSequentialDataBlock(0, [17] * 100), 39 | co=ModbusSequentialDataBlock(0, [17] * 100), 40 | hr=ModbusSequentialDataBlock(0, [25185] * 1024), 41 | ir=ModbusSequentialDataBlock(0, [25185] * 1024), 42 | ) 43 | slaves = { 44 | 1: copy.deepcopy(store), 45 | 2: copy.deepcopy(store), 46 | 3: copy.deepcopy(store), 47 | 4: copy.deepcopy(store), 48 | 5: copy.deepcopy(store), 49 | 6: copy.deepcopy(store), 50 | 246: copy.deepcopy(store), 51 | } 52 | context = ModbusServerContext( 53 | slaves=slaves, 54 | single=False, 55 | ) 56 | 57 | # RTU Server 58 | StartSerialServer( 59 | context, 60 | identity=None, 61 | port=device, 62 | framer=ModbusRtuFramer, 63 | stopbits=1, 64 | timeout=3.5 * 10 / baud, 65 | bytesize=8, 66 | parity=serial.PARITY_NONE, 67 | baudrate=baud, 68 | ) 69 | 70 | 71 | def read_to_queue(s, q): 72 | while True: 73 | q.put(s.read(1)) 74 | 75 | 76 | if __name__ == "__main__": 77 | baud = 9600 78 | try: 79 | port = sys.argv[1] 80 | except IndexError: 81 | sys.exit(-1) 82 | with contextlib.suppress(IndexError, ValueError): 83 | baud = int(sys.argv[2]) 84 | 85 | server = Process(target=run_server, args=("/tmp/ttyp0", baud)) 86 | server.start() 87 | try: 88 | master_sniffer = SerialSnooper(port, baud) 89 | slave_sniffer = SerialSnooper("/tmp/ptyp0", baud) 90 | while True: 91 | master_data = b"" 92 | slave_data = b"" 93 | 94 | slave_data += slave_sniffer.connection.read( 95 | slave_sniffer.connection.in_waiting 96 | ) # read response from slave server 97 | master_sniffer.connection.write(slave_data) # send slave response to master 98 | 99 | master_data += master_sniffer.connection.read( 100 | master_sniffer.connection.in_waiting 101 | ) # read data from usb 102 | slave_sniffer.connection.write(master_data) # connect data to slave 103 | 104 | slave_sniffer.process(slave_data) # read slave packet 105 | master_sniffer.process(master_data) # read master packet 106 | finally: 107 | master_sniffer.close() 108 | slave_sniffer.close() 109 | -------------------------------------------------------------------------------- /tools/synchronous_client.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | Pymodbus Synchronous Client Examples 4 | -------------------------------------------------------------------------- 5 | 6 | The following is an example of how to use the synchronous modbus client 7 | implementation from pymodbus. 8 | 9 | It should be noted that the client can also be used with 10 | the guard construct that is available in python 2.5 and up:: 11 | 12 | with ModbusClient('127.0.0.1') as client: 13 | result = client.read_coils(1,10) 14 | print result 15 | """ 16 | 17 | # --------------------------------------------------------------------------- # 18 | # import the various server implementations 19 | # --------------------------------------------------------------------------- # 20 | # from pymodbus.client.sync import ModbusTcpClient as ModbusClient 21 | # from pymodbus.client.sync import ModbusUdpClient as ModbusClient 22 | # --------------------------------------------------------------------------- # 23 | # configure the client logging 24 | # --------------------------------------------------------------------------- # 25 | import logging 26 | import sys 27 | 28 | from pymodbus.client.sync import ModbusSerialClient as ModbusClient 29 | 30 | FORMAT = ( 31 | "%(asctime)-15s %(threadName)-15s " 32 | "%(levelname)-8s %(module)-15s:%(lineno)-8s %(message)s" 33 | ) 34 | logging.basicConfig(format=FORMAT) 35 | log = logging.getLogger() 36 | log.setLevel(logging.DEBUG) 37 | 38 | UNIT = 0x2 39 | 40 | 41 | def run_sync_client(): 42 | # ------------------------------------------------------------------------# 43 | # choose the client you want 44 | # ------------------------------------------------------------------------# 45 | # make sure to start an implementation to hit against. For this 46 | # you can use an existing device, the reference implementation in the tools 47 | # directory, or start a pymodbus server. 48 | # 49 | # If you use the UDP or TCP clients, you can override the framer being used 50 | # to use a custom implementation (say RTU over TCP). By default they use 51 | # the socket framer:: 52 | # 53 | # client = ModbusClient('localhost', port=5020, framer=ModbusRtuFramer) 54 | # 55 | # It should be noted that you can supply an ipv4 or an ipv6 host address 56 | # for both the UDP and TCP clients. 57 | # 58 | # There are also other options that can be set on the client that controls 59 | # how transactions are performed. The current ones are: 60 | # 61 | # * retries - Specify how many retries to allow per transaction (default=3) 62 | # * retry_on_empty - Is an empty response a retry (default = False) 63 | # * source_address - Specifies the TCP source address to bind to 64 | # * strict - Applicable only for Modbus RTU clients. 65 | # Adheres to modbus protocol for timing restrictions 66 | # (default = True). 67 | # Setting this to False would disable the inter char timeout 68 | # restriction (t1.5) for Modbus RTU 69 | # 70 | # 71 | # Here is an example of using these options:: 72 | # 73 | # client = ModbusClient('localhost', retries=3, retry_on_empty=True) 74 | # ------------------------------------------------------------------------# 75 | # client = ModbusClient('localhost', port=5020) 76 | # from pymodbus.transaction import ModbusRtuFramer 77 | # client = ModbusClient('localhost', port=5020, framer=ModbusRtuFramer) 78 | # client = ModbusClient(method='binary', port='/dev/ptyp0', timeout=1) 79 | # client = ModbusClient(method='ascii', port='/dev/ptyp0', timeout=1) 80 | client = ModbusClient( 81 | method="rtu", port=sys.argv[1], timeout=1, baudrate=int(sys.argv[2]) 82 | ) 83 | client.connect() 84 | 85 | # ------------------------------------------------------------------------# 86 | # specify slave to query 87 | # ------------------------------------------------------------------------# 88 | # The slave to query is specified in an optional parameter for each 89 | # individual request. This can be done by specifying the `unit` parameter 90 | # which defaults to `0x00` 91 | # ----------------------------------------------------------------------- # 92 | log.debug("Reading Coils") 93 | rr = client.read_coils(1, 1, unit=UNIT) 94 | log.debug(rr) 95 | 96 | # ----------------------------------------------------------------------- # 97 | # example requests 98 | # ----------------------------------------------------------------------- # 99 | # simply call the methods that you would like to use. An example session 100 | # is displayed below along with some assert checks. Note that some modbus 101 | # implementations differentiate holding/input discrete/coils and as such 102 | # you will not be able to write to these, therefore the starting values 103 | # are not known to these tests. Furthermore, some use the same memory 104 | # blocks for the two sets, so a change to one is a change to the other. 105 | # Keep both of these cases in mind when testing as the following will 106 | # _only_ pass with the supplied asynchronous modbus server (script supplied). 107 | # ----------------------------------------------------------------------- # 108 | log.debug("Write to a Coil and read back") 109 | rq = client.write_coil(0, True, unit=UNIT) 110 | rr = client.read_coils(0, 1, unit=UNIT) 111 | assert not rq.isError() # test that we are not an error 112 | assert rr.bits[0] # test the expected value 113 | 114 | log.debug("Write to multiple coils and read back- test 1") 115 | rq = client.write_coils(1, [True] * 8, unit=UNIT) 116 | assert not rq.isError() # test that we are not an error 117 | rr = client.read_coils(1, 21, unit=UNIT) 118 | assert not rr.isError() # test that we are not an error 119 | resp = [True] * 21 120 | 121 | # If the returned output quantity is not a multiple of eight, 122 | # the remaining bits in the final data byte will be padded with zeros 123 | # (toward the high order end of the byte). 124 | 125 | resp.extend([False] * 3) 126 | assert rr.bits == resp # test the expected value 127 | 128 | log.debug("Write to multiple coils and read back - test 2") 129 | rq = client.write_coils(1, [False] * 8, unit=UNIT) 130 | rr = client.read_coils(1, 8, unit=UNIT) 131 | assert not rq.isError() # test that we are not an error 132 | assert rr.bits == [False] * 8 # test the expected value 133 | 134 | log.debug("Read discrete inputs") 135 | rr = client.read_discrete_inputs(0, 8, unit=UNIT) 136 | assert not rq.isError() # test that we are not an error 137 | 138 | value = 10 139 | log.debug("Write to a holding register and read back") 140 | rq = client.write_register(1, value, unit=UNIT) 141 | rr = client.read_holding_registers(1, 1, unit=UNIT) 142 | assert not rq.isError() # test that we are not an error 143 | assert rr.registers[0] == value # test the expected value 144 | 145 | registers = 8 146 | value = [10] * registers 147 | log.debug("Write to multiple holding registers and read back") 148 | rq = client.write_registers(1, value, unit=UNIT) 149 | rr = client.read_holding_registers(1, registers, unit=UNIT) 150 | assert not rq.isError() # test that we are not an error 151 | assert rr.registers == value # test the expected value 152 | 153 | log.debug("Read input registers") 154 | rr = client.read_input_registers(1, registers, unit=UNIT) 155 | assert not rq.isError() # test that we are not an error 156 | 157 | arguments = { 158 | "read_address": 1, 159 | "read_count": 8, 160 | "write_address": 1, 161 | "write_registers": [20] * 8, 162 | } 163 | log.debug("Read write registeres simulataneously") 164 | rq = client.readwrite_registers(unit=UNIT, **arguments) 165 | rr = client.read_holding_registers(1, 8, unit=UNIT) 166 | assert not rq.isError() # test that we are not an error 167 | assert rq.registers == [20] * 8 # test the expected value 168 | assert rr.registers == [20] * 8 # test the expected value 169 | 170 | # ----------------------------------------------------------------------- # 171 | # close the client 172 | # ----------------------------------------------------------------------- # 173 | client.close() 174 | 175 | 176 | if __name__ == "__main__": 177 | run_sync_client() 178 | --------------------------------------------------------------------------------