├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ └── issue.yml └── workflows │ ├── release.yml │ ├── test-release.yaml │ └── test.yml ├── docs ├── readme_link.rst ├── changelog_link.rst ├── fakes.rst ├── requirements.txt ├── index.rst ├── umodbus.rst ├── DOCUMENTATION.md ├── SETUP.md ├── conf.py ├── UPGRADE.md ├── INSTALLATION.md ├── TESTING.md ├── CONTRIBUTING.md └── EXAMPLES.md ├── requirements.txt ├── umodbus ├── __init__.py ├── version.py ├── typing.py ├── const.py └── common.py ├── requirements-deploy.txt ├── .yamllint ├── requirements-test.txt ├── .gitmodules ├── examples ├── boot.py ├── read_registers_tcp.sh ├── write_registers_tcp.sh ├── read_registers_rtu.sh ├── rtu_client_example.py ├── tcp_host_example.py ├── tcp_client_example.py └── rtu_host_example.py ├── tests ├── __init__.py ├── test_const.py ├── test_absolute_truth.py ├── test_rtu_example.py └── ulogging.py ├── Dockerfile.host_rtu ├── Dockerfile.host_tcp ├── Dockerfile.client_rtu ├── Dockerfile.client_tcp ├── Dockerfile.test_examples ├── .editorconfig ├── .readthedocs.yaml ├── Dockerfile.tests_manually ├── Dockerfile.tests ├── registers ├── set-example.json ├── set-modbusRegisters-MyEVSE.json ├── example.json └── modbusRegisters-MyEVSE.json ├── CITATION.cff ├── .pre-commit-config.yaml ├── package.json ├── setup.py ├── docker-compose.yaml ├── docker-compose-rtu.yaml ├── docker-compose-tcp-test.yaml ├── boot.py ├── docker-compose-rtu-test.yaml ├── .gitignore ├── .flake8 ├── main.py ├── sdist_upip.py ├── fakes └── queue.py ├── README.md └── mpy_unittest.py /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | blank_issues_enabled: false 4 | -------------------------------------------------------------------------------- /docs/readme_link.rst: -------------------------------------------------------------------------------- 1 | 2 | .. include:: ../README.md 3 | :parser: myst_parser.sphinx_ 4 | -------------------------------------------------------------------------------- /docs/changelog_link.rst: -------------------------------------------------------------------------------- 1 | 2 | .. include:: ../changelog.md 3 | :parser: myst_parser.sphinx_ 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # adafruit-ampy>=1.1.0,<2.0.0 2 | esptool 3 | rshell>=0.0.30,<1.0.0 4 | mpremote>=0.4.0,<1 5 | -------------------------------------------------------------------------------- /umodbus/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: UTF-8 -*- 3 | 4 | from .version import __version__ 5 | -------------------------------------------------------------------------------- /umodbus/version.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: UTF-8 -*- 3 | 4 | __version_info__ = ("2", "3", "7") 5 | __version__ = '.'.join(__version_info__) 6 | -------------------------------------------------------------------------------- /requirements-deploy.txt: -------------------------------------------------------------------------------- 1 | # List external packages here 2 | # Avoid fixed versions 3 | # # to upload package to PyPi or other package hosts 4 | twine>=4.0.1,<5 5 | changelog2version>=0.5.0,<1 6 | -------------------------------------------------------------------------------- /.yamllint: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | extends: default 4 | 5 | ignore: 6 | - .tox 7 | - .venv 8 | - .idea 9 | - .thinking 10 | 11 | rules: 12 | line-length: 13 | level: warning 14 | ignore: 15 | - .github/workflows/* 16 | -------------------------------------------------------------------------------- /requirements-test.txt: -------------------------------------------------------------------------------- 1 | # List external packages here 2 | # Avoid fixed versions 3 | changelog2version>=0.10.0,<1 4 | coverage>=6.4.2,<7 5 | flake8>=5.0.0,<6 6 | nose2>=0.12.0,<1 7 | setup2upypackage>=0.4.0,<1 8 | pre-commit>=3.3.3,<4 9 | yamllint>=1.29,<2 10 | -------------------------------------------------------------------------------- /docs/fakes.rst: -------------------------------------------------------------------------------- 1 | Fakes API 2 | ======================= 3 | 4 | .. autosummary:: 5 | :toctree: generated 6 | 7 | UART and Pin fakes 8 | --------------------------------- 9 | 10 | .. automodule:: fakes.machine 11 | :members: 12 | :private-members: 13 | :show-inheritance: 14 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "modules"] 2 | path = modules 3 | # changed to https due to RTD build issue 4 | # see https://github.com/readthedocs/readthedocs.org/issues/4043 5 | # url = git@github.com:brainelectronics/python-modules.git 6 | url = https://github.com/brainelectronics/python-modules.git 7 | -------------------------------------------------------------------------------- /examples/boot.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: UTF-8 -*- 3 | 4 | """ 5 | Boot script 6 | 7 | Do initial stuff here, similar to the setup() function on Arduino 8 | """ 9 | 10 | # This file is executed on every boot (including wake-boot from deepsleep) 11 | # import esp 12 | # esp.osdebug(None) 13 | # import webrepl 14 | # webrepl.start() 15 | 16 | print('System booted successfully!') 17 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: UTF-8 -*- 3 | 4 | from .test_absolute_truth import * 5 | from .test_const import * 6 | from .test_functions import * 7 | 8 | # TestTcpExample is a non static test and requires a running TCP client 9 | # from .test_tcp_example import * 10 | 11 | # TestRtuExample is a non static test and requires a running RTU client 12 | # from .test_rtu_example import * 13 | -------------------------------------------------------------------------------- /Dockerfile.host_rtu: -------------------------------------------------------------------------------- 1 | # Build image 2 | # $ docker build -t micropython-host-rtu -f Dockerfile.host_rtu . 3 | # 4 | # Run image 5 | # $ docker run -it --rm --name micropython-host-rtu micropython-host-rtu 6 | 7 | FROM micropython/unix:v1.18 8 | 9 | # use "volumes" in docker-compose file to remove need of rebuilding 10 | # COPY ./ /home 11 | # COPY umodbus /root/.micropython/lib/umodbus 12 | 13 | CMD [ "micropython-dev", "-m", "examples/rtu_host_example.py" ] 14 | -------------------------------------------------------------------------------- /Dockerfile.host_tcp: -------------------------------------------------------------------------------- 1 | # Build image 2 | # $ docker build -t micropython-host-tcp -f Dockerfile.host_tcp . 3 | # 4 | # Run image 5 | # $ docker run -it --rm --name micropython-host-tcp micropython-host-tcp 6 | 7 | FROM micropython/unix:v1.18 8 | 9 | # use "volumes" in docker-compose file to remove need of rebuilding 10 | # COPY ./ /home 11 | # COPY umodbus /root/.micropython/lib/umodbus 12 | 13 | CMD [ "micropython-dev", "-m", "examples/tcp_host_example.py" ] 14 | -------------------------------------------------------------------------------- /Dockerfile.client_rtu: -------------------------------------------------------------------------------- 1 | # Build image 2 | # $ docker build -t micropython-client-rtu -f Dockerfile.client_rtu . 3 | # 4 | # Run image 5 | # $ docker run -it --rm --name micropython-client-rtu micropython-client-rtu 6 | 7 | FROM micropython/unix:v1.18 8 | 9 | # use "volumes" in docker-compose file to remove need of rebuilding 10 | # COPY ./ /home 11 | # COPY umodbus /root/.micropython/lib/umodbus 12 | 13 | CMD [ "micropython-dev", "-m", "examples/rtu_client_example.py" ] 14 | -------------------------------------------------------------------------------- /Dockerfile.client_tcp: -------------------------------------------------------------------------------- 1 | # Build image 2 | # $ docker build -t micropython-client-tcp -f Dockerfile.client_tcp . 3 | # 4 | # Run image 5 | # $ docker run -it --rm --name micropython-client-tcp micropython-client-tcp 6 | 7 | FROM micropython/unix:v1.18 8 | 9 | # use "volumes" in docker-compose file to remove need of rebuilding 10 | # COPY ./ /home 11 | # COPY umodbus /root/.micropython/lib/umodbus 12 | 13 | CMD [ "micropython-dev", "-m", "examples/tcp_client_example.py" ] 14 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | # use fixed versions 2 | # 3 | # fix docutils to a version working for all 4 | docutils >=0.14,<0.18 5 | 6 | # sphinx 5.3.0 requires Jinja2 >=3.0 and docutils >=0.14,<0.20 7 | sphinx >=5.0.0,<6 8 | 9 | # sphinx-rtd-theme >=1.0.0 would require docutils <0.18 10 | sphinx-rtd-theme >=1.0.0,<2 11 | 12 | # replaces outdated and no longer maintained m2rr 13 | myst-parser >= 0.18.1,<1 14 | 15 | # mock imports of "micropython" 16 | mock >=4.0.3,<5 17 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | micropython-modbus 2 | =================================== 3 | 4 | Contents 5 | -------- 6 | 7 | .. toctree:: 8 | :maxdepth: 1 9 | 10 | readme_link 11 | SETUP 12 | INSTALLATION 13 | USAGE 14 | EXAMPLES 15 | TESTING 16 | DOCUMENTATION 17 | CONTRIBUTING 18 | umodbus 19 | fakes 20 | UPGRADE 21 | changelog_link 22 | 23 | Indices and tables 24 | ================== 25 | 26 | * :ref:`genindex` 27 | * :ref:`modindex` 28 | -------------------------------------------------------------------------------- /Dockerfile.test_examples: -------------------------------------------------------------------------------- 1 | # Build image 2 | # $ docker build -t micropython-tcp-example -f Dockerfile.test_tcp_example . 3 | # if a command fails, it will exit with non-zero code 4 | # 5 | # Run image, only possible if all tests passed 6 | # $ docker run -it --rm --name micropython-tcp-example micropython-tcp-example 7 | 8 | FROM micropython/unix:v1.18 9 | 10 | # use "volumes" in docker-compose file to remove need of rebuilding 11 | # COPY ./ /home 12 | # COPY umodbus /root/.micropython/lib/umodbus 13 | # COPY mpy_unittest.py /root/.micropython/lib/mpy_unittest.py 14 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # Unix-style newlines with a newline ending every file 7 | [*] 8 | charset = utf-8 9 | end_of_line = lf 10 | indent_style = space 11 | insert_final_newline = true 12 | trim_trailing_whitespace = true 13 | 14 | # 4 space indentation 15 | [*.py] 16 | indent_size = 4 17 | 18 | [*.json] 19 | indent_size = 4 20 | 21 | # 2 space indentation 22 | [*.yml] 23 | indent_size = 2 24 | 25 | [*.{md,rst}] 26 | indent_size = 4 27 | trim_trailing_whitespace = false 28 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | # Read the Docs configuration file 4 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 5 | 6 | # Required 7 | version: 2 8 | 9 | # Set the version of Python and other tools you might need 10 | build: 11 | os: ubuntu-22.04 12 | tools: 13 | python: "3.9" 14 | 15 | # Build documentation in the docs/ directory with Sphinx 16 | sphinx: 17 | configuration: docs/conf.py 18 | 19 | # Optionally declare the Python requirements required to build your docs 20 | python: 21 | install: 22 | - requirements: docs/requirements.txt 23 | -------------------------------------------------------------------------------- /Dockerfile.tests_manually: -------------------------------------------------------------------------------- 1 | # Build image 2 | # $ docker build -t micropython-test-manually -f Dockerfile.tests_manually . 3 | # if a unittest fails, it will exit with non-zero code 4 | # 5 | # Run image, only possible if all tests passed 6 | # $ docker run -it --rm --name micropython-test-manually micropython-test-manually 7 | 8 | FROM micropython/unix:v1.18 9 | 10 | COPY ./ /home 11 | # keep examples and tests registers JSON file easily in sync 12 | COPY registers/example.json /home/tests/test-registers.json 13 | COPY umodbus /root/.micropython/lib/umodbus 14 | COPY mpy_unittest.py /root/.micropython/lib/mpy_unittest.py 15 | COPY tests/ulogging.py /root/.micropython/lib/ulogging.py 16 | 17 | ENTRYPOINT ["/bin/bash"] 18 | -------------------------------------------------------------------------------- /Dockerfile.tests: -------------------------------------------------------------------------------- 1 | # Build image 2 | # $ docker build -t micropython-test -f Dockerfile.tests . 3 | # if a unittest fails, it will exit with non-zero code 4 | # 5 | # Run image, only possible if all tests passed 6 | # $ docker run -it --rm --name micropython-test micropython-test 7 | 8 | FROM micropython/unix:v1.18 9 | 10 | COPY ./ /home 11 | # keep examples and tests registers JSON file easily in sync 12 | COPY registers/example.json /home/tests/test-registers.json 13 | COPY umodbus /root/.micropython/lib/umodbus 14 | COPY mpy_unittest.py /root/.micropython/lib/mpy_unittest.py 15 | COPY tests/ulogging.py /root/.micropython/lib/ulogging.py 16 | 17 | RUN micropython-dev -c "import mpy_unittest as unittest; unittest.main('tests')" 18 | 19 | ENTRYPOINT ["/bin/bash"] 20 | -------------------------------------------------------------------------------- /registers/set-example.json: -------------------------------------------------------------------------------- 1 | { 2 | "COILS": { 3 | "EXAMPLE_COIL": { 4 | "register": 123, 5 | "val": 0, 6 | "description": "Example COILS register, Coils (setter+getter) [0, 1]", 7 | "unit": "" 8 | } 9 | }, 10 | "HREGS": { 11 | "EXAMPLE_HREG": { 12 | "register": 93, 13 | "len": 1, 14 | "val": 19, 15 | "description": "Example HREGS register, Hregs (setter+getter) [0, 65535]", 16 | "unit": "" 17 | } 18 | }, 19 | "META": { 20 | "created": "12.11.2022", 21 | "modified": "12.11.2022" 22 | }, 23 | "CONNECTION": { 24 | "type": "rtu", 25 | "unit": 10, 26 | "address": "/dev/tty.SLAB_USBtoUART", 27 | "baudrate": 9600, 28 | "mode": "slave" 29 | } 30 | } -------------------------------------------------------------------------------- /CITATION.cff: -------------------------------------------------------------------------------- 1 | # This CITATION.cff file was generated with cffinit. 2 | # Visit https://bit.ly/cffinit to generate yours today! 3 | 4 | cff-version: 1.2.0 5 | title: MicroPython Modbus 6 | message: >- 7 | If you use this software, please cite it using the 8 | metadata from this file. 9 | type: software 10 | authors: 11 | - name: Pycom Limited 12 | - given-names: Jonas 13 | family-names: Scharpf 14 | affiliation: brainelectronics 15 | - name: The MicroPython Modbus team 16 | repository-code: 'https://github.com/brainelectronics/micropython-modbus/' 17 | url: 'https://brainelectronics.de' 18 | repository-artifact: 'https://pypi.org/project/micropython-modbus/' 19 | abstract: A lightweight Modbus TCP/RTU library for Micropython 20 | keywords: 21 | - micropython 22 | - modbus 23 | license: AGPL-3.0 24 | commit: '2975f78' 25 | version: 2.3.4 26 | date-released: '2023-03-20' 27 | -------------------------------------------------------------------------------- /docs/umodbus.rst: -------------------------------------------------------------------------------- 1 | API 2 | ======================= 3 | 4 | .. autosummary:: 5 | :toctree: generated 6 | 7 | Modbus Constants 8 | --------------------------------- 9 | 10 | .. automodule:: umodbus.const 11 | :members: 12 | :private-members: 13 | :show-inheritance: 14 | 15 | Common module 16 | --------------------------------- 17 | 18 | .. automodule:: umodbus.common 19 | :members: 20 | :private-members: 21 | :show-inheritance: 22 | 23 | Common functions 24 | --------------------------------- 25 | 26 | .. automodule:: umodbus.functions 27 | :members: 28 | :private-members: 29 | :show-inheritance: 30 | 31 | Modbus client module 32 | --------------------------------- 33 | 34 | .. automodule:: umodbus.modbus 35 | :members: 36 | :private-members: 37 | :show-inheritance: 38 | 39 | Serial 40 | --------------------------------- 41 | 42 | .. automodule:: umodbus.serial 43 | :members: 44 | :private-members: 45 | :show-inheritance: 46 | 47 | TCP 48 | --------------------------------- 49 | 50 | .. automodule:: umodbus.tcp 51 | :members: 52 | :private-members: 53 | :show-inheritance: 54 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | # To install pre-commit hooks, install `pre-commit` and activate it here: 4 | # pip3 install pre-commit 5 | # pre-commit install 6 | # 7 | default_stages: 8 | - commit 9 | - push 10 | - manual 11 | 12 | repos: 13 | - repo: https://github.com/pre-commit/pre-commit-hooks 14 | rev: v2.3.0 15 | hooks: 16 | - id: check-yaml 17 | - id: trailing-whitespace 18 | args: [--markdown-linebreak-ext=md] 19 | - repo: https://github.com/PyCQA/flake8 20 | rev: 5.0.4 21 | hooks: 22 | - id: flake8 23 | - repo: https://github.com/brainelectronics/micropython-package-validation 24 | rev: 0.5.0 25 | hooks: 26 | - id: upy-package 27 | args: 28 | - "--setup_file=setup.py" 29 | - "--package_changelog_file=changelog.md" 30 | - "--package_file=package.json" 31 | - "--validate" 32 | - repo: https://github.com/brainelectronics/changelog2version 33 | rev: 0.10.0 34 | hooks: 35 | - id: changelog2version 36 | args: 37 | - "--changelog_file=changelog.md" 38 | - "--version_file=umodbus/version.py" 39 | - "--validate" 40 | -------------------------------------------------------------------------------- /registers/set-modbusRegisters-MyEVSE.json: -------------------------------------------------------------------------------- 1 | { 2 | "COILS": { 3 | "SYSTEM_RESET_COIL": { 4 | "register": 10, 5 | "val": 0, 6 | "description": "perform controller reset on 1", 7 | "unit": "" 8 | }, 9 | "CONFIG_RESET_COIL": { 10 | "register": 11, 11 | "val": 0, 12 | "description": "perform EEPROM config reset on 1", 13 | "unit": "" 14 | }, 15 | "USE_MB_CURRENT_COIL": { 16 | "register": 20, 17 | "val": 1, 18 | "description": "use CHARGING_CURRENT_IREG value", 19 | "unit": "" 20 | } 21 | }, 22 | "HREGS": { 23 | "CHARGING_CURRENT_HREG": { 24 | "register": 21, 25 | "len": 1, 26 | "val": 29, 27 | "description": "[A] charging current, requires USE_MB_CURRENT_COIL", 28 | "unit": "A" 29 | } 30 | }, 31 | "META": { 32 | "created": "20.02.2022", 33 | "modified": "20.02.2022" 34 | }, 35 | "CONNECTION": { 36 | "type": "rtu", 37 | "unit": 10, 38 | "address": "/dev/tty.wchusbserial1420", 39 | "baudrate": 9600, 40 | "mode": "slave" 41 | } 42 | } -------------------------------------------------------------------------------- /docs/DOCUMENTATION.md: -------------------------------------------------------------------------------- 1 | # Documentation 2 | 3 | Documentation is generated by using Sphinx and published on RTD 4 | 5 | --------------- 6 | 7 | ## Documentation 8 | 9 | Documentation is automatically created on each merge to the development 10 | branch, as well as with each pull request and available 11 | [📚 here at Read the Docs][ref-rtd-micropython-modbus] 12 | 13 | ### Install required packages 14 | 15 | ```bash 16 | # create and activate virtual environment 17 | python3 -m venv .venv 18 | source .venv/bin/activate 19 | 20 | # install and upgrade required packages 21 | pip install -U -r docs/requirements.txt 22 | ``` 23 | 24 | ### Create documentation 25 | 26 | Some usefull checks have been disabled in the `docs/conf.py` file. Please 27 | check the documentation build output locally before opening a PR. 28 | 29 | ```bash 30 | # perform link checks 31 | sphinx-build docs/ docs/build/linkcheck -d docs/build/docs_doctree/ --color -blinkcheck -j auto -W 32 | 33 | # create documentation 34 | sphinx-build docs/ docs/build/html/ -d docs/build/docs_doctree/ --color -bhtml -j auto -W 35 | ``` 36 | 37 | The created documentation can be found at [`docs/build/html`](docs/build/html). 38 | 39 | 40 | [ref-rtd-micropython-modbus]: https://micropython-modbus.readthedocs.io/en/latest/ 41 | -------------------------------------------------------------------------------- /examples/read_registers_tcp.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # title :read_registers_tcp.sh 3 | # description :Read Modbus registers via TCP based on register JSON 4 | # author :brainelectronics 5 | # date :20221203 6 | # version :0.1.1 7 | # usage :sh read_registers_tcp.sh \ 8 | # 192.168.178.188 \ 9 | # example.json \ 10 | # 502 11 | # Register file and modbus port are optional 12 | # notes :Install python3 and its requirements file to use this script. 13 | # bash_version :3.2.53(1)-release 14 | #============================================================================= 15 | 16 | connection_address=$1 17 | register_file_path=$2 18 | modbus_port=$3 19 | 20 | if [[ -z "$register_file_path" ]]; then 21 | register_file_path=../registers/example.json 22 | echo "No register file given, using default at $register_file_path" 23 | fi 24 | 25 | if [[ -z "$modbus_port" ]]; then 26 | modbus_port=255 27 | echo "No modbus unit given, using default $modbus_port" 28 | fi 29 | 30 | echo "Read registers defined in $register_file_path via TCP from $connection_address" 31 | 32 | python3 ../modules/read_device_info_registers.py \ 33 | --file=$register_file_path \ 34 | --connection=tcp \ 35 | --address=$connection_address \ 36 | --port=$modbus_port \ 37 | --print \ 38 | --pretty \ 39 | --debug \ 40 | --verbose=3 41 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "urls": [ 3 | [ 4 | "umodbus/__init__.py", 5 | "github:brainelectronics/micropython-modbus/umodbus/__init__.py" 6 | ], 7 | [ 8 | "umodbus/common.py", 9 | "github:brainelectronics/micropython-modbus/umodbus/common.py" 10 | ], 11 | [ 12 | "umodbus/const.py", 13 | "github:brainelectronics/micropython-modbus/umodbus/const.py" 14 | ], 15 | [ 16 | "umodbus/functions.py", 17 | "github:brainelectronics/micropython-modbus/umodbus/functions.py" 18 | ], 19 | [ 20 | "umodbus/modbus.py", 21 | "github:brainelectronics/micropython-modbus/umodbus/modbus.py" 22 | ], 23 | [ 24 | "umodbus/serial.py", 25 | "github:brainelectronics/micropython-modbus/umodbus/serial.py" 26 | ], 27 | [ 28 | "umodbus/tcp.py", 29 | "github:brainelectronics/micropython-modbus/umodbus/tcp.py" 30 | ], 31 | [ 32 | "umodbus/typing.py", 33 | "github:brainelectronics/micropython-modbus/umodbus/typing.py" 34 | ], 35 | [ 36 | "umodbus/version.py", 37 | "github:brainelectronics/micropython-modbus/umodbus/version.py" 38 | ] 39 | ], 40 | "deps": [], 41 | "version": "2.3.7" 42 | } -------------------------------------------------------------------------------- /examples/write_registers_tcp.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # title :write_registers_tcp.sh 3 | # description :Write Modbus registers via TCP based on register JSON 4 | # author :brainelectronics 5 | # date :20221203 6 | # version :0.1.1 7 | # usage :sh write_registers_tcp.sh \ 8 | # 192.168.178.69 \ 9 | # set-example.json \ 10 | # 502 11 | # Register file and modbus port are optional 12 | # notes :Install python3 and its requirements file to use this script. 13 | # bash_version :3.2.53(1)-release 14 | #============================================================================= 15 | 16 | connection_address=$1 17 | register_file_path=$2 18 | modbus_port=$3 19 | 20 | if [[ -z "$register_file_path" ]]; then 21 | register_file_path=../registers/set-example.json 22 | echo "No register file given, using default at $register_file_path" 23 | fi 24 | 25 | if [[ -z "$modbus_port" ]]; then 26 | modbus_port=255 27 | echo "No modbus unit given, using default $modbus_port" 28 | fi 29 | 30 | echo "Write registers defined in $register_file_path via TCP from $connection_address" 31 | 32 | python3 ../modules/write_device_info_registers.py \ 33 | --file=$register_file_path \ 34 | --connection=tcp \ 35 | --address=$connection_address \ 36 | --port=$modbus_port \ 37 | --print \ 38 | --pretty \ 39 | --debug \ 40 | --verbose=3 41 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: UTF-8 -*- 3 | 4 | from setuptools import setup 5 | import pathlib 6 | import sdist_upip 7 | 8 | here = pathlib.Path(__file__).parent.resolve() 9 | 10 | # Get the long description from the README file 11 | long_description = (here / 'README.md').read_text(encoding='utf-8') 12 | 13 | # load elements of version.py 14 | exec(open(here / 'umodbus' / 'version.py').read()) 15 | 16 | setup( 17 | name='micropython-modbus', 18 | version=__version__, 19 | description="MicroPython ModBus TCP and RTU library supporting client and host mode", 20 | long_description=long_description, 21 | long_description_content_type='text/markdown', 22 | url='https://github.com/brainelectronics/micropython-modbus', 23 | author='brainelectronics', 24 | author_email='info@brainelectronics.de', 25 | classifiers=[ 26 | 'Development Status :: 4 - Beta', 27 | 'Intended Audience :: Developers', 28 | 'License :: OSI Approved :: MIT License', 29 | ], 30 | keywords='micropython, modbus, tcp, rtu, client, host, library', 31 | project_urls={ 32 | 'Bug Reports': 'https://github.com/brainelectronics/micropython-modbus/issues', 33 | 'Source': 'https://github.com/brainelectronics/micropython-modbus', 34 | }, 35 | license='MIT', 36 | cmdclass={'sdist': sdist_upip.sdist}, 37 | packages=['umodbus'], 38 | install_requires=[] 39 | ) 40 | -------------------------------------------------------------------------------- /examples/read_registers_rtu.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # title :read_registers_rtu.sh 3 | # description :Read Modbus registers via Serial RTU based on register JSON 4 | # author :brainelectronics 5 | # date :20221203 6 | # version :0.1.1 7 | # usage :sh read_registers_rtu.sh \ 8 | # /dev/tty.SLAB_USBtoUART \ 9 | # example.json \ 10 | # 10 11 | # Register file and modbus unit are optional 12 | # notes :Install python3 and its requirements file to use this script. 13 | # bash_version :3.2.53(1)-release 14 | #============================================================================= 15 | 16 | connection_port=$1 17 | register_file_path=$2 18 | modbus_unit=$3 19 | 20 | if [[ -z "$register_file_path" ]]; then 21 | register_file_path=../registers/example.json 22 | echo "No register file given, using default at $register_file_path" 23 | fi 24 | 25 | if [[ -z "$modbus_unit" ]]; then 26 | modbus_unit=10 27 | echo "No modbus unit given, using default $modbus_unit" 28 | fi 29 | 30 | echo "Read registers defined in $register_file_path via RS485 on $connection_port @ $modbus_unit" 31 | 32 | python3 ../modules/read_device_info_registers.py \ 33 | --file=$register_file_path \ 34 | --connection=rtu \ 35 | --address=$connection_port \ 36 | --unit=$modbus_unit \ 37 | --baudrate=9600 \ 38 | --print \ 39 | --pretty \ 40 | --debug \ 41 | --verbose=3 42 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | # 4 | # build all non-image containers 5 | # $ docker-compose build 6 | # can be combined into one command to also start it afterwards 7 | # $ docker-compose up --build 8 | # 9 | 10 | version: "3.8" 11 | 12 | services: 13 | micropython-client: 14 | build: 15 | context: . 16 | dockerfile: Dockerfile.client_tcp 17 | container_name: micropython-client 18 | volumes: 19 | - ./:/home 20 | - ./umodbus:/root/.micropython/lib/umodbus 21 | expose: 22 | - "502" 23 | ports: 24 | # reach "micropython-client" at 172.24.0.2:502, see networks 25 | - "502:502" 26 | networks: 27 | my_bridge: 28 | # fix IPv4 address to be known and in the MicroPython scripts 29 | # https://docs.docker.com/compose/compose-file/#ipv4_address 30 | ipv4_address: 172.24.0.2 31 | 32 | micropython-host: 33 | build: 34 | context: . 35 | dockerfile: Dockerfile.host_tcp 36 | container_name: micropython-host 37 | volumes: 38 | - ./:/home 39 | - ./umodbus:/root/.micropython/lib/umodbus 40 | depends_on: 41 | - micropython-client 42 | networks: 43 | my_bridge: 44 | # fix IPv4 address to be known and in the MicroPython scripts 45 | # https://docs.docker.com/compose/compose-file/#ipv4_address 46 | ipv4_address: 172.24.0.3 47 | 48 | networks: 49 | my_bridge: 50 | # use "external: true" if the network already exists 51 | # check available networks with "docker network ls" 52 | # external: true 53 | driver: bridge 54 | # https://docs.docker.com/compose/compose-file/#ipam 55 | ipam: 56 | config: 57 | - subnet: 172.24.0.0/16 58 | gateway: 172.24.0.1 59 | -------------------------------------------------------------------------------- /docker-compose-rtu.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | # 4 | # build all non-image containers 5 | # $ docker-compose build 6 | # can be combined into one command to also start it afterwards 7 | # $ docker-compose up --build 8 | # 9 | 10 | version: "3.8" 11 | 12 | services: 13 | micropython-client: 14 | build: 15 | context: . 16 | dockerfile: Dockerfile.client_rtu 17 | container_name: micropython-client 18 | volumes: 19 | - ./:/home 20 | - ./umodbus:/root/.micropython/lib/umodbus 21 | - ./fakes:/usr/lib/micropython 22 | - ./tests/ulogging.py:/root/.micropython/lib/ulogging.py 23 | expose: 24 | - "65433" 25 | ports: 26 | # reach "micropython-client" at 172.25.0.2:65433, see networks 27 | - "65433:65433" 28 | networks: 29 | serial_bridge: 30 | # fix IPv4 address to be known and in the MicroPython scripts 31 | # https://docs.docker.com/compose/compose-file/#ipv4_address 32 | ipv4_address: 172.25.0.2 33 | 34 | micropython-host: 35 | build: 36 | context: . 37 | dockerfile: Dockerfile.host_rtu 38 | container_name: micropython-host 39 | volumes: 40 | - ./:/home 41 | - ./umodbus:/root/.micropython/lib/umodbus 42 | - ./fakes:/usr/lib/micropython 43 | - ./tests/ulogging.py:/root/.micropython/lib/ulogging.py 44 | depends_on: 45 | - micropython-client 46 | networks: 47 | serial_bridge: 48 | # fix IPv4 address to be known and in the MicroPython scripts 49 | # https://docs.docker.com/compose/compose-file/#ipv4_address 50 | ipv4_address: 172.25.0.3 51 | 52 | networks: 53 | serial_bridge: 54 | # use "external: true" if the network already exists 55 | # check available networks with "docker network ls" 56 | # external: true 57 | driver: bridge 58 | # https://docs.docker.com/compose/compose-file/#ipam 59 | ipam: 60 | config: 61 | - subnet: 172.25.0.0/16 62 | gateway: 172.25.0.1 63 | -------------------------------------------------------------------------------- /docs/SETUP.md: -------------------------------------------------------------------------------- 1 | # Setup 2 | 3 | Setup the development environment and the MicroPython board 4 | 5 | --------------- 6 | 7 | ## Development environment 8 | 9 | This section describes the necessary steps on the computer to get ready to 10 | test and run the examples. 11 | 12 | ### Update submodule 13 | 14 | [brainelectronics python modules submodule][ref-github-be-python-modules] have 15 | to be cloned as well. A standard clone command won't clone the submodule. 16 | 17 | ```bash 18 | git submodule update --init --recursive 19 | cd modules 20 | git fetch 21 | 22 | # maybe checkout a newer version with the following command 23 | git checkout x.y.z 24 | 25 | # or use the latest develop branch 26 | git checkout develop 27 | git pull 28 | ``` 29 | 30 | ### Install required tools 31 | 32 | Python3 must be installed on your system. Check the current Python version 33 | with the following command 34 | 35 | ```bash 36 | python --version 37 | python3 --version 38 | ``` 39 | 40 | Depending on which command `Python 3.x.y` (with x.y as some numbers) is 41 | returned, use that command to proceed. 42 | 43 | ```bash 44 | python3 -m venv .venv 45 | source .venv/bin/activate 46 | 47 | pip install -r requirements.txt 48 | pip install -r modules/requirements.txt 49 | ``` 50 | 51 | ## MicroPython 52 | 53 | This section describes the necessary steps on the MicroPython device to get 54 | ready to test and run the examples. 55 | 56 | ### Flash firmware 57 | 58 | Flash the [MicroPython firmware][ref-upy-firmware-download] to the MicroPython 59 | board. The following example call is valid for ESP32 boards. 60 | 61 | ```bash 62 | esptool.py --chip esp32 --port /dev/tty.SLAB_USBtoUART erase_flash 63 | esptool.py --chip esp32 --port /dev/tty.SLAB_USBtoUART --baud 921600 write_flash -z 0x1000 esp32spiram-20220117-v1.18.bin 64 | ``` 65 | 66 | 67 | [ref-github-be-python-modules]: https://github.com/brainelectronics/python-modules 68 | [ref-upy-firmware-download]: https://micropython.org/download/ 69 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | # this file is *not* meant to cover or endorse the use of GitHub Actions, but 4 | # rather to help make automated releases for this project 5 | 6 | name: Upload Python Package 7 | 8 | on: 9 | push: 10 | branches: 11 | - develop 12 | 13 | permissions: 14 | contents: write 15 | 16 | jobs: 17 | deploy: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - name: Checkout 21 | uses: actions/checkout@v3 22 | - name: Set up Python 23 | uses: actions/setup-python@v3 24 | with: 25 | python-version: '3.9' 26 | - name: Install build dependencies 27 | run: | 28 | if [ -f requirements-deploy.txt ]; then pip install -r requirements-deploy.txt; fi 29 | - name: Build package 30 | run: | 31 | changelog2version \ 32 | --changelog_file changelog.md \ 33 | --version_file umodbus/version.py \ 34 | --version_file_type py \ 35 | --debug 36 | python setup.py sdist 37 | rm dist/*.orig 38 | # sdist call create non conform twine files *.orig, remove them 39 | - name: Publish package 40 | uses: pypa/gh-action-pypi-publish@release/v1.5 41 | with: 42 | password: ${{ secrets.PYPI_API_TOKEN }} 43 | skip_existing: true 44 | verbose: true 45 | print_hash: true 46 | - name: 'Create changelog based release' 47 | uses: brainelectronics/changelog-based-release@v1 48 | with: 49 | # note you'll typically need to create a personal access token 50 | # with permissions to create releases in the other repo 51 | # or you set the "contents" permissions to "write" as in this example 52 | changelog-path: changelog.md 53 | tag-name-prefix: '' 54 | tag-name-extension: '' 55 | release-name-prefix: '' 56 | release-name-extension: '' 57 | draft-release: true 58 | prerelease: false 59 | -------------------------------------------------------------------------------- /docker-compose-tcp-test.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | # 4 | # build all non-image containers 5 | # $ docker-compose -f docker-compose-tcp-test.yaml build 6 | # can be combined into one command to also start it afterwards 7 | # $ docker-compose -f docker-compose-tcp-test.yaml up --build 8 | # 9 | 10 | version: "3.8" 11 | 12 | services: 13 | micropython-client: 14 | build: 15 | context: . 16 | dockerfile: Dockerfile.client_tcp 17 | container_name: micropython-client 18 | volumes: 19 | - ./:/home 20 | - ./registers:/home/registers 21 | - ./umodbus:/root/.micropython/lib/umodbus 22 | - ./tests/ulogging.py:/root/.micropython/lib/ulogging.py 23 | expose: 24 | - "502" 25 | ports: 26 | # reach "micropython-client" at 172.24.0.2:502, see networks 27 | - "502:502" 28 | networks: 29 | my_bridge: 30 | # fix IPv4 address to be known and in the MicroPython scripts 31 | # https://docs.docker.com/compose/compose-file/#ipv4_address 32 | ipv4_address: 172.24.0.2 33 | 34 | micropython-host: 35 | build: 36 | context: . 37 | dockerfile: Dockerfile.test_examples 38 | container_name: micropython-host 39 | volumes: 40 | - ./:/home 41 | - ./umodbus:/root/.micropython/lib/umodbus 42 | - ./mpy_unittest.py:/root/.micropython/lib/mpy_unittest.py 43 | - ./tests/ulogging.py:/root/.micropython/lib/ulogging.py 44 | depends_on: 45 | - micropython-client 46 | command: 47 | - /bin/bash 48 | - -c 49 | - | 50 | micropython-dev -c "import mpy_unittest as unittest; unittest.main(name='tests.test_tcp_example', fromlist=['TestTcpExample'])" 51 | networks: 52 | my_bridge: 53 | # fix IPv4 address to be known and in the MicroPython scripts 54 | # https://docs.docker.com/compose/compose-file/#ipv4_address 55 | ipv4_address: 172.24.0.3 56 | 57 | networks: 58 | my_bridge: 59 | # use "external: true" if the network already exists 60 | # check available networks with "docker network ls" 61 | # external: true 62 | driver: bridge 63 | # https://docs.docker.com/compose/compose-file/#ipam 64 | ipam: 65 | config: 66 | - subnet: 172.24.0.0/16 67 | gateway: 172.24.0.1 68 | -------------------------------------------------------------------------------- /boot.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: UTF-8 -*- 3 | 4 | """ 5 | Boot script 6 | 7 | Do initial stuff here, similar to the setup() function on Arduino 8 | 9 | Connect to network, create an AccessPoint if connection failed otherwise 10 | """ 11 | 12 | # system packages 13 | import esp 14 | import gc 15 | import machine 16 | import network 17 | import time 18 | 19 | # custom modules 20 | from be_helpers.generic_helper import GenericHelper 21 | from be_helpers.led_helper import Led, Neopixel 22 | 23 | 24 | # set clock speed to 240MHz instead of default 160MHz 25 | # import machine 26 | # machine.freq(240000000) 27 | 28 | # disable ESP os debug output 29 | esp.osdebug(None) 30 | 31 | led = Led() 32 | pixel = Neopixel() 33 | # turn Neopixel and onboard LED off 34 | led.turn_off() 35 | pixel.clear() 36 | 37 | # flash onboard LED 3 times 38 | led.flash(amount=3, delay_ms=50) 39 | 40 | # turn onboard LED on 41 | led.turn_on() 42 | 43 | station = network.WLAN(network.STA_IF) 44 | if station.active() and station.isconnected(): 45 | station.disconnect() 46 | time.sleep(1) 47 | station.active(False) 48 | time.sleep(1) 49 | station.active(True) 50 | 51 | station.connect('SSID', 'PASSWORD') 52 | time.sleep(1) 53 | 54 | result = station.isconnected() 55 | # force an accesspoint creation 56 | # result = False 57 | 58 | if result is False: 59 | # disconnect as/from station and disable WiFi for it 60 | station.disconnect() 61 | station.active(False) 62 | time.sleep(1) 63 | 64 | # create a true AccessPoint without any active Station mode 65 | accesspoint = network.WLAN(network.AP_IF) 66 | 67 | # activate accesspoint if not yet enabled 68 | if not accesspoint.active(): 69 | accesspoint.active(True) 70 | 71 | accesspoint.config(essid="MicroPython AP", 72 | authmode=network.AUTH_OPEN, 73 | password='', 74 | channel=11) 75 | 76 | print('Created Accesspoint') 77 | else: 78 | print('Successfully connected to a network :)') 79 | 80 | # turn Neopixel and onboard LED off 81 | led.turn_off() 82 | pixel.clear() 83 | 84 | print('Restart cause: {}'.format(machine.reset_cause())) 85 | print('RAM info: {}'.format(GenericHelper.free(full=True))) 86 | 87 | # run garbage collector at the end to clean up 88 | gc.collect() 89 | -------------------------------------------------------------------------------- /docker-compose-rtu-test.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | # 4 | # build all non-image containers 5 | # $ docker-compose -f docker-compose-rtu-test.yaml build 6 | # can be combined into one command to also start it afterwards 7 | # $ docker-compose -f docker-compose-rtu-test.yaml up --build 8 | # 9 | 10 | version: "3.8" 11 | 12 | services: 13 | micropython-client: 14 | build: 15 | context: . 16 | dockerfile: Dockerfile.client_rtu 17 | container_name: micropython-client 18 | volumes: 19 | - ./:/home 20 | - ./registers:/home/registers 21 | - ./umodbus:/root/.micropython/lib/umodbus 22 | - ./fakes:/usr/lib/micropython 23 | - ./tests/ulogging.py:/root/.micropython/lib/ulogging.py 24 | expose: 25 | - "65433" 26 | ports: 27 | # reach "micropython-client" at 172.25.0.2:65433, see networks 28 | - "65433:65433" 29 | networks: 30 | serial_bridge: 31 | # fix IPv4 address to be known and in the MicroPython scripts 32 | # https://docs.docker.com/compose/compose-file/#ipv4_address 33 | ipv4_address: 172.25.0.2 34 | 35 | micropython-host: 36 | build: 37 | context: . 38 | dockerfile: Dockerfile.test_examples 39 | container_name: micropython-host 40 | volumes: 41 | - ./:/home 42 | - ./umodbus:/root/.micropython/lib/umodbus 43 | - ./fakes:/usr/lib/micropython 44 | - ./mpy_unittest.py:/root/.micropython/lib/mpy_unittest.py 45 | - ./tests/ulogging.py:/root/.micropython/lib/ulogging.py 46 | depends_on: 47 | - micropython-client 48 | command: 49 | - /bin/bash 50 | - -c 51 | - | 52 | micropython-dev -c "import mpy_unittest as unittest; unittest.main(name='tests.test_rtu_example', fromlist=['TestRtuExample'])" 53 | networks: 54 | serial_bridge: 55 | # fix IPv4 address to be known and in the MicroPython scripts 56 | # https://docs.docker.com/compose/compose-file/#ipv4_address 57 | ipv4_address: 172.25.0.3 58 | 59 | networks: 60 | serial_bridge: 61 | # use "external: true" if the network already exists 62 | # check available networks with "docker network ls" 63 | # external: true 64 | driver: bridge 65 | # https://docs.docker.com/compose/compose-file/#ipam 66 | ipam: 67 | config: 68 | - subnet: 172.25.0.0/16 69 | gateway: 172.25.0.1 70 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/issue.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | name: "Default issue" 4 | description: Report any kind of issue 5 | body: 6 | - type: textarea 7 | id: description 8 | attributes: 9 | label: Description 10 | description: Please enter an explicit description of your issue 11 | placeholder: Short and explicit description of your incident... 12 | validations: 13 | required: true 14 | - type: textarea 15 | id: reproduction 16 | attributes: 17 | label: Reproduction steps 18 | description: | 19 | Please enter an explicit description to reproduce this issue 20 | value: | 21 | 1. 22 | 2. 23 | 3. 24 | ... 25 | validations: 26 | required: true 27 | - type: input 28 | id: version 29 | attributes: 30 | label: MicroPython version 31 | description: Which MicroPython version are you using? 32 | placeholder: v1.19.1 33 | validations: 34 | required: true 35 | - type: dropdown 36 | id: board 37 | attributes: 38 | label: MicroPython board 39 | description: Which MicroPython board are you using? 40 | options: 41 | - pyboard 42 | - Raspberry Pico 43 | - ESP32 44 | - ESP8266 45 | - WiPy 46 | - i.MXRT 47 | - SAMD21/SAMD51 48 | - Renesas 49 | - Zephyr 50 | - UNIX 51 | - other 52 | validations: 53 | required: true 54 | - type: textarea 55 | id: modbus-version 56 | attributes: 57 | label: MicroPython Modbus version 58 | description: Which version of this lib are you using? 59 | value: | 60 | # e.g. v2.3.3 61 | # use the following command to get the used version 62 | import os 63 | from umodbus import version 64 | print('MicroPython infos:', os.uname()) 65 | print('Used micropthon-modbus version:', version.__version__)) 66 | render: python 67 | validations: 68 | required: true 69 | - type: textarea 70 | id: logs 71 | attributes: 72 | label: Relevant log output 73 | description: > 74 | Please copy and paste any relevant log output. 75 | This will be automatically formatted into code, so no need for 76 | backticks. 77 | render: bash 78 | - type: textarea 79 | id: usercode 80 | attributes: 81 | label: User code 82 | description: > 83 | Please copy and paste any relevant user code. 84 | This will be automatically formatted into Python code, so no need for 85 | backticks. 86 | render: python 87 | - type: textarea 88 | id: additional 89 | attributes: 90 | label: Additional informations 91 | description: Please provide additional informations if available 92 | placeholder: Some more informations 93 | validations: 94 | required: false 95 | -------------------------------------------------------------------------------- /.github/workflows/test-release.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | # this file is *not* meant to cover or endorse the use of GitHub Actions, but 4 | # rather to help make automated test releases for this project 5 | 6 | name: Upload Python Package to test.pypi.org 7 | 8 | on: [pull_request] 9 | 10 | permissions: 11 | contents: write 12 | 13 | jobs: 14 | test-deploy: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v3 19 | - name: Set up Python 20 | uses: actions/setup-python@v3 21 | with: 22 | python-version: '3.9' 23 | - name: Install build dependencies 24 | run: | 25 | if [ -f requirements-deploy.txt ]; then pip install -r requirements-deploy.txt; fi 26 | - name: Build package 27 | run: | 28 | changelog2version \ 29 | --changelog_file changelog.md \ 30 | --version_file umodbus/version.py \ 31 | --version_file_type py \ 32 | --additional_version_info="-rc${{ github.run_number }}.dev${{ github.event.number }}" \ 33 | --debug 34 | python setup.py sdist 35 | - name: Test built package 36 | # sdist call creates non twine conform "*.orig" files, remove them 37 | run: | 38 | rm dist/*.orig 39 | twine check dist/*.tar.gz 40 | - name: Archive build package artifact 41 | uses: actions/upload-artifact@v3 42 | with: 43 | # https://docs.github.com/en/actions/learn-github-actions/contexts#github-context 44 | # ${{ github.repository }} and ${{ github.ref_name }} can't be used for artifact name due to unallowed '/' 45 | name: dist_repo.${{ github.event.repository.name }}_sha.${{ github.sha }}_build.${{ github.run_number }} 46 | path: dist/*.tar.gz 47 | retention-days: 14 48 | - name: Publish package 49 | uses: pypa/gh-action-pypi-publish@release/v1.5 50 | with: 51 | repository_url: https://test.pypi.org/legacy/ 52 | password: ${{ secrets.TEST_PYPI_API_TOKEN }} 53 | skip_existing: true 54 | verbose: true 55 | print_hash: true 56 | - name: 'Create changelog based prerelease' 57 | uses: brainelectronics/changelog-based-release@v1 58 | with: 59 | # note you'll typically need to create a personal access token 60 | # with permissions to create releases in the other repo 61 | # or you set the "contents" permissions to "write" as in this example 62 | changelog-path: changelog.md 63 | tag-name-prefix: '' 64 | tag-name-extension: '-rc${{ github.run_number }}.dev${{ github.event.number }}' 65 | release-name-prefix: '' 66 | release-name-extension: '-rc${{ github.run_number }}.dev${{ github.event.number }}' 67 | draft-release: true 68 | prerelease: true 69 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | # This workflow will install Python dependencies, run tests and lint with a 4 | # specific Python version 5 | # For more information see: 6 | # https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 7 | 8 | name: Test Python package 9 | 10 | on: 11 | push: 12 | # branches: [ $default-branch ] 13 | branches-ignore: 14 | - 'main' 15 | - 'develop' 16 | pull_request: 17 | branches: 18 | - 'main' 19 | - 'develop' 20 | 21 | permissions: 22 | contents: read 23 | 24 | jobs: 25 | build: 26 | runs-on: ubuntu-latest 27 | steps: 28 | - name: Checkout 29 | uses: actions/checkout@v3 30 | - name: Set up Python 31 | uses: actions/setup-python@v3 32 | with: 33 | python-version: '3.9' 34 | - name: Install test dependencies 35 | run: | 36 | pip install -r requirements-test.txt 37 | - name: Lint with flake8 38 | run: | 39 | flake8 . 40 | - name: Lint with yamllint 41 | run: | 42 | yamllint . 43 | - name: Validate package version file 44 | # the package version file has to be always up to date as mip is using 45 | # the file directly from the repo. On a PyPi package the version file 46 | # is updated and then packed 47 | run: | 48 | changelog2version \ 49 | --changelog_file changelog.md \ 50 | --version_file umodbus/version.py \ 51 | --version_file_type py \ 52 | --validate \ 53 | --debug 54 | - name: Validate mip package file 55 | run: | 56 | upy-package \ 57 | --setup_file setup.py \ 58 | --package_changelog_file changelog.md \ 59 | --package_file package.json \ 60 | --validate 61 | - name: Execute tests 62 | run: | 63 | docker build --tag micropython-test --file Dockerfile.tests . 64 | - name: Run Client/Host TCP example 65 | run: | 66 | docker compose up --build --exit-code-from micropython-host 67 | - name: Run Client/Host TCP test 68 | run: | 69 | docker compose -f docker-compose-tcp-test.yaml up --build --exit-code-from micropython-host 70 | - name: Run Client/Host RTU example 71 | run: | 72 | docker compose -f docker-compose-rtu.yaml up --build --exit-code-from micropython-host 73 | - name: Run Client/Host RTU test 74 | run: | 75 | docker compose -f docker-compose-rtu-test.yaml up --build --exit-code-from micropython-host 76 | - name: Install deploy dependencies 77 | run: | 78 | python -m pip install --upgrade pip 79 | if [ -f requirements-deploy.txt ]; then pip install -r requirements-deploy.txt; fi 80 | - name: Build package 81 | run: | 82 | python setup.py sdist 83 | rm dist/*.orig 84 | - name: Test built package 85 | run: | 86 | twine check dist/* 87 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # custom, package specific ignores 2 | .DS_Store 3 | .DS_Store? 4 | pymakr.conf 5 | config/config*.py 6 | thinking/ 7 | *.bin 8 | .idea 9 | *.bak 10 | 11 | *.o 12 | 13 | .vagrant/ 14 | 15 | # meson files under development 16 | untitled.meson.build 17 | 18 | # Byte-compiled / optimized / DLL files 19 | __pycache__/ 20 | *.py[cod] 21 | *$py.class 22 | 23 | # C extensions 24 | *.so 25 | 26 | # Distribution / packaging 27 | .Python 28 | build/ 29 | develop-eggs/ 30 | dist/ 31 | downloads/ 32 | eggs/ 33 | .eggs/ 34 | # micropython libs are stored in lib/ 35 | # lib/ 36 | lib64/ 37 | parts/ 38 | sdist/ 39 | var/ 40 | wheels/ 41 | share/python-wheels/ 42 | *.egg-info/ 43 | .installed.cfg 44 | *.egg 45 | MANIFEST 46 | 47 | # PyInstaller 48 | # Usually these files are written by a python script from a template 49 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 50 | *.manifest 51 | *.spec 52 | 53 | # Installer logs 54 | pip-log.txt 55 | pip-delete-this-directory.txt 56 | 57 | # Unit test / coverage reports 58 | htmlcov/ 59 | .tox/ 60 | .nox/ 61 | .coverage 62 | .coverage.* 63 | .cache 64 | nosetests.xml 65 | coverage.xml 66 | *.cover 67 | *.py,cover 68 | .hypothesis/ 69 | .pytest_cache/ 70 | cover/ 71 | 72 | # Translations 73 | *.mo 74 | *.pot 75 | 76 | # Django stuff: 77 | *.log 78 | local_settings.py 79 | db.sqlite3 80 | db.sqlite3-journal 81 | *.sqlite3 82 | 83 | # Flask stuff: 84 | instance/ 85 | .webassets-cache 86 | 87 | # Scrapy stuff: 88 | .scrapy 89 | 90 | # Sphinx documentation 91 | docs/_build/ 92 | 93 | # PyBuilder 94 | .pybuilder/ 95 | target/ 96 | 97 | # Jupyter Notebook 98 | .ipynb_checkpoints 99 | 100 | # IPython 101 | profile_default/ 102 | ipython_config.py 103 | 104 | # pyenv 105 | # For a library or package, you might want to ignore these files since the code is 106 | # intended to run in multiple environments; otherwise, check them in: 107 | # .python-version 108 | 109 | # pipenv 110 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 111 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 112 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 113 | # install all needed dependencies. 114 | #Pipfile.lock 115 | 116 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 117 | __pypackages__/ 118 | 119 | # Celery stuff 120 | celerybeat-schedule 121 | celerybeat.pid 122 | 123 | # SageMath parsed files 124 | *.sage.py 125 | 126 | # Environments 127 | .env 128 | .venv 129 | env/ 130 | venv/ 131 | ENV/ 132 | env.bak/ 133 | venv.bak/ 134 | 135 | # Spyder project settings 136 | .spyderproject 137 | .spyproject 138 | 139 | # Rope project settings 140 | .ropeproject 141 | 142 | # mkdocs documentation 143 | /site 144 | 145 | # mypy 146 | .mypy_cache/ 147 | .dmypy.json 148 | dmypy.json 149 | 150 | # Pyre type checker 151 | .pyre/ 152 | 153 | # pytype static type analyzer 154 | .pytype/ 155 | 156 | # Cython debug symbols 157 | cython_debug/ 158 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | 3 | import os 4 | import sys 5 | from pathlib import Path 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | sys.path.insert(0, os.path.abspath('../')) 14 | here = Path(__file__).parent.resolve() 15 | 16 | try: 17 | import umodbus 18 | except ImportError: 19 | raise SystemExit("umodbus and fakes have to be importable") 20 | else: 21 | # Inject mock modules so that we can build the 22 | # documentation without having the real stuff available 23 | from mock import Mock 24 | from fakes import machine 25 | 26 | sys.modules['micropython'] = Mock() 27 | print("Mocked 'micropython' module") 28 | sys.modules['machine'] = machine 29 | print("Imported 'machine' module from 'fakes'") 30 | 31 | # load elements of version.py 32 | exec(open(here / '..' / 'umodbus' / 'version.py').read()) 33 | 34 | # -- Project information 35 | 36 | project = 'micropython-modbus' 37 | copyright = '2023, brainelectronics' 38 | author = 'brainelectronics' 39 | 40 | version = __version__ 41 | release = version 42 | 43 | # -- General configuration 44 | 45 | # Add any Sphinx extension module names here, as strings. They can be 46 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 47 | # ones. 48 | extensions = [ 49 | 'myst_parser', 50 | 'sphinx.ext.autodoc', 51 | 'sphinx.ext.autosectionlabel', 52 | 'sphinx.ext.autosummary', 53 | 'sphinx.ext.doctest', 54 | 'sphinx.ext.duration', 55 | 'sphinx.ext.intersphinx', 56 | 'sphinx.ext.viewcode', 57 | ] 58 | autosectionlabel_prefix_document = True 59 | 60 | # The suffix of source filenames. 61 | source_suffix = ['.rst', '.md'] 62 | 63 | intersphinx_mapping = { 64 | 'python': ('https://docs.python.org/3/', None), 65 | 'sphinx': ('https://www.sphinx-doc.org/en/master/', None), 66 | } 67 | intersphinx_disabled_domains = ['std'] 68 | suppress_warnings = [ 69 | # throws an error due to not found reference targets to files not in docs/ 70 | 'ref.myst', 71 | # throws an error due to multiple "Added" labels in "changelog.md" 72 | 'autosectionlabel.*' 73 | ] 74 | 75 | # A list of regular expressions that match URIs that should not be checked 76 | # when doing a linkcheck build. 77 | linkcheck_ignore = [ 78 | # tag 2.0.0 did not exist during docs introduction 79 | 'https://github.com/brainelectronics/micropython-modbus/tree/2.0.0', 80 | # RTD page did not exist during docs introduction 81 | 'https://micropython-modbus.readthedocs.io/en/latest/', 82 | ] 83 | 84 | templates_path = ['_templates'] 85 | 86 | # -- Options for HTML output 87 | 88 | # The theme to use for HTML and HTML Help pages. See the documentation for 89 | # a list of builtin themes. 90 | # 91 | html_theme = 'sphinx_rtd_theme' 92 | 93 | # -- Options for EPUB output 94 | epub_show_urls = 'footnote' 95 | -------------------------------------------------------------------------------- /umodbus/typing.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: UTF-8 -*- 3 | 4 | """ 5 | Fake classes of typing module. 6 | 7 | https://github.com/micropython/micropython-lib/issues/190 8 | 9 | https://github.com/micropython/micropython-lib/blob/3d779b8ceab5b65b9f70accbcbb15ab3509eceb7/typing/typing.py 10 | """ 11 | 12 | 13 | class _Subscriptable(): 14 | def __getitem__(self, item): 15 | return None 16 | 17 | 18 | _subscriptable = _Subscriptable() 19 | 20 | 21 | class Any: 22 | pass 23 | 24 | 25 | class NoReturn: 26 | pass 27 | 28 | 29 | class ClassVar: 30 | pass 31 | 32 | 33 | Union = _subscriptable 34 | 35 | 36 | Optional = _subscriptable 37 | 38 | 39 | class Generic: 40 | pass 41 | 42 | 43 | class NamedTuple: 44 | pass 45 | 46 | 47 | class Hashable: 48 | pass 49 | 50 | 51 | class Awaitable: 52 | pass 53 | 54 | 55 | class Coroutine: 56 | pass 57 | 58 | 59 | class AsyncIterable: 60 | pass 61 | 62 | 63 | class AsyncIterator: 64 | pass 65 | 66 | 67 | class Iterable: 68 | pass 69 | 70 | 71 | class Iterator: 72 | pass 73 | 74 | 75 | class Reversible: 76 | pass 77 | 78 | 79 | class Sized: 80 | pass 81 | 82 | 83 | class Container: 84 | pass 85 | 86 | 87 | class Collection: 88 | pass 89 | 90 | 91 | # class Callable: 92 | # pass 93 | Callable = _subscriptable 94 | 95 | 96 | class AbstractSet: 97 | pass 98 | 99 | 100 | class MutableSet: 101 | pass 102 | 103 | 104 | class Mapping: 105 | pass 106 | 107 | 108 | class MutableMapping: 109 | pass 110 | 111 | 112 | class Sequence: 113 | pass 114 | 115 | 116 | class MutableSequence: 117 | pass 118 | 119 | 120 | class ByteString: 121 | pass 122 | 123 | 124 | Tuple = _subscriptable 125 | 126 | 127 | List = _subscriptable 128 | 129 | 130 | class Deque: 131 | pass 132 | 133 | 134 | class Set: 135 | pass 136 | 137 | 138 | class dict_keys: 139 | pass 140 | 141 | 142 | class FrozenSet: 143 | pass 144 | 145 | 146 | class MappingView: 147 | pass 148 | 149 | 150 | class KeysView: 151 | pass 152 | 153 | 154 | class ItemsView: 155 | pass 156 | 157 | 158 | class ValuesView: 159 | pass 160 | 161 | 162 | class ContextManager: 163 | pass 164 | 165 | 166 | class AsyncContextManager: 167 | pass 168 | 169 | 170 | Dict = _subscriptable 171 | 172 | 173 | class DefaultDict: 174 | pass 175 | 176 | 177 | class Counter: 178 | pass 179 | 180 | 181 | class ChainMap: 182 | pass 183 | 184 | 185 | class Generator: 186 | pass 187 | 188 | 189 | class AsyncGenerator: 190 | pass 191 | 192 | 193 | class Type: 194 | pass 195 | 196 | 197 | def cast(typ, val): 198 | return val 199 | 200 | 201 | def _overload_dummy(*args, **kwds): 202 | """Helper for @overload to raise when called.""" 203 | raise NotImplementedError( 204 | "You should not call an overloaded function. " 205 | "A series of @overload-decorated functions " 206 | "outside a stub module should always be followed " 207 | "by an implementation that is not @overload-ed." 208 | ) 209 | 210 | 211 | def overload(): 212 | return _overload_dummy 213 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | # Configuration for flake8 analysis 2 | [flake8] 3 | # Set the maximum length that any line (with some exceptions) may be. 4 | # max-line-length = 120 5 | 6 | # Set the maximum length that a comment or docstring line may be. 7 | # max-doc-length = 120 8 | 9 | # Set the maximum allowed McCabe complexity value for a block of code. 10 | # max-complexity = 15 11 | 12 | # Specify a list of codes to ignore. 13 | # D107: Missing docstring in __init__ 14 | # D400: First line should end with a period 15 | # E501: Line too long (82 > 79 characters) 16 | # W504: line break after binary operator -> Cannot break line with a long pathlib Path 17 | # D204: 1 blank line required after class docstring 18 | ignore = D107, D400, E501, W504, D204 19 | 20 | # Specify a list of mappings of files and the codes that should be ignored for the entirety of the file. 21 | per-file-ignores = 22 | tests/*:D101,D102,D104 23 | umodbus/const.py:F821 24 | 25 | # Provide a comma-separated list of glob patterns to exclude from checks. 26 | exclude = 27 | # No need to traverse our git directory 28 | .git, 29 | # Python virtual environments 30 | .venv, 31 | # tox virtual environments 32 | .tox, 33 | # There's no value in checking cache directories 34 | __pycache__, 35 | # The conf file is mostly autogenerated, ignore it 36 | docs/conf.py, 37 | # This contains our built documentation 38 | build, 39 | # This contains builds that we don't want to check 40 | dist, 41 | # We don't use __init__.py for scripts 42 | __init__.py 43 | # example testing folder before going live 44 | thinking 45 | .idea 46 | .bak 47 | # custom scripts, not being part of the distribution 48 | libs_external 49 | modules 50 | sdist_upip.py 51 | setup.py 52 | 53 | # Provide a comma-separated list of glob patterns to add to the list of excluded ones. 54 | # extend-exclude = 55 | # legacy/, 56 | # vendor/ 57 | 58 | # Provide a comma-separate list of glob patterns to include for checks. 59 | # filename = 60 | # example.py, 61 | # another-example*.py 62 | 63 | # Enable PyFlakes syntax checking of doctests in docstrings. 64 | doctests = False 65 | 66 | # Specify which files are checked by PyFlakes for doctest syntax. 67 | # include-in-doctest = 68 | # dir/subdir/file.py, 69 | # dir/other/file.py 70 | 71 | # Specify which files are not to be checked by PyFlakes for doctest syntax. 72 | # exclude-from-doctest = 73 | # tests/* 74 | 75 | # Enable off-by-default extensions. 76 | # enable-extensions = 77 | # H111, 78 | # G123 79 | 80 | # If True, report all errors, even if it is on the same line as a # NOQA comment. 81 | disable-noqa = False 82 | 83 | # Specify the number of subprocesses that Flake8 will use to run checks in parallel. 84 | jobs = auto 85 | 86 | # Also print output to stdout if output-file has been configured. 87 | tee = True 88 | 89 | # Count the number of occurrences of each error/warning code and print a report. 90 | statistics = True 91 | 92 | # Print the total number of errors. 93 | count = True 94 | 95 | # Print the source code generating the error/warning in question. 96 | show-source = True 97 | 98 | # Decrease the verbosity of Flake8's output. Each time you specify it, it will print less and less information. 99 | quiet = 0 100 | 101 | # Select the formatter used to display errors to the user. 102 | format = pylint 103 | 104 | [pydocstyle] 105 | # choose the basic list of checked errors by specifying an existing convention. Possible conventions: pep257, numpy, google. 106 | convention = pep257 107 | 108 | # check only files that exactly match regular expression 109 | # match = (?!test_).*\.py 110 | 111 | # search only dirs that exactly match regular expression 112 | # match_dir = [^\.].* 113 | 114 | # ignore any functions or methods that are decorated by a function with a name fitting the regular expression. 115 | # ignore_decorators = 116 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: UTF-8 -*- 3 | 4 | """ 5 | Main script 6 | 7 | Do your stuff here, this file is similar to the loop() function on Arduino 8 | 9 | Create a ModbusBridge betweeen a RTU client (slave) and act as host (master) 10 | on TCP to provide the data of the client and accept settings of new register 11 | values on it. 12 | 13 | The TCP port and RTU communication pins can be choosen freely. The register 14 | definitions of the client as well as its connection settings like bus address 15 | and UART communication speed are defined in the JSON file at 16 | 'registers/modbusRegisters-MyEVSE.json'. 17 | """ 18 | 19 | # system packages 20 | import time 21 | import machine 22 | 23 | # custom packages 24 | from be_helpers.modbus_bridge import ModbusBridge 25 | # from be_helpers.generic_helper import GenericHelper 26 | 27 | register_file = 'registers/modbusRegisters-MyEVSE.json' 28 | rtu_pins = (25, 26) # (TX, RX) 29 | tcp_port = 180 # TCP port for Modbus connection 30 | run_time = 120 # run this example for this amount of seconds 31 | 32 | # default level is 'warning', may use custom logger to get initial log data 33 | mb_bridge = ModbusBridge(register_file=register_file) 34 | 35 | # for testing use 'debug' level 36 | # for beta testing with full BE32-01 board use 'info' level 37 | # for production use 'warning' level, default 38 | # GenericHelper.set_level(mb_bridge.logger, 'info') 39 | 40 | print('##### MAIN TEST PRINT CONTENT BEGIN #####') 41 | print('Register file: {}'.format(mb_bridge.register_file)) 42 | print('Connection settings:') 43 | print('\t Host: {}'.format(mb_bridge.connection_settings_host)) 44 | print('\t Client: {}'.format(mb_bridge.connection_settings_client)) 45 | print('\t Host Unit: {}'.format(mb_bridge.host_unit)) 46 | print('\t Client Unit: {}'.format(mb_bridge.client_unit)) 47 | 48 | # define and apply Modbus TCP host settings 49 | host_settings = { 50 | 'type': 'tcp', 51 | 'unit': tcp_port, 52 | 'address': -1, 53 | 'baudrate': -1, 54 | 'mode': 'master' 55 | } 56 | mb_bridge.connection_settings_host = host_settings 57 | 58 | print('Updated connection settings:') 59 | print('\t Host: {}'.format(mb_bridge.connection_settings_host)) 60 | print('\t Client: {}'.format(mb_bridge.connection_settings_client)) 61 | print('\t Host Unit: {}'.format(mb_bridge.host_unit)) 62 | print('\t Client Unit: {}'.format(mb_bridge.client_unit)) 63 | 64 | # setup Modbus connections to host and client 65 | mb_bridge.setup_connection(pins=rtu_pins) # (TX, RX) 66 | 67 | print('Modbus instances:') 68 | print('\t Act as Host: {} on {}'.format(mb_bridge.host, mb_bridge.host_unit)) 69 | print('\t Act as Client: {} on {}'.format(mb_bridge.client, 70 | mb_bridge.client_unit)) 71 | 72 | # start collecting latest RTU client data in thread and TCP data provision 73 | mb_bridge.collecting_client_data = True 74 | mb_bridge.provisioning_host_data = True 75 | 76 | print('Run client and host for {} seconds'.format(run_time)) 77 | print('Collect latest client data every {} seconds'. 78 | format(mb_bridge.collection_interval)) 79 | print('Synchronize Host-Client every {} seconds'. 80 | format(mb_bridge.synchronisation_interval)) 81 | 82 | print('##### MAIN TEST PRINT CONTENT END #####') 83 | 84 | start_time = time.time() 85 | while time.time() < (start_time + run_time): 86 | try: 87 | machine.idle() 88 | except KeyboardInterrupt: 89 | print('KeyboardInterrupt, stop collection + provisioning after {}'. 90 | format(time.time() - start_time)) 91 | break 92 | except Exception as e: 93 | print('Exception: {}'.format(e)) 94 | 95 | # stop collecting latest client data in thread and data provision via TCP 96 | mb_bridge.collecting_client_data = False 97 | mb_bridge.provisioning_host_data = False 98 | 99 | # wait for 5 more seconds to safely finish the may still running threads 100 | time.sleep(5) 101 | 102 | print('Returning to REPL') 103 | -------------------------------------------------------------------------------- /tests/test_const.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: UTF-8 -*- 3 | """Unittest for testing const definitions of umodbus""" 4 | 5 | from umodbus.typing import List 6 | import ulogging as logging 7 | import mpy_unittest as unittest 8 | from umodbus import const as Const 9 | 10 | 11 | class TestConst(unittest.TestCase): 12 | def setUp(self) -> None: 13 | """Run before every test method""" 14 | # set basic config and level for the logger 15 | logging.basicConfig(level=logging.INFO) 16 | 17 | # create a logger for this TestSuite 18 | self.test_logger = logging.getLogger(__name__) 19 | 20 | # set the test logger level 21 | self.test_logger.setLevel(logging.DEBUG) 22 | 23 | # enable/disable the log output of the device logger for the tests 24 | # if enabled log data inside this test will be printed 25 | self.test_logger.disabled = False 26 | 27 | def test_function_codes(self) -> None: 28 | """Test Modbus function codes""" 29 | self.assertEqual(Const.READ_COILS, 0x01) 30 | self.assertEqual(Const.READ_DISCRETE_INPUTS, 0x02) 31 | self.assertEqual(Const.READ_HOLDING_REGISTERS, 0x03) 32 | self.assertEqual(Const.READ_INPUT_REGISTER, 0x04) 33 | 34 | self.assertEqual(Const.WRITE_SINGLE_COIL, 0x05) 35 | self.assertEqual(Const.WRITE_SINGLE_REGISTER, 0x06) 36 | self.assertEqual(Const.WRITE_MULTIPLE_COILS, 0x0F) 37 | self.assertEqual(Const.WRITE_MULTIPLE_REGISTERS, 0x10) 38 | 39 | self.assertEqual(Const.MASK_WRITE_REGISTER, 0x16) 40 | self.assertEqual(Const.READ_WRITE_MULTIPLE_REGISTERS, 0x17) 41 | 42 | self.assertEqual(Const.READ_FIFO_QUEUE, 0x18) 43 | 44 | self.assertEqual(Const.READ_FILE_RECORD, 0x14) 45 | self.assertEqual(Const.WRITE_FILE_RECORD, 0x15) 46 | 47 | self.assertEqual(Const.READ_EXCEPTION_STATUS, 0x07) 48 | self.assertEqual(Const.DIAGNOSTICS, 0x08) 49 | self.assertEqual(Const.GET_COM_EVENT_COUNTER, 0x0B) 50 | self.assertEqual(Const.GET_COM_EVENT_LOG, 0x0C) 51 | self.assertEqual(Const.REPORT_SERVER_ID, 0x11) 52 | self.assertEqual(Const.READ_DEVICE_IDENTIFICATION, 0x2B) 53 | 54 | def test_exception_codes(self) -> None: 55 | """Test Modbus exception codes""" 56 | self.assertEqual(Const.ILLEGAL_FUNCTION, 0x01) 57 | self.assertEqual(Const.ILLEGAL_DATA_ADDRESS, 0x02) 58 | self.assertEqual(Const.ILLEGAL_DATA_VALUE, 0x03) 59 | self.assertEqual(Const.SERVER_DEVICE_FAILURE, 0x04) 60 | self.assertEqual(Const.ACKNOWLEDGE, 0x05) 61 | self.assertEqual(Const.SERVER_DEVICE_BUSY, 0x06) 62 | self.assertEqual(Const.MEMORY_PARITY_ERROR, 0x08) 63 | self.assertEqual(Const.GATEWAY_PATH_UNAVAILABLE, 0x0A) 64 | self.assertEqual(Const.DEVICE_FAILED_TO_RESPOND, 0x0B) 65 | 66 | def test_pdu_constants(self) -> None: 67 | """Test Modbus Protocol Data Unit constants""" 68 | self.assertEqual(Const.CRC_LENGTH, 0x02) 69 | self.assertEqual(Const.ERROR_BIAS, 0x80) 70 | self.assertEqual(Const.RESPONSE_HDR_LENGTH, 0x02) 71 | self.assertEqual(Const.ERROR_RESP_LEN, 0x05) 72 | self.assertEqual(Const.FIXED_RESP_LEN, 0x08) 73 | self.assertEqual(Const.MBAP_HDR_LENGTH, 0x07) 74 | 75 | def test_crc16_table(self): 76 | """Test CRC16-Modbus table""" 77 | def generate_crc16_table() -> List[int, ...]: 78 | crc_table = [] 79 | for byte in range(256): 80 | crc = 0x0000 81 | for _ in range(8): 82 | if (byte ^ crc) & 0x0001: 83 | crc = (crc >> 1) ^ 0xa001 84 | else: 85 | crc >>= 1 86 | byte >>= 1 87 | crc_table.append(crc) 88 | return crc_table 89 | 90 | crc16_table = generate_crc16_table() 91 | self.assertEqual(len(crc16_table), 256) 92 | self.assertEqual(len(Const.CRC16_TABLE), 256) 93 | for idx, ele in enumerate(crc16_table): 94 | with self.subTest(ele=ele, idx=idx): 95 | self.assertEqual(ele, Const.CRC16_TABLE[idx]) 96 | 97 | def tearDown(self) -> None: 98 | """Run after every test method""" 99 | pass 100 | 101 | 102 | if __name__ == '__main__': 103 | unittest.main() 104 | -------------------------------------------------------------------------------- /tests/test_absolute_truth.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: UTF-8 -*- 3 | """Unittest for testing the absolute truth""" 4 | 5 | # import sys 6 | import ulogging as logging 7 | import mpy_unittest as unittest 8 | 9 | 10 | class TestAbsoluteTruth(unittest.TestCase): 11 | def setUp(self) -> None: 12 | """Run before every test method""" 13 | # set basic config and level for the logger 14 | logging.basicConfig(level=logging.INFO) 15 | 16 | # create a logger for this TestSuite 17 | self.test_logger = logging.getLogger(__name__) 18 | 19 | # set the test logger level 20 | self.test_logger.setLevel(logging.DEBUG) 21 | 22 | # enable/disable the log output of the device logger for the tests 23 | # if enabled log data inside this test will be printed 24 | self.test_logger.disabled = False 25 | 26 | def test_absolute_truth(self) -> None: 27 | """Test the unittest itself""" 28 | x = 0 29 | y = 1 30 | z = 2 31 | none_thing = None 32 | some_dict = dict() 33 | some_list = [x, y, 40, "asdf", z] 34 | 35 | self.assertTrue(True) 36 | self.assertFalse(False) 37 | 38 | self.assertEqual(y, 1) 39 | assert y == 1 40 | with self.assertRaises(AssertionError): 41 | self.assertEqual(x, y) 42 | 43 | self.assertNotEqual(x, y) 44 | assert x != y 45 | with self.assertRaises(AssertionError): 46 | self.assertNotEqual(x, x) 47 | 48 | self.assertIs(some_list, some_list) 49 | self.assertIsNot(some_list, some_dict) 50 | 51 | self.assertIsNone(none_thing) 52 | self.assertIsNotNone(some_dict) 53 | 54 | self.assertIn(y, some_list) 55 | self.assertNotIn(12, some_list) 56 | 57 | # self.assertRaises(exc, fun, args, *kwds) 58 | with self.assertRaises(ZeroDivisionError): 59 | 1 / 0 60 | 61 | self.assertIsInstance(some_dict, dict) 62 | self.assertNotIsInstance(some_list, dict) 63 | 64 | self.assertGreater(y, x) 65 | self.assertGreaterEqual(y, x) 66 | self.assertLess(x, y) 67 | self.assertLessEqual(x, y) 68 | 69 | self.test_logger.warning('Dummy logger warning') 70 | 71 | def testAssert(self): 72 | e1 = None 73 | try: 74 | def func_under_test(a): 75 | assert a > 10 76 | 77 | self.assertRaises(AssertionError, func_under_test, 20) 78 | except AssertionError as e: 79 | e1 = e 80 | 81 | if not e1 or "not raised" not in e1.args[0]: 82 | self.fail("Expected to catch lack of AssertionError from assert \ 83 | in func_under_test") 84 | 85 | @unittest.skip('Reasoning for skipping this test') 86 | def testSkip(self): 87 | self.fail('this should be skipped') 88 | 89 | def testSkipNoDecorator(self): 90 | do_skip = True 91 | 92 | if do_skip: 93 | self.skipTest("External resource triggered skipping this test") 94 | 95 | self.fail('this should be skipped') 96 | 97 | @unittest.skipIf('a' in ['a', 'b'], 'Reasoning for skipping another test') 98 | def testSkipIf(self): 99 | self.fail('this should be skipped') 100 | 101 | @unittest.skipUnless(42 == 24, 'Reasoning for skipping test 42') 102 | def testSkipUnless(self): 103 | self.fail('this should be skipped') 104 | 105 | @unittest.expectedFailure 106 | def testExpectedFailure(self): 107 | self.assertEqual(1, 0) 108 | 109 | def testExpectedFailureNot(self): 110 | @unittest.expectedFailure 111 | def testInner(): 112 | self.assertEqual(1, 1) 113 | try: 114 | testInner() 115 | except: # noqa: E722 116 | pass 117 | else: 118 | self.fail("Unexpected success was not detected") 119 | 120 | @unittest.expectedFailure 121 | def testSubTest(self): 122 | for i in range(0, 6): 123 | with self.subTest(i=i): 124 | # will fail on 1, 3, 5 125 | # but expect the failure by using the expectedFailure decorator 126 | self.assertEqual(i % 2, 0) 127 | 128 | def tearDown(self) -> None: 129 | """Run after every test method""" 130 | pass 131 | 132 | 133 | if __name__ == '__main__': 134 | unittest.main() 135 | -------------------------------------------------------------------------------- /registers/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "COILS": { 3 | "RESET_REGISTER_DATA_COIL": { 4 | "register": 42, 5 | "len": 1, 6 | "val": 0, 7 | "description": "Set this COIL to true to reset all register values back to the default state/value", 8 | "range": "", 9 | "unit": "" 10 | }, 11 | "EXAMPLE_COIL": { 12 | "register": 123, 13 | "len": 1, 14 | "val": 1, 15 | "description": "Example COILS register, Coils (setter+getter) [0, 1]", 16 | "range": "", 17 | "unit": "" 18 | }, 19 | "EXAMPLE_COIL_OFF": { 20 | "register": 124, 21 | "len": 1, 22 | "val": 0, 23 | "description": "Example COILS register, Coils (setter+getter) [0, 1]", 24 | "range": "", 25 | "unit": "" 26 | }, 27 | "EXAMPLE_COIL_MIXED": { 28 | "register": 125, 29 | "len": 2, 30 | "val": [1, 0], 31 | "description": "Example COILS registers with length of 2, Coils (setter+getter) [0, 1]", 32 | "range": "", 33 | "unit": "" 34 | }, 35 | "ANOTHER_EXAMPLE_COIL": { 36 | "register": 127, 37 | "len": 3, 38 | "val": [0, 1, 0], 39 | "description": "Example COILS registers with length of 3, Coils (setter+getter) [0, 1]", 40 | "range": "", 41 | "unit": "" 42 | }, 43 | "MANY_COILS": { 44 | "register": 150, 45 | "len": 19, 46 | "val": [ 47 | 1, 0, 1, 1, 0, 0, 1, 1, 48 | 1, 1, 0, 1, 0, 1, 1, 0, 49 | 1, 0, 1 50 | ], 51 | "description": "Example COILS registers with length of 19, representing Modbus_Application_Protocol_V1_1b3 Read Coils example, Coils (setter+getter) [0, 1]", 52 | "range": "", 53 | "unit": "" 54 | } 55 | }, 56 | "HREGS": { 57 | "EXAMPLE_HREG_NEGATIVE": { 58 | "register": 92, 59 | "len": 1, 60 | "val": -29, 61 | "description": "Example HREGS register, Hregs (setter+getter) [0, 65535]", 62 | "range": "", 63 | "unit": "" 64 | }, 65 | "EXAMPLE_HREG": { 66 | "register": 93, 67 | "len": 1, 68 | "val": 19, 69 | "description": "Example HREGS register, Hregs (setter+getter) [0, 65535]", 70 | "range": "", 71 | "unit": "" 72 | }, 73 | "ANOTHER_EXAMPLE_HREG": { 74 | "register": 94, 75 | "len": 3, 76 | "val": [29, 38, 0], 77 | "description": "Example HREGS registers with length of 3, Hregs (setter+getter) [0, 65535]", 78 | "range": "", 79 | "unit": "" 80 | } 81 | }, 82 | "ISTS": { 83 | "EXAMPLE_ISTS": { 84 | "register": 67, 85 | "len": 1, 86 | "val": 0, 87 | "description": "Example ISTS register, Ists (only getter) [0, 1]", 88 | "range": "", 89 | "unit": "" 90 | }, 91 | "EXAMPLE_ISTS_MIXED": { 92 | "register": 68, 93 | "len": 2, 94 | "val": [1, 0], 95 | "description": "Example ISTS registers with length of 2, Ists (only getter) [0, 1]", 96 | "range": "", 97 | "unit": "" 98 | }, 99 | "ANOTHER_EXAMPLE_ISTS": { 100 | "register": 70, 101 | "len": 3, 102 | "val": [0, 1, 0], 103 | "description": "Example ISTS registers with length of 3, Ists (only getter) [0, 1]", 104 | "range": "", 105 | "unit": "" 106 | } 107 | }, 108 | "IREGS": { 109 | "EXAMPLE_IREG": { 110 | "register": 10, 111 | "len": 1, 112 | "val": 60001, 113 | "description": "Example IREGS register, Iregs (only getter) [0, 65535]", 114 | "range": "", 115 | "unit": "" 116 | }, 117 | "ANOTHER_EXAMPLE_IREG": { 118 | "register": 11, 119 | "len": 3, 120 | "val": [59123, 0, 390], 121 | "description": "Example IREGS registers with length of 3, Iregs (only getter) [0, 65535]", 122 | "range": "", 123 | "unit": "" 124 | } 125 | } 126 | } -------------------------------------------------------------------------------- /sdist_upip.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: UTF-8 -*- 3 | # This module is part of Pycopy https://github.com/pfalcon/pycopy 4 | # and pycopy-lib https://github.com/pfalcon/pycopy-lib, projects to 5 | # create a (very) lightweight full-stack Python distribution. 6 | # 7 | # Copyright (c) 2016-2019 Paul Sokolovsky 8 | # Licence: MIT 9 | # 10 | # This module overrides distutils (also compatible with setuptools) "sdist" 11 | # command to perform pre- and post-processing as required for MicroPython's 12 | # upip package manager. 13 | # 14 | # Preprocessing steps: 15 | # * Creation of Python resource module (R.py) from each top-level package's 16 | # resources. 17 | # Postprocessing steps: 18 | # * Removing metadata files not used by upip (this includes setup.py) 19 | # * Recompressing gzip archive with 4K dictionary size so it can be 20 | # installed even on low-heap targets. 21 | # 22 | import sys 23 | import os 24 | import zlib 25 | import tarfile 26 | import re 27 | import io 28 | 29 | from distutils.filelist import FileList 30 | from setuptools.command.sdist import sdist as _sdist 31 | 32 | 33 | FILTERS = [ 34 | # include, exclude, repeat 35 | (r".+\.egg-info/(PKG-INFO|requires\.txt)", r"setup.py$"), 36 | (r".+\.py$", r"[^/]+$"), 37 | (None, r".+\.egg-info/.+"), 38 | ] 39 | outbuf = io.BytesIO() 40 | 41 | 42 | def gzip_4k(inf, fname): 43 | comp = zlib.compressobj(level=9, wbits=16 + 12) 44 | with open(fname + ".out", "wb") as outf: 45 | while 1: 46 | data = inf.read(1024) 47 | if not data: 48 | break 49 | outf.write(comp.compress(data)) 50 | outf.write(comp.flush()) 51 | os.rename(fname, fname + ".orig") 52 | os.rename(fname + ".out", fname) 53 | 54 | 55 | def filter_tar(name): 56 | fin = tarfile.open(name, "r:gz") 57 | fout = tarfile.open(fileobj=outbuf, mode="w") 58 | for info in fin: 59 | # print(info) 60 | if not "/" in info.name: 61 | continue 62 | fname = info.name.split("/", 1)[1] 63 | include = None 64 | 65 | for inc_re, exc_re in FILTERS: 66 | if include is None and inc_re: 67 | if re.match(inc_re, fname): 68 | include = True 69 | 70 | if include is None and exc_re: 71 | if re.match(exc_re, fname): 72 | include = False 73 | 74 | if include is None: 75 | include = True 76 | 77 | if include: 78 | print("including:", fname) 79 | else: 80 | print("excluding:", fname) 81 | continue 82 | 83 | farch = fin.extractfile(info) 84 | fout.addfile(info, farch) 85 | fout.close() 86 | fin.close() 87 | 88 | 89 | def make_resource_module(manifest_files): 90 | resources = [] 91 | # Any non-python file included in manifest is resource 92 | for fname in manifest_files: 93 | ext = fname.rsplit(".", 1)[1] 94 | if ext != "py": 95 | resources.append(fname) 96 | 97 | if resources: 98 | print("creating resource module R.py") 99 | resources.sort() 100 | last_pkg = None 101 | r_file = None 102 | for fname in resources: 103 | try: 104 | pkg, res_name = fname.split("/", 1) 105 | except ValueError: 106 | print("not treating %s as a resource" % fname) 107 | continue 108 | if last_pkg != pkg: 109 | last_pkg = pkg 110 | if r_file: 111 | r_file.write("}\n") 112 | r_file.close() 113 | r_file = open(pkg + "/R.py", "w") 114 | r_file.write("R = {\n") 115 | 116 | with open(fname, "rb") as f: 117 | r_file.write("%r: %r,\n" % (res_name, f.read())) 118 | 119 | if r_file: 120 | r_file.write("}\n") 121 | r_file.close() 122 | 123 | 124 | class sdist(_sdist): 125 | 126 | def run(self): 127 | self.filelist = FileList() 128 | self.get_file_list() 129 | make_resource_module(self.filelist.files) 130 | 131 | r = super().run() 132 | 133 | assert len(self.archive_files) == 1 134 | print("filtering files and recompressing with 4K dictionary") 135 | filter_tar(self.archive_files[0]) 136 | outbuf.seek(0) 137 | gzip_4k(outbuf, self.archive_files[0]) 138 | 139 | return r 140 | 141 | 142 | # For testing only 143 | if __name__ == "__main__": 144 | filter_tar(sys.argv[1]) 145 | outbuf.seek(0) 146 | gzip_4k(outbuf, sys.argv[1]) 147 | -------------------------------------------------------------------------------- /fakes/queue.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: UTF-8 -*- 3 | 4 | """ 5 | queue.py: adapted from uasyncio V2 6 | 7 | Copyright (c) 2018-2020 Peter Hinch 8 | Released under the MIT License (MIT) - see LICENSE file 9 | 10 | Code is based on Paul Sokolovsky's work. 11 | This is a temporary solution until uasyncio V3 gets an efficient official 12 | version 13 | """ 14 | 15 | try: 16 | import uasyncio as asyncio 17 | except ImportError: 18 | import asyncio 19 | 20 | 21 | class QueueEmpty(Exception): 22 | """Exception raised by get_nowait()""" 23 | pass 24 | 25 | 26 | class QueueFull(Exception): 27 | """Exception raised by put_nowait()""" 28 | pass 29 | 30 | 31 | class Queue: 32 | """AsyncIO based Queue""" 33 | def __init__(self, maxsize: int = 0): 34 | self.maxsize = maxsize 35 | self._queue = [] 36 | self._evput = asyncio.Event() # Triggered by put, tested by get 37 | self._evget = asyncio.Event() # Triggered by get, tested by put 38 | 39 | def _get(self): 40 | """ 41 | Remove and return an item from the queue without blocking 42 | 43 | :returns: Return an item if one is immediately available 44 | :rtype: Any 45 | """ 46 | # Schedule all tasks waiting on get 47 | self._evget.set() 48 | self._evget.clear() 49 | return self._queue.pop(0) 50 | 51 | async def get(self): 52 | """ 53 | Remove and return an item from the queue in async blocking mode 54 | 55 | Usage: item = await queue.get() 56 | 57 | :returns: Return an item if one is immediately available 58 | :rtype: Any 59 | """ 60 | # May be multiple tasks waiting on get() 61 | while self.empty(): 62 | # Queue is empty, suspend task until a put occurs 63 | # 1st of N tasks gets, the rest loop again 64 | await self._evput.wait() 65 | return self._get() 66 | 67 | def get_nowait(self): 68 | """ 69 | Remove and return an item from the queue without blocking 70 | 71 | :returns: Return an item if one is immediately available 72 | :rtype: Any 73 | 74 | :raises QueueEmpty: Queue is empty 75 | :raises QueueFull: Queue is full 76 | """ 77 | if self.empty(): 78 | raise QueueEmpty() 79 | return self._get() 80 | 81 | def _put(self, val) -> None: 82 | """ 83 | Put an item into the queue without blocking 84 | 85 | :param val: The value 86 | :type val: Any 87 | """ 88 | # Schedule tasks waiting on put 89 | self._evput.set() 90 | self._evput.clear() 91 | self._queue.append(val) 92 | 93 | async def put(self, val) -> None: 94 | """ 95 | Put an item into the queue in async blocking mode 96 | 97 | Usage: await queue.put(item) 98 | 99 | :param val: The value 100 | :type val: Any 101 | 102 | :raises QueueFull: Queue is full 103 | """ 104 | while self.full(): 105 | # Queue full 106 | await self._evget.wait() 107 | # Task(s) waiting to get from queue, schedule first Task 108 | self._put(val) 109 | 110 | def put_nowait(self, val) -> None: 111 | """ 112 | Put an item into the queue without blocking 113 | 114 | :param val: The value 115 | :type val: Any 116 | 117 | :raises QueueFull: Queue is full 118 | """ 119 | if self.full(): 120 | raise QueueFull() 121 | self._put(val) 122 | 123 | def qsize(self) -> int: 124 | """ 125 | Get number of items in the queue 126 | 127 | :returns: Number of items in the queue 128 | :rtype: int 129 | """ 130 | return len(self._queue) 131 | 132 | def empty(self) -> bool: 133 | """ 134 | Check is queue is empty 135 | 136 | :returns: Return True if the queue is empty, False otherwise 137 | :rtype: bool 138 | """ 139 | return len(self._queue) == 0 140 | 141 | def full(self) -> bool: 142 | """ 143 | Check if queue is full 144 | 145 | Note: if the Queue was initialized with maxsize=0 (the default) or 146 | any negative number, then full() is never True. 147 | 148 | :returns: Return True if there are maxsize items in the queue. 149 | :rtype: bool 150 | """ 151 | return self.maxsize > 0 and self.qsize() >= self.maxsize 152 | -------------------------------------------------------------------------------- /docs/UPGRADE.md: -------------------------------------------------------------------------------- 1 | # Upgrade Guide 2 | 3 | Detailed upgrade guide for upgrading between breaking versions 4 | 5 | --------------- 6 | 7 | ## Intro 8 | 9 | As this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html) 10 | this document thereby describes the necessary steps to upgrade between two 11 | major versions. 12 | 13 | ## Upgrade from major version 1 to 2 14 | 15 | ### Overview 16 | 17 | This is a compressed extraction of the changelog 18 | 19 | > - Remove dependency to `Serial` and `requests` from `umodbus.modbus`, see [#18](https://github.com/brainelectronics/micropython-modbus/issues/18) 20 | > - `ModbusRTU` class is part of [serial.py](umodbus/serial.py), see [#18](https://github.com/brainelectronics/micropython-modbus/issues/18) 21 | > - `ModbusTCP` class is part of [tcp.py](umodbus/tcp.py), see [#18](https://github.com/brainelectronics/micropython-modbus/issues/18) 22 | > - `ModbusRTU` and `ModbusTCP` classes and related functions removed from [modbus.py](umodbus/modbus.py), see [#18](https://github.com/brainelectronics/micropython-modbus/issues/18) 23 | > - Imports changed from: 24 | > - `from umodbus.modbus import ModbusRTU` to `from umodbus.serial import ModbusRTU` 25 | > - `from umodbus.modbus import ModbusTCP` to `from umodbus.tcp import ModbusTCP` 26 | > - `read_coils` and `read_discrete_inputs` return a list with the same length as the requested quantity instead of always 8, see [#12](https://github.com/brainelectronics/micropython-modbus/issues/12) and [#25](https://github.com/brainelectronics/micropython-modbus/issues/25) 27 | > - `read_holding_registers` returns list with amount of requested registers, see [#25](https://github.com/brainelectronics/micropython-modbus/issues/25) 28 | 29 | ### Steps to be performed 30 | 31 | #### Update imports 32 | 33 | The way of importing `ModbusRTU` and `ModbusTCP` changed. Update the imports 34 | according to the following table. For further details check [#18](https://github.com/brainelectronics/micropython-modbus/issues/18) 35 | 36 | | Version 1 | Version 2 | 37 | | --------- | --------- | 38 | | `from umodbus.modbus import ModbusRTU` | `from umodbus.serial import ModbusRTU` | 39 | | `from umodbus.modbus import ModbusTCP` | `from umodbus.tcp import ModbusTCP` | 40 | 41 | #### Return values changed 42 | 43 | The functions `read_coils`, `read_discrete_inputs` and `read_holding_registers` 44 | return now a list with the same length as the requested register quantity. 45 | 46 | ##### Coil registers 47 | 48 | All major version 1 releases of this package returned a list with 8 elements 49 | on a coil register request. 50 | 51 | ```python 52 | # example usage only, non productive code example 53 | 54 | # reading one coil returned a list of 8 boolean elements 55 | >>> host.read_coils(slave_addr=10, starting_addr=123, coil_qty=1) 56 | [True, False, False, False, False, False, False, False] 57 | # expectation is [True] 58 | 59 | # reading 3 coils returned a list of 8 boolean elements 60 | >>> host.read_coils(slave_addr=10, starting_addr=126, coil_qty=3) 61 | [False, False, False, False, False, False, False, False] 62 | # expectation is [False, True, False] 63 | ``` 64 | 65 | With the fixes of major version 2 a list with the expected length is returned 66 | 67 | ```python 68 | # example usage only, non productive code example 69 | 70 | # reading one coil returns a list of 1 boolean element 71 | >>> host.read_coils(slave_addr=10, starting_addr=123, coil_qty=1) 72 | [True] 73 | 74 | # reading 3 coils returns a list of 3 boolean elements 75 | >>> host.read_coils(slave_addr=10, starting_addr=126, coil_qty=3) 76 | [False, True, False] 77 | ``` 78 | 79 | ##### Discrete input registers 80 | 81 | All major version 1 releases of this package returned a list with 8 elements 82 | on a discrete input register request. 83 | 84 | ```python 85 | # example usage only, non productive code example 86 | 87 | # reading one discrete input register returned a list of 8 boolean elements 88 | >>> host.read_discrete_inputs(slave_addr=10, starting_addr=123, input_qty=1) 89 | [True, False, False, False, False, False, False, False] 90 | # expectation is [True] 91 | 92 | # reading 3 discrete input register returned a list of 8 boolean elements 93 | >>> host.read_discrete_inputs(slave_addr=10, starting_addr=126, input_qty=3) 94 | [False, False, False, False, False, False, False, False] 95 | # expectation is [False, True, False] 96 | ``` 97 | 98 | With the fixes of major version 2 a list with the expected length is returned 99 | 100 | ```python 101 | # example usage only, non productive code example 102 | 103 | # reading one discrete input register returns a list of 1 boolean element 104 | >>> host.read_discrete_inputs(slave_addr=10, starting_addr=123, input_qty=1) 105 | [True] 106 | 107 | # reading 3 discrete input registers returns a list of 3 boolean elements 108 | >>> host.read_discrete_inputs(slave_addr=10, starting_addr=126, input_qty=3) 109 | [False, True, False] 110 | ``` 111 | 112 | ##### Holding registers 113 | 114 | In all major version 1 releases of this package returned a tuple with only one 115 | element on a holding register request. 116 | 117 | ```python 118 | # example usage only, non productive code example 119 | 120 | # reading one register only worked as expected 121 | >>> host.read_holding_registers(slave_addr=10, starting_addr=93, register_qty=1, signed=False) 122 | (19,) 123 | # expectation is (19,) 124 | 125 | # reading multiple registers did not work as expected 126 | # register values of register 93 + 94 should be returned 127 | >>> host.read_holding_registers(slave_addr=10, starting_addr=93, register_qty=2, signed=False) 128 | (19,) 129 | # expectation is (19, 29) 130 | ``` 131 | 132 | With the fixes of major version 2 a list with the expected length is returned 133 | 134 | ```python 135 | # example usage only, non productive code example 136 | 137 | # reading one register only worked as expected 138 | >>> host.read_holding_registers(slave_addr=10, starting_addr=93, register_qty=1, signed=False) 139 | (19,) 140 | 141 | # reading multiple registers did not work as expected 142 | # register values of register 93 + 94 should be returned 143 | >>> host.read_holding_registers(slave_addr=10, starting_addr=93, register_qty=2, signed=False) 144 | (19, 29) 145 | ``` 146 | -------------------------------------------------------------------------------- /docs/INSTALLATION.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | Install the library on MicroPython boards 4 | 5 | --------------- 6 | 7 | ## Install package via network 8 | 9 | This section describes the installation of the library on network capable 10 | boards. 11 | 12 | ```{note} 13 | Not all MicroPython board have a network support, like the classic Raspberry 14 | Pi Pico or the pyboard. Please check the port specific documentation. 15 | ``` 16 | 17 | This is an example on how to connect to a wireless network. 18 | 19 | ```python 20 | import network 21 | station = network.WLAN(network.STA_IF) 22 | station.connect('SSID', 'PASSWORD') 23 | # wait some time to establish the connection 24 | station.isconnected() 25 | ``` 26 | 27 | ### Install with mip 28 | 29 | `mip` has been added in MicroPython 1.19.1 and later. For earlier MicroPython 30 | versions check the [upip section below](#install-with-upip) 31 | 32 | ```{eval-rst} 33 | .. epigraph:: 34 | `mip` ("mip installs packages") is similar in concept to Python's `pip` 35 | tool, however it does not use the PyPI index, rather it uses 36 | micropython-lib as its index by default. 37 | ``` 38 | 39 | As this library is not pushed to the default 40 | [micropython-lib index](https://micropython.org/pi/v2), the installation has 41 | to be done via the package definition file (`package.json`) provided with this 42 | repo. 43 | 44 | ```python 45 | import mip 46 | mip.install('github:brainelectronics/micropython-modbus') 47 | ``` 48 | 49 | In order to install the latest release candidate version, select a version from 50 | the [repo tags overview][ref-github-micropython-modbus-tags] 51 | 52 | ```python 53 | import mip 54 | mip.install('github:brainelectronics/micropython-modbus', version='2.3.3-rc31.dev59') 55 | ``` 56 | 57 | ### Install with upip 58 | 59 | This library is pushed to [PyPi][ref-micropython-modbus-pypi] and 60 | [TestPyPi][ref-micropython-modbus-test-pypi]. The installation from those 61 | package indices is currently not supported with MicroPython v1.19.1 or newer. 62 | The package can be installed on older MicroPython versions with the following 63 | commands. 64 | 65 | ```{note} 66 | `upip` is not able to install a specific version of a package. It will always 67 | use the latest available version. 68 | ``` 69 | 70 | ```python 71 | import upip 72 | upip.install('micropython-modbus') 73 | ``` 74 | 75 | In order to install the latest release candidate version, use the following 76 | commands. 77 | 78 | ```python 79 | import upip 80 | # overwrite index_urls to only take artifacts from test.pypi.org 81 | upip.index_urls = ['https://test.pypi.org/pypi'] 82 | upip.install('micropython-modbus') 83 | ``` 84 | 85 | ## Install package without network 86 | 87 | ### mpremote 88 | 89 | As of January 2022 the [`mpremote`][ref-mpremote] tool is available via `pip` 90 | and can be used to install packages on a connected device from a host machine. 91 | 92 | As described in the `Install required tools` section of [SETUP](SETUP.md), the 93 | tool will be installed with the provided `requirements.txt` file. Please 94 | follow the [mpremote documentation][ref-mpremote-doc] to connect to a 95 | MicroPython device. 96 | 97 | To install the latest officially released library version use the following 98 | command 99 | 100 | ```bash 101 | mpremote connect /dev/tty.SLAB_USBtoUART mip install github:brainelectronics/micropython-modbus 102 | ``` 103 | 104 | In order to install the latest release candidate version, use the following 105 | command 106 | 107 | ```bash 108 | mpremote connect /dev/tty.SLAB_USBtoUART mip install github:brainelectronics/micropython-modbus 109 | ``` 110 | 111 | ### Manually 112 | 113 | Copy all files of the [umodbus module][ref-umodbus-module] to the MicroPython 114 | board using [Remote MicroPython shell][ref-remote-upy-shell] or 115 | [mpremote][ref-mpremote] 116 | 117 | #### mpremote 118 | 119 | Perform the following command to copy all files and folders to the device 120 | 121 | ```bash 122 | mpremote connect /dev/tty.SLAB_USBtoUART cp -r umodbus/ : 123 | ``` 124 | 125 | #### rshell 126 | 127 | Open the remote shell with the following command. Additionally use `-b 115200` 128 | in case no CP210x is used but a CH34x. 129 | 130 | ```bash 131 | rshell -p /dev/tty.SLAB_USBtoUART --editor nano 132 | ``` 133 | 134 | Perform the following command to copy all files and folders to the device 135 | 136 | ```bash 137 | mkdir /pyboard/lib 138 | mkdir /pyboard/lib/umodbus 139 | 140 | cp umodbus/* /pyboard/lib/umodbus 141 | ``` 142 | 143 | ## Additional MicroPython packages for examples 144 | 145 | To use this package with the provided [`boot.py`][ref-package-boot-file] and 146 | [`main.py`][ref-package-boot-file] file to create a TCP-RTU bridge, additional 147 | modules are required, which are not part of this repo/package. 148 | 149 | ```bash 150 | rshell -p /dev/tty.SLAB_USBtoUART --editor nano 151 | ``` 152 | 153 | Install the additional package `micropython-modbus` as described in the 154 | previous section on the MicroPython device or download the 155 | [brainelectronics MicroPython Helpers repo][ref-github-be-mircopython-modules] 156 | and copy it to the device. 157 | 158 | Perform the following command to copy all files and folders to the device 159 | 160 | ```bash 161 | mkdir /pyboard/lib/be_helpers 162 | 163 | cp be_helpers/* /pyboard/lib/be_helpers 164 | ``` 165 | 166 | Additionally check the latest instructions of the 167 | [brainelectronics MicroPython modules][ref-github-be-mircopython-modules] 168 | README for further instructions. 169 | 170 | 171 | [ref-micropython-modbus-test-pypi]: https://test.pypi.org/project/micropython-modbus/ 172 | [ref-github-micropython-modbus-tags]: https://github.com/brainelectronics/micropython-modbus/tags 173 | [ref-micropython-modbus-pypi]: https://pypi.org/project/micropython-modbus/ 174 | [ref-mpremote]: https://docs.micropython.org/en/v1.19.1/reference/mpremote.html#mpremote 175 | [ref-mpremote-doc]: https://docs.micropython.org/en/v1.19.1/reference/mpremote.html 176 | [ref-remote-upy-shell]: https://github.com/dhylands/rshell 177 | [ref-umodbus-module]: https://github.com/brainelectronics/micropython-modbus/tree/develop/umodbus 178 | [ref-package-boot-file]: https://github.com/brainelectronics/micropython-modbus/blob/c45d6cc334b4adf0e0ffd9152c8f08724e1902d9/boot.py 179 | [ref-package-main-file]: https://github.com/brainelectronics/micropython-modbus/blob/c45d6cc334b4adf0e0ffd9152c8f08724e1902d9/main.py 180 | [ref-github-be-mircopython-modules]: https://github.com/brainelectronics/micropython-modules 181 | -------------------------------------------------------------------------------- /examples/rtu_client_example.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: UTF-8 -*- 3 | 4 | """ 5 | Main script 6 | 7 | Do your stuff here, this file is similar to the loop() function on Arduino 8 | 9 | Create a Modbus RTU client (slave) which can be requested for data or set with 10 | specific values by a host device. 11 | 12 | The RTU communication pins can be choosen freely (check MicroPython device/ 13 | port specific limitations). 14 | The register definitions of the client as well as its connection settings like 15 | bus address and UART communication speed can be defined by the user. 16 | """ 17 | 18 | # import modbus client classes 19 | from umodbus.serial import ModbusRTU 20 | 21 | IS_DOCKER_MICROPYTHON = False 22 | try: 23 | import machine 24 | machine.reset_cause() 25 | except ImportError: 26 | raise Exception('Unable to import machine, are all fakes available?') 27 | except AttributeError: 28 | # machine fake class has no "reset_cause" function 29 | IS_DOCKER_MICROPYTHON = True 30 | import json 31 | 32 | 33 | # =============================================== 34 | # RTU Slave setup 35 | # act as client, provide Modbus data via RTU to a host device 36 | # ModbusRTU can get serial requests from a host device to provide/set data 37 | # check MicroPython UART documentation 38 | # https://docs.micropython.org/en/latest/library/machine.UART.html 39 | # for Device/Port specific setup 40 | # 41 | # RP2 needs "rtu_pins = (Pin(4), Pin(5))" whereas ESP32 can use any pin 42 | # the following example is for an ESP32. 43 | # For further details check the latest MicroPython Modbus RTU documentation 44 | # example https://micropython-modbus.readthedocs.io/en/latest/EXAMPLES.html#rtu 45 | rtu_pins = (25, 26) # (TX, RX) 46 | slave_addr = 10 # address on bus as client 47 | baudrate = 9600 48 | uart_id = 1 49 | 50 | try: 51 | from machine import Pin 52 | import os 53 | from umodbus import version 54 | 55 | os_info = os.uname() 56 | print('MicroPython infos: {}'.format(os_info)) 57 | print('Used micropthon-modbus version: {}'.format(version.__version__)) 58 | 59 | if 'pyboard' in os_info: 60 | # NOT YET TESTED ! 61 | # https://docs.micropython.org/en/latest/library/pyb.UART.html#pyb-uart 62 | # (TX, RX) = (X9, X10) = (PB6, PB7) 63 | uart_id = 1 64 | # (TX, RX) 65 | rtu_pins = (Pin(PB6), Pin(PB7)) # noqa: F821 66 | elif 'esp8266' in os_info: 67 | # https://docs.micropython.org/en/latest/esp8266/quickref.html#uart-serial-bus 68 | raise Exception( 69 | 'UART0 of ESP8266 is used by REPL, UART1 can only be used for TX' 70 | ) 71 | elif 'esp32' in os_info: 72 | # https://docs.micropython.org/en/latest/esp32/quickref.html#uart-serial-bus 73 | uart_id = 1 74 | rtu_pins = (25, 26) # (TX, RX) 75 | elif 'rp2' in os_info: 76 | # https://docs.micropython.org/en/latest/rp2/quickref.html#uart-serial-bus 77 | uart_id = 0 78 | rtu_pins = (Pin(0), Pin(1)) # (TX, RX) 79 | except AttributeError: 80 | pass 81 | except Exception as e: 82 | raise e 83 | 84 | print('Using pins {} with UART ID {}'.format(rtu_pins, uart_id)) 85 | 86 | client = ModbusRTU( 87 | addr=slave_addr, # address on bus 88 | pins=rtu_pins, # given as tuple (TX, RX) 89 | baudrate=baudrate, # optional, default 9600 90 | # data_bits=8, # optional, default 8 91 | # stop_bits=1, # optional, default 1 92 | # parity=None, # optional, default None 93 | # ctrl_pin=12, # optional, control DE/RE 94 | uart_id=uart_id # optional, default 1, see port specific docs 95 | ) 96 | 97 | if IS_DOCKER_MICROPYTHON: 98 | # works only with fake machine UART 99 | assert client._itf._uart._is_server is True 100 | 101 | 102 | def reset_data_registers_cb(reg_type, address, val): 103 | # usage of global isn't great, but okay for an example 104 | global client 105 | global register_definitions 106 | 107 | print('Resetting register data to default values ...') 108 | client.setup_registers(registers=register_definitions) 109 | print('Default values restored') 110 | 111 | 112 | # common slave register setup, to be used with the Master example above 113 | register_definitions = { 114 | "COILS": { 115 | "RESET_REGISTER_DATA_COIL": { 116 | "register": 42, 117 | "len": 1, 118 | "val": 0 119 | }, 120 | "EXAMPLE_COIL": { 121 | "register": 123, 122 | "len": 1, 123 | "val": 1 124 | } 125 | }, 126 | "HREGS": { 127 | "EXAMPLE_HREG": { 128 | "register": 93, 129 | "len": 1, 130 | "val": 19 131 | } 132 | }, 133 | "ISTS": { 134 | "EXAMPLE_ISTS": { 135 | "register": 67, 136 | "len": 1, 137 | "val": 0 138 | } 139 | }, 140 | "IREGS": { 141 | "EXAMPLE_IREG": { 142 | "register": 10, 143 | "len": 1, 144 | "val": 60001 145 | } 146 | } 147 | } 148 | 149 | # alternatively the register definitions can also be loaded from a JSON file 150 | # this is always done if Docker is used for testing purpose in order to keep 151 | # the client registers in sync with the test registers 152 | if IS_DOCKER_MICROPYTHON: 153 | with open('registers/example.json', 'r') as file: 154 | register_definitions = json.load(file) 155 | 156 | # reset all registers back to their default value with a callback 157 | register_definitions['COILS']['RESET_REGISTER_DATA_COIL']['on_set_cb'] = \ 158 | reset_data_registers_cb 159 | 160 | print('Setting up registers ...') 161 | # use the defined values of each register type provided by register_definitions 162 | client.setup_registers(registers=register_definitions) 163 | # alternatively use dummy default values (True for bool regs, 999 otherwise) 164 | # client.setup_registers(registers=register_definitions, use_default_vals=True) 165 | print('Register setup done') 166 | 167 | print('Serving as RTU client on address {} at {} baud'. 168 | format(slave_addr, baudrate)) 169 | 170 | while True: 171 | try: 172 | result = client.process() 173 | except KeyboardInterrupt: 174 | print('KeyboardInterrupt, stopping RTU client...') 175 | break 176 | except Exception as e: 177 | print('Exception during execution: {}'.format(e)) 178 | 179 | print("Finished providing/accepting data as client") 180 | -------------------------------------------------------------------------------- /tests/test_rtu_example.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: UTF-8 -*- 3 | """Unittest for testing RTU functions of umodbus""" 4 | 5 | import json 6 | import ulogging as logging 7 | import mpy_unittest as unittest 8 | from umodbus.serial import Serial as ModbusRTUMaster 9 | 10 | 11 | class TestRtuExample(unittest.TestCase): 12 | def setUp(self) -> None: 13 | """Run before every test method""" 14 | # set basic config and level for the logger 15 | logging.basicConfig(level=logging.INFO) 16 | 17 | # create a logger for this TestSuite 18 | self.test_logger = logging.getLogger(__name__) 19 | 20 | # set the test logger level 21 | self.test_logger.setLevel(logging.DEBUG) 22 | 23 | # enable/disable the log output of the device logger for the tests 24 | # if enabled log data inside this test will be printed 25 | self.test_logger.disabled = False 26 | 27 | self._client_addr = 10 # bus address of client 28 | 29 | self._host = ModbusRTUMaster(baudrate=9600, pins=(25, 26)) # (TX, RX) 30 | 31 | test_register_file = 'registers/example.json' 32 | try: 33 | with open(test_register_file, 'r') as file: 34 | self._register_definitions = json.load(file) 35 | except Exception as e: 36 | self.test_logger.error( 37 | 'Is the test register file available at {}?'.format( 38 | test_register_file)) 39 | raise e 40 | 41 | def test_setup(self) -> None: 42 | """Test successful setup of ModbusRTUMaster and the defined register""" 43 | # although it is called "Master" the host is here a client connecting 44 | # to one or more clients/slaves/devices which are providing data 45 | # The reason for calling it "ModbusRTUMaster" is the status of having 46 | # the functions to request or get data from other client/slave/devices 47 | self.assertFalse(self._host._uart._is_server) 48 | self.assertIsInstance(self._register_definitions, dict) 49 | 50 | for reg_type in ['COILS', 'HREGS', 'ISTS', 'IREGS']: 51 | with self.subTest(reg_type=reg_type): 52 | self.assertIn(reg_type, self._register_definitions.keys()) 53 | self.assertIsInstance(self._register_definitions[reg_type], 54 | dict) 55 | self.assertGreaterEqual( 56 | len(self._register_definitions[reg_type]), 1) 57 | 58 | self._read_coils_single() 59 | 60 | def _read_coils_single(self) -> None: 61 | """Test reading sinlge coil of client""" 62 | # read coil with state ON/True 63 | coil_address = \ 64 | self._register_definitions['COILS']['EXAMPLE_COIL']['register'] 65 | coil_qty = self._register_definitions['COILS']['EXAMPLE_COIL']['len'] 66 | expectation_list = [ 67 | bool(self._register_definitions['COILS']['EXAMPLE_COIL']['val']) 68 | ] 69 | 70 | coil_status = self._host.read_coils( 71 | slave_addr=self._client_addr, 72 | starting_addr=coil_address, 73 | coil_qty=coil_qty) 74 | 75 | self.test_logger.debug('Status of COIL {}: {}, expectation: {}'. 76 | format(coil_address, 77 | coil_status, 78 | expectation_list)) 79 | self.assertIsInstance(coil_status, list) 80 | self.assertEqual(len(coil_status), coil_qty) 81 | self.assertTrue(all(isinstance(x, bool) for x in coil_status)) 82 | self.assertEqual(coil_status, expectation_list) 83 | 84 | # read coil with state OFF/False 85 | coil_address = \ 86 | self._register_definitions['COILS']['EXAMPLE_COIL_OFF']['register'] 87 | coil_qty = \ 88 | self._register_definitions['COILS']['EXAMPLE_COIL_OFF']['len'] 89 | expectation_list = [bool( 90 | self._register_definitions['COILS']['EXAMPLE_COIL_OFF']['val'] 91 | )] 92 | 93 | coil_status = self._host.read_coils( 94 | slave_addr=self._client_addr, 95 | starting_addr=coil_address, 96 | coil_qty=coil_qty) 97 | 98 | self.test_logger.debug('Status of COIL {}: {}, expectation: {}'. 99 | format(coil_address, 100 | coil_status, 101 | expectation_list)) 102 | self.assertIsInstance(coil_status, list) 103 | self.assertEqual(len(coil_status), coil_qty) 104 | self.assertTrue(all(isinstance(x, bool) for x in coil_status)) 105 | self.assertEqual(coil_status, expectation_list) 106 | 107 | @unittest.skip('Test not yet implemented') 108 | def test__calculate_crc16(self) -> None: 109 | """Test calculating Modbus CRC16""" 110 | pass 111 | 112 | @unittest.skip('Test not yet implemented') 113 | def test__exit_read(self) -> None: 114 | """Test validating received response""" 115 | pass 116 | 117 | @unittest.skip('Test not yet implemented') 118 | def test__uart_read(self) -> None: 119 | """Test reading data from UART""" 120 | pass 121 | 122 | @unittest.skip('Test not yet implemented') 123 | def test__uart_read_frame(self) -> None: 124 | """Test reading a Modbus frame""" 125 | pass 126 | 127 | @unittest.skip('Test not yet implemented') 128 | def test__send(self) -> None: 129 | """Test sending a Modbus frame""" 130 | pass 131 | 132 | @unittest.skip('Test not yet implemented') 133 | def test__send_receive(self) -> None: 134 | """Test sending a Modbus frame""" 135 | pass 136 | 137 | @unittest.skip('Test not yet implemented') 138 | def test__validate_resp_hdr(self) -> None: 139 | """Test response header validation""" 140 | pass 141 | 142 | @unittest.skip('Test not yet implemented') 143 | def test_send_response(self) -> None: 144 | """Test sending a response to a client""" 145 | pass 146 | 147 | @unittest.skip('Test not yet implemented') 148 | def test_send_exception_response(self) -> None: 149 | """Test sending a exception response to a client""" 150 | pass 151 | 152 | @unittest.skip('Test not yet implemented') 153 | def test_get_request(self) -> None: 154 | """Test checking for a request""" 155 | pass 156 | 157 | def tearDown(self) -> None: 158 | """Run after every test method""" 159 | self._host._uart._sock.close() 160 | self.test_logger.debug('Closed ModbusRTUMaster socket at tearDown') 161 | 162 | 163 | if __name__ == '__main__': 164 | unittest.main() 165 | -------------------------------------------------------------------------------- /docs/TESTING.md: -------------------------------------------------------------------------------- 1 | # Testing 2 | 3 | Testing is done inside MicroPython Docker container 4 | 5 | --------------- 6 | 7 | ## Basics 8 | 9 | This library is as of now tested with the `v1.18` version of MicroPython 10 | 11 | ### Pull container 12 | 13 | Checkout the available 14 | [MicroPython containers](https://hub.docker.com/r/micropython/unix/tags) and 15 | pull the `v1.18` locally. 16 | 17 | ```bash 18 | docker pull micropython/unix:v1.18 19 | ``` 20 | 21 | ### Spin up container 22 | 23 | #### Simple container 24 | 25 | Use this command for your first tests or to run some MicroPython commands in 26 | a simple REPL 27 | 28 | ```bash 29 | docker run -it --name micropython-1.18 --network=host --entrypoint bash micropython/unix:v1.18 30 | ``` 31 | 32 | #### Enter MicroPython REPL 33 | 34 | Inside the container enter the REPL by running `micropython-dev`. The console 35 | should now look similar to this 36 | 37 | ``` 38 | root@debian:/home# 39 | MicroPython v1.18 on 2022-01-17; linux version 40 | Use Ctrl-D to exit, Ctrl-E for paste mode 41 | >>> 42 | ``` 43 | 44 | ## Testing 45 | 46 | All tests are automatically executed on a push to GitHub and have to be passed 47 | in order to be allowed to merge to the main development branch, see also [CONTRIBUTING](CONTRIBUTING.md). 48 | 49 | ### Run unittests manually 50 | 51 | First build and run the docker image with the following command 52 | 53 | ```bash 54 | docker build -t micropython-test-manually -f Dockerfile.tests_manually . 55 | docker run -it --name micropython-test-manually micropython-test-manually 56 | ``` 57 | 58 | Run all unittests defined in the `tests` directory and exit with status result 59 | 60 | ```bash 61 | micropython-dev -c "import mpy_unittest as unittest; unittest.main('tests')" 62 | ``` 63 | 64 | In order to execute only a specific set of tests use the following command 65 | inside the built and running MicroPython container 66 | 67 | ```bash 68 | # run all tests of "TestAbsoluteTruth" defined in tests/test_absolute_truth.py 69 | # and exit with status result 70 | micropython-dev -c "import mpy_unittest as unittest; unittest.main(name='tests.test_absolute_truth', fromlist=['TestAbsoluteTruth'])" 71 | ``` 72 | 73 | ### Custom container for unittests 74 | 75 | ```bash 76 | docker build --tag micropython-test --file Dockerfile.tests . 77 | ``` 78 | 79 | As soon as the built image is executed all unittests are executed. The 80 | container will exit with a non-zero status in case of a unittest failure. 81 | 82 | The return value can be collected by `echo $?` (on Linux based systems) or 83 | `echo %errorlevel%` (on Windows), which will be either `0` in case all tests 84 | passed, or `1` if one or multiple tests failed. 85 | 86 | ### Docker compose 87 | 88 | For more complex unittests and integration tests, several Dockerfiles are 89 | combined into a docker-compose file. 90 | 91 | The option `--build` can be skipped on the second run, to avoid rebuilds of 92 | the containers. All "dynamic" data is shared via `volumes`. 93 | 94 | #### TCP integration tests 95 | 96 | ##### Overview 97 | 98 | The TCP integration tests defined by `test_tcp_example.py` uses the 99 | `Dockerfile.client_tcp` and `Dockerfile.test_examples`. 100 | 101 | A network bridge is created with fixed IPv4 addresses in order to bind the 102 | client to a known IP and let the host reach it on that predefined IP. 103 | 104 | The port defined in `tcp_client_example.py`, executed by 105 | `Dockerfile.client_tcp` has to be open, exposed to the `micropython-host` 106 | service running `Dockerfile.test_examples` and optionally exposed in the 107 | `docker-compose-tcp-test.yaml` file. 108 | 109 | Finally the tests defined in `TestTcpExample` are executed. The tests read and 110 | write all available register types on the client and check the response with 111 | the expected data. 112 | 113 | ##### Usage 114 | 115 | ```bash 116 | docker compose -f docker-compose-tcp-test.yaml up --build --exit-code-from micropython-host --remove-orphans 117 | ``` 118 | 119 | #### RTU integration tests 120 | 121 | ##### UART interface 122 | 123 | As the [MicroPython containers](https://hub.docker.com/r/micropython/unix/tags) 124 | do not have a UART interface, which is additionally anyway not connectable via 125 | two containers, a [`UART fake`](fakes.machine.UART) has been implemented. 126 | 127 | The fake [`UART`](fakes.machine.UART) class provides all required functions 128 | like [`any()`](fakes.machine.UART.any), [`read()`](fakes.machine.UART.read) and 129 | [`write()`](fakes.machine.UART.write) to simulate a UART interface. 130 | 131 | During the initialisation of the fake UART a simple and very basic socket 132 | request is made to `172.25.0.2`, a predefined IP address, see 133 | `docker-compose-rtu-test.yaml`. In case no response is received, a socket based 134 | server is started. It is thereby important to start the RTU client before the 135 | RTU host. The RTU host will perform the same check during the UART init, but 136 | will reach the (already running) socket server and connect to it. 137 | 138 | The data provided to the [`write()`](fakes.machine.UART.write) call of the RTU 139 | host, will be sent to the background socket server of the RTU client and be 140 | read inside the [`get_request()`](umodbus.serial.Serial.get_request) function 141 | which is constantly called by the [`process()`](umodbus.modbus.Modbus.process) 142 | function. 143 | 144 | After it has been processed from Modbus perspective, the RTU client response 145 | will then be put by the [`write()`](fakes.machine.UART.write) function into a 146 | queue on RTU client side, picked up by the RTU client background socket server 147 | thread and sent back to the RTU host where it is made available via the 148 | [`read()`](fakes.machine.UART.read) function. 149 | 150 | ##### Overview 151 | 152 | The RTU integration tests defined by `test_rtu_example.py` uses the 153 | `Dockerfile.client_rtu` and `Dockerfile.test_examples`. 154 | 155 | A network bridge is created with fixed IPv4 addresses in order to bind the 156 | client to a known IP and let the host reach it on that predefined IP. 157 | 158 | The port defined in `rtu_client_example.py`, executed by 159 | `Dockerfile.client_rtu` has to be open, exposed to the `micropython-host` 160 | service running `Dockerfile.test_examples` and optionally exposed in the 161 | `docker-compose-rtu-test.yaml` file. 162 | 163 | Finally the tests defined in `TestRtuExample` are executed. The tests read and 164 | write all available register types on the client and check the response with 165 | the expected data. 166 | 167 | ##### Usage 168 | 169 | ```bash 170 | docker compose -f docker-compose-rtu-test.yaml up --build --exit-code-from micropython-host-rtu --remove-orphans 171 | ``` 172 | 173 | 174 | [ref-fakes]: https://github.com/brainelectronics/micropython-modbus/blob/develop/fakes/machine.py 175 | -------------------------------------------------------------------------------- /examples/tcp_host_example.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: UTF-8 -*- 3 | 4 | """ 5 | Main script 6 | 7 | Do your stuff here, this file is similar to the loop() function on Arduino 8 | 9 | Create a Modbus TCP host (master) which requests or sets data on a client 10 | device. 11 | 12 | The TCP port and IP address can be choosen freely. The register definitions of 13 | the client can be defined by the user. 14 | """ 15 | 16 | # system packages 17 | import time 18 | 19 | # import modbus host classes 20 | from umodbus.tcp import TCP as ModbusTCPMaster 21 | 22 | IS_DOCKER_MICROPYTHON = False 23 | try: 24 | import network 25 | except ImportError: 26 | IS_DOCKER_MICROPYTHON = True 27 | import sys 28 | 29 | 30 | # =============================================== 31 | if IS_DOCKER_MICROPYTHON is False: 32 | # connect to a network 33 | station = network.WLAN(network.STA_IF) 34 | if station.active() and station.isconnected(): 35 | station.disconnect() 36 | time.sleep(1) 37 | station.active(False) 38 | time.sleep(1) 39 | station.active(True) 40 | 41 | # station.connect('SSID', 'PASSWORD') 42 | station.connect('TP-LINK_FBFC3C', 'C1FBFC3C') 43 | time.sleep(1) 44 | 45 | while True: 46 | print('Waiting for WiFi connection...') 47 | if station.isconnected(): 48 | print('Connected to WiFi.') 49 | print(station.ifconfig()) 50 | break 51 | time.sleep(2) 52 | 53 | # =============================================== 54 | # TCP Slave setup 55 | slave_tcp_port = 502 # port to listen to 56 | slave_addr = 10 # bus address of client 57 | 58 | # set IP address of the MicroPython device acting as client (slave) 59 | if IS_DOCKER_MICROPYTHON: 60 | slave_ip = '172.24.0.2' # static Docker IP address 61 | else: 62 | slave_ip = '192.168.178.69' # IP address 63 | 64 | # TCP Master setup 65 | # act as host, get Modbus data via TCP from a client device 66 | # ModbusTCPMaster can make TCP requests to a client device to get/set data 67 | # host = ModbusTCP( 68 | host = ModbusTCPMaster( 69 | slave_ip=slave_ip, 70 | slave_port=slave_tcp_port, 71 | timeout=5) # optional, default 5 72 | 73 | # commond slave register setup, to be used with the Master example above 74 | register_definitions = { 75 | "COILS": { 76 | "RESET_REGISTER_DATA_COIL": { 77 | "register": 42, 78 | "len": 1, 79 | "val": 0 80 | }, 81 | "EXAMPLE_COIL": { 82 | "register": 123, 83 | "len": 1, 84 | "val": 1 85 | } 86 | }, 87 | "HREGS": { 88 | "EXAMPLE_HREG": { 89 | "register": 93, 90 | "len": 1, 91 | "val": 19 92 | } 93 | }, 94 | "ISTS": { 95 | "EXAMPLE_ISTS": { 96 | "register": 67, 97 | "len": 1, 98 | "val": 0 99 | } 100 | }, 101 | "IREGS": { 102 | "EXAMPLE_IREG": { 103 | "register": 10, 104 | "len": 1, 105 | "val": 60001 106 | } 107 | } 108 | } 109 | 110 | """ 111 | # alternatively the register definitions can also be loaded from a JSON file 112 | import json 113 | 114 | with open('registers/example.json', 'r') as file: 115 | register_definitions = json.load(file) 116 | """ 117 | 118 | print('Requesting and updating data on TCP client at {}:{}'. 119 | format(slave_ip, slave_tcp_port)) 120 | print() 121 | 122 | # READ COILS 123 | coil_address = register_definitions['COILS']['EXAMPLE_COIL']['register'] 124 | coil_qty = register_definitions['COILS']['EXAMPLE_COIL']['len'] 125 | coil_status = host.read_coils( 126 | slave_addr=slave_addr, 127 | starting_addr=coil_address, 128 | coil_qty=coil_qty) 129 | print('Status of COIL {}: {}'.format(coil_address, coil_status)) 130 | time.sleep(1) 131 | 132 | # WRITE COILS 133 | new_coil_val = 0 134 | operation_status = host.write_single_coil( 135 | slave_addr=slave_addr, 136 | output_address=coil_address, 137 | output_value=new_coil_val) 138 | print('Result of setting COIL {}: {}'.format(coil_address, operation_status)) 139 | time.sleep(1) 140 | 141 | # READ COILS again 142 | coil_status = host.read_coils( 143 | slave_addr=slave_addr, 144 | starting_addr=coil_address, 145 | coil_qty=coil_qty) 146 | print('Status of COIL {}: {}'.format(coil_address, coil_status)) 147 | time.sleep(1) 148 | 149 | print() 150 | 151 | # READ HREGS 152 | hreg_address = register_definitions['HREGS']['EXAMPLE_HREG']['register'] 153 | register_qty = register_definitions['HREGS']['EXAMPLE_HREG']['len'] 154 | register_value = host.read_holding_registers( 155 | slave_addr=slave_addr, 156 | starting_addr=hreg_address, 157 | register_qty=register_qty, 158 | signed=False) 159 | print('Status of HREG {}: {}'.format(hreg_address, register_value)) 160 | time.sleep(1) 161 | 162 | # WRITE HREGS 163 | new_hreg_val = 44 164 | operation_status = host.write_single_register( 165 | slave_addr=slave_addr, 166 | register_address=hreg_address, 167 | register_value=new_hreg_val, 168 | signed=False) 169 | print('Result of setting HREG {}: {}'.format(hreg_address, operation_status)) 170 | time.sleep(1) 171 | 172 | # READ HREGS again 173 | register_value = host.read_holding_registers( 174 | slave_addr=slave_addr, 175 | starting_addr=hreg_address, 176 | register_qty=register_qty, 177 | signed=False) 178 | print('Status of HREG {}: {}'.format(hreg_address, register_value)) 179 | time.sleep(1) 180 | 181 | print() 182 | 183 | # READ ISTS 184 | ist_address = register_definitions['ISTS']['EXAMPLE_ISTS']['register'] 185 | input_qty = register_definitions['ISTS']['EXAMPLE_ISTS']['len'] 186 | input_status = host.read_discrete_inputs( 187 | slave_addr=slave_addr, 188 | starting_addr=ist_address, 189 | input_qty=input_qty) 190 | print('Status of IST {}: {}'.format(ist_address, input_status)) 191 | time.sleep(1) 192 | 193 | print() 194 | 195 | # READ IREGS 196 | ireg_address = register_definitions['IREGS']['EXAMPLE_IREG']['register'] 197 | register_qty = register_definitions['IREGS']['EXAMPLE_IREG']['len'] 198 | register_value = host.read_input_registers( 199 | slave_addr=slave_addr, 200 | starting_addr=ireg_address, 201 | register_qty=register_qty, 202 | signed=False) 203 | print('Status of IREG {}: {}'.format(ireg_address, register_value)) 204 | time.sleep(1) 205 | 206 | print() 207 | 208 | # reset all registers back to their default values on the client 209 | # WRITE COILS 210 | print('Resetting register data to default values...') 211 | coil_address = \ 212 | register_definitions['COILS']['RESET_REGISTER_DATA_COIL']['register'] 213 | new_coil_val = True 214 | operation_status = host.write_single_coil( 215 | slave_addr=slave_addr, 216 | output_address=coil_address, 217 | output_value=new_coil_val) 218 | print('Result of setting COIL {}: {}'.format(coil_address, operation_status)) 219 | time.sleep(1) 220 | 221 | print() 222 | 223 | print("Finished requesting/setting data on client") 224 | 225 | if IS_DOCKER_MICROPYTHON: 226 | sys.exit(0) 227 | -------------------------------------------------------------------------------- /umodbus/const.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # Copyright (c) 2019, Pycom Limited. 4 | # 5 | # This software is licensed under the GNU GPL version 3 or any 6 | # later version, with permitted additional terms. For more information 7 | # see the Pycom Licence v1.0 document supplied with this file, or 8 | # available at https://www.pycom.io/opensource/licensing 9 | # 10 | # Description summary taken from 11 | # https://modbus.org/docs/Modbus_Application_Protocol_V1_1b3.pdf 12 | 13 | from micropython import const 14 | 15 | # function codes 16 | # defined as const(), see https://github.com/micropython/micropython/issues/573 17 | #: Read contiguous status of coils 18 | READ_COILS = const(0x01) # COILS, [0, 1] 19 | #: Read contiguous status of discrete inputs 20 | READ_DISCRETE_INPUTS = const(0x02) # ISTS, [0, 1] 21 | #: Read the contents of a contiguous block of holding registers 22 | READ_HOLDING_REGISTERS = const(0x03) # HREGS, [0, 65535] 23 | #: Read contiguous input registers 24 | READ_INPUT_REGISTER = const(0x04) # IREGS, [0, 65535] 25 | 26 | #: Write a single coil output status to ON or OFF 27 | WRITE_SINGLE_COIL = const(0x05) # COILS, [0, 1] 28 | #: Write a single holding register 29 | WRITE_SINGLE_REGISTER = const(0x06) # HREGS, [0, 65535] 30 | #: Force each coil in a sequence of coils to either ON or OFF 31 | WRITE_MULTIPLE_COILS = const(0x0F) # COILS, [0, 1] 32 | #: Write a block of contiguous registers 33 | WRITE_MULTIPLE_REGISTERS = const(0x10) # HREGS, [0, 65535] 34 | 35 | """ 36 | Modify the contents of a specified holding register using a combination of an 37 | AND mask, an OR mask, and the register's current contents 38 | """ 39 | MASK_WRITE_REGISTER = const(0x16) 40 | """ 41 | Perform a combination of one read operation and one write operation in a 42 | single MODBUS transaction 43 | """ 44 | READ_WRITE_MULTIPLE_REGISTERS = const(0x17) 45 | 46 | #: Read the contents of a First-In-First-Out (FIFO) queue of register 47 | READ_FIFO_QUEUE = const(0x18) 48 | 49 | #: Perform a file record read 50 | READ_FILE_RECORD = const(0x14) 51 | #: Perform a file record write 52 | WRITE_FILE_RECORD = const(0x15) 53 | 54 | #: Read the contents of eight Exception Status outputs 55 | READ_EXCEPTION_STATUS = const(0x07) 56 | #: Provide series of tests for checking the communication system (serial only) 57 | DIAGNOSTICS = const(0x08) 58 | #: Get status word and an event count from the remote device com event counter 59 | GET_COM_EVENT_COUNTER = const(0x0B) 60 | #: Get a status word, event count, message count, and a field of event bytes 61 | GET_COM_EVENT_LOG = const(0x0C) 62 | #: Read the description of the type, the current status, and other informations 63 | REPORT_SERVER_ID = const(0x11) 64 | #: Encapsulated Interface Transport 65 | READ_DEVICE_IDENTIFICATION = const(0x2B) 66 | 67 | # exception codes 68 | #: Function code received in query is not an allowable action for the server 69 | ILLEGAL_FUNCTION = const(0x01) 70 | #: Data address received in query is not an allowable address for the server 71 | ILLEGAL_DATA_ADDRESS = const(0x02) 72 | #: A value contained in the query is not an allowable value for the server 73 | ILLEGAL_DATA_VALUE = const(0x03) 74 | """ 75 | An unrecoverable error occurred while the server was attempting to perform the 76 | requested action 77 | """ 78 | SERVER_DEVICE_FAILURE = const(0x04) 79 | #: Response is returned to prevent a timeout error from occurring in the client 80 | ACKNOWLEDGE = const(0x05) 81 | #: Server is engaged in processing a long duration program command 82 | SERVER_DEVICE_BUSY = const(0x06) 83 | #: Server attempted to read record file, but detected a parity error in memory 84 | MEMORY_PARITY_ERROR = const(0x08) 85 | """ 86 | Gateway was unable to allocate an internal communication path from the input 87 | port to the output port for processing the request 88 | """ 89 | GATEWAY_PATH_UNAVAILABLE = const(0x0A) 90 | #: No response was obtained from the target device 91 | DEVICE_FAILED_TO_RESPOND = const(0x0B) 92 | 93 | # Protocol Data Unit (PDU) constants 94 | #: CRC length 95 | CRC_LENGTH = const(0x02) 96 | #: Error code offset 97 | ERROR_BIAS = const(0x80) 98 | #: High Data Response length 99 | RESPONSE_HDR_LENGTH = const(0x02) 100 | #: Error response length 101 | ERROR_RESP_LEN = const(0x05) 102 | #: Fixed response length 103 | FIXED_RESP_LEN = const(0x08) 104 | #: Modbus Application Protocol High Data Response length 105 | MBAP_HDR_LENGTH = const(0x07) 106 | 107 | #: CRC16 lookup table 108 | CRC16_TABLE = ( 109 | 0x0000, 0xC0C1, 0xC181, 0x0140, 0xC301, 0x03C0, 0x0280, 0xC241, 0xC601, 110 | 0x06C0, 0x0780, 0xC741, 0x0500, 0xC5C1, 0xC481, 0x0440, 0xCC01, 0x0CC0, 111 | 0x0D80, 0xCD41, 0x0F00, 0xCFC1, 0xCE81, 0x0E40, 0x0A00, 0xCAC1, 0xCB81, 112 | 0x0B40, 0xC901, 0x09C0, 0x0880, 0xC841, 0xD801, 0x18C0, 0x1980, 0xD941, 113 | 0x1B00, 0xDBC1, 0xDA81, 0x1A40, 0x1E00, 0xDEC1, 0xDF81, 0x1F40, 0xDD01, 114 | 0x1DC0, 0x1C80, 0xDC41, 0x1400, 0xD4C1, 0xD581, 0x1540, 0xD701, 0x17C0, 115 | 0x1680, 0xD641, 0xD201, 0x12C0, 0x1380, 0xD341, 0x1100, 0xD1C1, 0xD081, 116 | 0x1040, 0xF001, 0x30C0, 0x3180, 0xF141, 0x3300, 0xF3C1, 0xF281, 0x3240, 117 | 0x3600, 0xF6C1, 0xF781, 0x3740, 0xF501, 0x35C0, 0x3480, 0xF441, 0x3C00, 118 | 0xFCC1, 0xFD81, 0x3D40, 0xFF01, 0x3FC0, 0x3E80, 0xFE41, 0xFA01, 0x3AC0, 119 | 0x3B80, 0xFB41, 0x3900, 0xF9C1, 0xF881, 0x3840, 0x2800, 0xE8C1, 0xE981, 120 | 0x2940, 0xEB01, 0x2BC0, 0x2A80, 0xEA41, 0xEE01, 0x2EC0, 0x2F80, 0xEF41, 121 | 0x2D00, 0xEDC1, 0xEC81, 0x2C40, 0xE401, 0x24C0, 0x2580, 0xE541, 0x2700, 122 | 0xE7C1, 0xE681, 0x2640, 0x2200, 0xE2C1, 0xE381, 0x2340, 0xE101, 0x21C0, 123 | 0x2080, 0xE041, 0xA001, 0x60C0, 0x6180, 0xA141, 0x6300, 0xA3C1, 0xA281, 124 | 0x6240, 0x6600, 0xA6C1, 0xA781, 0x6740, 0xA501, 0x65C0, 0x6480, 0xA441, 125 | 0x6C00, 0xACC1, 0xAD81, 0x6D40, 0xAF01, 0x6FC0, 0x6E80, 0xAE41, 0xAA01, 126 | 0x6AC0, 0x6B80, 0xAB41, 0x6900, 0xA9C1, 0xA881, 0x6840, 0x7800, 0xB8C1, 127 | 0xB981, 0x7940, 0xBB01, 0x7BC0, 0x7A80, 0xBA41, 0xBE01, 0x7EC0, 0x7F80, 128 | 0xBF41, 0x7D00, 0xBDC1, 0xBC81, 0x7C40, 0xB401, 0x74C0, 0x7580, 0xB541, 129 | 0x7700, 0xB7C1, 0xB681, 0x7640, 0x7200, 0xB2C1, 0xB381, 0x7340, 0xB101, 130 | 0x71C0, 0x7080, 0xB041, 0x5000, 0x90C1, 0x9181, 0x5140, 0x9301, 0x53C0, 131 | 0x5280, 0x9241, 0x9601, 0x56C0, 0x5780, 0x9741, 0x5500, 0x95C1, 0x9481, 132 | 0x5440, 0x9C01, 0x5CC0, 0x5D80, 0x9D41, 0x5F00, 0x9FC1, 0x9E81, 0x5E40, 133 | 0x5A00, 0x9AC1, 0x9B81, 0x5B40, 0x9901, 0x59C0, 0x5880, 0x9841, 0x8801, 134 | 0x48C0, 0x4980, 0x8941, 0x4B00, 0x8BC1, 0x8A81, 0x4A40, 0x4E00, 0x8EC1, 135 | 0x8F81, 0x4F40, 0x8D01, 0x4DC0, 0x4C80, 0x8C41, 0x4400, 0x84C1, 0x8581, 136 | 0x4540, 0x8701, 0x47C0, 0x4680, 0x8641, 0x8201, 0x42C0, 0x4380, 0x8341, 137 | 0x4100, 0x81C1, 0x8081, 0x4040 138 | ) 139 | 140 | 141 | # Code to generate the CRC-16 lookup table: 142 | # def generate_crc16_table(): 143 | # crc_table = [] 144 | # for byte in range(256): 145 | # crc = 0x0000 146 | # for _ in range(8): 147 | # if (byte ^ crc) & 0x0001: 148 | # crc = (crc >> 1) ^ 0xa001 149 | # else: 150 | # crc >>= 1 151 | # byte >>= 1 152 | # crc_table.append(crc) 153 | # return crc_table 154 | -------------------------------------------------------------------------------- /tests/ulogging.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: UTF-8 -*- 3 | 4 | """ 5 | This file has been copied from micropython-lib 6 | 7 | https://github.com/micropython/micropython-lib/blob/7128d423c2e7c0309ac17a1e6ba873b909b24fcc/python-stdlib/logging/logging.py 8 | """ 9 | 10 | try: 11 | from micropython import const # noqa: F401 12 | except ImportError: 13 | 14 | def const(x): 15 | return x 16 | 17 | 18 | import sys 19 | import time 20 | 21 | CRITICAL = const(50) 22 | ERROR = const(40) 23 | WARNING = const(30) 24 | INFO = const(20) 25 | DEBUG = const(10) 26 | NOTSET = const(0) 27 | 28 | _DEFAULT_LEVEL = const(WARNING) 29 | 30 | _level_dict = { 31 | CRITICAL: "CRITICAL", 32 | ERROR: "ERROR", 33 | WARNING: "WARNING", 34 | INFO: "INFO", 35 | DEBUG: "DEBUG", 36 | NOTSET: "NOTSET", 37 | } 38 | 39 | _loggers = {} 40 | _stream = sys.stderr 41 | _default_fmt = "%(levelname)s:%(name)s:%(message)s" 42 | _default_datefmt = "%Y-%m-%d %H:%M:%S" 43 | 44 | 45 | class LogRecord: 46 | def set(self, name, level, message): 47 | self.name = name 48 | self.levelno = level 49 | self.levelname = _level_dict[level] 50 | self.message = message 51 | self.ct = time.time() 52 | self.msecs = int((self.ct - int(self.ct)) * 1000) 53 | self.asctime = None 54 | 55 | 56 | class Handler: 57 | def __init__(self, level=NOTSET): 58 | self.level = level 59 | self.formatter = None 60 | 61 | def close(self): 62 | pass 63 | 64 | def setLevel(self, level): 65 | self.level = level 66 | 67 | def setFormatter(self, formatter): 68 | self.formatter = formatter 69 | 70 | def format(self, record): 71 | return self.formatter.format(record) 72 | 73 | 74 | class StreamHandler(Handler): 75 | def __init__(self, stream=None): 76 | self.stream = _stream if stream is None else stream 77 | self.terminator = "\n" 78 | 79 | def close(self): 80 | if hasattr(self.stream, "flush"): 81 | self.stream.flush() 82 | 83 | def emit(self, record): 84 | if record.levelno >= self.level: 85 | self.stream.write(self.format(record) + self.terminator) 86 | 87 | 88 | class FileHandler(StreamHandler): 89 | def __init__(self, filename, mode="a", encoding="UTF-8"): 90 | super().__init__(stream=open(filename, mode=mode, encoding=encoding)) 91 | 92 | def close(self): 93 | super().close() 94 | self.stream.close() 95 | 96 | 97 | class Formatter: 98 | def __init__(self, fmt=None, datefmt=None): 99 | self.fmt = _default_fmt if fmt is None else fmt 100 | self.datefmt = _default_datefmt if datefmt is None else datefmt 101 | 102 | def usesTime(self): 103 | return "asctime" in self.fmt 104 | 105 | def formatTime(self, datefmt, record): 106 | if hasattr(time, "strftime"): 107 | return time.strftime(datefmt, time.localtime(record.ct)) 108 | return None 109 | 110 | def format(self, record): 111 | if self.usesTime(): 112 | record.asctime = self.formatTime(self.datefmt, record) 113 | return self.fmt % { 114 | "name": record.name, 115 | "message": record.message, 116 | "msecs": record.msecs, 117 | "asctime": record.asctime, 118 | "levelname": record.levelname, 119 | } 120 | 121 | 122 | class Logger: 123 | def __init__(self, name, level=NOTSET): 124 | self.name = name 125 | self.level = level 126 | self.handlers = [] 127 | self.record = LogRecord() 128 | 129 | def setLevel(self, level): 130 | self.level = level 131 | 132 | def isEnabledFor(self, level): 133 | return level >= self.getEffectiveLevel() 134 | 135 | def getEffectiveLevel(self): 136 | return self.level or getLogger().level or _DEFAULT_LEVEL 137 | 138 | def log(self, level, msg, *args): 139 | if self.isEnabledFor(level): 140 | if args: 141 | if isinstance(args[0], dict): 142 | args = args[0] 143 | msg = msg % args 144 | self.record.set(self.name, level, msg) 145 | handlers = self.handlers 146 | if not handlers: 147 | handlers = getLogger().handlers 148 | for h in handlers: 149 | h.emit(self.record) 150 | 151 | def debug(self, msg, *args): 152 | self.log(DEBUG, msg, *args) 153 | 154 | def info(self, msg, *args): 155 | self.log(INFO, msg, *args) 156 | 157 | def warning(self, msg, *args): 158 | self.log(WARNING, msg, *args) 159 | 160 | def error(self, msg, *args): 161 | self.log(ERROR, msg, *args) 162 | 163 | def critical(self, msg, *args): 164 | self.log(CRITICAL, msg, *args) 165 | 166 | def exception(self, msg, *args): 167 | self.log(ERROR, msg, *args) 168 | if hasattr(sys, "exc_info"): 169 | sys.print_exception(sys.exc_info()[1], _stream) 170 | 171 | def addHandler(self, handler): 172 | self.handlers.append(handler) 173 | 174 | def hasHandlers(self): 175 | return len(self.handlers) > 0 176 | 177 | 178 | def getLogger(name=None): 179 | if name is None: 180 | name = "root" 181 | if name not in _loggers: 182 | _loggers[name] = Logger(name) 183 | if name == "root": 184 | basicConfig() 185 | return _loggers[name] 186 | 187 | 188 | def log(level, msg, *args): 189 | getLogger().log(level, msg, *args) 190 | 191 | 192 | def debug(msg, *args): 193 | getLogger().debug(msg, *args) 194 | 195 | 196 | def info(msg, *args): 197 | getLogger().info(msg, *args) 198 | 199 | 200 | def warning(msg, *args): 201 | getLogger().warning(msg, *args) 202 | 203 | 204 | def error(msg, *args): 205 | getLogger().error(msg, *args) 206 | 207 | 208 | def critical(msg, *args): 209 | getLogger().critical(msg, *args) 210 | 211 | 212 | def exception(msg, *args): 213 | getLogger().exception(msg, *args) 214 | 215 | 216 | def shutdown(): 217 | for k, logger in _loggers.items(): 218 | for h in logger.handlers: 219 | h.close() 220 | _loggers.pop(logger, None) 221 | 222 | 223 | def addLevelName(level, name): 224 | _level_dict[level] = name 225 | 226 | 227 | def basicConfig( 228 | filename=None, 229 | filemode="a", 230 | format=None, 231 | datefmt=None, 232 | level=WARNING, 233 | stream=None, 234 | encoding="UTF-8", 235 | force=False, 236 | ): 237 | if "root" not in _loggers: 238 | _loggers["root"] = Logger("root") 239 | 240 | logger = _loggers["root"] 241 | 242 | if force or not logger.handlers: 243 | for h in logger.handlers: 244 | h.close() 245 | logger.handlers = [] 246 | 247 | if filename is None: 248 | handler = StreamHandler(stream) 249 | else: 250 | handler = FileHandler(filename, filemode, encoding) 251 | 252 | handler.setLevel(level) 253 | handler.setFormatter(Formatter(format, datefmt)) 254 | 255 | logger.setLevel(level) 256 | logger.addHandler(handler) 257 | 258 | 259 | if hasattr(sys, "atexit"): 260 | sys.atexit(shutdown) 261 | -------------------------------------------------------------------------------- /examples/tcp_client_example.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: UTF-8 -*- 3 | 4 | """ 5 | Main script 6 | 7 | Do your stuff here, this file is similar to the loop() function on Arduino 8 | 9 | Create a Modbus TCP client (slave) which can be requested for data or set with 10 | specific values by a host device. 11 | 12 | The TCP port and IP address can be choosen freely. The register definitions of 13 | the client can be defined by the user. 14 | """ 15 | 16 | # system packages 17 | import time 18 | 19 | # import modbus client classes 20 | from umodbus.tcp import ModbusTCP 21 | 22 | IS_DOCKER_MICROPYTHON = False 23 | try: 24 | import network 25 | except ImportError: 26 | IS_DOCKER_MICROPYTHON = True 27 | import json 28 | 29 | 30 | # =============================================== 31 | if IS_DOCKER_MICROPYTHON is False: 32 | # connect to a network 33 | station = network.WLAN(network.STA_IF) 34 | if station.active() and station.isconnected(): 35 | station.disconnect() 36 | time.sleep(1) 37 | station.active(False) 38 | time.sleep(1) 39 | station.active(True) 40 | 41 | # station.connect('SSID', 'PASSWORD') 42 | station.connect('TP-LINK_FBFC3C', 'C1FBFC3C') 43 | time.sleep(1) 44 | 45 | while True: 46 | print('Waiting for WiFi connection...') 47 | if station.isconnected(): 48 | print('Connected to WiFi.') 49 | print(station.ifconfig()) 50 | break 51 | time.sleep(2) 52 | 53 | # =============================================== 54 | # TCP Slave setup 55 | tcp_port = 502 # port to listen to 56 | 57 | if IS_DOCKER_MICROPYTHON: 58 | local_ip = '172.24.0.2' # static Docker IP address 59 | else: 60 | # set IP address of the MicroPython device explicitly 61 | # local_ip = '192.168.4.1' # IP address 62 | # or get it from the system after a connection to the network has been made 63 | local_ip = station.ifconfig()[0] 64 | 65 | # ModbusTCP can get TCP requests from a host device to provide/set data 66 | client = ModbusTCP() 67 | is_bound = False 68 | 69 | # check whether client has been bound to an IP and port 70 | is_bound = client.get_bound_status() 71 | 72 | if not is_bound: 73 | client.bind(local_ip=local_ip, local_port=tcp_port) 74 | 75 | 76 | def my_coil_set_cb(reg_type, address, val): 77 | print('Custom callback, called on setting {} at {} to: {}'. 78 | format(reg_type, address, val)) 79 | 80 | 81 | def my_coil_get_cb(reg_type, address, val): 82 | print('Custom callback, called on getting {} at {}, currently: {}'. 83 | format(reg_type, address, val)) 84 | 85 | 86 | def my_holding_register_set_cb(reg_type, address, val): 87 | print('Custom callback, called on setting {} at {} to: {}'. 88 | format(reg_type, address, val)) 89 | 90 | 91 | def my_holding_register_get_cb(reg_type, address, val): 92 | print('Custom callback, called on getting {} at {}, currently: {}'. 93 | format(reg_type, address, val)) 94 | 95 | 96 | def my_discrete_inputs_register_get_cb(reg_type, address, val): 97 | print('Custom callback, called on getting {} at {}, currently: {}'. 98 | format(reg_type, address, val)) 99 | 100 | 101 | def my_inputs_register_get_cb(reg_type, address, val): 102 | # usage of global isn't great, but okay for an example 103 | global client 104 | 105 | print('Custom callback, called on getting {} at {}, currently: {}'. 106 | format(reg_type, address, val)) 107 | 108 | # any operation should be as short as possible to avoid response timeouts 109 | new_val = val[0] + 1 110 | 111 | # It would be also possible to read the latest ADC value at this time 112 | # adc = machine.ADC(12) # check MicroPython port specific syntax 113 | # new_val = adc.read() 114 | 115 | client.set_ireg(address=address, value=new_val) 116 | print('Incremented current value by +1 before sending response') 117 | 118 | 119 | def reset_data_registers_cb(reg_type, address, val): 120 | # usage of global isn't great, but okay for an example 121 | global client 122 | global register_definitions 123 | 124 | print('Resetting register data to default values ...') 125 | client.setup_registers(registers=register_definitions) 126 | print('Default values restored') 127 | 128 | 129 | # commond slave register setup, to be used with the Master example above 130 | register_definitions = { 131 | "COILS": { 132 | "RESET_REGISTER_DATA_COIL": { 133 | "register": 42, 134 | "len": 1, 135 | "val": 0 136 | }, 137 | "EXAMPLE_COIL": { 138 | "register": 123, 139 | "len": 1, 140 | "val": 1 141 | } 142 | }, 143 | "HREGS": { 144 | "EXAMPLE_HREG": { 145 | "register": 93, 146 | "len": 1, 147 | "val": 19 148 | } 149 | }, 150 | "ISTS": { 151 | "EXAMPLE_ISTS": { 152 | "register": 67, 153 | "len": 1, 154 | "val": 0 155 | } 156 | }, 157 | "IREGS": { 158 | "EXAMPLE_IREG": { 159 | "register": 10, 160 | "len": 1, 161 | "val": 60001 162 | } 163 | } 164 | } 165 | 166 | # alternatively the register definitions can also be loaded from a JSON file 167 | # this is always done if Docker is used for testing purpose in order to keep 168 | # the client registers in sync with the test registers 169 | if IS_DOCKER_MICROPYTHON: 170 | with open('registers/example.json', 'r') as file: 171 | register_definitions = json.load(file) 172 | 173 | # add callbacks for different Modbus functions 174 | # each register can have a different callback 175 | # coils and holding register support callbacks for set and get 176 | register_definitions['COILS']['EXAMPLE_COIL']['on_set_cb'] = my_coil_set_cb 177 | register_definitions['COILS']['EXAMPLE_COIL']['on_get_cb'] = my_coil_get_cb 178 | register_definitions['HREGS']['EXAMPLE_HREG']['on_set_cb'] = \ 179 | my_holding_register_set_cb 180 | register_definitions['HREGS']['EXAMPLE_HREG']['on_get_cb'] = \ 181 | my_holding_register_get_cb 182 | 183 | # discrete inputs and input registers support only get callbacks as they can't 184 | # be set externally 185 | register_definitions['ISTS']['EXAMPLE_ISTS']['on_get_cb'] = \ 186 | my_discrete_inputs_register_get_cb 187 | register_definitions['IREGS']['EXAMPLE_IREG']['on_get_cb'] = \ 188 | my_inputs_register_get_cb 189 | 190 | # reset all registers back to their default value with a callback 191 | register_definitions['COILS']['RESET_REGISTER_DATA_COIL']['on_set_cb'] = \ 192 | reset_data_registers_cb 193 | 194 | print('Setting up registers ...') 195 | # use the defined values of each register type provided by register_definitions 196 | client.setup_registers(registers=register_definitions) 197 | # alternatively use dummy default values (True for bool regs, 999 otherwise) 198 | # client.setup_registers(registers=register_definitions, use_default_vals=True) 199 | print('Register setup done') 200 | 201 | print('Serving as TCP client on {}:{}'.format(local_ip, tcp_port)) 202 | 203 | while True: 204 | try: 205 | result = client.process() 206 | except KeyboardInterrupt: 207 | print('KeyboardInterrupt, stopping TCP client...') 208 | break 209 | except Exception as e: 210 | print('Exception during execution: {}'.format(e)) 211 | 212 | print("Finished providing/accepting data as client") 213 | -------------------------------------------------------------------------------- /docs/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Guideline to contribute to this package 4 | 5 | --------------- 6 | 7 | ## General 8 | 9 | You're always welcome to contribute to this package with or without raising an 10 | issue before creating a PR. 11 | 12 | Please follow this guideline covering all necessary steps and hints to ensure 13 | a smooth review and contribution process. 14 | 15 | ## Code 16 | 17 | To test and verify your changes it is recommended to run all checks locally in 18 | a virtual environment. Use the following commands to setup and install all 19 | tools. 20 | 21 | ```bash 22 | python3 -m venv .venv 23 | source .venv/bin/activate 24 | 25 | pip install -r requirements-test.txt 26 | ``` 27 | 28 | For very old systems it might be necessary to use an older version of 29 | `pre-commit`, an "always" working version is `1.18.3` with the drawback of not 30 | having `flake8` and maybe other checks in place. 31 | 32 | ### Format 33 | 34 | The Python code format is checked by `flake8` with the default line length 35 | limit of 79. Further configuration can be found in the `.flake8` file in the 36 | repo root. 37 | 38 | The YAML code format is checked by `yamllint` with some small adjustments as 39 | defined in the `.yamllint` file in the repo root. 40 | 41 | Use the following commands (inside the virtual environment) to run the Python 42 | and YAML checks 43 | 44 | ```bash 45 | # check Python 46 | flake8 . 47 | 48 | # check YAML 49 | yamllint . 50 | ``` 51 | 52 | ### Tests 53 | 54 | Every code should be covered by a unittest. This can be achieved for 55 | MicroPython up to some degree, as hardware specific stuff can't be always 56 | tested by a unittest. 57 | 58 | For now `mpy_unittest` is used as tool of choice and runs directly on the 59 | divice. For ease of use a Docker container is used as not always a device is 60 | at hand or connected to the CI. 61 | 62 | The hardware UART connection is faked by a TCP connection providing the same 63 | interface and basic functions as a real hardware interface. 64 | 65 | The tests are defined, as usual, in the `tests` folder. The `mpy_unittest` 66 | takes and runs all tests defined and imported there by the `__init__.py` file. 67 | 68 | Further tests, which could be called Integration tests, are defined in this 69 | folder as well. To be usable they may require a counterpart e.g. a client 70 | communicating with a host, which is simply achieved by two Docker containers, 71 | defined in the `docker-compose-tcp-test.yaml` or `docker-compose-rtu-test.yaml` 72 | file, located in the repo root. The examples for TCP or RTU client usage are 73 | used to provide a static setup. 74 | 75 | Incontrast to Python, no individual test results will be reported as parsable 76 | XML or similar, the container will exit with either `1` in case of an error or 77 | with `0` on success. 78 | 79 | ```bash 80 | # build and run the "native" unittests 81 | docker build --tag micropython-test --file Dockerfile.tests . 82 | 83 | # Execute client/host TCP examples 84 | docker compose up --build --exit-code-from micropython-host 85 | 86 | # Run client/host TCP tests 87 | docker compose -f docker-compose-tcp-test.yaml up --build --exit-code-from micropython-host 88 | 89 | # Run client/host RTU examples with faked RTU via TCP 90 | docker compose -f docker-compose-rtu.yaml up --build --exit-code-from micropython-host 91 | 92 | # Run client/host RTU tests 93 | docker compose -f docker-compose-rtu-test.yaml up --build --exit-code-from micropython-host 94 | ``` 95 | 96 | ### Precommit hooks 97 | 98 | This repo is equipped with a `.pre-commit-config.yaml` file to combine most of 99 | the previously mentioned checks plus the changelog validation, see next 100 | section, into one handy command. It additionally allows to automatically run 101 | the checks on every commit. 102 | 103 | In order to run this repo's pre commit hooks, perform the following steps 104 | 105 | ```bash 106 | # install pre-commit to run before each commit, optionally 107 | pre-commit install 108 | 109 | pre-commit run --all-files 110 | ``` 111 | 112 | ## Changelog 113 | 114 | The changelog format is based on [Keep a Changelog][ref-keep-a-changelog], and 115 | this project adheres to [Semantic Versioning][ref-semantic-versioning]. 116 | 117 | Please add a changelog entry for every PR you contribute. The versions are 118 | seperated into `MAJOR.MINOR.PATCH`: 119 | 120 | - Increment the major version by 1 in case you created a breaking, non 121 | backwards compatible change which requires the user to perform additional 122 | tasks, adopt his currently running code or in general can't be used as is anymore. 123 | - Increment the minor version by 1 on new "features" which can be used or are 124 | optional, but in either case do not require any changes by the user to keep 125 | the system running after upgrading. 126 | - Increment the patch version by 1 on bugfixes which fix an issue but can be 127 | used out of the box, like features, without any changes by the user. In some 128 | cases bugfixes can be breaking changes of course. 129 | 130 | Please add the date the change has been made as well to the changelog 131 | following the format `## [MAJOR.MINOR.PATCH] - YYYY-MM-DD`. It is not 132 | necessary to keep this date up to date, it is just used as meta data. 133 | 134 | The changelog entry shall be short but meaningful and can of course contain 135 | links and references to other issues or PRs. New lines are only allowed for a 136 | new bulletpoint entry. Usage examples or other code snippets should be placed 137 | in the code documentation, README or the docs folder. 138 | 139 | ### General 140 | 141 | The package version file, located at `umodbus/version.py` contains the latest 142 | changelog version. 143 | 144 | To avoid a manual sync of the changelog version and the package version file 145 | content, the `changelog2version` package is used. It parses the changelog, 146 | extracts the latest version and updates the version file. 147 | 148 | The package version file can be generated with the following command consuming 149 | the latest changelog entry 150 | 151 | ```bash 152 | changelog2version \ 153 | --changelog_file changelog.md \ 154 | --version_file umodbus/version.py \ 155 | --version_file_type py \ 156 | --debug 157 | ``` 158 | 159 | To validate the existing package version file against the latest changelog 160 | entry use this command 161 | 162 | ```bash 163 | changelog2version \ 164 | --changelog_file=changelog.md \ 165 | --version_file=umodbus/version.py \ 166 | --validate 167 | ``` 168 | 169 | ### MicroPython 170 | 171 | On MicroPython the `mip` package is used to install packages instead of `pip` 172 | at MicroPython version 1.20.0 and newer. This utilizes a `package.json` file 173 | in the repo root to define all files and dependencies of a package to be 174 | downloaded by [`mip`][ref-mip-docs]. 175 | 176 | To avoid a manual sync of the changelog version and the MicroPython package 177 | file content, the `setup2upypackage` package is used. It parses the changelog, 178 | extracts the latest version and updates the package file version entry. It 179 | additionally parses the `setup.py` file and adds entries for all files 180 | contained in the package to the `urls` section and all other external 181 | dependencies to the `deps` section. 182 | 183 | The MicroPython package file can be generated with the following command based 184 | on the latest changelog entry and `setup` file. 185 | 186 | ```bash 187 | upy-package \ 188 | --setup_file setup.py \ 189 | --package_changelog_file changelog.md \ 190 | --package_file package.json 191 | ``` 192 | 193 | To validate the existing package file against the latest changelog entry and 194 | setup file content use this command 195 | 196 | ```bash 197 | upy-package \ 198 | --setup_file setup.py \ 199 | --package_changelog_file changelog.md \ 200 | --package_file package.json \ 201 | --validate 202 | ``` 203 | 204 | ## Documentation 205 | 206 | Please check the `docs/DOCUMENTATION.md` file for further details. 207 | 208 | 209 | [ref-keep-a-changelog]: https://keepachangelog.com/en/1.0.0/ 210 | [ref-semantic-versioning]: https://semver.org/spec/v2.0.0.html 211 | [ref-mip-docs]: https://docs.micropython.org/en/v1.20.0/reference/packages.html 212 | -------------------------------------------------------------------------------- /examples/rtu_host_example.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: UTF-8 -*- 3 | 4 | """ 5 | Main script 6 | 7 | Do your stuff here, this file is similar to the loop() function on Arduino 8 | 9 | Create a Modbus RTU host (master) which requests or sets data on a client 10 | device. 11 | 12 | The RTU communication pins can be choosen freely (check MicroPython device/ 13 | port specific limitations). 14 | The register definitions of the client as well as its connection settings like 15 | bus address and UART communication speed can be defined by the user. 16 | """ 17 | 18 | # system packages 19 | import time 20 | 21 | # import modbus host classes 22 | from umodbus.serial import Serial as ModbusRTUMaster 23 | 24 | IS_DOCKER_MICROPYTHON = False 25 | try: 26 | import machine 27 | machine.reset_cause() 28 | except ImportError: 29 | raise Exception('Unable to import machine, are all fakes available?') 30 | except AttributeError: 31 | # machine fake class has no "reset_cause" function 32 | IS_DOCKER_MICROPYTHON = True 33 | import sys 34 | 35 | 36 | # =============================================== 37 | # RTU Slave setup 38 | slave_addr = 10 # address on bus of the client/slave 39 | 40 | # RTU Master setup 41 | # act as host, collect Modbus data via RTU from a client device 42 | # ModbusRTU can perform serial requests to a client device to get/set data 43 | # check MicroPython UART documentation 44 | # https://docs.micropython.org/en/latest/library/machine.UART.html 45 | # for Device/Port specific setup 46 | # 47 | # RP2 needs "rtu_pins = (Pin(4), Pin(5))" whereas ESP32 can use any pin 48 | # the following example is for an ESP32 49 | # For further details check the latest MicroPython Modbus RTU documentation 50 | # example https://micropython-modbus.readthedocs.io/en/latest/EXAMPLES.html#rtu 51 | rtu_pins = (25, 26) # (TX, RX) 52 | baudrate = 9600 53 | uart_id = 1 54 | 55 | try: 56 | from machine import Pin 57 | import os 58 | from umodbus import version 59 | 60 | os_info = os.uname() 61 | print('MicroPython infos: {}'.format(os_info)) 62 | print('Used micropthon-modbus version: {}'.format(version.__version__)) 63 | 64 | if 'pyboard' in os_info: 65 | # NOT YET TESTED ! 66 | # https://docs.micropython.org/en/latest/library/pyb.UART.html#pyb-uart 67 | # (TX, RX) = (X9, X10) = (PB6, PB7) 68 | uart_id = 1 69 | # (TX, RX) 70 | rtu_pins = (Pin(PB6), Pin(PB7)) # noqa: F821 71 | elif 'esp8266' in os_info: 72 | # https://docs.micropython.org/en/latest/esp8266/quickref.html#uart-serial-bus 73 | raise Exception( 74 | 'UART0 of ESP8266 is used by REPL, UART1 can only be used for TX' 75 | ) 76 | elif 'esp32' in os_info: 77 | # https://docs.micropython.org/en/latest/esp32/quickref.html#uart-serial-bus 78 | uart_id = 1 79 | rtu_pins = (25, 26) # (TX, RX) 80 | elif 'rp2' in os_info: 81 | # https://docs.micropython.org/en/latest/rp2/quickref.html#uart-serial-bus 82 | uart_id = 0 83 | rtu_pins = (Pin(0), Pin(1)) # (TX, RX) 84 | except AttributeError: 85 | pass 86 | except Exception as e: 87 | raise e 88 | 89 | print('Using pins {} with UART ID {}'.format(rtu_pins, uart_id)) 90 | 91 | host = ModbusRTUMaster( 92 | pins=rtu_pins, # given as tuple (TX, RX) 93 | baudrate=baudrate, # optional, default 9600 94 | # data_bits=8, # optional, default 8 95 | # stop_bits=1, # optional, default 1 96 | # parity=None, # optional, default None 97 | # ctrl_pin=12, # optional, control DE/RE 98 | uart_id=uart_id # optional, default 1, see port specific docs 99 | ) 100 | 101 | if IS_DOCKER_MICROPYTHON: 102 | # works only with fake machine UART 103 | assert host._uart._is_server is False 104 | 105 | # commond slave register setup, to be used with the Master example above 106 | register_definitions = { 107 | "COILS": { 108 | "RESET_REGISTER_DATA_COIL": { 109 | "register": 42, 110 | "len": 1, 111 | "val": 0 112 | }, 113 | "EXAMPLE_COIL": { 114 | "register": 123, 115 | "len": 1, 116 | "val": 1 117 | } 118 | }, 119 | "HREGS": { 120 | "EXAMPLE_HREG": { 121 | "register": 93, 122 | "len": 1, 123 | "val": 19 124 | } 125 | }, 126 | "ISTS": { 127 | "EXAMPLE_ISTS": { 128 | "register": 67, 129 | "len": 1, 130 | "val": 0 131 | } 132 | }, 133 | "IREGS": { 134 | "EXAMPLE_IREG": { 135 | "register": 10, 136 | "len": 1, 137 | "val": 60001 138 | } 139 | } 140 | } 141 | 142 | """ 143 | # alternatively the register definitions can also be loaded from a JSON file 144 | import json 145 | 146 | with open('registers/example.json', 'r') as file: 147 | register_definitions = json.load(file) 148 | """ 149 | 150 | print('Requesting and updating data on RTU client at address {} with {} baud'. 151 | format(slave_addr, baudrate)) 152 | print() 153 | 154 | # READ COILS 155 | coil_address = register_definitions['COILS']['EXAMPLE_COIL']['register'] 156 | coil_qty = register_definitions['COILS']['EXAMPLE_COIL']['len'] 157 | coil_status = host.read_coils( 158 | slave_addr=slave_addr, 159 | starting_addr=coil_address, 160 | coil_qty=coil_qty) 161 | print('Status of COIL {}: {}'.format(coil_address, coil_status)) 162 | time.sleep(1) 163 | 164 | # WRITE COILS 165 | new_coil_val = 0 166 | operation_status = host.write_single_coil( 167 | slave_addr=slave_addr, 168 | output_address=coil_address, 169 | output_value=new_coil_val) 170 | print('Result of setting COIL {} to {}'.format(coil_address, operation_status)) 171 | time.sleep(1) 172 | 173 | # READ COILS again 174 | coil_status = host.read_coils( 175 | slave_addr=slave_addr, 176 | starting_addr=coil_address, 177 | coil_qty=coil_qty) 178 | print('Status of COIL {}: {}'.format(coil_address, coil_status)) 179 | time.sleep(1) 180 | 181 | print() 182 | 183 | # READ HREGS 184 | hreg_address = register_definitions['HREGS']['EXAMPLE_HREG']['register'] 185 | register_qty = register_definitions['HREGS']['EXAMPLE_HREG']['len'] 186 | register_value = host.read_holding_registers( 187 | slave_addr=slave_addr, 188 | starting_addr=hreg_address, 189 | register_qty=register_qty, 190 | signed=False) 191 | print('Status of HREG {}: {}'.format(hreg_address, register_value)) 192 | time.sleep(1) 193 | 194 | # WRITE HREGS 195 | new_hreg_val = 44 196 | operation_status = host.write_single_register( 197 | slave_addr=slave_addr, 198 | register_address=hreg_address, 199 | register_value=new_hreg_val, 200 | signed=False) 201 | print('Result of setting HREG {} to {}'.format(hreg_address, operation_status)) 202 | time.sleep(1) 203 | 204 | # READ HREGS again 205 | register_value = host.read_holding_registers( 206 | slave_addr=slave_addr, 207 | starting_addr=hreg_address, 208 | register_qty=register_qty, 209 | signed=False) 210 | print('Status of HREG {}: {}'.format(hreg_address, register_value)) 211 | time.sleep(1) 212 | 213 | print() 214 | 215 | # READ ISTS 216 | ist_address = register_definitions['ISTS']['EXAMPLE_ISTS']['register'] 217 | input_qty = register_definitions['ISTS']['EXAMPLE_ISTS']['len'] 218 | input_status = host.read_discrete_inputs( 219 | slave_addr=slave_addr, 220 | starting_addr=ist_address, 221 | input_qty=input_qty) 222 | print('Status of IST {}: {}'.format(ist_address, input_status)) 223 | time.sleep(1) 224 | 225 | print() 226 | 227 | # READ IREGS 228 | ireg_address = register_definitions['IREGS']['EXAMPLE_IREG']['register'] 229 | register_qty = register_definitions['IREGS']['EXAMPLE_IREG']['len'] 230 | register_value = host.read_input_registers( 231 | slave_addr=slave_addr, 232 | starting_addr=ireg_address, 233 | register_qty=register_qty, 234 | signed=False) 235 | print('Status of IREG {}: {}'.format(ireg_address, register_value)) 236 | time.sleep(1) 237 | 238 | print() 239 | 240 | # reset all registers back to their default values on the client 241 | # WRITE COILS 242 | print('Resetting register data to default values...') 243 | coil_address = \ 244 | register_definitions['COILS']['RESET_REGISTER_DATA_COIL']['register'] 245 | new_coil_val = True 246 | operation_status = host.write_single_coil( 247 | slave_addr=slave_addr, 248 | output_address=coil_address, 249 | output_value=new_coil_val) 250 | print('Result of setting COIL {}: {}'.format(coil_address, operation_status)) 251 | time.sleep(1) 252 | 253 | print() 254 | 255 | print("Finished requesting/setting data on client") 256 | 257 | if IS_DOCKER_MICROPYTHON: 258 | sys.exit(0) 259 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MicroPython Modbus library 2 | 3 | [![Downloads](https://pepy.tech/badge/micropython-modbus)](https://pepy.tech/project/micropython-modbus) 4 | ![Release](https://img.shields.io/github/v/release/brainelectronics/micropython-modbus?include_prereleases&color=success) 5 | ![MicroPython](https://img.shields.io/badge/micropython-Ok-green.svg) 6 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 7 | [![CI](https://github.com/brainelectronics/micropython-modbus/actions/workflows/release.yml/badge.svg)](https://github.com/brainelectronics/micropython-modbus/actions/workflows/release.yml) 8 | [![Test Python package](https://github.com/brainelectronics/micropython-modbus/actions/workflows/test.yml/badge.svg)](https://github.com/brainelectronics/micropython-modbus/actions/workflows/test.yml) 9 | [![Documentation Status](https://readthedocs.org/projects/micropython-modbus/badge/?version=latest)](https://micropython-modbus.readthedocs.io/en/latest/?badge=latest) 10 | 11 | MicroPython ModBus TCP and RTU library supporting client and host mode 12 | 13 | --------------- 14 | 15 | ## General 16 | 17 | Forked from [Exo Sense Py][ref-sferalabs-exo-sense], based on 18 | [PyCom Modbus][ref-pycom-modbus] and extended with other functionalities to 19 | become a powerfull MicroPython library 20 | 21 | 📚 The latest documentation is available at 22 | [MicroPython Modbus ReadTheDocs][ref-rtd-micropython-modbus] 📚 23 | 24 | 25 | 26 | - [Quickstart](#quickstart) 27 | - [Install package on board with mip or upip](#install-package-on-board-with-mip-or-upip) 28 | - [Request coil status](#request-coil-status) 29 | - [TCP](#tcp) 30 | - [RTU](#rtu) 31 | - [Install additional MicroPython packages](#install-additional-micropython-packages) 32 | - [Usage](#usage) 33 | - [Supported Modbus functions](#supported-modbus-functions) 34 | - [Credits](#credits) 35 | 36 | 37 | 38 | ## Quickstart 39 | 40 | This is a quickstart to install the `micropython-modbus` library on a 41 | MicroPython board. 42 | 43 | A more detailed guide of the development environment can be found in 44 | [SETUP](SETUP.md), further details about the usage can be found in 45 | [USAGE](USAGE.md), descriptions for testing can be found in 46 | [TESTING](TESTING.md) and several examples in [EXAMPLES](EXAMPLES.md) 47 | 48 | ```bash 49 | python3 -m venv .venv 50 | source .venv/bin/activate 51 | 52 | pip install 'rshell>=0.0.30,<1.0.0' 53 | pip install 'mpremote>=0.4.0,<1' 54 | ``` 55 | 56 | ### Install package on board with mip or upip 57 | 58 | ```bash 59 | rshell -p /dev/tty.SLAB_USBtoUART --editor nano 60 | ``` 61 | 62 | Inside the [rshell][ref-remote-upy-shell] open a REPL and execute these 63 | commands inside the REPL 64 | 65 | ```python 66 | import machine 67 | import network 68 | import time 69 | import mip 70 | station = network.WLAN(network.STA_IF) 71 | station.active(True) 72 | station.connect('SSID', 'PASSWORD') 73 | time.sleep(1) 74 | print('Device connected to network: {}'.format(station.isconnected())) 75 | mip.install('github:brainelectronics/micropython-modbus') 76 | print('Installation completed') 77 | machine.soft_reset() 78 | ``` 79 | 80 | For MicroPython versions below 1.19.1 use the `upip` package instead of `mip` 81 | 82 | ```python 83 | import machine 84 | import network 85 | import time 86 | import upip 87 | station = network.WLAN(network.STA_IF) 88 | station.active(True) 89 | station.connect('SSID', 'PASSWORD') 90 | time.sleep(1) 91 | print('Device connected to network: {}'.format(station.isconnected())) 92 | upip.install('micropython-modbus') 93 | print('Installation completed') 94 | machine.soft_reset() 95 | ``` 96 | 97 | ### Request coil status 98 | 99 | After a successful installation of the package and reboot of the system as 100 | described in the [installation section](#install-package-on-board-with-pip) 101 | the following commands can be used to request a coil state of a target/client 102 | device. Further usage examples can be found in the 103 | [examples folder][ref-examples-folder] and in the [USAGE chapter](USAGE.md) 104 | 105 | #### TCP 106 | 107 | ```python 108 | from ummodbus.tcp import ModbusTCPMaster 109 | 110 | tcp_device = ModbusTCPMaster( 111 | slave_ip='172.24.0.2', # IP address of the target/client/slave device 112 | slave_port=502, # TCP port of the target/client/slave device 113 | # timeout=5.0 # optional, timeout in seconds, default 5.0 114 | ) 115 | 116 | # address of the target/client/slave device on the bus 117 | slave_addr = 10 118 | coil_address = 123 119 | coil_qty = 1 120 | 121 | coil_status = host.read_coils( 122 | slave_addr=slave_addr, 123 | starting_addr=coil_address, 124 | coil_qty=coil_qty) 125 | print('Status of coil {}: {}'.format(coil_status, coil_address)) 126 | ``` 127 | 128 | For further details check the latest 129 | [MicroPython Modbus TCP documentation example][ref-latest-tcp-docs-example] 130 | 131 | #### RTU 132 | 133 | ```python 134 | from umodbus.serial import Serial as ModbusRTUMaster 135 | 136 | host = ModbusRTUMaster( 137 | pins=(25, 26), # given as tuple (TX, RX), check MicroPython port specific syntax 138 | # baudrate=9600, # optional, default 9600 139 | # data_bits=8, # optional, default 8 140 | # stop_bits=1, # optional, default 1 141 | # parity=None, # optional, default None 142 | # ctrl_pin=12, # optional, control DE/RE 143 | # uart_id=1 # optional, see port specific documentation 144 | ) 145 | 146 | # address of the target/client/slave device on the bus 147 | slave_addr = 10 148 | coil_address = 123 149 | coil_qty = 1 150 | 151 | coil_status = host.read_coils( 152 | slave_addr=slave_addr, 153 | starting_addr=coil_address, 154 | coil_qty=coil_qty) 155 | print('Status of coil {}: {}'.format(coil_address, coil_status)) 156 | ``` 157 | 158 | For further details check the latest 159 | [MicroPython Modbus RTU documentation example][ref-latest-rtu-docs-example] 160 | 161 | ### Install additional MicroPython packages 162 | 163 | To use this package with the provided [`boot.py`][ref-package-boot-file] and 164 | [`main.py`][ref-package-boot-file] file, additional modules are required, 165 | which are not part of this repo/package. To install these modules on the 166 | device, connect to a network and install them via `upip` as follows 167 | 168 | ```python 169 | # with MicroPython version 1.19.1 or newer 170 | import mip 171 | mip.install('github:brainelectronics/micropython-modules') 172 | 173 | # before MicroPython version 1.19.1 174 | import upip 175 | upip.install('micropython-brainelectronics-helpers') 176 | ``` 177 | 178 | Check also the README of the 179 | [brainelectronics MicroPython modules][ref-github-be-mircopython-modules], the 180 | [INSTALLATION](INSTALLATION.md) and the [SETUP](SETUP.md) guides. 181 | 182 | ## Usage 183 | 184 | See [USAGE](USAGE.md) and [DOCUMENTATION](DOCUMENTATION.md) 185 | 186 | ## Supported Modbus functions 187 | 188 | Refer to the following table for the list of supported Modbus functions. 189 | 190 | | ID | Description | 191 | |----|-------------| 192 | | 1 | Read coils | 193 | | 2 | Read discrete inputs | 194 | | 3 | Read holding registers | 195 | | 4 | Read input registers | 196 | | 5 | Write single coil | 197 | | 6 | Write single register | 198 | | 15 | Write multiple coils | 199 | | 16 | Write multiple registers | 200 | 201 | ## Credits 202 | 203 | Big thank you to [giampiero7][ref-giampiero7] for the initial implementation 204 | of this library. 205 | 206 | * **sfera-labs** - *Initial work* - [giampiero7][ref-sferalabs-exo-sense] 207 | * **pycom** - *Initial Modbus work* - [pycom-modbus][ref-pycom-modbus] 208 | * **pfalcon** - *Initial MicroPython unittest module* - [micropython-unittest][ref-pfalcon-unittest] 209 | 210 | 211 | [ref-sferalabs-exo-sense]: https://github.com/sfera-labs/exo-sense-py-modbus 212 | [ref-pycom-modbus]: https://github.com/pycom/pycom-modbus 213 | [ref-rtd-micropython-modbus]: https://micropython-modbus.readthedocs.io/en/latest/ 214 | [ref-remote-upy-shell]: https://github.com/dhylands/rshell 215 | [ref-examples-folder]: https://github.com/brainelectronics/micropython-modbus/tree/develop/examples 216 | [ref-latest-rtu-docs-example]: https://micropython-modbus.readthedocs.io/en/latest/EXAMPLES.html#rtu 217 | [ref-latest-tcp-docs-example]: https://micropython-modbus.readthedocs.io/en/latest/EXAMPLES.html#tcp 218 | [ref-package-boot-file]: https://github.com/brainelectronics/micropython-modbus/blob/c45d6cc334b4adf0e0ffd9152c8f08724e1902d9/boot.py 219 | [ref-package-main-file]: https://github.com/brainelectronics/micropython-modbus/blob/c45d6cc334b4adf0e0ffd9152c8f08724e1902d9/main.py 220 | [ref-github-be-mircopython-modules]: https://github.com/brainelectronics/micropython-modules 221 | [ref-giampiero7]: https://github.com/giampiero7 222 | [ref-pfalcon-unittest]: https://github.com/pfalcon/pycopy-lib/blob/56ebf2110f3caa63a3785d439ce49b11e13c75c0/unittest/unittest.py 223 | -------------------------------------------------------------------------------- /registers/modbusRegisters-MyEVSE.json: -------------------------------------------------------------------------------- 1 | { 2 | "COILS": { 3 | "SYSTEM_RESET_COIL": { 4 | "register": 10, 5 | "len": 1, 6 | "description": "perform controller reset on 1", 7 | "range": "", 8 | "unit": "" 9 | }, 10 | "CONFIG_RESET_COIL": { 11 | "register": 11, 12 | "len": 1, 13 | "description": "perform EEPROM config reset on 1", 14 | "range": "", 15 | "unit": "" 16 | }, 17 | "USE_MB_CURRENT_COIL": { 18 | "register": 20, 19 | "len": 1, 20 | "description": "use CHARGING_CURRENT_IREG value", 21 | "range": "", 22 | "unit": "" 23 | } 24 | }, 25 | "HREGS": { 26 | "DEVICE_ID_HREG": { 27 | "register": 10, 28 | "len": 1, 29 | "description": "modbus address of this device", 30 | "range": "", 31 | "unit": "" 32 | }, 33 | "CHARGING_CURRENT_HREG": { 34 | "register": 21, 35 | "len": 1, 36 | "description": "[A] charging current, requires USE_MB_CURRENT_COIL", 37 | "range": "", 38 | "unit": "A" 39 | } 40 | }, 41 | "ISTS": { 42 | "SSR_STATE_ISTS": { 43 | "register": 10, 44 | "len": 1, 45 | "description": "state of the SSR", 46 | "range": "", 47 | "unit": "" 48 | }, 49 | "ENABLE_BUTTON_STATE_ISTS": { 50 | "register": 11, 51 | "len": 1, 52 | "description": "state of the enable button", 53 | "range": "", 54 | "unit": "" 55 | }, 56 | "CHARGING_ACTIVE_ISTS": { 57 | "register": 12, 58 | "len": 1, 59 | "description": "currently charging or not", 60 | "range": "", 61 | "unit": "" 62 | } 63 | }, 64 | "IREGS": { 65 | "LOOP_TIME_US_IREG": { 66 | "register": 10, 67 | "len": 2, 68 | "description": "[us] Time for one loop cycle", 69 | "range": "", 70 | "unit": "us" 71 | }, 72 | "UPTIME_MS_IREG": { 73 | "register": 12, 74 | "len": 2, 75 | "description": "[ms] System uptime", 76 | "range": "", 77 | "unit": "ms" 78 | }, 79 | "DEVICE_UUID_IREG": { 80 | "register": 14, 81 | "len": 6, 82 | "description": "UUID of the device microcontroller", 83 | "range": "", 84 | "unit": "" 85 | }, 86 | "SW_VERSION_IREG": { 87 | "register": 20, 88 | "len": 2, 89 | "description": "Software version of the device", 90 | "range": "", 91 | "unit": "" 92 | }, 93 | "FREE_RAM_IREG": { 94 | "register": 22, 95 | "len": 2, 96 | "description": "[byte] estimated available RAM", 97 | "range": "", 98 | "unit": "byte" 99 | }, 100 | "PROGRAM_RAM_IREG": { 101 | "register": 24, 102 | "len": 2, 103 | "description": "[byte] used RAM of application", 104 | "range": "", 105 | "unit": "byte" 106 | }, 107 | "SW_VERSION_MAJOR_IREG": { 108 | "register": 30, 109 | "len": 1, 110 | "description": "Major version of the software", 111 | "range": "", 112 | "unit": "" 113 | }, 114 | "SW_VERSION_MINOR_IREG": { 115 | "register": 31, 116 | "len": 1, 117 | "description": "Minor version of the software", 118 | "range": "", 119 | "unit": "" 120 | }, 121 | "SW_VERSION_PATCH_IREG": { 122 | "register": 32, 123 | "len": 1, 124 | "description": "Patch version of the software", 125 | "range": "", 126 | "unit": "" 127 | }, 128 | "HW_VERSION_MAJOR_IREG": { 129 | "register": 33, 130 | "len": 1, 131 | "description": "Major version of the hardware", 132 | "range": "", 133 | "unit": "" 134 | }, 135 | "HW_VERSION_MINOR_IREG": { 136 | "register": 34, 137 | "len": 1, 138 | "description": "Minor version of the hardware", 139 | "range": "", 140 | "unit": "" 141 | }, 142 | "HW_VERSION_PATCH_IREG": { 143 | "register": 35, 144 | "len": 1, 145 | "description": "Patch version of the hardware", 146 | "range": "", 147 | "unit": "" 148 | }, 149 | "CREATION_DATE_IREG": { 150 | "register": 36, 151 | "len": 1, 152 | "description": "Creation date of the software", 153 | "range": "", 154 | "unit": "" 155 | }, 156 | "CHARGING_BEGIN_TIME_IREG": { 157 | "register": 51, 158 | "len": 2, 159 | "description": "[ms] charging begin timestamp", 160 | "range": "", 161 | "unit": "ms" 162 | }, 163 | "CHARGING_END_TIME_IREG": { 164 | "register": 53, 165 | "len": 2, 166 | "description": "[ms] charging end timestamp", 167 | "range": "", 168 | "unit": "ms" 169 | }, 170 | "CHARGING_DURATION_IREG": { 171 | "register": 55, 172 | "len": 2, 173 | "description": "[ms] duration of last charge", 174 | "range": "", 175 | "unit": "ms" 176 | }, 177 | "COMPLETE_CHARGING_CYCLES_IREG": { 178 | "register": 57, 179 | "len": 2, 180 | "description": "completed charging cycles", 181 | "range": "", 182 | "unit": "" 183 | }, 184 | "CHARGING_CURRENT_IREG": { 185 | "register": 59, 186 | "len": 1, 187 | "description": "[A] active current for charging", 188 | "range": "", 189 | "unit": "A" 190 | }, 191 | "CHARGING_DUTYCYCLE_IREG": { 192 | "register": 60, 193 | "len": 1, 194 | "description": "[0, 255] duty cycle of applied charging current", 195 | "range": "0, 255", 196 | "unit": "" 197 | }, 198 | "CABLE_AMPACITY_IREG": { 199 | "register": 61, 200 | "len": 1, 201 | "description": "[A] ampacity of the cable", 202 | "range": "", 203 | "unit": "A" 204 | }, 205 | "CHARGING_STATE_IREG": { 206 | "register": 62, 207 | "len": 1, 208 | "description": "statemaschine state based on CP", 209 | "range": "", 210 | "unit": "" 211 | }, 212 | "RAW_PP_VALUE_MEDIAN_IREG": { 213 | "register": 100, 214 | "len": 1, 215 | "description": "[0, 4096] median ADC value of PP", 216 | "range": "0, 4096", 217 | "unit": "" 218 | }, 219 | "PP_VOLTAGE_MEDIAN_IREG": { 220 | "register": 101, 221 | "len": 1, 222 | "description": "[mV] median voltage of PP", 223 | "range": "", 224 | "unit": "mV" 225 | }, 226 | "RAW_CP_LOW_VALUE_MEDIAN_IREG": { 227 | "register": 102, 228 | "len": 1, 229 | "description": "[0, 4096] median ADC value of low CP signal", 230 | "range": "0, 4096", 231 | "unit": "" 232 | }, 233 | "RAW_CP_HIGH_VALUE_MEDIAN_IREG": { 234 | "register": 103, 235 | "len": 1, 236 | "description": "[0, 4096] median ADC value of high CP signal", 237 | "range": "0, 4096", 238 | "unit": "" 239 | }, 240 | "CP_LOW_VOLTAGE_MEDIAN_IREG": { 241 | "register": 104, 242 | "len": 1, 243 | "description": "[mV] median low voltage of CP", 244 | "range": "", 245 | "unit": "mV" 246 | }, 247 | "CP_HIGH_VOLTAGE_MEDIAN_IREG": { 248 | "register": 105, 249 | "len": 1, 250 | "description": "[mV] median high voltage of CP", 251 | "range": "", 252 | "unit": "mV" 253 | }, 254 | "MEASUREMENT_TIME_CP_IREG": { 255 | "register": 106, 256 | "len": 1, 257 | "description": "[us] time for CP measurement cycle", 258 | "range": "", 259 | "unit": "us" 260 | }, 261 | "MEASUREMENT_TIME_PP_IREG": { 262 | "register": 107, 263 | "len": 1, 264 | "description": "[us] time for PP measurement cycle", 265 | "range": "", 266 | "unit": "us" 267 | } 268 | }, 269 | "META": { 270 | "created": "10.11.2020", 271 | "modified": "07.08.2021" 272 | }, 273 | "CONNECTION": { 274 | "type": "rtu", 275 | "unit": 10, 276 | "address": "/dev/tty.wchusbserial1420", 277 | "baudrate": 9600, 278 | "mode": "slave" 279 | } 280 | } -------------------------------------------------------------------------------- /docs/EXAMPLES.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | Usage examples of this `micropython-modbus` library 4 | 5 | --------------- 6 | 7 | ## RTU 8 | 9 | ```{note} 10 | Check the port specific 11 | [MicroPython UART documentation](https://docs.micropython.org/en/latest/library/machine.UART.html) 12 | for further details. 13 | 14 | A Raspberry Pi Pico e.g. requires the UART pins as a tuple of `Pin`, like 15 | `rtu_pins = (Pin(4), Pin(5))` and the corresponding `uart_id` for those pins, 16 | whereas ESP32 boards can use almost any pin for UART communication as shown in 17 | the following examples and shall be given as `rtu_pins = (25, 26)`. If 18 | necessary, the `uart_id` parameter may has to be adapted to the pins used. 19 | ``` 20 | 21 | ### Client/Slave 22 | 23 | With this example the device is acting as client (slave) and providing data via 24 | RTU (serial/UART) to a requesting host device. 25 | 26 | ```python 27 | from umodbus.serial import ModbusRTU 28 | 29 | # RTU Client/Slave setup 30 | 31 | # the following definition is for an ESP32 32 | rtu_pins = (25, 26) # (TX, RX) 33 | uart_id = 1 34 | 35 | # the following definition is for a RP2 36 | # rtu_pins = (Pin(0), Pin(1)) # (TX, RX) 37 | # uart_id = 0 38 | # 39 | # rtu_pins = (Pin(4), Pin(5)) # (TX, RX) 40 | # uart_id = 1 41 | 42 | # the following definition is for a pyboard 43 | # rtu_pins = (Pin(PB6), Pin(PB7)) # (TX, RX) 44 | # uart_id = 1 45 | 46 | slave_addr = 10 # address on bus as client 47 | 48 | client = ModbusRTU( 49 | addr=slave_addr, # address on bus 50 | pins=rtu_pins, # given as tuple (TX, RX) 51 | # baudrate=9600, # optional, default 9600 52 | # data_bits=8, # optional, default 8 53 | # stop_bits=1, # optional, default 1 54 | # parity=None, # optional, default None 55 | # ctrl_pin=12, # optional, control DE/RE 56 | uart_id=uart_id # optional, default 1, see port specific documentation 57 | ) 58 | 59 | register_definitions = { 60 | "COILS": { 61 | "EXAMPLE_COIL": { 62 | "register": 123, 63 | "len": 1, 64 | "val": 1 65 | } 66 | }, 67 | "HREGS": { 68 | "EXAMPLE_HREG": { 69 | "register": 93, 70 | "len": 1, 71 | "val": 19 72 | } 73 | }, 74 | "ISTS": { 75 | "EXAMPLE_ISTS": { 76 | "register": 67, 77 | "len": 1, 78 | "val": 0 79 | } 80 | }, 81 | "IREGS": { 82 | "EXAMPLE_IREG": { 83 | "register": 10, 84 | "len": 1, 85 | "val": 60001 86 | } 87 | } 88 | } 89 | 90 | # use the defined values of each register type provided by register_definitions 91 | client.setup_registers(registers=register_definitions) 92 | 93 | while True: 94 | try: 95 | result = client.process() 96 | except KeyboardInterrupt: 97 | print('KeyboardInterrupt, stopping RTU client...') 98 | break 99 | except Exception as e: 100 | print('Exception during execution: {}'.format(e)) 101 | ``` 102 | 103 | ### Host/Master 104 | 105 | With this example the device is acting as host (master) and requesting on or 106 | setting data at a RTU (serial/UART) client/slave. 107 | 108 | ```python 109 | from umodbus.serial import Serial as ModbusRTUMaster 110 | 111 | # RTU Host/Master setup 112 | 113 | # the following definition is for an ESP32 114 | rtu_pins = (25, 26) # (TX, RX) 115 | uart_id = 1 116 | 117 | # the following definition is for a RP2 118 | # rtu_pins = (Pin(0), Pin(1)) # (TX, RX) 119 | # uart_id = 0 120 | # 121 | # rtu_pins = (Pin(4), Pin(5)) # (TX, RX) 122 | # uart_id = 1 123 | 124 | # the following definition is for a pyboard 125 | # rtu_pins = (Pin(PB6), Pin(PB7)) # (TX, RX) 126 | # uart_id = 1 127 | 128 | host = ModbusRTUMaster( 129 | pins=rtu_pins, # given as tuple (TX, RX) 130 | # baudrate=9600, # optional, default 9600 131 | # data_bits=8, # optional, default 8 132 | # stop_bits=1, # optional, default 1 133 | # parity=None, # optional, default None 134 | # ctrl_pin=12, # optional, control DE/RE 135 | uart_id=uart_id # optional, default 1, see port specific documentation 136 | ) 137 | 138 | coil_status = host.read_coils(slave_addr=10, starting_addr=123, coil_qty=1) 139 | print('Status of coil 123: {}'.format(coil_status)) 140 | ``` 141 | 142 | ## TCP 143 | 144 | ### Client/Slave 145 | 146 | With this example the device is acting as client (slave) and providing data via 147 | TCP (socket) to a requesting host device. 148 | 149 | ```python 150 | import network 151 | from umodbus.tcp import ModbusTCP 152 | 153 | # network connections shall be made here, check the MicroPython port specific 154 | # documentation for connecting to or creating a network 155 | 156 | # TCP Client/Slave setup 157 | # set IP address of this MicroPython device explicitly 158 | # local_ip = '192.168.4.1' # IP address 159 | # or get it from the system after a connection to the network has been made 160 | # it is not the task of this lib to provide a detailed explanation for this 161 | station = network.WLAN(network.STA_IF) 162 | local_ip = station.ifconfig()[0] 163 | tcp_port = 502 # port to listen for requests/providing data 164 | 165 | client = ModbusTCP() 166 | 167 | # check whether client has been bound to an IP and a port 168 | if not client.get_bound_status(): 169 | client.bind(local_ip=local_ip, local_port=tcp_port) 170 | 171 | register_definitions = { 172 | "COILS": { 173 | "EXAMPLE_COIL": { 174 | "register": 123, 175 | "len": 1, 176 | "val": 1 177 | } 178 | }, 179 | "HREGS": { 180 | "EXAMPLE_HREG": { 181 | "register": 93, 182 | "len": 1, 183 | "val": 19 184 | } 185 | }, 186 | "ISTS": { 187 | "EXAMPLE_ISTS": { 188 | "register": 67, 189 | "len": 1, 190 | "val": 0 191 | } 192 | }, 193 | "IREGS": { 194 | "EXAMPLE_IREG": { 195 | "register": 10, 196 | "len": 1, 197 | "val": 60001 198 | } 199 | } 200 | } 201 | 202 | # use the defined values of each register type provided by register_definitions 203 | client.setup_registers(registers=register_definitions) 204 | 205 | while True: 206 | try: 207 | result = client.process() 208 | except KeyboardInterrupt: 209 | print('KeyboardInterrupt, stopping TCP client...') 210 | break 211 | except Exception as e: 212 | print('Exception during execution: {}'.format(e)) 213 | ``` 214 | 215 | ### Host/Master 216 | 217 | With this example the device is acting as host (master) and requesting on or 218 | setting data at a TCP (socket) client/slave. 219 | 220 | ```python 221 | from umodbus.tcp import TCP as ModbusTCPMaster 222 | 223 | # valid network connections shall be made here 224 | 225 | # RTU Host/Master setup 226 | slave_tcp_port = 502 # port to send request on 227 | slave_ip = '192.168.178.69' # IP address of client, to be adjusted 228 | 229 | host = ModbusTCPMaster( 230 | slave_ip=slave_ip, 231 | slave_port=slave_tcp_port, 232 | # timeout=5.0 # optional, timeout in seconds, default 5.0 233 | ) 234 | 235 | coil_status = host.read_coils(slave_addr=10, starting_addr=123, coil_qty=1) 236 | print('Status of coil 123: {}'.format(coil_status)) 237 | ``` 238 | 239 | ## Callbacks 240 | 241 | Callbacks can be registered to be executed *after* setting a register with 242 | `on_set_cb` or to be executed *before* getting a register with `on_get_cb`. 243 | 244 | ```{note} 245 | Getter callbacks can be registered for all registers with the `on_get_cb` 246 | parameter whereas the `on_set_cb` parameter is only available for coils and 247 | holding registers as only those can be set by a external host. 248 | ``` 249 | 250 | ```{eval-rst} 251 | .. warning:: 252 | Keep the get callback actions as short as possible to avoid potential 253 | request timeouts due to a to long processing time. 254 | ``` 255 | 256 | ```python 257 | def my_coil_set_cb(reg_type, address, val): 258 | print('Custom callback, called on setting {} at {} to: {}'. 259 | format(reg_type, address, val)) 260 | 261 | 262 | def my_coil_get_cb(reg_type, address, val): 263 | print('Custom callback, called on getting {} at {}, currently: {}'. 264 | format(reg_type, address, val)) 265 | 266 | 267 | # define some registers, for simplicity only a single coil is used 268 | register_definitions = { 269 | "COILS": { 270 | "EXAMPLE_COIL": { 271 | "register": 123, 272 | "len": 1, 273 | "val": 1, 274 | "on_get_cb": my_coil_get_cb, 275 | "on_set_cb": my_coil_set_cb 276 | } 277 | } 278 | } 279 | 280 | # use the defined values of each register type provided by register_definitions 281 | client.setup_registers(registers=register_definitions) 282 | 283 | # callbacks can also be defined after a register setup has been performed 284 | client.add_coil( 285 | address=123, 286 | value=bool(1), 287 | on_set_cb=my_coil_set_cb, 288 | on_get_cb=my_coil_get_cb 289 | ) 290 | ``` 291 | -------------------------------------------------------------------------------- /mpy_unittest.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: UTF-8 -*- 3 | 4 | # Taken from pfalcon's pycopy-lib, see 5 | # https://github.com/pfalcon/pycopy-lib/blob/56ebf2110f3caa63a3785d439ce49b11e13c75c0/unittest/unittest.py 6 | # 7 | # Copyright (c) 2014-2021 Paul Sokolovsky 8 | # Copyright (c) 2014-2020 pycopy-lib contributors 9 | 10 | # Copyright (c) 2022 brainelectronics 11 | # Added: 12 | # - New properties in TestResult class 13 | # - errors 14 | # - failures 15 | # - skipped 16 | # - testsRun 17 | # - All tests or a specific TestCase can be executed 18 | # - sys exit status can be enabled (default) or disabled 19 | # - assertNotIn, assertNotIsInstance, assertLess, assertGreater 20 | # - Shebang header 21 | # 22 | # Fixed: 23 | # - All flake8 warnings 24 | # 25 | 26 | import sys 27 | try: 28 | import io 29 | import traceback 30 | except ImportError: 31 | import uio as io 32 | traceback = None 33 | 34 | 35 | class SkipTest(Exception): 36 | pass 37 | 38 | 39 | class AssertRaisesContext: 40 | 41 | def __init__(self, exc): 42 | self.expected = exc 43 | 44 | def __enter__(self): 45 | return self 46 | 47 | def __exit__(self, exc_type, exc_value, tb): 48 | self.exception = exc_value 49 | if exc_type is None: 50 | assert False, "%r not raised" % self.expected 51 | if issubclass(exc_type, self.expected): 52 | return True 53 | return False 54 | 55 | 56 | class NullContext: 57 | 58 | def __enter__(self): 59 | pass 60 | 61 | def __exit__(self, a, b, c): 62 | pass 63 | 64 | 65 | class TestCase: 66 | """ 67 | This class describes a test case. 68 | 69 | https://docs.python.org/3/library/unittest.html 70 | """ 71 | def __init__(self): 72 | pass 73 | 74 | def addCleanup(self, func, *args, **kwargs): 75 | if not hasattr(self, "_cleanups"): 76 | self._cleanups = [] 77 | self._cleanups.append((func, args, kwargs)) 78 | 79 | def doCleanups(self): 80 | if hasattr(self, "_cleanups"): 81 | while self._cleanups: 82 | func, args, kwargs = self._cleanups.pop() 83 | func(*args, **kwargs) 84 | 85 | def subTest(self, msg=None, **params): 86 | return NullContext() 87 | 88 | def skipTest(self, reason): 89 | raise SkipTest(reason) 90 | 91 | def fail(self, msg=''): 92 | assert False, msg 93 | 94 | def assertEqual(self, x, y, msg=''): 95 | if not msg: 96 | msg = "%r vs (expected) %r" % (x, y) 97 | assert x == y, msg 98 | 99 | def assertNotEqual(self, x, y, msg=''): 100 | if not msg: 101 | msg = "%r not expected to be equal %r" % (x, y) 102 | assert x != y, msg 103 | 104 | def assertLess(self, x, y, msg=None): 105 | if msg is None: 106 | msg = "%r is expected to be < %r" % (x, y) 107 | assert x < y, msg 108 | 109 | def assertLessEqual(self, x, y, msg=None): 110 | if msg is None: 111 | msg = "%r is expected to be <= %r" % (x, y) 112 | assert x <= y, msg 113 | 114 | def assertGreater(self, x, y, msg=None): 115 | if msg is None: 116 | msg = "%r is expected to be > %r" % (x, y) 117 | assert x > y, msg 118 | 119 | def assertGreaterEqual(self, x, y, msg=None): 120 | if msg is None: 121 | msg = "%r is expected to be >= %r" % (x, y) 122 | assert x >= y, msg 123 | 124 | def assertAlmostEqual(self, x, y, places=None, msg='', delta=None): 125 | if x == y: 126 | return 127 | if delta is not None and places is not None: 128 | raise TypeError("specify delta or places not both") 129 | 130 | if delta is not None: 131 | if abs(x - y) <= delta: 132 | return 133 | if not msg: 134 | msg = '%r != %r within %r delta' % (x, y, delta) 135 | else: 136 | if places is None: 137 | places = 7 138 | if round(abs(y - x), places) == 0: 139 | return 140 | if not msg: 141 | msg = '%r != %r within %r places' % (x, y, places) 142 | 143 | assert False, msg 144 | 145 | def assertNotAlmostEqual(self, x, y, places=None, msg='', delta=None): 146 | if delta is not None and places is not None: 147 | raise TypeError("specify delta or places not both") 148 | 149 | if delta is not None: 150 | if not (x == y) and abs(x - y) > delta: 151 | return 152 | if not msg: 153 | msg = '%r == %r within %r delta' % (x, y, delta) 154 | else: 155 | if places is None: 156 | places = 7 157 | if not (x == y) and round(abs(y - x), places) != 0: 158 | return 159 | if not msg: 160 | msg = '%r == %r within %r places' % (x, y, places) 161 | 162 | assert False, msg 163 | 164 | def assertIs(self, x, y, msg=''): 165 | if not msg: 166 | msg = "%r is not %r" % (x, y) 167 | assert x is y, msg 168 | 169 | def assertIsNot(self, x, y, msg=''): 170 | if not msg: 171 | msg = "%r is %r" % (x, y) 172 | assert x is not y, msg 173 | 174 | def assertIsNone(self, x, msg=''): 175 | if not msg: 176 | msg = "%r is not None" % x 177 | assert x is None, msg 178 | 179 | def assertIsNotNone(self, x, msg=''): 180 | if not msg: 181 | msg = "%r is None" % x 182 | assert x is not None, msg 183 | 184 | def assertTrue(self, x, msg=''): 185 | if not msg: 186 | msg = "Expected %r to be True" % x 187 | assert x, msg 188 | 189 | def assertFalse(self, x, msg=''): 190 | if not msg: 191 | msg = "Expected %r to be False" % x 192 | assert not x, msg 193 | 194 | def assertIn(self, x, y, msg=''): 195 | if not msg: 196 | msg = "Expected %r to be in %r" % (x, y) 197 | assert x in y, msg 198 | 199 | def assertNotIn(self, x, y, msg=''): 200 | if not msg: 201 | msg = "Expected %r to be in %r" % (x, y) 202 | assert x not in y, msg 203 | 204 | def assertIsInstance(self, x, y, msg=''): 205 | assert isinstance(x, y), msg 206 | 207 | def assertNotIsInstance(self, x, y, msg=''): 208 | assert not isinstance(x, y), msg 209 | 210 | def assertRaises(self, exc, func=None, *args, **kwargs): 211 | if func is None: 212 | return AssertRaisesContext(exc) 213 | 214 | try: 215 | func(*args, **kwargs) 216 | except Exception as e: 217 | if isinstance(e, exc): 218 | return 219 | raise 220 | 221 | assert False, "%r not raised" % exc 222 | 223 | def assertWarns(self, warn): 224 | return NullContext() 225 | 226 | 227 | def skip(msg): 228 | def _decor(fun): 229 | # We just replace original fun with _inner 230 | def _inner(self): 231 | raise SkipTest(msg) 232 | return _inner 233 | return _decor 234 | 235 | 236 | def skipIf(cond, msg): 237 | if not cond: 238 | return lambda x: x 239 | return skip(msg) 240 | 241 | 242 | def skipUnless(cond, msg): 243 | if cond: 244 | return lambda x: x 245 | return skip(msg) 246 | 247 | 248 | def expectedFailure(test): 249 | 250 | def test_exp_fail(*args, **kwargs): 251 | try: 252 | test(*args, **kwargs) 253 | except: # noqa: E722 254 | pass 255 | else: 256 | assert False, "unexpected success" 257 | 258 | return test_exp_fail 259 | 260 | 261 | class TestSuite: 262 | def __init__(self): 263 | self._tests = [] 264 | 265 | def addTest(self, cls): 266 | self._tests.append(cls) 267 | 268 | def run(self, result): 269 | for c in self._tests: 270 | run_suite(c, result) 271 | return result 272 | 273 | 274 | class TestRunner: 275 | def run(self, suite): 276 | res = TestResult() 277 | suite.run(res) 278 | 279 | res.printErrors() 280 | print("----------------------------------------------------------------------") # noqa: E501 281 | print("Ran {} tests\n".format(res.testsRun)) 282 | if res.failuresNum > 0 or res.errorsNum > 0: 283 | print("FAILED (failures={}, errors={})".format(res.failuresNum, 284 | res.errorsNum)) 285 | else: 286 | msg = "OK" 287 | if res.skippedNum > 0: 288 | msg += " (skipped={})".format(res.skippedNum) 289 | print(msg) 290 | 291 | return res 292 | 293 | 294 | TextTestRunner = TestRunner 295 | 296 | 297 | class TestResult: 298 | def __init__(self): 299 | self.errorsNum = 0 300 | self.failuresNum = 0 301 | self.skippedNum = 0 302 | self._testsRun = 0 303 | self._errors = [] 304 | self._failures = [] 305 | self._skipped = [] 306 | 307 | @property 308 | def errors(self): 309 | return self._errors 310 | 311 | @property 312 | def failures(self): 313 | return self._failures 314 | 315 | @property 316 | def skipped(self): 317 | return self._skipped 318 | 319 | @property 320 | def testsRun(self): 321 | return self._testsRun 322 | 323 | def wasSuccessful(self): 324 | return self.errorsNum == 0 and self.failuresNum == 0 325 | 326 | def printErrors(self): 327 | # print() 328 | self.printErrorList(self.errors) 329 | self.printErrorList(self.failures) 330 | 331 | def printErrorList(self, lst): 332 | sep = "----------------------------------------------------------------------" # noqa: E501 333 | for c, e in lst: 334 | print("======================================================================") # noqa: E501 335 | print(c) 336 | print(sep) 337 | print(e) 338 | 339 | def __repr__(self): 340 | # Format is compatible with CPython. 341 | return ("". 342 | format(self._testsRun, self.errorsNum, self.failuresNum)) 343 | 344 | 345 | def capture_exc(e): 346 | buf = io.StringIO() 347 | if hasattr(sys, "print_exception"): 348 | sys.print_exception(e, buf) 349 | elif traceback is not None: 350 | traceback.print_exception(None, e, sys.exc_info()[2], file=buf) 351 | return buf.getvalue() 352 | 353 | 354 | # TODO: Uncompliant 355 | def run_suite(c, test_result): 356 | if isinstance(c, TestSuite): 357 | c.run(test_result) 358 | return 359 | 360 | if isinstance(c, type): 361 | o = c() 362 | else: 363 | o = c 364 | set_up = getattr(o, "setUp", lambda: None) 365 | tear_down = getattr(o, "tearDown", lambda: None) 366 | exceptions = [] 367 | 368 | def run_one(m): 369 | print("{} ({}) ...".format(name, c.__qualname__), end="") 370 | set_up() 371 | try: 372 | test_result._testsRun += 1 373 | m() 374 | print(" ok") 375 | except SkipTest as e: 376 | print(" skipped:", e.args[0]) 377 | test_result.skippedNum += 1 378 | test_result._skipped.append((name, e.args[0])) 379 | except Exception as ex: 380 | ex_str = capture_exc(ex) 381 | if isinstance(ex, AssertionError): 382 | test_result.failuresNum += 1 383 | test_result._failures.append(((name, c), ex_str)) 384 | print(" FAIL") 385 | else: 386 | test_result.errorsNum += 1 387 | test_result._errors.append(((name, c), ex_str)) 388 | print(" ERROR") 389 | # Uncomment to investigate failure in detail 390 | # raise 391 | finally: 392 | tear_down() 393 | o.doCleanups() 394 | 395 | if hasattr(o, "runTest"): 396 | name = str(o) 397 | run_one(o.runTest) 398 | return 399 | 400 | for name in dir(o): 401 | if name.startswith("test"): 402 | m = getattr(o, name) 403 | if not callable(m): 404 | continue 405 | run_one(m) 406 | return exceptions 407 | 408 | 409 | def test_cases(m): 410 | for tn in dir(m): 411 | c = getattr(m, tn) 412 | if (isinstance(c, object) and 413 | isinstance(c, type) and 414 | issubclass(c, TestCase)): 415 | yield c 416 | 417 | 418 | def main(name="__main__", fromlist: bool = list(), do_exit: bool = True): 419 | # Import the complete module of only a subset, see 420 | # https://docs.python.org/3/library/functions.html#__import__ 421 | if len(fromlist): 422 | m = __import__(name, globals(), locals(), fromlist) 423 | else: 424 | m = __import__(name) if isinstance(name, str) else name 425 | suite = TestSuite() 426 | for c in test_cases(m): 427 | suite.addTest(c) 428 | runner = TestRunner() 429 | result = runner.run(suite) 430 | 431 | if do_exit: 432 | # Terminate with non zero return code in case of failures 433 | sys.exit(result.failuresNum or result.errorsNum) 434 | -------------------------------------------------------------------------------- /umodbus/common.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # Copyright (c) 2019, Pycom Limited. 4 | # 5 | # This software is licensed under the GNU GPL version 3 or any 6 | # later version, with permitted additional terms. For more information 7 | # see the Pycom Licence v1.0 document supplied with this file, or 8 | # available at https://www.pycom.io/opensource/licensing 9 | # 10 | 11 | # system packages 12 | import struct 13 | 14 | # custom packages 15 | from . import const as Const 16 | from . import functions 17 | 18 | # typing not natively supported on MicroPython 19 | from .typing import List, Optional, Tuple, Union 20 | 21 | 22 | class Request(object): 23 | """Deconstruct request data received via TCP or Serial""" 24 | def __init__(self, interface, data: bytearray) -> None: 25 | self._itf = interface 26 | self.unit_addr = data[0] 27 | self.function, self.register_addr = struct.unpack_from('>BH', data, 1) 28 | 29 | if self.function in [Const.READ_COILS, Const.READ_DISCRETE_INPUTS]: 30 | self.quantity = struct.unpack_from('>H', data, 4)[0] 31 | 32 | if self.quantity < 0x0001 or self.quantity > 0x07D0: 33 | raise ModbusException(self.function, Const.ILLEGAL_DATA_VALUE) 34 | 35 | self.data = None 36 | elif self.function in [Const.READ_HOLDING_REGISTERS, Const.READ_INPUT_REGISTER]: 37 | self.quantity = struct.unpack_from('>H', data, 4)[0] 38 | 39 | if self.quantity < 0x0001 or self.quantity > 0x007D: 40 | raise ModbusException(self.function, Const.ILLEGAL_DATA_VALUE) 41 | 42 | self.data = None 43 | elif self.function == Const.WRITE_SINGLE_COIL: 44 | self.quantity = None 45 | self.data = data[4:6] 46 | 47 | # allowed values: 0x0000 or 0xFF00 48 | if (self.data[0] not in [0x00, 0xFF]) or self.data[1] != 0x00: 49 | raise ModbusException(self.function, Const.ILLEGAL_DATA_VALUE) 50 | elif self.function == Const.WRITE_SINGLE_REGISTER: 51 | self.quantity = None 52 | self.data = data[4:6] 53 | # all values allowed 54 | elif self.function == Const.WRITE_MULTIPLE_COILS: 55 | self.quantity = struct.unpack_from('>H', data, 4)[0] 56 | if self.quantity < 0x0001 or self.quantity > 0x07D0: 57 | raise ModbusException(self.function, Const.ILLEGAL_DATA_VALUE) 58 | self.data = data[7:] 59 | if len(self.data) != ((self.quantity - 1) // 8) + 1: 60 | raise ModbusException(self.function, Const.ILLEGAL_DATA_VALUE) 61 | elif self.function == Const.WRITE_MULTIPLE_REGISTERS: 62 | self.quantity = struct.unpack_from('>H', data, 4)[0] 63 | if self.quantity < 0x0001 or self.quantity > 0x007B: 64 | raise ModbusException(self.function, Const.ILLEGAL_DATA_VALUE) 65 | self.data = data[7:] 66 | if len(self.data) != self.quantity * 2: 67 | raise ModbusException(self.function, Const.ILLEGAL_DATA_VALUE) 68 | else: 69 | # Not implemented functions 70 | self.quantity = None 71 | self.data = data[4:] 72 | 73 | def send_response(self, 74 | values: Optional[list] = None, 75 | signed: bool = True) -> None: 76 | """ 77 | Send a response via the configured interface. 78 | 79 | :param values: The values 80 | :type values: Optional[list] 81 | :param signed: Indicates if signed values are used 82 | :type signed: bool 83 | """ 84 | self._itf.send_response(self.unit_addr, 85 | self.function, 86 | self.register_addr, 87 | self.quantity, 88 | self.data, 89 | values, 90 | signed) 91 | 92 | def send_exception(self, exception_code: int) -> None: 93 | """ 94 | Send an exception response. 95 | 96 | :param exception_code: The exception code 97 | :type exception_code: int 98 | """ 99 | self._itf.send_exception_response(self.unit_addr, 100 | self.function, 101 | exception_code) 102 | 103 | 104 | class ModbusException(Exception): 105 | """Exception for signaling modbus errors""" 106 | def __init__(self, function_code: int, exception_code: int) -> None: 107 | self.function_code = function_code 108 | self.exception_code = exception_code 109 | 110 | 111 | class CommonModbusFunctions(object): 112 | """Common Modbus functions""" 113 | def __init__(self): 114 | pass 115 | 116 | def read_coils(self, 117 | slave_addr: int, 118 | starting_addr: int, 119 | coil_qty: int) -> List[bool]: 120 | """ 121 | Read coils (COILS). 122 | 123 | :param slave_addr: The slave address 124 | :type slave_addr: int 125 | :param starting_addr: The coil starting address 126 | :type starting_addr: int 127 | :param coil_qty: The amount of coils to read 128 | :type coil_qty: int 129 | 130 | :returns: State of read coils as list 131 | :rtype: List[bool] 132 | """ 133 | modbus_pdu = functions.read_coils(starting_address=starting_addr, 134 | quantity=coil_qty) 135 | 136 | response = self._send_receive(slave_addr=slave_addr, 137 | modbus_pdu=modbus_pdu, 138 | count=True) 139 | 140 | status_pdu = functions.bytes_to_bool(byte_list=response, 141 | bit_qty=coil_qty) 142 | 143 | return status_pdu 144 | 145 | def read_discrete_inputs(self, 146 | slave_addr: int, 147 | starting_addr: int, 148 | input_qty: int) -> List[bool]: 149 | """ 150 | Read discrete inputs (ISTS). 151 | 152 | :param slave_addr: The slave address 153 | :type slave_addr: int 154 | :param starting_addr: The discrete input starting address 155 | :type starting_addr: int 156 | :param input_qty: The amount of discrete inputs to read 157 | :type input_qty: int 158 | 159 | :returns: State of read discrete inputs as list 160 | :rtype: List[bool] 161 | """ 162 | modbus_pdu = functions.read_discrete_inputs( 163 | starting_address=starting_addr, 164 | quantity=input_qty) 165 | 166 | response = self._send_receive(slave_addr=slave_addr, 167 | modbus_pdu=modbus_pdu, 168 | count=True) 169 | 170 | status_pdu = functions.bytes_to_bool(byte_list=response, 171 | bit_qty=input_qty) 172 | 173 | return status_pdu 174 | 175 | def read_holding_registers(self, 176 | slave_addr: int, 177 | starting_addr: int, 178 | register_qty: int, 179 | signed: bool = True) -> Tuple[int, ...]: 180 | """ 181 | Read holding registers (HREGS). 182 | 183 | :param slave_addr: The slave address 184 | :type slave_addr: int 185 | :param starting_addr: The holding register starting address 186 | :type starting_addr: int 187 | :param register_qty: The amount of holding registers to read 188 | :type register_qty: int 189 | :param signed: Indicates if signed 190 | :type signed: bool 191 | 192 | :returns: State of read holding register as tuple 193 | :rtype: Tuple[int, ...] 194 | """ 195 | modbus_pdu = functions.read_holding_registers( 196 | starting_address=starting_addr, 197 | quantity=register_qty) 198 | 199 | response = self._send_receive(slave_addr=slave_addr, 200 | modbus_pdu=modbus_pdu, 201 | count=True) 202 | 203 | register_value = functions.to_short(byte_array=response, signed=signed) 204 | 205 | return register_value 206 | 207 | def read_input_registers(self, 208 | slave_addr: int, 209 | starting_addr: int, 210 | register_qty: int, 211 | signed: bool = True) -> Tuple[int, ...]: 212 | """ 213 | Read input registers (IREGS). 214 | 215 | :param slave_addr: The slave address 216 | :type slave_addr: int 217 | :param starting_addr: The input register starting address 218 | :type starting_addr: int 219 | :param register_qty: The amount of input registers to read 220 | :type register_qty: int 221 | :param signed: Indicates if signed 222 | :type signed: bool 223 | 224 | :returns: State of read input register as tuple 225 | :rtype: Tuple[int, ...] 226 | """ 227 | modbus_pdu = functions.read_input_registers( 228 | starting_address=starting_addr, 229 | quantity=register_qty) 230 | 231 | response = self._send_receive(slave_addr=slave_addr, 232 | modbus_pdu=modbus_pdu, 233 | count=True) 234 | 235 | register_value = functions.to_short(byte_array=response, signed=signed) 236 | 237 | return register_value 238 | 239 | def write_single_coil(self, 240 | slave_addr: int, 241 | output_address: int, 242 | output_value: Union[int, bool]) -> bool: 243 | """ 244 | Update a single coil. 245 | 246 | :param slave_addr: The slave address 247 | :type slave_addr: int 248 | :param output_address: The output address 249 | :type output_address: int 250 | :param output_value: The output value 251 | :type output_value: Union[int, bool] 252 | 253 | :returns: Result of operation 254 | :rtype: bool 255 | """ 256 | modbus_pdu = functions.write_single_coil(output_address=output_address, 257 | output_value=output_value) 258 | 259 | response = self._send_receive(slave_addr=slave_addr, 260 | modbus_pdu=modbus_pdu, 261 | count=False) 262 | 263 | if response is None: 264 | return False 265 | 266 | operation_status = functions.validate_resp_data( 267 | data=response, 268 | function_code=Const.WRITE_SINGLE_COIL, 269 | address=output_address, 270 | value=output_value, 271 | signed=False) 272 | 273 | return operation_status 274 | 275 | def write_single_register(self, 276 | slave_addr: int, 277 | register_address: int, 278 | register_value: int, 279 | signed: bool = True) -> bool: 280 | """ 281 | Update a single register. 282 | 283 | :param slave_addr: The slave address 284 | :type slave_addr: int 285 | :param register_address: The register address 286 | :type register_address: int 287 | :param register_value: The register value 288 | :type register_value: int 289 | :param signed: Indicates if signed 290 | :type signed: bool 291 | 292 | :returns: Result of operation 293 | :rtype: bool 294 | """ 295 | modbus_pdu = functions.write_single_register( 296 | register_address=register_address, 297 | register_value=register_value, 298 | signed=signed) 299 | 300 | response = self._send_receive(slave_addr=slave_addr, 301 | modbus_pdu=modbus_pdu, 302 | count=False) 303 | 304 | if response is None: 305 | return False 306 | 307 | operation_status = functions.validate_resp_data( 308 | data=response, 309 | function_code=Const.WRITE_SINGLE_REGISTER, 310 | address=register_address, 311 | value=register_value, 312 | signed=signed) 313 | 314 | return operation_status 315 | 316 | def write_multiple_coils(self, 317 | slave_addr: int, 318 | starting_address: int, 319 | output_values: List[Union[int, bool]]) -> bool: 320 | """ 321 | Update multiple coils. 322 | 323 | :param slave_addr: The slave address 324 | :type slave_addr: int 325 | :param starting_address: The address of the first coil 326 | :type starting_address: int 327 | :param output_values: The output values 328 | :type output_values: List[Union[int, bool]] 329 | 330 | :returns: Result of operation 331 | :rtype: bool 332 | """ 333 | modbus_pdu = functions.write_multiple_coils( 334 | starting_address=starting_address, 335 | value_list=output_values) 336 | 337 | response = self._send_receive(slave_addr=slave_addr, 338 | modbus_pdu=modbus_pdu, 339 | count=False) 340 | 341 | if response is None: 342 | return False 343 | 344 | operation_status = functions.validate_resp_data( 345 | data=response, 346 | function_code=Const.WRITE_MULTIPLE_COILS, 347 | address=starting_address, 348 | quantity=len(output_values)) 349 | 350 | return operation_status 351 | 352 | def write_multiple_registers(self, 353 | slave_addr: int, 354 | starting_address: int, 355 | register_values: List[int], 356 | signed: bool = True) -> bool: 357 | """ 358 | Update multiple registers. 359 | 360 | :param slave_addr: The slave address 361 | :type slave_addr: int 362 | :param starting_address: The starting address 363 | :type starting_address: int 364 | :param register_values: The register values 365 | :type register_values: List[int] 366 | :param signed: Indicates if signed 367 | :type signed: bool 368 | 369 | :returns: Result of operation 370 | :rtype: bool 371 | """ 372 | modbus_pdu = functions.write_multiple_registers( 373 | starting_address=starting_address, 374 | register_values=register_values, 375 | signed=signed) 376 | 377 | response = self._send_receive(slave_addr=slave_addr, 378 | modbus_pdu=modbus_pdu, 379 | count=False) 380 | 381 | if response is None: 382 | return False 383 | 384 | operation_status = functions.validate_resp_data( 385 | data=response, 386 | function_code=Const.WRITE_MULTIPLE_REGISTERS, 387 | address=starting_address, 388 | quantity=len(register_values), 389 | signed=signed 390 | ) 391 | 392 | return operation_status 393 | --------------------------------------------------------------------------------