├── .editorconfig ├── .github ├── ISSUE_TEMPLATE.md └── workflows │ └── ci.yml ├── .gitignore ├── .travis.yml ├── CONTRIBUTING.md ├── Dockerfile ├── HISTORY.md ├── LICENSE ├── MANIFEST.in ├── README.md ├── examples ├── kubernetes │ ├── 00_namespace.yaml │ ├── 10_modbus-proxy.configmap.yaml │ ├── 20_modbus-proxy-deployment.yaml │ └── 30_modbus-proxy-service.yaml ├── log-verbose.conf ├── log-verbose.yml ├── log.conf ├── log.yml ├── modbus-proxy.yaml ├── simple_tcp_client.py └── simple_tcp_server.py ├── pyproject.toml ├── requirements_dev.txt ├── setup.cfg ├── setup.py ├── src └── modbus_proxy.py ├── systemd └── modbus-proxy.service ├── tests ├── __init__.py ├── conftest.py └── test_modbus_proxy.py └── tox.ini /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 4 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | charset = utf-8 11 | end_of_line = lf 12 | 13 | [*.bat] 14 | indent_style = tab 15 | end_of_line = crlf 16 | 17 | [LICENSE] 18 | insert_final_newline = false 19 | 20 | [Makefile] 21 | indent_style = tab 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | * ModBus TCP proxy version: 2 | * Python version: 3 | * Operating System: 4 | 5 | ### Description 6 | 7 | Describe what you were trying to get done. 8 | Tell us what happened, what went wrong, and what you expected to happen. 9 | 10 | ### What I Did 11 | 12 | ``` 13 | Paste the command(s) you ran and the output. 14 | If there was a crash, please include the traceback here. 15 | ``` 16 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | tests: 9 | runs-on: ${{ matrix.os }} 10 | strategy: 11 | matrix: 12 | os: [ubuntu-latest, macos-latest, windows-latest] 13 | python-version: [3.9, "3.10", "3.11", "3.12"] 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v2 17 | - name: Set up Python ${{ matrix.python-version }} 18 | uses: actions/setup-python@v2 19 | with: 20 | python-version: ${{ matrix.python-version }} 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install tox tox-gh-actions 25 | - name: Test with tox 26 | run: tox 27 | - name: Upload coverage 28 | uses: codecov/codecov-action@v1 29 | with: 30 | env_vars: OS,PYTHON 31 | 32 | publish: 33 | runs-on: ubuntu-latest 34 | needs: [tests] 35 | if: startsWith(github.ref, 'refs/tags') 36 | steps: 37 | - name: Checkout 38 | uses: actions/checkout@v2 39 | - name: Set up Python 40 | uses: actions/setup-python@v2 41 | with: 42 | python-version: 3.x 43 | - name: Build source distribution 44 | run: python setup.py sdist 45 | - run: echo "${{ github.ref }}" 46 | - name: Publish package on PyPI 47 | uses: pypa/gh-action-pypi-publish@v1.4.2 48 | with: 49 | user: __token__ 50 | password: ${{ secrets.PYPI_API_TOKEN }} 51 | -------------------------------------------------------------------------------- /.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 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | 58 | # Flask stuff: 59 | instance/ 60 | .webassets-cache 61 | 62 | # Scrapy stuff: 63 | .scrapy 64 | 65 | # Sphinx documentation 66 | docs/_build/ 67 | 68 | # PyBuilder 69 | target/ 70 | 71 | # Jupyter Notebook 72 | .ipynb_checkpoints 73 | 74 | # pyenv 75 | .python-version 76 | 77 | # celery beat schedule file 78 | celerybeat-schedule 79 | 80 | # SageMath parsed files 81 | *.sage.py 82 | 83 | # dotenv 84 | .env 85 | 86 | # virtualenv 87 | .venv 88 | venv/ 89 | ENV/ 90 | 91 | # Spyder project settings 92 | .spyderproject 93 | .spyproject 94 | 95 | # Rope project settings 96 | .ropeproject 97 | 98 | # mkdocs documentation 99 | /site 100 | 101 | # mypy 102 | .mypy_cache/ 103 | 104 | # IDE settings 105 | .vscode/ -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | # Config file for automatic testing at travis-ci.com 2 | 3 | language: python 4 | python: 5 | - 3.8 6 | - 3.7 7 | - 3.6 8 | - 3.5 9 | 10 | # Command to install dependencies, e.g. pip install -r requirements.txt --use-mirrors 11 | install: pip install -U tox-travis 12 | 13 | # Command to run tests, e.g. python setup.py test 14 | script: tox 15 | 16 | 17 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are welcome, and they are greatly appreciated! Every little bit 4 | helps, and credit will always be given. 5 | 6 | You can contribute in many ways: 7 | 8 | ## Types of Contributions 9 | 10 | ### Report Bugs 11 | 12 | Report bugs at https://github.com/tiagocoutinho/modbus-proxy/issues. 13 | 14 | If you are reporting a bug, please include: 15 | 16 | * Your operating system name and version. 17 | * Any details about your local setup that might be helpful in troubleshooting. 18 | * Detailed steps to reproduce the bug. 19 | 20 | ### Fix Bugs 21 | 22 | Look through the GitHub issues for bugs. Anything tagged with "bug" and "help 23 | wanted" is open to whoever wants to implement it. 24 | 25 | ### Implement Features 26 | 27 | Look through the GitHub issues for features. Anything tagged with "enhancement" 28 | and "help wanted" is open to whoever wants to implement it. 29 | 30 | ### Write Documentation 31 | 32 | ModBus TCP proxy could always use more documentation, whether as part of the 33 | official ModBus TCP proxy docs, in docstrings, or even on the web in blog posts, 34 | articles, and such. 35 | 36 | ### Submit Feedback 37 | 38 | The best way to send feedback is to file an issue at https://github.com/tiagocoutinho/modbus-proxy/issues. 39 | 40 | If you are proposing a feature: 41 | 42 | * Explain in detail how it would work. 43 | * Keep the scope as narrow as possible, to make it easier to implement. 44 | * Remember that this is a volunteer-driven project, and that contributions 45 | are welcome :) 46 | 47 | ### Get Started! 48 | 49 | Ready to contribute? Here's how to set up `modbus_proxy` for local development. 50 | 51 | 1. Fork the `modbus_proxy` repo on GitHub. 52 | 2. Clone your fork locally: 53 | ``` 54 | $ git clone git@github.com:your_name_here/modbus_proxy.git 55 | ``` 56 | 3. Install your local copy into a virtualenv. Assuming you have virtualenvwrapper installed, this is how you set up your fork for local development: 57 | ``` 58 | $ mkvirtualenv modbus_proxy 59 | $ cd modbus_proxy/ 60 | $ python setup.py develop 61 | ``` 62 | 4. Create a branch for local development: 63 | ``` 64 | $ git checkout -b name-of-your-bugfix-or-feature 65 | ``` 66 | Now you can make your changes locally. 67 | 68 | 5. When you're done making changes, check that your changes pass flake8 and the 69 | tests, including testing other Python versions with tox: 70 | ``` 71 | $ flake8 modbus_proxy tests 72 | $ python setup.py test or pytest 73 | $ tox 74 | ``` 75 | To get flake8 and tox, just pip install them into your virtualenv. 76 | 77 | 6. Commit your changes and push your branch to GitHub: 78 | ``` 79 | $ git add . 80 | $ git commit -m "Your detailed description of your changes." 81 | $ git push origin name-of-your-bugfix-or-feature 82 | ``` 83 | 84 | 7. Submit a pull request through the GitHub website. 85 | 86 | ## Pull Request Guidelines 87 | 88 | Before you submit a pull request, check that it meets these guidelines: 89 | 90 | 1. The pull request should include tests. 91 | 2. If the pull request adds functionality, the docs should be updated. Put 92 | your new functionality into a function with a docstring, and add the 93 | feature to the list in README.rst. 94 | 3. The pull request should work for Python 3.5, 3.6, 3.7 and 3.8, and for PyPy. Check 95 | https://travis-ci.com/tiagocoutinho/modbus-proxy/pull_requests 96 | and make sure that the tests pass for all supported Python versions. 97 | 98 | ## Tips 99 | 100 | To run a subset of tests: 101 | 102 | ``` 103 | $ pytest tests.test_modbus_proxy 104 | 105 | ``` 106 | 107 | ## Deploying 108 | 109 | A reminder for the maintainers on how to deploy. 110 | Make sure all your changes are committed (including an entry in HISTORY.md). 111 | Then run: 112 | ``` 113 | $ bump2version patch # possible: major / minor / patch 114 | $ git push 115 | $ git push --tags 116 | ``` 117 | Travis will then deploy to PyPI if tests pass. 118 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.12-alpine 2 | 3 | COPY . /src 4 | 5 | # install dependencies to the local user directory 6 | RUN pip --disable-pip-version-check --no-input --no-cache-dir --timeout 3 install src/[yaml] && \ 7 | rm /src -r 8 | 9 | ENTRYPOINT ["modbus-proxy"] 10 | CMD ["-c", "/config/modbus-proxy.yml"] 11 | -------------------------------------------------------------------------------- /HISTORY.md: -------------------------------------------------------------------------------- 1 | ## History 2 | 3 | ### 0.6.1 (2021-09-29) 4 | 5 | * Change default command line `--modbus-connection-time` from 0.1 to 0 6 | * Add basic unit tests 7 | * Github actions 8 | * Repository cleanup 9 | 10 | ### 0.5.0 (2021-09-28) 11 | 12 | * Add support for multiple devices 13 | * Adapt docker to changes 14 | * Deprecate `--log-config-file` command line parameter 15 | 16 | ### 0.4.2 (2021-09-23) 17 | 18 | * Add connection time delay (fixes #4) 19 | 20 | ### 0.4.1 (2021-01-26) 21 | 22 | * Logging improvements 23 | 24 | ### 0.4.0 (2021-01-26) 25 | 26 | * Logging improvements 27 | 28 | ### 0.3.0 (2021-01-25) 29 | 30 | * More robust server (fixes #2) 31 | 32 | ### 0.2.0 (2021-01-23) 33 | 34 | * Document (README) 35 | * Add docker intructions (fixes #1) 36 | * Fix setup dependencies and meta data 37 | 38 | ### 0.1.1 (2020-12-02) 39 | 40 | * Fix project package 41 | 42 | ### 0.1.0 (2020-11-11) 43 | 44 | * First release on PyPI. 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | ModBus TCP proxy 5 | Copyright (C) 2020 Tiago Coutinho 6 | 7 | This program is free software: you can redistribute it and/or modify 8 | it under the terms of the GNU General Public License as published by 9 | the Free Software Foundation, either version 3 of the License, or 10 | (at your option) any later version. 11 | 12 | This program is distributed in the hope that it will be useful, 13 | but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | GNU General Public License for more details. 16 | 17 | You should have received a copy of the GNU General Public License 18 | along with this program. If not, see . 19 | 20 | Also add information on how to contact you by electronic and paper mail. 21 | 22 | You should also get your employer (if you work as a programmer) or school, 23 | if any, to sign a "copyright disclaimer" for the program, if necessary. 24 | For more information on this, and how to apply and follow the GNU GPL, see 25 | . 26 | 27 | The GNU General Public License does not permit incorporating your program 28 | into proprietary programs. If your program is a subroutine library, you 29 | may consider it more useful to permit linking proprietary applications with 30 | the library. If this is what you want to do, use the GNU Lesser General 31 | Public License instead of this License. But first, please read 32 | . 33 | 34 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include CONTRIBUTING.md 2 | include HISTORY.md 3 | include LICENSE 4 | include README.md 5 | 6 | recursive-include tests * 7 | recursive-exclude * __pycache__ 8 | recursive-exclude * *.py[co] 9 | 10 | recursive-include docs *.rst conf.py Makefile make.bat *.jpg *.png *.gif 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ModBus TCP proxy 2 | 3 | [![ModBus proxy][pypi-version]](https://pypi.python.org/pypi/modbus-proxy) 4 | [![Python Versions][pypi-python-versions]](https://pypi.python.org/pypi/modbus-proxy) 5 | [![Pypi status][pypi-status]](https://pypi.python.org/pypi/modbus-proxy) 6 | ![License][license] 7 | [![CI][CI]](https://github.com/tiagocoutinho/modbus-proxy/actions/workflows/ci.yml) 8 | 9 | Many modbus devices support only one or very few clients. This proxy acts as a bridge between the client and the modbus device. It can be seen as a 10 | layer 7 reverse proxy. 11 | This allows multiple clients to communicate with the same modbus device. 12 | 13 | When multiple clients are connected, cross messages are avoided by serializing communication on a first come first served REQ/REP basis. 14 | 15 | ## Installation 16 | 17 | From within your favorite python 3 environment type: 18 | 19 | `$ pip install modbus-proxy` 20 | 21 | Note: On some systems `pip` points to a python 2 installation. 22 | You might need to use `pip3` command instead. 23 | 24 | Additionally, if you want logging configuration: 25 | * YAML: `pip install modbus-proxy[yaml]` (see below) 26 | * TOML: `pip install modbus-proxy[toml]` (see below) 27 | 28 | ## Running the server 29 | 30 | First, you will need write a configuration file where you specify for each modbus device you which to control: 31 | 32 | * modbus connection (the modbus device url) 33 | * listen interface (to which url your clients should connect) 34 | 35 | Configuration files can be written in YAML (*.yml* or *.yaml*) or TOML (*.toml*). 36 | 37 | Suppose you have a PLC modbus device listening on *plc1.acme.org:502* and you want your clients to 38 | connect to your machine on port 9000. A YAML configuration would look like this: 39 | 40 | ```yaml 41 | devices: 42 | - modbus: 43 | url: plc1.acme.org:502 # device url (mandatory) 44 | timeout: 10 # communication timeout (s) (optional, default: 10) 45 | connection_time: 0.1 # delay after connection (s) (optional, default: 0) 46 | listen: 47 | bind: 0:9000 # listening address (mandatory) 48 | unit_id_remapping: # remap/forward unit IDs (optional, empty by default) 49 | 1: 0 50 | ``` 51 | 52 | Assuming you saved this file as `modbus-config.yml`, start the server with: 53 | 54 | ```bash 55 | $ modbus-proxy -c ./modbus-config.yml 56 | ``` 57 | 58 | Now, instead of connecting your client(s) to `plc1.acme.org:502` you just need to 59 | tell them to connect to `*machine*:9000` (where *machine* is the host where 60 | modbus-proxy is running). 61 | 62 | Note that the server is capable of handling multiple modbus devices. Here is a 63 | configuration example for 2 devices: 64 | 65 | ```yaml 66 | devices: 67 | - modbus: 68 | url: plc1.acme.org:502 69 | listen: 70 | bind: 0:9000 71 | - modbus: 72 | url: plc2.acme.org:502 73 | listen: 74 | bind: 0:9001 75 | ``` 76 | 77 | If you have a *single* modbus device, you can avoid writting a configuration file by 78 | providing all arguments in the command line: 79 | 80 | ```bash 81 | modbus-proxy -b tcp://0:9000 --modbus tcp://plc1.acme.org:502 82 | ``` 83 | 84 | (hint: run `modbus-proxy --help` to see all available options) 85 | 86 | ### Forwarding Unit Identifiers 87 | 88 | You can also forward one unit ID to another whilst proxying. This is handy if the target 89 | modbus server has a unit on an index that is not supported by one of your clients. 90 | 91 | ```yaml 92 | devices: 93 | - modbus: ... # see above. 94 | listen: ... # see above. 95 | unit_id_remapping: 96 | 1: 0 97 | ``` 98 | 99 | The above forwards requests to unit ID 1 to your modbus-proxy server to unit ID 0 on the 100 | actual modbus server. 101 | 102 | Note that **the reverse also applies**: if you forward unit ID 1 to unit ID 0, **all** responses coming from unit 0 will look as if they are coming from 1, so this may pose problems if you want to use unit ID 0 for some clients and unit ID 1 for others (use unit ID 1 for all in that case). 103 | 104 | ## Running the examples 105 | 106 | To run the examples you will need to have 107 | [umodbus](https://github.com/AdvancedClimateSystems/uModbus) installed (do it 108 | with `pip install umodbus`). 109 | 110 | Start the `simple_tcp_server.py` (this will simulate an actual modbus hardware): 111 | 112 | ```bash 113 | $ python examples/simple_tcp_server.py -b :5020 114 | ``` 115 | 116 | You can run the example client just to be sure direct communication works: 117 | 118 | ```bash 119 | $ python examples/simple_tcp_client.py -a 0:5020 120 | holding registers: [1, 2, 3, 4] 121 | ``` 122 | 123 | Now for the real test: 124 | 125 | Start a modbus-proxy bridge server with: 126 | 127 | ```bash 128 | $ modbus-proxy -b tcp://:9000 --modbus tcp://:5020 129 | ``` 130 | 131 | Finally run a the example client but now address the proxy instead of the server 132 | (notice we are now using port *9000* and not *5020*): 133 | 134 | ```bash 135 | $ python examples/simple_tcp_client.py -a 0:9000 136 | holding registers: [1, 2, 3, 4] 137 | ``` 138 | ## Running as a Service 139 | 1. move the config file to a location you can remember, for example: to `/usr/lib/mproxy-conf.yaml` 140 | 2. go to `/etc/systemd/system/` 141 | 3. use nano or any other text editor of your choice to create a service file `mproxy.service` 142 | 4. the file should contain the following information: 143 | ``` 144 | [Unit] 145 | Description=Modbus-Proxy 146 | After=network.target 147 | 148 | [Service] 149 | Type=simple 150 | Restart=always 151 | ExecStart = modbus-proxy -c ./usr/lib/mproxy-conf.yaml 152 | 153 | [Install] 154 | WantedBy=multi-user.target 155 | ``` 156 | 5. `run systemctl daemon-reload` 157 | 6. `systemctl enable mproxy.service` 158 | 7. `systemctl start mproxy.service` 159 | 160 | The file names given here are examples, you can choose other names, if you wish. 161 | 162 | ## Docker 163 | 164 | This project ships with a basic [Dockerfile]( 165 | ./Dockerfile) which you can use 166 | as a base to launch modbus-proxy inside a docker container. 167 | 168 | First, build the docker image with: 169 | 170 | ```bash 171 | $ docker build -t modbus-proxy . 172 | ``` 173 | 174 | 175 | To bridge a single modbus device without needing a configuration file is 176 | straight forward: 177 | 178 | ```bash 179 | $ docker run -d -p 5020:502 modbus-proxy -b tcp://0:502 --modbus tcp://plc1.acme.org:502 180 | ``` 181 | 182 | Now you should be able to access your modbus device through the modbus-proxy by 183 | connecting your client(s) to `:5020`. 184 | 185 | If, instead, you want to use a configuration file, you must mount the file so 186 | it is visible by the container. 187 | 188 | Assuming you have prepared a `conf.yml` in the current directory: 189 | 190 | ```yaml 191 | devices: 192 | - modbus: 193 | url: plc1.acme.org:502 194 | listen: 195 | bind: 0:502 196 | ``` 197 | 198 | Here is an example of how to run the container: 199 | 200 | ```bash 201 | docker run -p 5020:502 -v $PWD/conf.yml:/config/modbus-proxy.yml modbus-proxy 202 | ``` 203 | 204 | By default the Dockerfile will run `modbus-proxy -c /config/modbus-proxy.yml` so 205 | if your mounting that volume you don't need to pass any arguments. 206 | 207 | Note that for each modbus device you add in the configuration file you need 208 | to publish the corresponding bind port on the host 209 | (`-p :` argument). 210 | 211 | ## Logging configuration 212 | 213 | Logging configuration can be added to the configuration file by adding a new `logging` keyword. 214 | 215 | The logging configuration will be passed to 216 | [logging.config.dictConfig()](https://docs.python.org/library/logging.config.html#logging.config.dictConfig) 217 | so the file contents must obey the 218 | [Configuration dictionary schema](https://docs.python.org/library/logging.config.html#configuration-dictionary-schema). 219 | 220 | Here is a YAML example: 221 | 222 | ```yaml 223 | devices: 224 | - modbus: 225 | url: plc1.acme.org:502 226 | listen: 227 | bind: 0:9000 228 | logging: 229 | version: 1 230 | formatters: 231 | standard: 232 | format: "%(asctime)s %(levelname)8s %(name)s: %(message)s" 233 | handlers: 234 | console: 235 | class: logging.StreamHandler 236 | formatter: standard 237 | root: 238 | handlers: ['console'] 239 | level: DEBUG 240 | ``` 241 | 242 | ### `--log-config-file` (deprecated) 243 | 244 | Logging configuration file. 245 | 246 | If a relative path is given, it is relative to the current working directory. 247 | 248 | If a `.conf` or `.ini` file is given, it is passed directly to 249 | [logging.config.fileConfig()](https://docs.python.org/library/logging.config.html#logging.config.fileConfig) so the file contents must 250 | obey the 251 | [Configuration file format](https://docs.python.org/library/logging.config.html#configuration-file-format). 252 | 253 | A simple logging configuration (also available at [log.conf](examples/log.conf)) 254 | which mimics the default configuration looks like this: 255 | 256 | ```toml 257 | [formatters] 258 | keys=standard 259 | 260 | [handlers] 261 | keys=console 262 | 263 | [loggers] 264 | keys=root 265 | 266 | [formatter_standard] 267 | format=%(asctime)s %(levelname)8s %(name)s: %(message)s 268 | 269 | [handler_console] 270 | class=StreamHandler 271 | formatter=standard 272 | 273 | [logger_root] 274 | level=INFO 275 | handlers=console 276 | ``` 277 | 278 | A more verbose example logging with a rotating file handler: 279 | [log-verbose.conf](examples/log-verbose.conf) 280 | 281 | The same example above (also available at [log.yml](examples/log.yml)) can be achieved in YAML with: 282 | 283 | ```yaml 284 | version: 1 285 | formatters: 286 | standard: 287 | format: "%(asctime)s %(levelname)8s %(name)s: %(message)s" 288 | handlers: 289 | console: 290 | class: logging.StreamHandler 291 | formatter: standard 292 | root: 293 | handlers: ['console'] 294 | level: DEBUG 295 | ``` 296 | 297 | ## Kubernetes 298 | 299 | Based on the existing [Dockerfile](Dockerfile) an example Kubernetes manifest has been created and is located at [examples/kubernetes](examples/kubernetes). 300 | 301 | * `ConfigMap` for modbus-proxy config: [`examples/kubernetes/10_modbus-proxy.configmap.yaml`](examples/kubernetes/10_modbus-proxy.configmap.yaml) 302 | 303 | After the adjustment of the configmap, the manifest could get applied to your Kubernetes Cluster: 304 | ``` 305 | # adjust config 306 | vim examples/kubernetes/10_modbus-proxy.configmap.yaml 307 | 308 | # apply to cluster 309 | kubectl apply -f examples/kubernetes 310 | ``` 311 | 312 | ## Installing modbus-proxy on Raspberry PI VENV (virtual environment) 313 | 314 | Create subfolder for all virtual environments and activate venv 'modbusproxy' 315 | ```bash 316 | sudo mkdir python 317 | cd python 318 | python3 -m venv modbusproxy 319 | source modbusproxy/bin/activate 320 | ``` 321 | It should look like this now: 322 | ```bash 323 | (modbusproxy) pi@raspberrypi:~/python $ 324 | ``` 325 | 326 | Install modbus-proxy and create config yaml 327 | ```bash 328 | pip install modbus-proxy[yaml] 329 | sudo nano modbus-config.yml 330 | ``` 331 | 332 | Update config file 333 | ```yaml 334 | devices: 335 | # Daheimlader 336 | - modbus: 337 | url: 192.168.###.###:502 338 | listen: 339 | bind: 0:5001 340 | # SolarEdge 341 | - modbus: 342 | url: 192.168.###.###:1502 343 | listen: 344 | bind: 0:5002 345 | ``` 346 | Create autostart in systemd 347 | 348 | ```bash 349 | cd /etc/systemd/system/ 350 | sudo nano mproxy.service 351 | ``` 352 | 353 | ``` 354 | [Unit] 355 | Description=Modbus-Proxy 356 | After=network.target 357 | 358 | [Service] 359 | Type=simple 360 | Restart=always 361 | Environment="PATH=/home/pi/python/modbusproxy/bin" 362 | ExecStart= /home/pi/python/modbusproxy/bin/modbus-proxy -c /home/pi/python/modbus-config.yml 363 | user=pi 364 | 365 | [Install] 366 | WantedBy=multi-user.target 367 | ``` 368 | ```bash 369 | sudo systemctl daemon-reload 370 | sudo systemctl enable mproxy.service 371 | sudo systemctl start mproxy.service 372 | ``` 373 | 374 | ## Credits 375 | 376 | ### Development Lead 377 | 378 | * Tiago Coutinho 379 | 380 | ### Contributors 381 | 382 | None yet. Why not be the first? 383 | 384 | [pypi-python-versions]: https://img.shields.io/pypi/pyversions/modbus-proxy.svg 385 | [pypi-version]: https://img.shields.io/pypi/v/modbus-proxy.svg 386 | [pypi-status]: https://img.shields.io/pypi/status/modbus-proxy.svg 387 | [license]: https://img.shields.io/pypi/l/modbus-proxy.svg 388 | [CI]: https://github.com/tiagocoutinho/modbus-proxy/actions/workflows/ci.yml/badge.svg 389 | -------------------------------------------------------------------------------- /examples/kubernetes/00_namespace.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Namespace 3 | metadata: 4 | name: modbus-proxy -------------------------------------------------------------------------------- /examples/kubernetes/10_modbus-proxy.configmap.yaml: -------------------------------------------------------------------------------- 1 | # Sample configuration file for modbus-proxy 2 | # You can configure multiple proxies for multiple modbus devices. 3 | # The first proxy configuration provides description for all supported options. 4 | # Pass this file location as an --config-file option argument, e.g.: 5 | # $ modbus-proxy --config-file /etc/modbus-proxy.yaml 6 | # See also: systemd modbus-proxy.service example how to run it 7 | # as a systemd service. 8 | # 9 | apiVersion: v1 10 | kind: ConfigMap 11 | metadata: 12 | labels: 13 | app: modbus-proxy 14 | name: modbus-proxy-config 15 | namespace: modbus-proxy 16 | data: 17 | #Example config 18 | config.yaml: | 19 | devices: 20 | - modbus: # First proxy configuration 21 | url: modbus.host:502 # modbus connection (the modbus device url) 22 | timeout: 10 # communication timeout [s] (optional, default: 10) 23 | connection_time: 1 # delay after connection [s] (optional, default: 0) 24 | listen: # listen interface 25 | bind: 0:30502 # listening address (mandatory) [IP:port] 26 | # (to which url your clients should connect) 27 | -------------------------------------------------------------------------------- /examples/kubernetes/20_modbus-proxy-deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | labels: 5 | app: modbus-proxy 6 | annotations: 7 | # Optional: trigger config reloader: https://github.com/stakater/Reloader 8 | reloader.stakater.com/auto: "true" 9 | name: modbus-proxy 10 | namespace: modbus-proxy 11 | spec: 12 | replicas: 1 13 | selector: 14 | matchLabels: 15 | app: modbus-proxy 16 | strategy: 17 | type: Recreate 18 | template: 19 | metadata: 20 | labels: 21 | app: modbus-proxy 22 | spec: 23 | terminationGracePeriodSeconds: 1 24 | containers: 25 | - args: 26 | - -c 27 | - /config.yaml 28 | image: quay.io/toschneck/modbus-proxy:2024-09-21 29 | name: modbus-proxy 30 | ports: 31 | - name: modbus-outgoing 32 | containerPort: 30502 33 | protocol: TCP 34 | volumeMounts: 35 | - mountPath: /config.yaml 36 | name: modbus-proxy-config 37 | subPath: config.yaml 38 | restartPolicy: Always 39 | volumes: 40 | - configMap: 41 | name: modbus-proxy-config 42 | name: modbus-proxy-config 43 | -------------------------------------------------------------------------------- /examples/kubernetes/30_modbus-proxy-service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | labels: 5 | app: modbus-proxy 6 | name: modbus-proxy 7 | namespace: modbus-proxy 8 | spec: 9 | type: ClusterIP 10 | ports: 11 | ### modbus proxy to mirror traffic from e.g. Huawei pv 12 | - name: modbusproxy 13 | port: 502 14 | protocol: TCP 15 | targetPort: 30502 16 | #nodePort: 30502 17 | selector: 18 | app: modbus-proxy 19 | -------------------------------------------------------------------------------- /examples/log-verbose.conf: -------------------------------------------------------------------------------- 1 | [formatters] 2 | keys=standard 3 | 4 | [handlers] 5 | keys=console,file 6 | 7 | [loggers] 8 | keys=root 9 | 10 | [formatter_standard] 11 | format=%(asctime)s %(levelname)8s %(name)s: %(message)s 12 | 13 | [handler_file] 14 | class=logging.handlers.RotatingFileHandler 15 | level=DEBUG 16 | formatter=standard 17 | kwargs={'filename': 'modbus-proxy.log', 'maxBytes': 10000000, 'backupCount': 10} 18 | 19 | [handler_console] 20 | class=StreamHandler 21 | level=INFO 22 | formatter=standard 23 | 24 | [logger_root] 25 | level=NOTSET 26 | handlers=console,file 27 | -------------------------------------------------------------------------------- /examples/log-verbose.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | formatters: 3 | standard: 4 | format: "%(asctime)s %(levelname)8s %(name)s: %(message)s" 5 | handlers: 6 | console: 7 | class: logging.StreamHandler 8 | formatter: standard 9 | level: INFO 10 | file: 11 | class: logging.handlers.RotatingFileHandler 12 | formatter: standard 13 | filename: modbus-proxy.log 14 | maxBytes: 10000000 15 | backupCount: 10 16 | level: DEBUG 17 | root: 18 | handlers: ['console', 'file'] 19 | level: NOTSET 20 | -------------------------------------------------------------------------------- /examples/log.conf: -------------------------------------------------------------------------------- 1 | [formatters] 2 | keys=standard 3 | 4 | [handlers] 5 | keys=console 6 | 7 | [loggers] 8 | keys=root 9 | 10 | [formatter_standard] 11 | format=%(asctime)s %(levelname)8s %(name)s: %(message)s 12 | 13 | [handler_console] 14 | class=StreamHandler 15 | formatter=standard 16 | 17 | [logger_root] 18 | level=INFO 19 | handlers=console 20 | -------------------------------------------------------------------------------- /examples/log.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | formatters: 3 | standard: 4 | format: "%(asctime)s %(levelname)8s %(name)s: %(message)s" 5 | handlers: 6 | console: 7 | class: logging.StreamHandler 8 | formatter: standard 9 | root: 10 | handlers: ['console'] 11 | level: DEBUG 12 | -------------------------------------------------------------------------------- /examples/modbus-proxy.yaml: -------------------------------------------------------------------------------- 1 | # Sample configuration file for modbus-proxy 2 | # 3 | # You can configure multiple proxies for multiple modbus devices. 4 | # 5 | # The first proxy configuration provides description for all supported options. 6 | # 7 | # Pass this file location as an --config-file option argument, e.g.: 8 | # $ modbus-proxy --config-file /etc/modbus-proxy.yaml 9 | # 10 | # See also: systemd modbus-proxy.service example how to run it 11 | # as a systemd service. 12 | # 13 | devices: 14 | - modbus: # First proxy configuration 15 | url: plc1.acme.org:502 # modbus connection (the modbus device url) 16 | timeout: 10 # communication timeout [s] (optional, default: 10) 17 | connection_time: 0.1 # delay after connection [s] (optional, default: 0) 18 | listen: # listen interface 19 | # (to which url your clients should connect) 20 | bind: 0:9000 # listening address (mandatory) [IP:port] 21 | unit_id_remapping: # remap/forward unit IDs (optional, empty by default) 22 | 1: 0 # forwards requests to unit ID 1 to your modbus-proxy 23 | # server to unit ID 0 on the actual modbus server. 24 | # Note, that the reverse also applies: if you forward 25 | # unit ID 1 to unit ID 0, all responses coming from 26 | # unit 0 will look as if they are coming from 1, 27 | # so this may pose problems if you want to use 28 | # unit ID 0 for some clients and unit ID 1 for others. 29 | 30 | - modbus: # Second proxy configuration 31 | url: 192.168.1.2:502 32 | timeout: 12 33 | connection_time: 1.0 34 | listen: 35 | bind: 0:5503 36 | 37 | - modbus: # Third proxy configuration 38 | url: example.com:502 39 | listen: 40 | bind: 0:5504 41 | -------------------------------------------------------------------------------- /examples/simple_tcp_client.py: -------------------------------------------------------------------------------- 1 | from argparse import ArgumentParser 2 | from socket import create_connection 3 | 4 | from umodbus.client import tcp 5 | 6 | parser = ArgumentParser() 7 | parser.add_argument("-a", "--address") 8 | args = parser.parse_args() 9 | host, port = args.address.rsplit(":", 1) 10 | port = int(port) 11 | 12 | values = [1, 2, 3, 4] 13 | 14 | with create_connection((host, port)) as sock: 15 | message = tcp.write_multiple_registers( 16 | slave_id=1, starting_address=1, values=values 17 | ) 18 | response = tcp.send_message(message, sock) 19 | assert response == 4 20 | 21 | message = tcp.read_holding_registers( 22 | slave_id=1, starting_address=1, quantity=len(values) 23 | ) 24 | response = tcp.send_message(message, sock) 25 | assert response == values 26 | print("holding registers:", response) 27 | -------------------------------------------------------------------------------- /examples/simple_tcp_server.py: -------------------------------------------------------------------------------- 1 | from socketserver import TCPServer 2 | from collections import defaultdict 3 | from argparse import ArgumentParser 4 | 5 | from umodbus.server.tcp import RequestHandler, get_server 6 | 7 | # A very simple data store which maps addresses against their values. 8 | data_store = defaultdict(int) 9 | 10 | # Parse command line arguments 11 | parser = ArgumentParser() 12 | parser.add_argument("-b", "--bind") 13 | args = parser.parse_args() 14 | host, port = args.bind.rsplit(":", 1) 15 | port = int(port) 16 | 17 | TCPServer.allow_reuse_address = True 18 | app = get_server(TCPServer, (host, port), RequestHandler) 19 | 20 | 21 | @app.route(slave_ids=[1], function_codes=[3, 4], addresses=list(range(0, 10))) 22 | def read_data_store(slave_id, function_code, address): 23 | """" Return value of address. """ 24 | return data_store[address] 25 | 26 | 27 | @app.route(slave_ids=[1], function_codes=[6, 16], addresses=list(range(0, 10))) 28 | def write_data_store(slave_id, function_code, address, value): 29 | """" Set value for address. """ 30 | data_store[address] = value 31 | 32 | try: 33 | app.serve_forever() 34 | finally: 35 | app.shutdown() 36 | app.server_close() 37 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=51.0", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [tool.pytest.ini_options] 6 | minversion = "6.0" 7 | addopts = [ 8 | "--cov=modbus_proxy", 9 | "--cov-report=html", "--cov-report=term", 10 | "--durations=2", "--verbose" 11 | ] 12 | testpaths = ["tests"] 13 | 14 | [tool.black] 15 | # just signal the project uses black for editors like emacs python-black 16 | -------------------------------------------------------------------------------- /requirements_dev.txt: -------------------------------------------------------------------------------- 1 | bumpversion>=0.6.0 2 | pytest>=7.4.3 3 | pytest-asyncio>=0.23.5 4 | pytest-cov>=4.0.0 5 | tox>=4.16.0 6 | flake8>=3.9.2 7 | toml>=0.10.2 8 | pyyaml>=6.0.1 9 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 0.8.0 3 | commit = True 4 | tag = True 5 | 6 | [metadata] 7 | name = modbus-proxy 8 | version = attr: modbus_proxy.__version__ 9 | author = Tiago Coutinho 10 | author_email = coutinhotiago@gmail.com 11 | license = GNU General Public License v3 12 | license_file = LICENSE 13 | description = ModBus TCP proxy 14 | long_description = file: README.md, HISTORY.md 15 | long_description_content_type = text/markdown 16 | keywords = modbus, tcp, proxy 17 | url = https://github.com/tiagocoutinho/modbus-proxy 18 | classifiers = 19 | Development Status :: 4 - Beta 20 | Intended Audience :: Developers 21 | Intended Audience :: Manufacturing 22 | Intended Audience :: Science/Research 23 | License :: OSI Approved :: GNU General Public License v3 (GPLv3) 24 | Natural Language :: English 25 | Programming Language :: Python :: 3 26 | Programming Language :: Python :: 3 :: Only 27 | Programming Language :: Python :: 3.9 28 | Programming Language :: Python :: 3.10 29 | Programming Language :: Python :: 3.11 30 | Programming Language :: Python :: 3.12 31 | 32 | [options] 33 | py_modules = modbus_proxy 34 | package_dir = 35 | =src 36 | zip_safe = true 37 | python_requires = >=3.9 38 | tests_require = pytest >=3 39 | 40 | [options.entry_points] 41 | console_scripts = 42 | modbus-proxy = modbus_proxy:main 43 | 44 | [options.extras_require] 45 | yaml = PyYAML 46 | toml = toml 47 | test = 48 | pytest>=6 49 | pytest-cov>=2 50 | pytest-asyncio>=0.15 51 | flake8>=3.9 52 | tox>=3.24 53 | 54 | [bdist_wheel] 55 | universal = 1 56 | 57 | [aliases] 58 | test = pytest 59 | 60 | [bumpversion:file:src/modbus_proxy.py] 61 | search = __version__ = "{current_version}" 62 | replace = __version__ = "{new_version}" 63 | 64 | [flake8] 65 | max-line-length = 88 66 | extend-ignore = E203 67 | 68 | [tox:tox] 69 | envlist = py3 70 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # This file is part of the modbus-proxy project 4 | # 5 | # Copyright (c) 2020-2021 Tiago Coutinho 6 | # Distributed under the GPLv3 license. See LICENSE for more info. 7 | 8 | """The setup script.""" 9 | 10 | from setuptools import setup 11 | 12 | if __name__ == "__main__": 13 | setup() 14 | -------------------------------------------------------------------------------- /src/modbus_proxy.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # This file is part of the modbus-proxy project 4 | # 5 | # Copyright (c) 2020-2021 Tiago Coutinho 6 | # Distributed under the GPLv3 license. See LICENSE for more info. 7 | 8 | 9 | import asyncio 10 | import pathlib 11 | import argparse 12 | import warnings 13 | import contextlib 14 | import logging.config 15 | from urllib.parse import urlparse 16 | 17 | __version__ = "0.8.0" 18 | 19 | 20 | DEFAULT_LOG_CONFIG = { 21 | "version": 1, 22 | "formatters": { 23 | "standard": {"format": "%(asctime)s %(levelname)8s %(name)s: %(message)s"} 24 | }, 25 | "handlers": { 26 | "console": {"class": "logging.StreamHandler", "formatter": "standard"} 27 | }, 28 | "root": {"handlers": ["console"], "level": "INFO"}, 29 | } 30 | 31 | log = logging.getLogger("modbus-proxy") 32 | 33 | 34 | def parse_url(url): 35 | if "://" not in url: 36 | url = f"tcp://{url}" 37 | result = urlparse(url) 38 | if not result.hostname: 39 | url = result.geturl().replace("://", "://0") 40 | result = urlparse(url) 41 | return result 42 | 43 | 44 | class Connection: 45 | def __init__(self, name, reader, writer): 46 | self.name = name 47 | self.reader = reader 48 | self.writer = writer 49 | self.log = log.getChild(name) 50 | 51 | async def __aenter__(self): 52 | return self 53 | 54 | async def __aexit__(self, exc_type, exc_value, tb): 55 | await self.close() 56 | 57 | @property 58 | def opened(self): 59 | return ( 60 | self.writer is not None 61 | and not self.writer.is_closing() 62 | and not self.reader.at_eof() 63 | ) 64 | 65 | async def close(self): 66 | if self.writer is not None: 67 | self.log.info("closing connection...") 68 | try: 69 | self.writer.close() 70 | await self.writer.wait_closed() 71 | except Exception as error: 72 | self.log.info("failed to close: %r", error) 73 | else: 74 | self.log.info("connection closed") 75 | finally: 76 | self.reader = None 77 | self.writer = None 78 | 79 | async def _write(self, data): 80 | self.log.debug("sending %r", data) 81 | self.writer.write(data) 82 | await self.writer.drain() 83 | 84 | async def write(self, data): 85 | try: 86 | await self._write(data) 87 | except Exception as error: 88 | self.log.error("writting error: %r", error) 89 | await self.close() 90 | return False 91 | return True 92 | 93 | async def _read(self): 94 | """Read ModBus TCP message""" 95 | # TODO: Handle Modbus RTU and ASCII 96 | header = await self.reader.readexactly(6) 97 | size = int.from_bytes(header[4:], "big") 98 | reply = header + await self.reader.readexactly(size) 99 | self.log.debug("received %r", reply) 100 | return reply 101 | 102 | async def read(self): 103 | try: 104 | return await self._read() 105 | except asyncio.IncompleteReadError as error: 106 | if error.partial: 107 | self.log.error("reading error: %r", error) 108 | else: 109 | self.log.info("client closed connection") 110 | await self.close() 111 | except Exception as error: 112 | self.log.error("reading error: %r", error) 113 | await self.close() 114 | 115 | 116 | class Client(Connection): 117 | def __init__(self, reader, writer): 118 | peer = writer.get_extra_info("peername") 119 | super().__init__(f"Client({peer[0]}:{peer[1]})", reader, writer) 120 | self.log.info("new client connection") 121 | 122 | 123 | class ModBus(Connection): 124 | def __init__(self, config): 125 | modbus = config["modbus"] 126 | url = parse_url(modbus["url"]) 127 | bind = parse_url(config["listen"]["bind"]) 128 | super().__init__(f"ModBus({url.hostname}:{url.port})", None, None) 129 | self.host = bind.hostname 130 | self.port = 502 if bind.port is None else bind.port 131 | self.modbus_host = url.hostname 132 | self.modbus_port = url.port 133 | self.timeout = modbus.get("timeout", None) 134 | self.connection_time = modbus.get("connection_time", 0) 135 | self.unit_id_remapping = config.get("unit_id_remapping") or {} 136 | self.server = None 137 | self.lock = asyncio.Lock() 138 | 139 | @property 140 | def address(self): 141 | if self.server is not None: 142 | return self.server.sockets[0].getsockname() 143 | 144 | async def open(self): 145 | self.log.info("connecting to modbus...") 146 | self.reader, self.writer = await asyncio.open_connection( 147 | self.modbus_host, self.modbus_port 148 | ) 149 | self.log.info("connected!") 150 | 151 | async def connect(self): 152 | if not self.opened: 153 | await asyncio.wait_for(self.open(), self.timeout) 154 | if self.connection_time > 0: 155 | self.log.info("delay after connect: %s", self.connection_time) 156 | await asyncio.sleep(self.connection_time) 157 | 158 | async def write_read(self, data, attempts=2): 159 | async with self.lock: 160 | for i in range(attempts): 161 | try: 162 | await self.connect() 163 | coro = self._write_read(data) 164 | return await asyncio.wait_for(coro, self.timeout) 165 | except Exception as error: 166 | self.log.error( 167 | "write_read error [%s/%s]: %r", i + 1, attempts, error 168 | ) 169 | await self.close() 170 | 171 | async def _write_read(self, data): 172 | await self._write(data) 173 | return await self._read() 174 | 175 | def _transform_request(self, request): 176 | uid = request[6] 177 | new_uid = self.unit_id_remapping.setdefault(uid, uid) 178 | if uid != new_uid: 179 | request = bytearray(request) 180 | request[6] = new_uid 181 | self.log.debug("remapping unit ID %s to %s in request", uid, new_uid) 182 | return request 183 | 184 | def _transform_reply(self, reply): 185 | uid = reply[6] 186 | inverse_unit_id_map = {v: k for k, v in self.unit_id_remapping.items()} 187 | new_uid = inverse_unit_id_map.setdefault(uid, uid) 188 | if uid != new_uid: 189 | reply = bytearray(reply) 190 | reply[6] = new_uid 191 | self.log.debug("remapping unit ID %s to %s in reply", uid, new_uid) 192 | return reply 193 | 194 | async def handle_client(self, reader, writer): 195 | async with Client(reader, writer) as client: 196 | while True: 197 | request = await client.read() 198 | if not request: 199 | break 200 | reply = await self.write_read(self._transform_request(request)) 201 | if not reply: 202 | break 203 | result = await client.write(self._transform_reply(reply)) 204 | if not result: 205 | break 206 | 207 | async def start(self): 208 | self.server = await asyncio.start_server( 209 | self.handle_client, self.host, self.port, start_serving=True 210 | ) 211 | 212 | async def stop(self): 213 | if self.server is not None: 214 | self.server.close() 215 | await self.server.wait_closed() 216 | await self.close() 217 | 218 | async def serve_forever(self): 219 | if self.server is None: 220 | await self.start() 221 | async with self.server: 222 | self.log.info("Ready to accept requests on %s:%d", self.host, self.port) 223 | await self.server.serve_forever() 224 | 225 | 226 | def load_config(file_name): 227 | file_name = pathlib.Path(file_name) 228 | ext = file_name.suffix 229 | if ext.endswith("toml"): 230 | from toml import load 231 | elif ext.endswith("yml") or ext.endswith("yaml"): 232 | import yaml 233 | 234 | def load(fobj): 235 | return yaml.load(fobj, Loader=yaml.Loader) 236 | 237 | elif ext.endswith("json"): 238 | from json import load 239 | else: 240 | raise NotImplementedError 241 | with open(file_name) as fobj: 242 | return load(fobj) 243 | 244 | 245 | def prepare_log(config): 246 | cfg = config.get("logging") 247 | if not cfg: 248 | cfg = DEFAULT_LOG_CONFIG 249 | if cfg: 250 | cfg.setdefault("version", 1) 251 | cfg.setdefault("disable_existing_loggers", False) 252 | logging.config.dictConfig(cfg) 253 | warnings.simplefilter("always", DeprecationWarning) 254 | logging.captureWarnings(True) 255 | return log 256 | 257 | 258 | def parse_args(args=None): 259 | parser = argparse.ArgumentParser( 260 | description="ModBus proxy", 261 | formatter_class=argparse.ArgumentDefaultsHelpFormatter, 262 | ) 263 | parser.add_argument( 264 | "-c", "--config-file", default=None, type=str, help="config file" 265 | ) 266 | parser.add_argument("-b", "--bind", default=None, type=str, help="listen address") 267 | parser.add_argument( 268 | "--modbus", 269 | default=None, 270 | type=str, 271 | help="modbus device address (ex: tcp://plc.acme.org:502)", 272 | ) 273 | parser.add_argument( 274 | "--modbus-connection-time", 275 | type=float, 276 | default=0, 277 | help="delay after establishing connection with modbus before first request", 278 | ) 279 | parser.add_argument( 280 | "--timeout", 281 | type=float, 282 | default=10, 283 | help="modbus connection and request timeout in seconds", 284 | ) 285 | options = parser.parse_args(args=args) 286 | 287 | if not options.config_file and not options.modbus: 288 | parser.exit(1, "must give a config-file or/and a --modbus") 289 | return options 290 | 291 | 292 | def create_config(args): 293 | if args.config_file is None: 294 | assert args.modbus 295 | config = load_config(args.config_file) if args.config_file else {} 296 | prepare_log(config) 297 | log.info("Starting...") 298 | devices = config.setdefault("devices", []) 299 | if args.modbus: 300 | listen = {"bind": ":502" if args.bind is None else args.bind} 301 | devices.append( 302 | { 303 | "modbus": { 304 | "url": args.modbus, 305 | "timeout": args.timeout, 306 | "connection_time": args.modbus_connection_time, 307 | }, 308 | "listen": listen, 309 | } 310 | ) 311 | return config 312 | 313 | 314 | def create_bridges(config): 315 | return [ModBus(cfg) for cfg in config["devices"]] 316 | 317 | 318 | async def start_bridges(bridges): 319 | coros = [bridge.start() for bridge in bridges] 320 | await asyncio.gather(*coros) 321 | 322 | 323 | async def run_bridges(bridges, ready=None): 324 | async with contextlib.AsyncExitStack() as stack: 325 | coros = [stack.enter_async_context(bridge) for bridge in bridges] 326 | await asyncio.gather(*coros) 327 | await start_bridges(bridges) 328 | if ready is not None: 329 | ready.set(bridges) 330 | coros = [bridge.serve_forever() for bridge in bridges] 331 | await asyncio.gather(*coros) 332 | 333 | 334 | async def run(args=None, ready=None): 335 | args = parse_args(args) 336 | config = create_config(args) 337 | bridges = create_bridges(config) 338 | await run_bridges(bridges, ready=ready) 339 | 340 | 341 | def main(): 342 | try: 343 | asyncio.run(run()) 344 | except KeyboardInterrupt: 345 | log.warning("Ctrl-C pressed. Bailing out!") 346 | 347 | 348 | if __name__ == "__main__": 349 | main() 350 | -------------------------------------------------------------------------------- /systemd/modbus-proxy.service: -------------------------------------------------------------------------------- 1 | # This file is part of modbus-proxy. 2 | # 3 | # Copyright 2022 Damian Wrobel 4 | # 5 | # modbus-proxy is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # modbus-proxy is distributed in the hope that it will be useful, but 11 | # WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 13 | # General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with modbus-proxy. If not, see . 17 | 18 | [Unit] 19 | Description=ModBus TCP proxy 20 | Documentation=https://github.com/tiagocoutinho/modbus-proxy 21 | After=network.target 22 | ConditionPathExists=/etc/modbus-proxy.yaml 23 | 24 | [Service] 25 | Restart=on-failure 26 | ExecStart=modbus-proxy --config-file /etc/modbus-proxy.yaml 27 | 28 | [Install] 29 | WantedBy=multi-user.target 30 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Unit test package for modbus_proxy.""" 2 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import pytest_asyncio 4 | 5 | from modbus_proxy import ModBus 6 | 7 | # read_holding_registers(unit=1, start=1, size=4) 8 | # |tid | tcp | size |uni|cod|s .addr|nb. reg| 9 | REQ = b"m\xf5\x00\x00\x00\x06\x01\x03\x00\x01\x00\x04" 10 | # |tid | tcp | size |uni|cod|len| 1 | 2 | 3 | 4 | 11 | REP = b"m\xf5\x00\x00\x00\x0b\x01\x03\x08\x00\x01\x00\x02\x00\x03\x00\x04" 12 | 13 | 14 | # read_holding_registers(unit=1, start=2, size=3) 15 | # |tid | tcp | size |uni|cod|s .addr|nb. reg| 16 | REQ2 = b"m\xf5\x00\x00\x00\x06\x01\x03\x00\x02\x00\x03" 17 | # |tid | tcp | size |uni|cod|len| 2 | 3 | 4 | 18 | REP2 = b"m\xf5\x00\x00\x00\x09\x01\x03\x08\x00\x02\x00\x03\x00\x04" 19 | 20 | 21 | # read_holding_registers(unit=1, start=2, size=3) 22 | # |tid | tcp | size |uni|cod|s .addr|nb. reg| 23 | REQ3_ORIGINAL = b"m\xf5\x00\x00\x00\x06\xFF\x03\x00\x02\x00\x03" 24 | REQ3_MODIFIED = b"m\xf5\x00\x00\x00\x06\xFE\x03\x00\x02\x00\x03" 25 | # |tid | tcp | size |uni|cod|len| 2 | 3 | 4 | 26 | REP3_ORIGINAL = b"m\xf5\x00\x00\x00\x09\xFE\x03\x08\x00\x02\x00\x03\x00\x04" 27 | REP3_MODIFIED = b"m\xf5\x00\x00\x00\x09\xFF\x03\x08\x00\x02\x00\x03\x00\x04" 28 | 29 | 30 | @pytest_asyncio.fixture 31 | async def modbus_device(): 32 | async def cb(r, w): 33 | while True: 34 | data = await r.readexactly(6) 35 | size = int.from_bytes(data[4:6], "big") 36 | data += await r.readexactly(size) 37 | if data == REQ: 38 | reply = REP 39 | elif data == REQ2: 40 | reply = REP2 41 | elif data == REQ3_MODIFIED: 42 | reply = REP3_ORIGINAL 43 | 44 | w.write(reply) 45 | await w.drain() 46 | 47 | try: 48 | server = await asyncio.start_server(cb, host="127.0.0.1") 49 | server.address = server.sockets[0].getsockname() 50 | yield server 51 | finally: 52 | server.close() 53 | await server.wait_closed() 54 | 55 | 56 | def modbus_config(modbus_device): 57 | return { 58 | "modbus": {"url": "{}:{}".format(*modbus_device.address)}, 59 | "listen": {"bind": "127.0.0.1:0"}, 60 | "unit_id_remapping": {255: 254}, 61 | } 62 | 63 | 64 | @pytest_asyncio.fixture 65 | async def modbus(modbus_device): 66 | cfg = modbus_config(modbus_device) 67 | modbus = ModBus(cfg) 68 | await modbus.start() 69 | modbus.device = modbus_device 70 | modbus.cfg = cfg 71 | async with modbus: 72 | yield modbus 73 | modbus_device.close() -------------------------------------------------------------------------------- /tests/test_modbus_proxy.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # This file is part of the modbus-proxy project 4 | # 5 | # Copyright (c) 2020-2021 Tiago Coutinho 6 | # Distributed under the GPLv3 license. See LICENSE for more info. 7 | 8 | """Tests for `modbus_proxy` package.""" 9 | 10 | import os 11 | import json 12 | import asyncio 13 | from collections import namedtuple 14 | from urllib.parse import urlparse 15 | from tempfile import NamedTemporaryFile 16 | 17 | import toml 18 | import yaml 19 | import pytest 20 | 21 | from modbus_proxy import parse_url, parse_args, load_config, run 22 | 23 | from .conftest import REQ, REP, REQ2, REP2, REQ3_ORIGINAL, REP3_MODIFIED 24 | 25 | 26 | Args = namedtuple( 27 | "Args", "config_file bind modbus modbus_connection_time timeout" 28 | ) 29 | 30 | 31 | CFG_YAML_TEXT = """\ 32 | devices: 33 | - modbus: 34 | url: plc1.acme.org:502 35 | listen: 36 | bind: 0:9000 37 | - modbus: 38 | url: plc2.acme.org:502 39 | listen: 40 | bind: 0:9001 41 | """ 42 | 43 | CFG_TOML_TEXT = """ 44 | [[devices]] 45 | 46 | [devices.modbus] 47 | url = "plc1.acme.org:502" 48 | [devices.listen] 49 | bind = "0:9000" 50 | [[devices]] 51 | 52 | [devices.modbus] 53 | url = "plc2.acme.org:502" 54 | [devices.listen] 55 | bind = "0:9001" 56 | """ 57 | 58 | CFG_JSON_TEXT = """ 59 | { 60 | "devices": [ 61 | { 62 | "modbus": { 63 | "url": "plc1.acme.org:502" 64 | }, 65 | "listen": { 66 | "bind": "0:9000" 67 | } 68 | }, 69 | { 70 | "modbus": { 71 | "url": "plc2.acme.org:502" 72 | }, 73 | "listen": { 74 | "bind": "0:9001" 75 | } 76 | } 77 | ] 78 | } 79 | """ 80 | 81 | 82 | class Ready(asyncio.Event): 83 | def set(self, data): 84 | self.data = data 85 | super().set() 86 | 87 | 88 | @pytest.mark.parametrize( 89 | "url, expected", 90 | [ 91 | ("tcp://host:502", urlparse("tcp://host:502")), 92 | ("host:502", urlparse("tcp://host:502")), 93 | ("tcp://:502", urlparse("tcp://0:502")), 94 | (":502", urlparse("tcp://0:502")), 95 | ], 96 | ids=["scheme://host:port", "host:port", "scheme://:port", ":port"], 97 | ) 98 | def test_parse_url(url, expected): 99 | assert parse_url(url) == expected 100 | 101 | 102 | @pytest.mark.parametrize( 103 | "args, expected", 104 | [ 105 | (["-c", "conf.yml"], Args("conf.yml", None, None, 0, 10)), 106 | (["--config-file", "conf.yml"], Args("conf.yml", None, None, 0, 10)), 107 | ], 108 | ids=["-c", "--config-file"], 109 | ) 110 | def test_parse_args(args, expected): 111 | result = parse_args(args) 112 | assert result.config_file == expected.config_file 113 | assert result.bind == expected.bind 114 | assert result.modbus == expected.modbus 115 | assert result.modbus_connection_time == expected.modbus_connection_time 116 | assert result.timeout == expected.timeout 117 | 118 | 119 | @pytest.mark.parametrize( 120 | "text, parser, suffix", 121 | [ 122 | (CFG_YAML_TEXT, yaml.safe_load, ".yml"), 123 | (CFG_TOML_TEXT, toml.loads, ".toml"), 124 | (CFG_JSON_TEXT, json.loads, ".json"), 125 | ], 126 | ids=["yaml", "toml", "json"], 127 | ) 128 | def test_load_config(text, parser, suffix): 129 | with NamedTemporaryFile("w+", suffix=suffix, delete=False) as f: 130 | f.write(text) 131 | try: 132 | config = load_config(f.name) 133 | finally: 134 | os.remove(f.name) 135 | assert parser(text) == config 136 | 137 | 138 | async def open_connection(modbus): 139 | return await asyncio.open_connection(*modbus.address) 140 | 141 | 142 | async def make_requests(modbus, requests): 143 | reader, writer = await open_connection(modbus) 144 | for request, reply in requests: 145 | writer.write(request) 146 | await writer.drain() 147 | assert await reader.readexactly(len(reply)) == reply 148 | writer.close() 149 | await writer.wait_closed() 150 | 151 | 152 | @pytest.mark.parametrize( 153 | "req, rep", 154 | [ 155 | (REQ, REP), 156 | (REQ2, REP2), 157 | (REQ3_ORIGINAL, REP3_MODIFIED), 158 | ], 159 | ids=["req1", "req2", "req3"], 160 | ) 161 | @pytest.mark.asyncio 162 | async def test_modbus(modbus, req, rep): 163 | 164 | assert not modbus.opened 165 | 166 | await make_requests(modbus, [(req, rep)]) 167 | 168 | assert modbus.opened 169 | 170 | # Don't make any request 171 | _, w = await open_connection(modbus) 172 | w.close() 173 | await w.wait_closed() 174 | await make_requests(modbus, [(req, rep)]) 175 | 176 | # Don't wait for answer 177 | _, w = await open_connection(modbus) 178 | w.write(REQ) 179 | await w.drain() 180 | w.close() 181 | await w.wait_closed() 182 | await make_requests(modbus, [(req, rep)]) 183 | 184 | 185 | @pytest.mark.asyncio 186 | async def test_concurrent_clients(modbus): 187 | task1 = asyncio.create_task(make_requests(modbus, 10 * [(REQ, REP)])) 188 | task2 = asyncio.create_task(make_requests(modbus, 12 * [(REQ2, REP2)])) 189 | await task1 190 | await task2 191 | 192 | 193 | @pytest.mark.asyncio 194 | async def test_concurrent_clients_with_misbihaved(modbus): 195 | task1 = asyncio.create_task(make_requests(modbus, 10 * [(REQ, REP)])) 196 | task2 = asyncio.create_task(make_requests(modbus, 12 * [(REQ2, REP2)])) 197 | 198 | async def misbihaved(n): 199 | for i in range(n): 200 | # Don't make any request 201 | _, writer = await open_connection(modbus) 202 | writer.close() 203 | await writer.wait_closed() 204 | await make_requests(modbus, [(REQ, REP)]) 205 | 206 | # Don't wait for answer 207 | _, writer = await open_connection(modbus) 208 | writer.write(REQ2) 209 | await writer.drain() 210 | writer.close() 211 | await writer.wait_closed() 212 | 213 | task3 = asyncio.create_task(misbihaved(10)) 214 | await task1 215 | await task2 216 | await task3 217 | 218 | 219 | @pytest.mark.parametrize( 220 | "req, rep", 221 | [ 222 | (REQ, REP), 223 | (REQ2, REP2), 224 | ], 225 | ids=["req1", "req2"], 226 | ) 227 | @pytest.mark.asyncio 228 | async def test_run(modbus_device, req, rep): 229 | addr = "{}:{}".format(*modbus_device.address) 230 | args = ["--modbus", addr, "--bind", "127.0.0.1:0"] 231 | ready = Ready() 232 | task = asyncio.create_task(run(args, ready)) 233 | try: 234 | await ready.wait() 235 | modbus = ready.data[0] 236 | await make_requests(modbus, [(req, rep)]) 237 | finally: 238 | for bridge in ready.data: 239 | await bridge.stop() 240 | try: 241 | await task 242 | except asyncio.CancelledError: 243 | pass 244 | 245 | 246 | @pytest.mark.asyncio 247 | async def test_device_not_connected(modbus): 248 | modbus.device.close() 249 | await modbus.device.wait_closed() 250 | 251 | with pytest.raises(asyncio.IncompleteReadError): 252 | await make_requests(modbus, [(REQ, REP)]) 253 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | minversion = 3.8 3 | envlist = py37, py38, py39, flake8 4 | isolated_build = true 5 | 6 | [gh-actions] 7 | python = 8 | 3.7: py37, flake8 9 | 3.8: py38 10 | 3.9: py39 11 | 12 | [testenv] 13 | setenv = 14 | PYTHONPATH = {toxinidir}/src 15 | deps = 16 | -r{toxinidir}/requirements_dev.txt 17 | commands = 18 | pytest --basetemp={envtmpdir} 19 | 20 | [testenv:flake8] 21 | basepython = python3.7 22 | deps = flake8 23 | commands = flake8 src tests 24 | 25 | --------------------------------------------------------------------------------