├── .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 |
--------------------------------------------------------------------------------