diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4a60686..eeee8f7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -40,10 +40,24 @@ jobs: - name: Lint with yamllint run: | yamllint . - - name: Install deploy dependencies + - name: Validate package version file + # the package version file has to be always up to date as mip is using + # the file directly from the repo. On a PyPi package the version file + # is updated and then packed run: | - python -m pip install --upgrade pip - if [ -f requirements-deploy.txt ]; then pip install -r requirements-deploy.txt; fi + changelog2version \ + --changelog_file changelog.md \ + --version_file umodbus/version.py \ + --version_file_type py \ + --validate \ + --debug + - name: Validate mip package file + run: | + upy-package \ + --setup_file setup.py \ + --package_changelog_file changelog.md \ + --package_file package.json \ + --validate - name: Execute tests run: | docker build --tag micropython-test --file Dockerfile.tests . @@ -59,13 +73,12 @@ jobs: - name: Run Client/Host RTU test run: | docker compose -f docker-compose-rtu-test.yaml up --build --exit-code-from micropython-host + - name: Install deploy dependencies + run: | + python -m pip install --upgrade pip + if [ -f requirements-deploy.txt ]; then pip install -r requirements-deploy.txt; fi - name: Build package run: | - changelog2version \ - --changelog_file changelog.md \ - --version_file umodbus/version.py \ - --version_file_type py \ - --debug python setup.py sdist rm dist/*.orig - name: Test built package diff --git a/.gitmodules b/.gitmodules index 839d04e..2ec7249 100644 --- a/.gitmodules +++ b/.gitmodules @@ -3,4 +3,4 @@ # changed to https due to RTD build issue # see https://github.com/readthedocs/readthedocs.org/issues/4043 # url = git@github.com:brainelectronics/python-modules.git - url = https://github.com/brainelectronics/python-modules.git \ No newline at end of file + url = https://github.com/brainelectronics/python-modules.git diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..f79eb72 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,39 @@ +--- + +# To install pre-commit hooks, install `pre-commit` and activate it here: +# pip3 install pre-commit +# pre-commit install +# +default_stages: + - commit + - push + - manual + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v2.3.0 + hooks: + - id: check-yaml + - id: trailing-whitespace + args: [--markdown-linebreak-ext=md] + - repo: https://github.com/PyCQA/flake8 + rev: 5.0.4 + hooks: + - id: flake8 + - repo: https://github.com/brainelectronics/micropython-package-validation + rev: 0.5.0 + hooks: + - id: upy-package + args: + - "--setup_file=setup.py" + - "--package_changelog_file=changelog.md" + - "--package_file=package.json" + - "--validate" + - repo: https://github.com/brainelectronics/changelog2version + rev: 0.10.0 + hooks: + - id: changelog2version + args: + - "--changelog_file=changelog.md" + - "--version_file=umodbus/version.py" + - "--validate" diff --git a/Dockerfile.client_rtu b/Dockerfile.client_rtu index 0b37d95..3dfd300 100644 --- a/Dockerfile.client_rtu +++ b/Dockerfile.client_rtu @@ -10,6 +10,4 @@ FROM micropython/unix:v1.18 # COPY ./ /home # COPY umodbus /root/.micropython/lib/umodbus -RUN micropython-dev -m upip install micropython-ulogging - CMD [ "micropython-dev", "-m", "examples/rtu_client_example.py" ] diff --git a/Dockerfile.client_tcp b/Dockerfile.client_tcp index e1b5f20..8b52fe5 100644 --- a/Dockerfile.client_tcp +++ b/Dockerfile.client_tcp @@ -10,6 +10,4 @@ FROM micropython/unix:v1.18 # COPY ./ /home # COPY umodbus /root/.micropython/lib/umodbus -RUN micropython-dev -m upip install micropython-ulogging - CMD [ "micropython-dev", "-m", "examples/tcp_client_example.py" ] diff --git a/Dockerfile.host_rtu b/Dockerfile.host_rtu index e1f4dd3..869d063 100644 --- a/Dockerfile.host_rtu +++ b/Dockerfile.host_rtu @@ -10,6 +10,4 @@ FROM micropython/unix:v1.18 # COPY ./ /home # COPY umodbus /root/.micropython/lib/umodbus -RUN micropython-dev -m upip install micropython-ulogging - CMD [ "micropython-dev", "-m", "examples/rtu_host_example.py" ] diff --git a/Dockerfile.host_tcp b/Dockerfile.host_tcp index 689a2bc..7e1091f 100644 --- a/Dockerfile.host_tcp +++ b/Dockerfile.host_tcp @@ -10,6 +10,4 @@ FROM micropython/unix:v1.18 # COPY ./ /home # COPY umodbus /root/.micropython/lib/umodbus -RUN micropython-dev -m upip install micropython-ulogging - CMD [ "micropython-dev", "-m", "examples/tcp_host_example.py" ] diff --git a/Dockerfile.test_examples b/Dockerfile.test_examples index ae80e57..3978860 100644 --- a/Dockerfile.test_examples +++ b/Dockerfile.test_examples @@ -11,5 +11,3 @@ FROM micropython/unix:v1.18 # COPY ./ /home # COPY umodbus /root/.micropython/lib/umodbus # COPY mpy_unittest.py /root/.micropython/lib/mpy_unittest.py - -RUN micropython-dev -m upip install micropython-ulogging diff --git a/Dockerfile.tests b/Dockerfile.tests index d7ad927..a01b4b1 100644 --- a/Dockerfile.tests +++ b/Dockerfile.tests @@ -12,8 +12,8 @@ COPY ./ /home COPY registers/example.json /home/tests/test-registers.json COPY umodbus /root/.micropython/lib/umodbus COPY mpy_unittest.py /root/.micropython/lib/mpy_unittest.py +COPY tests/ulogging.py /root/.micropython/lib/ulogging.py -RUN micropython-dev -m upip install micropython-ulogging RUN micropython-dev -c "import mpy_unittest as unittest; unittest.main('tests')" ENTRYPOINT ["/bin/bash"] diff --git a/Dockerfile.tests_manually b/Dockerfile.tests_manually index 0753480..f34b381 100644 --- a/Dockerfile.tests_manually +++ b/Dockerfile.tests_manually @@ -12,7 +12,6 @@ COPY ./ /home COPY registers/example.json /home/tests/test-registers.json COPY umodbus /root/.micropython/lib/umodbus COPY mpy_unittest.py /root/.micropython/lib/mpy_unittest.py - -RUN micropython-dev -m upip install micropython-ulogging +COPY tests/ulogging.py /root/.micropython/lib/ulogging.py ENTRYPOINT ["/bin/bash"] diff --git a/changelog.md b/changelog.md index 7ac479b..2abedc8 100644 --- a/changelog.md +++ b/changelog.md @@ -15,6 +15,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Released +## [3.0.0] - 2024-07-01 +### Added +- Add support for async TCP and RTU client and server, see #5 +- Add `CommonRTUFunctions` class to [serial.py](umodbus/serial.py) as an abstract class for use by the RTU client and server +- Add `Async` versions of existing synchronous classes (e.g. `AsyncModbusRTU`, `AsyncTCP`, etc.) + +### Changed +- `ists`, `iregs`, `coils` and `hregs` use the type `KeysView` instead of `dict_keys` +- `Serial` class cleaned up, now mainly for requesting data from Modbus RTU masters (e.g. from `ModbusRTU`) +- Refactor examples, now examples need the `common` directory contents to be present as well to run + +## [2.3.7] - 2023-07-19 +### Fixed +- Add a single character wait time after flush to avoid timing issues with RTU control pin, see #68 and #72 + +## [2.3.6] - 2023-07-19 +### Added +- Add contribution guideline, see #67 +- Content of `package.json` is validated on each test workflow run +- Precommit hooks for `package.json` and package version file validation, yaml style, flake8 and trailing whitespace checks, contributes to #67 + +### Changed +- `umodbus/version.py` file is validated against the latest changelog entry before running all tests and testing the package creation +- `ulogging` placed into `tests` folder instead of installing it with deprecated `upip` in all Docker containers + +### Fixed +- Added missing empty line in several files + +## [2.3.5] - 2023-07-01 +### Fixed +- Time between RS485 control pin raise and UART transmission reduced by 80% from 1000us to 200us +- The RS485 control pin is lowered as fast as possible by using `time.sleep_us()` instead of `machine.idle()` which uses an IRQ on the order of milliseconds. This kept the control pin active longer than necessary, causing the response message to be missed at higher baud rates. This applies only to MicroPython firmwares below v1.20.0 +- The following fixes were provided by @wpyoga +- RS485 control pin handling fixed by using UART `flush` function, see #68 +- Invalid CRC while reading multiple coils and fixed, see #50 and #52 + ## [2.3.4] - 2023-03-20 ### Added - `package.json` for `mip` installation with MicroPython v1.19.1 or newer @@ -282,8 +318,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - PEP8 style issues on all files of [`lib/uModbus`](lib/uModbus) -[Unreleased]: https://github.com/brainelectronics/micropython-modbus/compare/2.3.4...develop +[Unreleased]: https://github.com/brainelectronics/micropython-modbus/compare/2.3.7...develop +[2.3.7]: https://github.com/brainelectronics/micropython-modbus/tree/2.3.7 +[2.3.6]: https://github.com/brainelectronics/micropython-modbus/tree/2.3.6 +[2.3.5]: https://github.com/brainelectronics/micropython-modbus/tree/2.3.5 [2.3.4]: https://github.com/brainelectronics/micropython-modbus/tree/2.3.4 [2.3.3]: https://github.com/brainelectronics/micropython-modbus/tree/2.3.3 [2.3.2]: https://github.com/brainelectronics/micropython-modbus/tree/2.3.2 diff --git a/docker-compose-rtu-test.yaml b/docker-compose-rtu-test.yaml index 702fd9b..0cb4944 100644 --- a/docker-compose-rtu-test.yaml +++ b/docker-compose-rtu-test.yaml @@ -20,6 +20,7 @@ services: - ./registers:/home/registers - ./umodbus:/root/.micropython/lib/umodbus - ./fakes:/usr/lib/micropython + - ./tests/ulogging.py:/root/.micropython/lib/ulogging.py expose: - "65433" ports: @@ -41,6 +42,7 @@ services: - ./umodbus:/root/.micropython/lib/umodbus - ./fakes:/usr/lib/micropython - ./mpy_unittest.py:/root/.micropython/lib/mpy_unittest.py + - ./tests/ulogging.py:/root/.micropython/lib/ulogging.py depends_on: - micropython-client command: diff --git a/docker-compose-rtu.yaml b/docker-compose-rtu.yaml index c28332b..f4cd743 100644 --- a/docker-compose-rtu.yaml +++ b/docker-compose-rtu.yaml @@ -19,6 +19,7 @@ services: - ./:/home - ./umodbus:/root/.micropython/lib/umodbus - ./fakes:/usr/lib/micropython + - ./tests/ulogging.py:/root/.micropython/lib/ulogging.py expose: - "65433" ports: @@ -39,6 +40,7 @@ services: - ./:/home - ./umodbus:/root/.micropython/lib/umodbus - ./fakes:/usr/lib/micropython + - ./tests/ulogging.py:/root/.micropython/lib/ulogging.py depends_on: - micropython-client networks: diff --git a/docker-compose-tcp-test.yaml b/docker-compose-tcp-test.yaml index 09ad0c7..c215d80 100644 --- a/docker-compose-tcp-test.yaml +++ b/docker-compose-tcp-test.yaml @@ -19,6 +19,7 @@ services: - ./:/home - ./registers:/home/registers - ./umodbus:/root/.micropython/lib/umodbus + - ./tests/ulogging.py:/root/.micropython/lib/ulogging.py expose: - "502" ports: @@ -39,6 +40,7 @@ services: - ./:/home - ./umodbus:/root/.micropython/lib/umodbus - ./mpy_unittest.py:/root/.micropython/lib/mpy_unittest.py + - ./tests/ulogging.py:/root/.micropython/lib/ulogging.py depends_on: - micropython-client command: diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index bd05ca4..2307ccd 100755 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -4,4 +4,208 @@ Guideline to contribute to this package --------------- -## TBD +## General + +You're always welcome to contribute to this package with or without raising an +issue before creating a PR. + +Please follow this guideline covering all necessary steps and hints to ensure +a smooth review and contribution process. + +## Code + +To test and verify your changes it is recommended to run all checks locally in +a virtual environment. Use the following commands to setup and install all +tools. + +```bash +python3 -m venv .venv +source .venv/bin/activate + +pip install -r requirements-test.txt +``` + +For very old systems it might be necessary to use an older version of +`pre-commit`, an "always" working version is `1.18.3` with the drawback of not +having `flake8` and maybe other checks in place. + +### Format + +The Python code format is checked by `flake8` with the default line length +limit of 79. Further configuration can be found in the `.flake8` file in the +repo root. + +The YAML code format is checked by `yamllint` with some small adjustments as +defined in the `.yamllint` file in the repo root. + +Use the following commands (inside the virtual environment) to run the Python +and YAML checks + +```bash +# check Python +flake8 . + +# check YAML +yamllint . +``` + +### Tests + +Every code should be covered by a unittest. This can be achieved for +MicroPython up to some degree, as hardware specific stuff can't be always +tested by a unittest. + +For now `mpy_unittest` is used as tool of choice and runs directly on the +divice. For ease of use a Docker container is used as not always a device is +at hand or connected to the CI. + +The hardware UART connection is faked by a TCP connection providing the same +interface and basic functions as a real hardware interface. + +The tests are defined, as usual, in the `tests` folder. The `mpy_unittest` +takes and runs all tests defined and imported there by the `__init__.py` file. + +Further tests, which could be called Integration tests, are defined in this +folder as well. To be usable they may require a counterpart e.g. a client +communicating with a host, which is simply achieved by two Docker containers, +defined in the `docker-compose-tcp-test.yaml` or `docker-compose-rtu-test.yaml` +file, located in the repo root. The examples for TCP or RTU client usage are +used to provide a static setup. + +Incontrast to Python, no individual test results will be reported as parsable +XML or similar, the container will exit with either `1` in case of an error or +with `0` on success. + +```bash +# build and run the "native" unittests +docker build --tag micropython-test --file Dockerfile.tests . + +# Execute client/host TCP examples +docker compose up --build --exit-code-from micropython-host + +# Run client/host TCP tests +docker compose -f docker-compose-tcp-test.yaml up --build --exit-code-from micropython-host + +# Run client/host RTU examples with faked RTU via TCP +docker compose -f docker-compose-rtu.yaml up --build --exit-code-from micropython-host + +# Run client/host RTU tests +docker compose -f docker-compose-rtu-test.yaml up --build --exit-code-from micropython-host +``` + +### Precommit hooks + +This repo is equipped with a `.pre-commit-config.yaml` file to combine most of +the previously mentioned checks plus the changelog validation, see next +section, into one handy command. It additionally allows to automatically run +the checks on every commit. + +In order to run this repo's pre commit hooks, perform the following steps + +```bash +# install pre-commit to run before each commit, optionally +pre-commit install + +pre-commit run --all-files +``` + +## Changelog + +The changelog format is based on [Keep a Changelog][ref-keep-a-changelog], and +this project adheres to [Semantic Versioning][ref-semantic-versioning]. + +Please add a changelog entry for every PR you contribute. The versions are +seperated into `MAJOR.MINOR.PATCH`: + +- Increment the major version by 1 in case you created a breaking, non +backwards compatible change which requires the user to perform additional +tasks, adopt his currently running code or in general can't be used as is anymore. +- Increment the minor version by 1 on new "features" which can be used or are +optional, but in either case do not require any changes by the user to keep +the system running after upgrading. +- Increment the patch version by 1 on bugfixes which fix an issue but can be +used out of the box, like features, without any changes by the user. In some +cases bugfixes can be breaking changes of course. + +Please add the date the change has been made as well to the changelog +following the format `## [MAJOR.MINOR.PATCH] - YYYY-MM-DD`. It is not +necessary to keep this date up to date, it is just used as meta data. + +The changelog entry shall be short but meaningful and can of course contain +links and references to other issues or PRs. New lines are only allowed for a +new bulletpoint entry. Usage examples or other code snippets should be placed +in the code documentation, README or the docs folder. + +### General + +The package version file, located at `umodbus/version.py` contains the latest +changelog version. + +To avoid a manual sync of the changelog version and the package version file +content, the `changelog2version` package is used. It parses the changelog, +extracts the latest version and updates the version file. + +The package version file can be generated with the following command consuming +the latest changelog entry + +```bash +changelog2version \ + --changelog_file changelog.md \ + --version_file umodbus/version.py \ + --version_file_type py \ + --debug +``` + +To validate the existing package version file against the latest changelog +entry use this command + +```bash +changelog2version \ + --changelog_file=changelog.md \ + --version_file=umodbus/version.py \ + --validate +``` + +### MicroPython + +On MicroPython the `mip` package is used to install packages instead of `pip` +at MicroPython version 1.20.0 and newer. This utilizes a `package.json` file +in the repo root to define all files and dependencies of a package to be +downloaded by [`mip`][ref-mip-docs]. + +To avoid a manual sync of the changelog version and the MicroPython package +file content, the `setup2upypackage` package is used. It parses the changelog, +extracts the latest version and updates the package file version entry. It +additionally parses the `setup.py` file and adds entries for all files +contained in the package to the `urls` section and all other external +dependencies to the `deps` section. + +The MicroPython package file can be generated with the following command based +on the latest changelog entry and `setup` file. + +```bash +upy-package \ + --setup_file setup.py \ + --package_changelog_file changelog.md \ + --package_file package.json +``` + +To validate the existing package file against the latest changelog entry and +setup file content use this command + +```bash +upy-package \ + --setup_file setup.py \ + --package_changelog_file changelog.md \ + --package_file package.json \ + --validate +``` + +## Documentation + +Please check the `docs/DOCUMENTATION.md` file for further details. + + +[ref-keep-a-changelog]: https://keepachangelog.com/en/1.0.0/ +[ref-semantic-versioning]: https://semver.org/spec/v2.0.0.html +[ref-mip-docs]: https://docs.micropython.org/en/v1.20.0/reference/packages.html diff --git a/docs/changelog_link.rst b/docs/changelog_link.rst index e446ab8..cdb4bb4 100755 --- a/docs/changelog_link.rst +++ b/docs/changelog_link.rst @@ -1,3 +1,3 @@ .. include:: ../changelog.md - :parser: myst_parser.sphinx_ \ No newline at end of file + :parser: myst_parser.sphinx_ diff --git a/docs/readme_link.rst b/docs/readme_link.rst index dd42123..9bd843d 100755 --- a/docs/readme_link.rst +++ b/docs/readme_link.rst @@ -1,3 +1,3 @@ .. include:: ../README.md - :parser: myst_parser.sphinx_ \ No newline at end of file + :parser: myst_parser.sphinx_ diff --git a/examples/async_rtu_client_example.py b/examples/async_rtu_client_example.py new file mode 100644 index 0000000..ec652b2 --- /dev/null +++ b/examples/async_rtu_client_example.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +# -*- coding: UTF-8 -*- + +""" +Main script + +Do your stuff here, this file is similar to the loop() function on Arduino + +Create an async Modbus RTU client (slave) which can be requested for data or +set with specific values by a host device. + +The RTU communication pins can be choosen freely (check MicroPython device/ +port specific limitations). +The register definitions of the client as well as its connection settings like +bus address and UART communication speed can be defined by the user. +""" + +# system imports +try: + import uasyncio as asyncio +except ImportError: + import asyncio + +# import modbus client classes +from umodbus.asynchronous.serial import AsyncModbusRTU as ModbusRTU +from examples.common.register_definitions import register_definitions, setup_callbacks +from examples.common.rtu_client_common import IS_DOCKER_MICROPYTHON +from examples.common.rtu_client_common import slave_addr, rtu_pins +from examples.common.rtu_client_common import baudrate, uart_id + + +async def start_rtu_server(slave_addr, + rtu_pins, + baudrate, + uart_id, + **kwargs): + """Creates an RTU client and runs tests""" + + client = ModbusRTU(addr=slave_addr, + pins=rtu_pins, + baudrate=baudrate, + uart_id=uart_id, + **kwargs) + + if IS_DOCKER_MICROPYTHON: + # works only with fake machine UART + assert client._itf._uart._is_server is True + + # start continuously listening in background + await client.bind() + + # reset all registers back to their default value with a callback + setup_callbacks(client, register_definitions) + + print('Setting up registers ...') + # use the defined values of each register type provided by register_definitions + client.setup_registers(registers=register_definitions) + # alternatively use dummy default values (True for bool regs, 999 otherwise) + # client.setup_registers(registers=register_definitions, use_default_vals=True) + print('Register setup done') + + await client.serve_forever() + + +# create and run task +task = start_rtu_server(slave_addr=slave_addr, + rtu_pins=rtu_pins, # given as tuple (TX, RX) + baudrate=baudrate, # optional, default 9600 + # data_bits=8, # optional, default 8 + # stop_bits=1, # optional, default 1 + # parity=None, # optional, default None + # ctrl_pin=12, # optional, control DE/RE + uart_id=uart_id) # optional, default 1, see port specific docs +asyncio.run(task) + +if IS_DOCKER_MICROPYTHON: + import sys + sys.exit(0) diff --git a/examples/async_rtu_host_example.py b/examples/async_rtu_host_example.py new file mode 100644 index 0000000..e7efc00 --- /dev/null +++ b/examples/async_rtu_host_example.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +# -*- coding: UTF-8 -*- + +""" +Main script + +Do your stuff here, this file is similar to the loop() function on Arduino + +Create an async Modbus RTU host (master) which requests or sets data on a +client device. + +The RTU communication pins can be choosen freely (check MicroPython device/ +port specific limitations). +The register definitions of the client as well as its connection settings like +bus address and UART communication speed can be defined by the user. +""" + +# system imports +try: + import uasyncio as asyncio +except ImportError: + import asyncio + +from umodbus.asynchronous.serial import AsyncSerial as ModbusRTUMaster +from examples.common.register_definitions import register_definitions +from examples.common.rtu_host_common import IS_DOCKER_MICROPYTHON +from examples.common.rtu_host_common import slave_addr, uart_id, read_timeout +from examples.common.rtu_host_common import baudrate, rtu_pins, exit +from examples.common.async_host_tests import run_host_tests + + +async def start_rtu_host(rtu_pins, + baudrate=9600, + data_bits=8, + stop_bits=1, + parity=None, + ctrl_pin=12, + uart_id=1, + read_timeout=120, + **extra_args): + """Creates an RTU host (client) and runs tests""" + + host = ModbusRTUMaster( + pins=rtu_pins, # given as tuple (TX, RX) + baudrate=baudrate, # optional, default 9600 + data_bits=data_bits, # optional, default 8 + stop_bits=stop_bits, # optional, default 1 + parity=parity, # optional, default None + ctrl_pin=ctrl_pin, # optional, control DE/RE + uart_id=uart_id, # optional, default 1, see port specific docs + read_timeout=read_timeout, # optional, default 120 + **extra_args # untested args: timeout_char (default 2) + ) + + print('Requesting and updating data on RTU client at address {} with {} baud'. + format(slave_addr, baudrate)) + print() + + if IS_DOCKER_MICROPYTHON: + # works only with fake machine UART + assert host._uart._is_server is False + + await run_host_tests(host=host, + slave_addr=slave_addr, + register_definitions=register_definitions) + +# create and run task +task = start_rtu_host( + rtu_pins=rtu_pins, # given as tuple (TX, RX) + baudrate=baudrate, # optional, default 9600 + # data_bits=8, # optional, default 8 + # stop_bits=1, # optional, default 1 + # parity=None, # optional, default None + # ctrl_pin=12, # optional, control DE/RE + uart_id=uart_id, # optional, default 1, see port specific docs + read_timeout=read_timeout, # optional, default 120 + # timeout_char=2 # untested, default 2 (ms) +) +asyncio.run(task) + +exit() diff --git a/examples/async_tcp_client_example.py b/examples/async_tcp_client_example.py new file mode 100644 index 0000000..a066319 --- /dev/null +++ b/examples/async_tcp_client_example.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +# -*- coding: UTF-8 -*- + +""" +Main script + +Do your stuff here, this file is similar to the loop() function on Arduino + +Create an async Modbus TCP client (slave) which can be requested for data or +set with specific values by a host device. + +The TCP port and IP address can be choosen freely. The register definitions of +the client can be defined by the user. +""" + +# system imports +try: + import uasyncio as asyncio +except ImportError: + import asyncio + +from umodbus.asynchronous.tcp import AsyncModbusTCP as ModbusTCP +from examples.common.register_definitions import register_definitions, setup_callbacks +from examples.common.tcp_client_common import IS_DOCKER_MICROPYTHON +from examples.common.tcp_client_common import local_ip, tcp_port + + +async def start_tcp_server(host, port, backlog, register_definitions): + client = ModbusTCP() # TODO: rename to `server` + await client.bind(local_ip=host, local_port=port, max_connections=backlog) + + print('Setting up registers ...') + # setup remaining callbacks after creating client + setup_callbacks(client, register_definitions) + # use the defined values of each register type provided by register_definitions + client.setup_registers(registers=register_definitions) + # alternatively use dummy default values (True for bool regs, 999 otherwise) + # client.setup_registers(registers=register_definitions, use_default_vals=True) + print('Register setup done') + + print('Serving as TCP client on {}:{}'.format(local_ip, tcp_port)) + await client.serve_forever() + + +# alternatively the register definitions can also be loaded from a JSON file +# this is always done if Docker is used for testing purpose in order to keep +# the client registers in sync with the test registers +if IS_DOCKER_MICROPYTHON: + import json + with open('registers/example.json', 'r') as file: + register_definitions = json.load(file) # noqa: F811 + +# create and run task +task = start_tcp_server(host=local_ip, + port=tcp_port, + backlog=10, # arbitrary backlog + register_definitions=register_definitions) +asyncio.run(task) diff --git a/examples/async_tcp_host_example.py b/examples/async_tcp_host_example.py new file mode 100644 index 0000000..5d74947 --- /dev/null +++ b/examples/async_tcp_host_example.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +# -*- coding: UTF-8 -*- + +""" +Main script + +Do your stuff here, this file is similar to the loop() function on Arduino + +Create an async Modbus TCP host (master) which requests or sets data on a +client device. + +The TCP port and IP address can be choosen freely. The register definitions of +the client can be defined by the user. +""" + +# system packages +try: + import uasyncio as asyncio +except ImportError: + import asyncio + +# import modbus host classes +from umodbus.asynchronous.tcp import AsyncTCP as ModbusTCPMaster +from examples.common.register_definitions import register_definitions +from examples.common.tcp_host_common import slave_ip, slave_tcp_port +from examples.common.tcp_host_common import slave_addr, exit +from examples.common.async_host_tests import run_host_tests + + +async def start_tcp_client(host, port, unit_id, timeout): + # TCP Master setup + # act as host, get Modbus data via TCP from a client device + # ModbusTCPMaster can make TCP requests to a client device to get/set data + client = ModbusTCPMaster( + slave_ip=host, + slave_port=port, + timeout=timeout) + + # unlike synchronous client, need to call connect() here + await client.connect() + if client.is_connected: + print('Requesting and updating data on TCP client at {}:{}'. + format(host, port)) + print() + + await run_host_tests(host=client, + slave_addr=unit_id, + register_definitions=register_definitions) + + +# create and run task +task = start_tcp_client(host=slave_ip, + port=slave_tcp_port, + unit_id=slave_addr, + timeout=5) +asyncio.run(task) + +exit() diff --git a/examples/common/async_host_tests.py b/examples/common/async_host_tests.py new file mode 100644 index 0000000..71b7d84 --- /dev/null +++ b/examples/common/async_host_tests.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +# -*- coding: UTF-8 -*- + +""" +Defines the tests for both async TCP/RTU hosts. +""" + + +async def _read_coils_test(host, slave_addr, register_definitions, sleep_fn, **kwargs): + coil_address = register_definitions['COILS']['EXAMPLE_COIL']['register'] + coil_qty = register_definitions['COILS']['EXAMPLE_COIL']['len'] + coil_status = await host.read_coils( + slave_addr=slave_addr, + starting_addr=coil_address, + coil_qty=coil_qty) + print('Status of COIL {}: {}'.format(coil_address, coil_status)) + await sleep_fn(1) + return { + "coil_address": coil_address, + "coil_qty": coil_qty + } + + +async def _write_coils_test(host, slave_addr, register_definitions, sleep_fn, **kwargs): + new_coil_val = 0 + coil_address = kwargs["coil_address"] + operation_status = await host.write_single_coil( + slave_addr=slave_addr, + output_address=coil_address, + output_value=new_coil_val) + print('Result of setting COIL {}: {}'.format(coil_address, operation_status)) + await sleep_fn(1) + + return {"new_coil_val": new_coil_val} + + +async def _read_hregs_test(host, slave_addr, register_definitions, sleep_fn, **kwargs): + hreg_address = register_definitions['HREGS']['EXAMPLE_HREG']['register'] + register_qty = register_definitions['HREGS']['EXAMPLE_HREG']['len'] + register_value = await host.read_holding_registers( + slave_addr=slave_addr, + starting_addr=hreg_address, + register_qty=register_qty, + signed=False) + print('Status of HREG {}: {}'.format(hreg_address, register_value)) + await sleep_fn(1) + + return {"hreg_address": hreg_address} + + +async def _write_hregs_test(host, slave_addr, register_definitions, sleep_fn, **kwargs): + new_hreg_val = 44 + hreg_address = kwargs["hreg_address"] + operation_status = await host.write_single_register( + slave_addr=slave_addr, + register_address=hreg_address, + register_value=new_hreg_val, + signed=False) + print('Result of setting HREG {}: {}'.format(hreg_address, operation_status)) + await sleep_fn(1) + + +async def _write_hregs_beyond_limits_test(host, slave_addr, register_definitions, sleep_fn, **kwargs): + # try to set value outside specified range of [0, 101] + # in register_definitions on_pre_set_cb callback + new_hreg_val = 500 + hreg_address = kwargs["hreg_address"] + operation_status = await host.write_single_register( + slave_addr=slave_addr, + register_address=hreg_address, + register_value=new_hreg_val, + signed=False) + # should be error: illegal data value + print('Result of setting HREG {}: {}'.format(hreg_address, operation_status)) + await sleep_fn(1) + + +async def _read_ists_test(host, slave_addr, register_definitions, sleep_fn, **kwargs): + ist_address = register_definitions['ISTS']['EXAMPLE_ISTS']['register'] + input_qty = register_definitions['ISTS']['EXAMPLE_ISTS']['len'] + input_status = await host.read_discrete_inputs( + slave_addr=slave_addr, + starting_addr=ist_address, + input_qty=input_qty) + print('Status of IST {}: {}'.format(ist_address, input_status)) + await sleep_fn(1) + + +async def _read_iregs_test(host, slave_addr, register_definitions, sleep_fn, **kwargs): + ireg_address = register_definitions['IREGS']['EXAMPLE_IREG']['register'] + register_qty = register_definitions['IREGS']['EXAMPLE_IREG']['len'] + register_value = await host.read_input_registers( + slave_addr=slave_addr, + starting_addr=ireg_address, + register_qty=register_qty, + signed=False) + print('Status of IREG {}: {}'.format(ireg_address, register_value)) + await sleep_fn(1) + + +async def _reset_registers_test(host, slave_addr, register_definitions, sleep_fn, **kwargs): + print('Resetting register data to default values...') + coil_address = \ + register_definitions['COILS']['RESET_REGISTER_DATA_COIL']['register'] + new_coil_val = True + operation_status = await host.write_single_coil( + slave_addr=slave_addr, + output_address=coil_address, + output_value=new_coil_val) + print('Result of setting COIL {}: {}'.format(coil_address, operation_status)) + await sleep_fn(1) + + +async def run_host_tests(host, slave_addr, register_definitions, exit_on_timeout=False): + """Runs tests with a Modbus host (client)""" + + try: + import uasyncio as asyncio + except ImportError: + import asyncio + + callbacks = [ + _read_coils_test, _write_coils_test, _read_coils_test, + _read_hregs_test, _write_hregs_test, _read_hregs_test, + _write_hregs_beyond_limits_test, _read_hregs_test, + _read_ists_test, _read_iregs_test, _reset_registers_test + ] + + test_vars = {} + current_callback_idx = 0 + # run test pipeline + while current_callback_idx < len(callbacks): + try: + current_callback = callbacks[current_callback_idx] + new_vars = await current_callback( + host=host, slave_addr=slave_addr, register_definitions=register_definitions, + sleep_fn=asyncio.sleep, **test_vars) + + # test succeeded, move on to the next + if new_vars is not None: + test_vars.update(new_vars) + current_callback_idx += 1 + print() + except OSError as err: + print("Potential timeout error:", err) + if exit_on_timeout: + break + + print("Finished requesting/setting data on client") diff --git a/examples/common/register_definitions.py b/examples/common/register_definitions.py new file mode 100644 index 0000000..b8d41ac --- /dev/null +++ b/examples/common/register_definitions.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +# -*- coding: UTF-8 -*- + +""" +Defines the common registers for all examples, as well as the +callbacks that can be used when setting up the various clients. +""" + + +def my_coil_set_cb(reg_type, address, val): + print('my_coil_set_cb, called on setting {} at {} to: {}'. + format(reg_type, address, val)) + + +def my_coil_get_cb(reg_type, address, val): + print('my_coil_get_cb, called on getting {} at {}, currently: {}'. + format(reg_type, address, val)) + + +def my_holding_register_set_cb(reg_type, address, val): + print('my_hr_set_sb, called on setting {} at {} to: {}'. + format(reg_type, address, val)) + + +def my_holding_register_get_cb(reg_type, address, val): + print('my_hr_get_cb, called on getting {} at {}, currently: {}'. + format(reg_type, address, val)) + + +def my_discrete_inputs_register_get_cb(reg_type, address, val): + print('my_di_get_cb, called on getting {} at {}, currently: {}'. + format(reg_type, address, val)) + + +def setup_callbacks(client, register_definitions): + """ + Sets up all the callbacks for the register definitions, including + those which require references to the client and the register + definitions themselves. Done to avoid use of `global`s as this + causes errors when defining the functions before the client(s). + """ + + def reset_data_registers_cb(reg_type, address, val): + print('Resetting register data to default values ...') + client.setup_registers(registers=register_definitions) + print('Default values restored') + + def my_inputs_register_get_cb(reg_type, address, val): + print('my_ir_get_cb, called on getting {} at {}, currently: {}'. + format(reg_type, address, val)) + + # any operation should be as short as possible to avoid response timeouts + new_val = val[0] + 1 + + # It would be also possible to read the latest ADC value at this time + # adc = machine.ADC(12) # check MicroPython port specific syntax + # new_val = adc.read() + + client.set_ireg(address=address, value=new_val) + print('Incremented current value by +1 before sending response') + + def my_holding_register_pre_set_cb(reg_type, address, val): + print('Custom callback, called on setting {} at {} to: {}'. + format(reg_type, address, val)) + + return val not in range(0, 101) + + def my_tcp_connect_cb(address): + print('my_tcp_connect_cb, called after tcp client connects ' + 'with address {}'.format(address)) + + def my_tcp_disconnect_cb(address): + print('my_tcp_disconnect_cb, called just before tcp client disconnects ' + 'with address {}'.format(address)) + + # reset all registers back to their default value with a callback + register_definitions['COILS']['RESET_REGISTER_DATA_COIL']['on_set_cb'] = \ + reset_data_registers_cb + # input registers support only get callbacks as they can't be set + # externally + register_definitions['IREGS']['EXAMPLE_IREG']['on_get_cb'] = \ + my_inputs_register_get_cb + + # add callbacks for different Modbus functions + # each register can have a different callback + # coils and holding register support callbacks for set and get + # as well as before-set - but before-set can only be specified + # in register_definitions, not dynamically as it is an "extra" + # callback + register_definitions['HREGS']['EXAMPLE_HREG']['on_pre_set_cb'] = \ + my_holding_register_pre_set_cb + register_definitions['COILS']['EXAMPLE_COIL']['on_set_cb'] = my_coil_set_cb + register_definitions['COILS']['EXAMPLE_COIL']['on_get_cb'] = my_coil_get_cb + register_definitions['HREGS']['EXAMPLE_HREG']['on_set_cb'] = \ + my_holding_register_set_cb + register_definitions['HREGS']['EXAMPLE_HREG']['on_get_cb'] = \ + my_holding_register_get_cb + # discrete inputs and input registers support only get callbacks as they can't + # be set externally + register_definitions['ISTS']['EXAMPLE_ISTS']['on_get_cb'] = \ + my_discrete_inputs_register_get_cb + + register_definitions['META'] = { + 'on_connect_cb': my_tcp_connect_cb, + 'on_disconnect_cb': my_tcp_disconnect_cb + } + + +register_definitions = { + "COILS": { + "RESET_REGISTER_DATA_COIL": { + "register": 42, + "len": 1, + "val": 0 + }, + "EXAMPLE_COIL": { + "register": 123, + "len": 1, + "val": 1 + } + }, + "HREGS": { + "EXAMPLE_HREG": { + "register": 93, + "len": 1, + "val": 19 + } + }, + "ISTS": { + "EXAMPLE_ISTS": { + "register": 67, + "len": 1, + "val": 0 + } + }, + "IREGS": { + "EXAMPLE_IREG": { + "register": 10, + "len": 1, + "val": 60001 + } + } +} diff --git a/examples/common/rtu_client_common.py b/examples/common/rtu_client_common.py new file mode 100644 index 0000000..cdf0adb --- /dev/null +++ b/examples/common/rtu_client_common.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +# -*- coding: UTF-8 -*- + +""" +Auxiliary script + +Defines the common imports and functions for running the client +examples for both the synchronous and asynchronous RTU clients. +""" + +IS_DOCKER_MICROPYTHON = False +try: + import machine + machine.reset_cause() +except ImportError: + raise Exception('Unable to import machine, are all fakes available?') +except AttributeError: + # machine fake class has no "reset_cause" function + IS_DOCKER_MICROPYTHON = True + + +def exit(): + if IS_DOCKER_MICROPYTHON: + import sys + sys.exit(0) + + +# =============================================== +# RTU Slave setup +# act as client, provide Modbus data via RTU to a host device +# ModbusRTU can get serial requests from a host device to provide/set data +# check MicroPython UART documentation +# https://docs.micropython.org/en/latest/library/machine.UART.html +# for Device/Port specific setup +# +# RP2 needs "rtu_pins = (Pin(4), Pin(5))" whereas ESP32 can use any pin +# the following example is for an ESP32. +# For further details check the latest MicroPython Modbus RTU documentation +# example https://micropython-modbus.readthedocs.io/en/latest/EXAMPLES.html#rtu +rtu_pins = (25, 26) # (TX, RX) +slave_addr = 10 # address on bus as client +baudrate = 9600 +uart_id = 1 + +try: + from machine import Pin + import os + from umodbus import version + + os_info = os.uname() + print('MicroPython infos: {}'.format(os_info)) + print('Used micropthon-modbus version: {}'.format(version.__version__)) + + if 'pyboard' in os_info: + # NOT YET TESTED ! + # https://docs.micropython.org/en/latest/library/pyb.UART.html#pyb-uart + # (TX, RX) = (X9, X10) = (PB6, PB7) + uart_id = 1 + # (TX, RX) + rtu_pins = (Pin(PB6), Pin(PB7)) # noqa: F821 + elif 'esp8266' in os_info: + # https://docs.micropython.org/en/latest/esp8266/quickref.html#uart-serial-bus + raise Exception( + 'UART0 of ESP8266 is used by REPL, UART1 can only be used for TX' + ) + elif 'esp32' in os_info: + # https://docs.micropython.org/en/latest/esp32/quickref.html#uart-serial-bus + uart_id = 1 + rtu_pins = (25, 26) # (TX, RX) + elif 'rp2' in os_info: + # https://docs.micropython.org/en/latest/rp2/quickref.html#uart-serial-bus + uart_id = 0 + rtu_pins = (Pin(0), Pin(1)) # (TX, RX) +except AttributeError: + pass +except Exception as e: + raise e + +print('Using pins {} with UART ID {}'.format(rtu_pins, uart_id)) + +# alternatively the register definitions can also be loaded from a JSON file +# this is always done if Docker is used for testing purpose in order to keep +# the client registers in sync with the test registers +if IS_DOCKER_MICROPYTHON: + import json + with open('registers/example.json', 'r') as file: + register_definitions = json.load(file) diff --git a/examples/common/rtu_host_common.py b/examples/common/rtu_host_common.py new file mode 100644 index 0000000..852e704 --- /dev/null +++ b/examples/common/rtu_host_common.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +# -*- coding: UTF-8 -*- + +""" +Auxiliary script + +Defines the common imports and functions for running the host +examples for both the synchronous and asynchronous RTU hosts. +""" + +IS_DOCKER_MICROPYTHON = False +try: + import machine + machine.reset_cause() +except ImportError: + raise Exception('Unable to import machine, are all fakes available?') +except AttributeError: + # machine fake class has no "reset_cause" function + IS_DOCKER_MICROPYTHON = True + + +def exit(): + if IS_DOCKER_MICROPYTHON: + import sys + sys.exit(0) + + +# =============================================== +# RTU Slave setup +slave_addr = 10 # address on bus of the client/slave + +# RTU Master setup +# act as host, collect Modbus data via RTU from a client device +# ModbusRTU can perform serial requests to a client device to get/set data +# check MicroPython UART documentation +# https://docs.micropython.org/en/latest/library/machine.UART.html +# for Device/Port specific setup +# +# RP2 needs "rtu_pins = (Pin(4), Pin(5))" whereas ESP32 can use any pin +# the following example is for an ESP32 +# For further details check the latest MicroPython Modbus RTU documentation +# example https://micropython-modbus.readthedocs.io/en/latest/EXAMPLES.html#rtu +rtu_pins = (25, 26) # (TX, RX) +baudrate = 9600 +uart_id = 1 +read_timeout = 120 + +try: + from machine import Pin + import os + from umodbus import version + + os_info = os.uname() + print('MicroPython infos: {}'.format(os_info)) + print('Used micropthon-modbus version: {}'.format(version.__version__)) + + if 'pyboard' in os_info: + # NOT YET TESTED ! + # https://docs.micropython.org/en/latest/library/pyb.UART.html#pyb-uart + # (TX, RX) = (X9, X10) = (PB6, PB7) + uart_id = 1 + # (TX, RX) + rtu_pins = (Pin(PB6), Pin(PB7)) # noqa: F821 + elif 'esp8266' in os_info: + # https://docs.micropython.org/en/latest/esp8266/quickref.html#uart-serial-bus + raise Exception( + 'UART0 of ESP8266 is used by REPL, UART1 can only be used for TX' + ) + elif 'esp32' in os_info: + # https://docs.micropython.org/en/latest/esp32/quickref.html#uart-serial-bus + uart_id = 1 + rtu_pins = (25, 26) # (TX, RX) + elif 'rp2' in os_info: + # https://docs.micropython.org/en/latest/rp2/quickref.html#uart-serial-bus + uart_id = 0 + rtu_pins = (Pin(0), Pin(1)) # (TX, RX) +except AttributeError: + pass +except Exception as e: + raise e + +print('Using pins {} with UART ID {}'.format(rtu_pins, uart_id)) diff --git a/examples/common/sync_host_tests.py b/examples/common/sync_host_tests.py new file mode 100644 index 0000000..5692b51 --- /dev/null +++ b/examples/common/sync_host_tests.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +# -*- coding: UTF-8 -*- + +""" +Defines the tests for both sync TCP/RTU hosts. +""" + + +def _read_coils_test(host, slave_addr, register_definitions, sleep_fn, **kwargs): + coil_address = register_definitions['COILS']['EXAMPLE_COIL']['register'] + coil_qty = register_definitions['COILS']['EXAMPLE_COIL']['len'] + coil_status = host.read_coils( + slave_addr=slave_addr, + starting_addr=coil_address, + coil_qty=coil_qty) + print('Status of COIL {}: {}'.format(coil_address, coil_status)) + sleep_fn(1) + return { + "coil_address": coil_address, + "coil_qty": coil_qty + } + + +def _write_coils_test(host, slave_addr, register_definitions, sleep_fn, **kwargs): + new_coil_val = 0 + coil_address = kwargs["coil_address"] + operation_status = host.write_single_coil( + slave_addr=slave_addr, + output_address=coil_address, + output_value=new_coil_val) + print('Result of setting COIL {}: {}'.format(coil_address, operation_status)) + sleep_fn(1) + + return {"new_coil_val": new_coil_val} + + +def _read_hregs_test(host, slave_addr, register_definitions, sleep_fn, **kwargs): + hreg_address = register_definitions['HREGS']['EXAMPLE_HREG']['register'] + register_qty = register_definitions['HREGS']['EXAMPLE_HREG']['len'] + register_value = host.read_holding_registers( + slave_addr=slave_addr, + starting_addr=hreg_address, + register_qty=register_qty, + signed=False) + print('Status of HREG {}: {}'.format(hreg_address, register_value)) + sleep_fn(1) + + return {"hreg_address": hreg_address} + + +def _write_hregs_test(host, slave_addr, register_definitions, sleep_fn, **kwargs): + new_hreg_val = 44 + hreg_address = kwargs["hreg_address"] + operation_status = host.write_single_register( + slave_addr=slave_addr, + register_address=hreg_address, + register_value=new_hreg_val, + signed=False) + print('Result of setting HREG {}: {}'.format(hreg_address, operation_status)) + sleep_fn(1) + + +def _write_hregs_beyond_limits_test(host, slave_addr, register_definitions, sleep_fn, **kwargs): + # try to set value outside specified range of [0, 101] + # in register_definitions on_pre_set_cb callback + new_hreg_val = 500 + hreg_address = kwargs["hreg_address"] + operation_status = host.write_single_register( + slave_addr=slave_addr, + register_address=hreg_address, + register_value=new_hreg_val, + signed=False) + # should be error: illegal data value + print('Result of setting HREG {}: {}'.format(hreg_address, operation_status)) + sleep_fn(1) + + +def _read_ists_test(host, slave_addr, register_definitions, sleep_fn, **kwargs): + ist_address = register_definitions['ISTS']['EXAMPLE_ISTS']['register'] + input_qty = register_definitions['ISTS']['EXAMPLE_ISTS']['len'] + input_status = host.read_discrete_inputs( + slave_addr=slave_addr, + starting_addr=ist_address, + input_qty=input_qty) + print('Status of IST {}: {}'.format(ist_address, input_status)) + sleep_fn(1) + + +def _read_iregs_test(host, slave_addr, register_definitions, sleep_fn, **kwargs): + ireg_address = register_definitions['IREGS']['EXAMPLE_IREG']['register'] + register_qty = register_definitions['IREGS']['EXAMPLE_IREG']['len'] + register_value = host.read_input_registers( + slave_addr=slave_addr, + starting_addr=ireg_address, + register_qty=register_qty, + signed=False) + print('Status of IREG {}: {}'.format(ireg_address, register_value)) + sleep_fn(1) + + +def _reset_registers_test(host, slave_addr, register_definitions, sleep_fn, **kwargs): + print('Resetting register data to default values...') + coil_address = \ + register_definitions['COILS']['RESET_REGISTER_DATA_COIL']['register'] + new_coil_val = True + operation_status = host.write_single_coil( + slave_addr=slave_addr, + output_address=coil_address, + output_value=new_coil_val) + print('Result of setting COIL {}: {}'.format(coil_address, operation_status)) + sleep_fn(1) + + +def run_host_tests(host, slave_addr, register_definitions, exit_on_timeout=False): + """Runs tests with a Modbus host (client)""" + + import time + + callbacks = [ + _read_coils_test, _write_coils_test, _read_coils_test, + _read_hregs_test, _write_hregs_test, _read_hregs_test, + _write_hregs_beyond_limits_test, _read_hregs_test, + _read_ists_test, _read_iregs_test, _reset_registers_test + ] + + test_vars = {} + current_callback_idx = 0 + # run test pipeline + while current_callback_idx < len(callbacks): + try: + current_callback = callbacks[current_callback_idx] + new_vars = current_callback( + host=host, slave_addr=slave_addr, register_definitions=register_definitions, + sleep_fn=time.sleep, **test_vars) + + # test succeeded, move on to the next + if new_vars is not None: + test_vars.update(new_vars) + current_callback_idx += 1 + print() + except OSError as err: + print("Potential timeout error:", err) + if exit_on_timeout: + break + + print("Finished requesting/setting data on client") diff --git a/examples/common/tcp_client_common.py b/examples/common/tcp_client_common.py new file mode 100644 index 0000000..eb8ce70 --- /dev/null +++ b/examples/common/tcp_client_common.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +# -*- coding: UTF-8 -*- + +""" +Auxiliary script + +Defines the common imports and functions for running the client +examples for both the synchronous and asynchronous TCP clients. +""" + +import time + +IS_DOCKER_MICROPYTHON = False +try: + import network +except ImportError: + IS_DOCKER_MICROPYTHON = True + +# =============================================== +if IS_DOCKER_MICROPYTHON is False: + # connect to a network + station = network.WLAN(network.STA_IF) + if station.active() and station.isconnected(): + station.disconnect() + time.sleep(1) + station.active(False) + time.sleep(1) + station.active(True) + + # station.connect('SSID', 'PASSWORD') + station.connect('TP-LINK_FBFC3C', 'C1FBFC3C') + time.sleep(1) + + while True: + print('Waiting for WiFi connection...') + if station.isconnected(): + print('Connected to WiFi.') + print(station.ifconfig()) + break + time.sleep(2) + +# =============================================== +# TCP Slave setup +tcp_port = 502 # port to listen to + +if IS_DOCKER_MICROPYTHON: + local_ip = '172.24.0.2' # static Docker IP address +else: + # set IP address of the MicroPython device explicitly + # local_ip = '192.168.4.1' # IP address + # or get it from the system after a connection to the network has been made + local_ip = station.ifconfig()[0] diff --git a/examples/common/tcp_host_common.py b/examples/common/tcp_host_common.py new file mode 100644 index 0000000..feddff1 --- /dev/null +++ b/examples/common/tcp_host_common.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +# -*- coding: UTF-8 -*- + +""" +Auxiliary script + +Defines the common imports and functions for running the host +examples for both the synchronous and asynchronous TCP hosts. +""" + +# system packages +import time + +IS_DOCKER_MICROPYTHON = False +try: + import network +except ImportError: + IS_DOCKER_MICROPYTHON = True + + +def exit(): + if IS_DOCKER_MICROPYTHON: + import sys + sys.exit(0) + + +# =============================================== +if IS_DOCKER_MICROPYTHON is False: + # connect to a network + station = network.WLAN(network.STA_IF) + if station.active() and station.isconnected(): + station.disconnect() + time.sleep(1) + station.active(False) + time.sleep(1) + station.active(True) + + # station.connect('SSID', 'PASSWORD') + station.connect('TP-LINK_FBFC3C', 'C1FBFC3C') + time.sleep(1) + + while True: + print('Waiting for WiFi connection...') + if station.isconnected(): + print('Connected to WiFi.') + print(station.ifconfig()) + break + time.sleep(2) + +# =============================================== +# TCP Slave setup +slave_tcp_port = 502 # port to listen to +slave_addr = 10 # bus address of client + +# set IP address of the MicroPython device acting as client (slave) +if IS_DOCKER_MICROPYTHON: + slave_ip = '172.24.0.2' # static Docker IP address +else: + slave_ip = '192.168.178.69' # IP address + +""" +# alternatively the register definitions can also be loaded from a JSON file +import json + +with open('registers/example.json', 'r') as file: + register_definitions = json.load(file) +""" diff --git a/examples/multi_client_example.py b/examples/multi_client_example.py new file mode 100644 index 0000000..c7a5c87 --- /dev/null +++ b/examples/multi_client_example.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +# -*- coding: UTF-8 -*- + +""" +Main script + +Do your stuff here, this file is similar to the loop() function on Arduino + +Create an async Modbus TCP and RTU client (slave) which run simultaneously, +share the same register definitions, and can be requested for data or set +with specific values by a host device. + +The TCP port and IP address, and the RTU communication pins can both be +chosen freely (check MicroPython device/port specific limitations). + +The register definitions of the client as well as its connection settings like +bus address and UART communication speed can be defined by the user. +""" + +# system imports +try: + import uasyncio as asyncio +except ImportError: + import asyncio + +# import modbus client classes +from umodbus.asynchronous.tcp import AsyncModbusTCP as ModbusTCP +from umodbus.asynchronous.serial import AsyncModbusRTU as ModbusRTU +from examples.common.register_definitions import setup_callbacks +from examples.common.tcp_client_common import register_definitions +from examples.common.tcp_client_common import local_ip, tcp_port +from examples.common.rtu_client_common import IS_DOCKER_MICROPYTHON +from examples.common.rtu_client_common import slave_addr, rtu_pins +from examples.common.rtu_client_common import baudrate, uart_id, exit + + +async def start_rtu_server(slave_addr, + rtu_pins, + baudrate, + uart_id, + **kwargs): + """Creates an RTU client and runs tests""" + + client = ModbusRTU(addr=slave_addr, + pins=rtu_pins, + baudrate=baudrate, + uart_id=uart_id, + **kwargs) + + if IS_DOCKER_MICROPYTHON: + # works only with fake machine UART + assert client._itf._uart._is_server is True + + # start listening in background + await client.bind() + + print('Setting up RTU registers ...') + # use the defined values of each register type provided by register_definitions + client.setup_registers(registers=register_definitions) + # alternatively use dummy default values (True for bool regs, 999 otherwise) + # client.setup_registers(registers=register_definitions, use_default_vals=True) + print('RTU Register setup done') + + await client.serve_forever() + + +async def start_tcp_server(host, port, backlog): + client = ModbusTCP() # TODO: rename to `server` + await client.bind(local_ip=host, local_port=port, max_connections=backlog) + + print('Setting up TCP registers ...') + # only one server for now can have callbacks setup for it + setup_callbacks(client, register_definitions) + # use the defined values of each register type provided by register_definitions + client.setup_registers(registers=register_definitions) + # alternatively use dummy default values (True for bool regs, 999 otherwise) + # client.setup_registers(registers=register_definitions, use_default_vals=True) + print('TCP Register setup done') + + print('Serving as TCP client on {}:{}'.format(local_ip, tcp_port)) + await client.serve_forever() + + +# define arbitrary backlog of 10 +backlog = 10 + +# create TCP server task +tcp_task = start_tcp_server(local_ip, tcp_port, backlog) + +# create RTU server task +rtu_task = start_rtu_server(addr=slave_addr, + pins=rtu_pins, # given as tuple (TX, RX) + baudrate=baudrate, # optional, default 9600 + # data_bits=8, # optional, default 8 + # stop_bits=1, # optional, default 1 + # parity=None, # optional, default None + # ctrl_pin=12, # optional, control DE/RE + uart_id=uart_id) # optional, default 1, see port specific docs + +# combine and run both tasks together +run_both_tasks = asyncio.gather(tcp_task, rtu_task) +asyncio.run(run_both_tasks) + +exit() diff --git a/examples/multi_client_modify_restart.py b/examples/multi_client_modify_restart.py new file mode 100644 index 0000000..34d322c --- /dev/null +++ b/examples/multi_client_modify_restart.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 +# -*- coding: UTF-8 -*- + +""" +Main script + +Do your stuff here, this file is similar to the loop() function on Arduino + +Create an async Modbus TCP and RTU client (slave) which run simultaneously, +share the same register definitions, and can be requested for data or set +with specific values by a host device. + +After 5 minutes (which in a real application would be any event that causes +a restart to be needed) the servers are restarted with different parameters +than originally specified. + +The TCP port and IP address, and the RTU communication pins can both be +chosen freely (check MicroPython device/port specific limitations). + +The register definitions of the client as well as its connection settings like +bus address and UART communication speed can be defined by the user. +""" + +# system imports +try: + import uasyncio as asyncio +except ImportError: + import asyncio + +# import modbus client classes +from umodbus.asynchronous.tcp import AsyncModbusTCP as ModbusTCP +from umodbus.asynchronous.serial import AsyncModbusRTU as ModbusRTU +from examples.common.register_definitions import setup_callbacks +from examples.common.tcp_client_common import register_definitions +from examples.common.tcp_client_common import local_ip, tcp_port +from examples.common.rtu_client_common import IS_DOCKER_MICROPYTHON +from examples.common.rtu_client_common import slave_addr, rtu_pins +from examples.common.rtu_client_common import baudrate, uart_id, exit +from umodbus.typing import Tuple, Dict, Any + + +async def start_rtu_server(slave_addr, + rtu_pins, + baudrate, + uart_id, + **kwargs) -> Tuple[ModbusRTU, asyncio.Task]: + """Creates an RTU client and runs tests""" + + client = ModbusRTU(addr=slave_addr, + pins=rtu_pins, + baudrate=baudrate, + uart_id=uart_id, + **kwargs) + + if IS_DOCKER_MICROPYTHON: + # works only with fake machine UART + assert client._itf._uart._is_server is True + + # start listening in background + await client.bind() + + print('Setting up RTU registers ...') + # use the defined values of each register type provided by register_definitions + client.setup_registers(registers=register_definitions) + # alternatively use dummy default values (True for bool regs, 999 otherwise) + # client.setup_registers(registers=register_definitions, use_default_vals=True) + print('RTU Register setup done') + + # create a task, since we want the server to run in the background but also + # want it to be able to stop anytime we want (by manipulating the server) + task = asyncio.create_task(client.serve_forever()) + + # we can stop the task by asking the server to stop + # but verify it's done by querying task + return client, task + + +async def start_tcp_server(host, port, backlog) -> Tuple[ModbusTCP, asyncio.Task]: + client = ModbusTCP() # TODO: rename to `server` + await client.bind(local_ip=host, local_port=port, max_connections=backlog) + + print('Setting up TCP registers ...') + # only one server for now can have callbacks setup for it + setup_callbacks(client, register_definitions) + # use the defined values of each register type provided by register_definitions + client.setup_registers(registers=register_definitions) + # alternatively use dummy default values (True for bool regs, 999 otherwise) + # client.setup_registers(registers=register_definitions, use_default_vals=True) + print('TCP Register setup done') + + print('Serving as TCP client on {}:{}'.format(local_ip, tcp_port)) + + # create a task, since we want the server to run in the background but also + # want it to be able to stop anytime we want (by manipulating the server) + task = asyncio.create_task(client.serve_forever()) + + # we can stop the task by asking the server to stop + # but verify it's done by querying task + return client, task + + +async def create_servers(parameters: Dict[str, Any]) -> Tuple[Tuple[ModbusTCP, ModbusRTU], + Tuple[asyncio.Task, asyncio.Task]]: + """Creates TCP and RTU servers based on the supplied parameters.""" + + # create TCP server task + tcp_server, tcp_task = await start_tcp_server(parameters['local_ip'], + parameters['tcp_port'], + parameters['backlog']) + + # create RTU server task + rtu_server, rtu_task = await start_rtu_server(addr=parameters['slave_addr'], + pins=parameters['rtu_pins'], # given as tuple (TX, RX) + baudrate=parameters['baudrate'], # optional, default 9600 + # data_bits=8, # optional, default 8 + # stop_bits=1, # optional, default 1 + # parity=None, # optional, default None + # ctrl_pin=12, # optional, control DE/RE + uart_id=parameters['uart_id']) # optional, default 1, see port specific docs + + # combine both tasks + return (tcp_server, rtu_server), (tcp_task, rtu_task) + + +async def start_servers(initial_params, final_params): + """ + Creates a TCP and RTU server with the initial parameters, then + waits for 5 minutes before restarting them with the new params + defined in `final_params` + """ + + tcp_server: ModbusTCP + rtu_server: ModbusRTU + tcp_task: asyncio.Task + rtu_task: asyncio.Task + + (tcp_server, rtu_server), (tcp_task, rtu_task) = await create_servers(initial_params) + + # wait for 5 minutes before stopping the RTU server + await asyncio.sleep(300) + + """ + # settings for server can be loaded from a json file like so + import json + + with open('registers/example.json', 'r') as file: + new_params = json.load(file) + + # but for now, just look up parameters defined directly in code + """ + + # request servers to stop, and defer to allow them time to stop + print("Stopping servers...") + rtu_server.server_close() + await asyncio.sleep(5) + + tcp_server.server_close() + await asyncio.sleep(5) + + try: + if not rtu_task.done: + rtu_task.cancel() + except asyncio.CancelledError: + print("RTU server did not stop in time") + pass + + try: + if not tcp_task.done(): + tcp_task.cancel() + except asyncio.CancelledError: + print("TCP server did not stop in time") + pass + + print("Creating new server") + (tcp_server, rtu_server), (tcp_task, rtu_task) = await create_servers(final_params) + + await asyncio.gather(tcp_task, rtu_task) + +initial_params = { + "local_ip": local_ip, + "tcp_port": tcp_port, + "backlog": 10, + "slave_addr": slave_addr, + "rtu_pins": rtu_pins, + "baudrate": baudrate, + "uart_id": uart_id +} + +final_params = initial_params.copy() +final_params["tcp_port"] = 5000 +final_params["baudrate"] = 4800 +final_params["backlog"] = 20 + +server_task = start_servers(initial_params=initial_params, + final_params=final_params) + +asyncio.run(server_task) + +exit() diff --git a/examples/multi_client_modify_shared_registers.py b/examples/multi_client_modify_shared_registers.py new file mode 100644 index 0000000..ab3da11 --- /dev/null +++ b/examples/multi_client_modify_shared_registers.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +# -*- coding: UTF-8 -*- + +""" +Main script + +Do your stuff here, this file is similar to the loop() function on Arduino + +Create an async Modbus TCP and RTU client (slave) which run simultaneously, +share the same register definitions, and can be requested for data or set +with specific values by a host device. A separate background task updates +the TCP and RTU client (slave) EXAMPLE_IREG input register every 5 seconds. + +The register definitions of the client as well as its connection settings like +bus address and UART communication speed can be defined by the user. +""" + +# system imports +try: + import uasyncio as asyncio +except ImportError: + import asyncio +import random + +# import modbus client classes +from umodbus.asynchronous.tcp import AsyncModbusTCP as ModbusTCP +from umodbus.asynchronous.serial import AsyncModbusRTU as ModbusRTU +from examples.common.register_definitions import setup_callbacks +from examples.common.tcp_client_common import register_definitions +from examples.common.tcp_client_common import local_ip, tcp_port +from examples.common.rtu_client_common import IS_DOCKER_MICROPYTHON +from examples.common.rtu_client_common import slave_addr, rtu_pins +from examples.common.rtu_client_common import baudrate, uart_id, exit +from umodbus.typing import Tuple, Dict, Any + + +async def start_rtu_server(slave_addr, + rtu_pins, + baudrate, + uart_id, + **kwargs) -> Tuple[ModbusRTU, asyncio.Task]: + """Creates an RTU client and runs tests""" + + client = ModbusRTU(addr=slave_addr, + pins=rtu_pins, + baudrate=baudrate, + uart_id=uart_id, + **kwargs) + + if IS_DOCKER_MICROPYTHON: + # works only with fake machine UART + assert client._itf._uart._is_server is True + + # start listening in background + await client.bind() + + print('Setting up RTU registers ...') + # use the defined values of each register type provided by register_definitions + client.setup_registers(registers=register_definitions) + # alternatively use dummy default values (True for bool regs, 999 otherwise) + # client.setup_registers(registers=register_definitions, use_default_vals=True) + print('RTU Register setup done') + + # create a task, since we want the server to run in the background but also + # want it to be able to stop anytime we want (by manipulating the server) + task = asyncio.create_task(client.serve_forever()) + + # we can stop the task by asking the server to stop + # but verify it's done by querying task + return client, task + + +async def start_tcp_server(host, port, backlog) -> Tuple[ModbusTCP, asyncio.Task]: + client = ModbusTCP() # TODO: rename to `server` + await client.bind(local_ip=host, local_port=port, max_connections=backlog) + + print('Setting up TCP registers ...') + # only one server for now can have callbacks setup for it + setup_callbacks(client, register_definitions) + # use the defined values of each register type provided by register_definitions + client.setup_registers(registers=register_definitions) + # alternatively use dummy default values (True for bool regs, 999 otherwise) + # client.setup_registers(registers=register_definitions, use_default_vals=True) + print('TCP Register setup done') + + print('Serving as TCP client on {}:{}'.format(local_ip, tcp_port)) + + # create a task, since we want the server to run in the background but also + # want it to be able to stop anytime we want (by manipulating the server) + task = asyncio.create_task(client.serve_forever()) + + # we can stop the task by asking the server to stop + # but verify it's done by querying task + return client, task + + +async def create_servers(parameters: Dict[str, Any]) -> Tuple[Tuple[ModbusTCP, ModbusRTU], + Tuple[asyncio.Task, asyncio.Task]]: + """Creates TCP and RTU servers based on the supplied parameters.""" + + # create TCP server task + tcp_server, tcp_task = await start_tcp_server(parameters['local_ip'], + parameters['tcp_port'], + parameters['backlog']) + + # create RTU server task + rtu_server, rtu_task = await start_rtu_server(addr=parameters['slave_addr'], + pins=parameters['rtu_pins'], # given as tuple (TX, RX) + baudrate=parameters['baudrate'], # optional, default 9600 + # data_bits=8, # optional, default 8 + # stop_bits=1, # optional, default 1 + # parity=None, # optional, default None + # ctrl_pin=12, # optional, control DE/RE + uart_id=parameters['uart_id']) # optional, default 1, see port specific docs + + # combine both tasks + return (tcp_server, rtu_server), (tcp_task, rtu_task) + + +async def update_register_definitions(register_definitions, *servers): + """ + Updates the EXAMPLE_IREG register every 5 seconds + to a random value for the given servers. + """ + + IREG = register_definitions['IREGS']['EXAMPLE_IREG'] + address = IREG['register'] + while True: + value = random.randrange(1, 1000) + print("Updating value to: ", value) + for server in servers: + curr_values = server.get_ireg(address) + if isinstance(curr_values, list): + curr_values[1] = value + else: + curr_values = value + server.set_ireg(address=address, value=curr_values) + + await asyncio.sleep(5) + + +async def start_servers(params) -> None: + """ + Creates a TCP and RTU server with the given parameters, and + starts a background task that updates their EXAMPLE_IREG registers + every 5 seconds, which should be visible to any clients that connect. + """ + + (tcp_server, rtu_server), (tcp_task, rtu_task) = await create_servers(params) + + """ + # settings for server can be loaded from a json file like so + import json + + with open('registers/example.json', 'r') as file: + new_params = json.load(file) + + # but for now, just look up parameters defined directly in code + """ + + background_task = update_register_definitions(register_definitions, + tcp_server, rtu_server) + + await asyncio.gather(tcp_task, rtu_task, background_task) + +params = { + "local_ip": local_ip, + "tcp_port": tcp_port, + "backlog": 10, + "slave_addr": slave_addr, + "rtu_pins": rtu_pins, + "baudrate": baudrate, + "uart_id": uart_id +} + +asyncio.run(start_servers(params=params)) + +exit() diff --git a/examples/rtu_client_example.py b/examples/rtu_client_example.py index e1d922f..80119c3 100644 --- a/examples/rtu_client_example.py +++ b/examples/rtu_client_example.py @@ -17,71 +17,11 @@ # import modbus client classes from umodbus.serial import ModbusRTU +from examples.common.register_definitions import register_definitions, setup_callbacks +from examples.common.rtu_client_common import IS_DOCKER_MICROPYTHON +from examples.common.rtu_client_common import slave_addr, rtu_pins +from examples.common.rtu_client_common import baudrate, uart_id, exit -IS_DOCKER_MICROPYTHON = False -try: - import machine - machine.reset_cause() -except ImportError: - raise Exception('Unable to import machine, are all fakes available?') -except AttributeError: - # machine fake class has no "reset_cause" function - IS_DOCKER_MICROPYTHON = True - import json - - -# =============================================== -# RTU Slave setup -# act as client, provide Modbus data via RTU to a host device -# ModbusRTU can get serial requests from a host device to provide/set data -# check MicroPython UART documentation -# https://docs.micropython.org/en/latest/library/machine.UART.html -# for Device/Port specific setup -# -# RP2 needs "rtu_pins = (Pin(4), Pin(5))" whereas ESP32 can use any pin -# the following example is for an ESP32. -# For further details check the latest MicroPython Modbus RTU documentation -# example https://micropython-modbus.readthedocs.io/en/latest/EXAMPLES.html#rtu -rtu_pins = (25, 26) # (TX, RX) -slave_addr = 10 # address on bus as client -baudrate = 9600 -uart_id = 1 - -try: - from machine import Pin - import os - from umodbus import version - - os_info = os.uname() - print('MicroPython infos: {}'.format(os_info)) - print('Used micropthon-modbus version: {}'.format(version.__version__)) - - if 'pyboard' in os_info: - # NOT YET TESTED ! - # https://docs.micropython.org/en/latest/library/pyb.UART.html#pyb-uart - # (TX, RX) = (X9, X10) = (PB6, PB7) - uart_id = 1 - # (TX, RX) - rtu_pins = (Pin(PB6), Pin(PB7)) # noqa: F821 - elif 'esp8266' in os_info: - # https://docs.micropython.org/en/latest/esp8266/quickref.html#uart-serial-bus - raise Exception( - 'UART0 of ESP8266 is used by REPL, UART1 can only be used for TX' - ) - elif 'esp32' in os_info: - # https://docs.micropython.org/en/latest/esp32/quickref.html#uart-serial-bus - uart_id = 1 - rtu_pins = (25, 26) # (TX, RX) - elif 'rp2' in os_info: - # https://docs.micropython.org/en/latest/rp2/quickref.html#uart-serial-bus - uart_id = 0 - rtu_pins = (Pin(0), Pin(1)) # (TX, RX) -except AttributeError: - pass -except Exception as e: - raise e - -print('Using pins {} with UART ID {}'.format(rtu_pins, uart_id)) client = ModbusRTU( addr=slave_addr, # address on bus @@ -95,67 +35,15 @@ ) if IS_DOCKER_MICROPYTHON: + import json # works only with fake machine UART assert client._itf._uart._is_server is True - - -def reset_data_registers_cb(reg_type, address, val): - # usage of global isn't great, but okay for an example - global client - global register_definitions - - print('Resetting register data to default values ...') - client.setup_registers(registers=register_definitions) - print('Default values restored') - - -# common slave register setup, to be used with the Master example above -register_definitions = { - "COILS": { - "RESET_REGISTER_DATA_COIL": { - "register": 42, - "len": 1, - "val": 0 - }, - "EXAMPLE_COIL": { - "register": 123, - "len": 1, - "val": 1 - } - }, - "HREGS": { - "EXAMPLE_HREG": { - "register": 93, - "len": 1, - "val": 19 - } - }, - "ISTS": { - "EXAMPLE_ISTS": { - "register": 67, - "len": 1, - "val": 0 - } - }, - "IREGS": { - "EXAMPLE_IREG": { - "register": 10, - "len": 1, - "val": 60001 - } - } -} - -# alternatively the register definitions can also be loaded from a JSON file -# this is always done if Docker is used for testing purpose in order to keep -# the client registers in sync with the test registers -if IS_DOCKER_MICROPYTHON: with open('registers/example.json', 'r') as file: - register_definitions = json.load(file) + register_definitions = json.load(file) # noqa: F811 + # reset all registers back to their default value with a callback -register_definitions['COILS']['RESET_REGISTER_DATA_COIL']['on_set_cb'] = \ - reset_data_registers_cb +setup_callbacks(client, register_definitions) print('Setting up registers ...') # use the defined values of each register type provided by register_definitions @@ -175,5 +63,8 @@ def reset_data_registers_cb(reg_type, address, val): break except Exception as e: print('Exception during execution: {}'.format(e)) + raise print("Finished providing/accepting data as client") + +exit() diff --git a/examples/rtu_host_example.py b/examples/rtu_host_example.py index fe96b47..56dd112 100644 --- a/examples/rtu_host_example.py +++ b/examples/rtu_host_example.py @@ -15,129 +15,29 @@ bus address and UART communication speed can be defined by the user. """ -# system packages -import time - # import modbus host classes from umodbus.serial import Serial as ModbusRTUMaster - -IS_DOCKER_MICROPYTHON = False -try: - import machine - machine.reset_cause() -except ImportError: - raise Exception('Unable to import machine, are all fakes available?') -except AttributeError: - # machine fake class has no "reset_cause" function - IS_DOCKER_MICROPYTHON = True - import sys - - -# =============================================== -# RTU Slave setup -slave_addr = 10 # address on bus of the client/slave - -# RTU Master setup -# act as host, collect Modbus data via RTU from a client device -# ModbusRTU can perform serial requests to a client device to get/set data -# check MicroPython UART documentation -# https://docs.micropython.org/en/latest/library/machine.UART.html -# for Device/Port specific setup -# -# RP2 needs "rtu_pins = (Pin(4), Pin(5))" whereas ESP32 can use any pin -# the following example is for an ESP32 -# For further details check the latest MicroPython Modbus RTU documentation -# example https://micropython-modbus.readthedocs.io/en/latest/EXAMPLES.html#rtu -rtu_pins = (25, 26) # (TX, RX) -baudrate = 9600 -uart_id = 1 - -try: - from machine import Pin - import os - from umodbus import version - - os_info = os.uname() - print('MicroPython infos: {}'.format(os_info)) - print('Used micropthon-modbus version: {}'.format(version.__version__)) - - if 'pyboard' in os_info: - # NOT YET TESTED ! - # https://docs.micropython.org/en/latest/library/pyb.UART.html#pyb-uart - # (TX, RX) = (X9, X10) = (PB6, PB7) - uart_id = 1 - # (TX, RX) - rtu_pins = (Pin(PB6), Pin(PB7)) # noqa: F821 - elif 'esp8266' in os_info: - # https://docs.micropython.org/en/latest/esp8266/quickref.html#uart-serial-bus - raise Exception( - 'UART0 of ESP8266 is used by REPL, UART1 can only be used for TX' - ) - elif 'esp32' in os_info: - # https://docs.micropython.org/en/latest/esp32/quickref.html#uart-serial-bus - uart_id = 1 - rtu_pins = (25, 26) # (TX, RX) - elif 'rp2' in os_info: - # https://docs.micropython.org/en/latest/rp2/quickref.html#uart-serial-bus - uart_id = 0 - rtu_pins = (Pin(0), Pin(1)) # (TX, RX) -except AttributeError: - pass -except Exception as e: - raise e - -print('Using pins {} with UART ID {}'.format(rtu_pins, uart_id)) +from examples.common.register_definitions import register_definitions +from examples.common.rtu_host_common import IS_DOCKER_MICROPYTHON +from examples.common.rtu_host_common import rtu_pins, baudrate +from examples.common.rtu_host_common import slave_addr, uart_id, read_timeout, exit +from examples.common.sync_host_tests import run_host_tests host = ModbusRTUMaster( - pins=rtu_pins, # given as tuple (TX, RX) - baudrate=baudrate, # optional, default 9600 - # data_bits=8, # optional, default 8 - # stop_bits=1, # optional, default 1 - # parity=None, # optional, default None - # ctrl_pin=12, # optional, control DE/RE - uart_id=uart_id # optional, default 1, see port specific docs + pins=rtu_pins, # given as tuple (TX, RX) + baudrate=baudrate, # optional, default 9600 + # data_bits=8, # optional, default 8 + # stop_bits=1, # optional, default 1 + # parity=None, # optional, default None + # ctrl_pin=12, # optional, control DE/RE + uart_id=uart_id, # optional, default 1, see port specific docs + read_timeout=read_timeout # optional, default 120 ) if IS_DOCKER_MICROPYTHON: # works only with fake machine UART assert host._uart._is_server is False -# commond slave register setup, to be used with the Master example above -register_definitions = { - "COILS": { - "RESET_REGISTER_DATA_COIL": { - "register": 42, - "len": 1, - "val": 0 - }, - "EXAMPLE_COIL": { - "register": 123, - "len": 1, - "val": 1 - } - }, - "HREGS": { - "EXAMPLE_HREG": { - "register": 93, - "len": 1, - "val": 19 - } - }, - "ISTS": { - "EXAMPLE_ISTS": { - "register": 67, - "len": 1, - "val": 0 - } - }, - "IREGS": { - "EXAMPLE_IREG": { - "register": 10, - "len": 1, - "val": 60001 - } - } -} """ # alternatively the register definitions can also be loaded from a JSON file @@ -151,108 +51,8 @@ format(slave_addr, baudrate)) print() -# READ COILS -coil_address = register_definitions['COILS']['EXAMPLE_COIL']['register'] -coil_qty = register_definitions['COILS']['EXAMPLE_COIL']['len'] -coil_status = host.read_coils( - slave_addr=slave_addr, - starting_addr=coil_address, - coil_qty=coil_qty) -print('Status of COIL {}: {}'.format(coil_address, coil_status)) -time.sleep(1) - -# WRITE COILS -new_coil_val = 0 -operation_status = host.write_single_coil( - slave_addr=slave_addr, - output_address=coil_address, - output_value=new_coil_val) -print('Result of setting COIL {} to {}'.format(coil_address, operation_status)) -time.sleep(1) - -# READ COILS again -coil_status = host.read_coils( - slave_addr=slave_addr, - starting_addr=coil_address, - coil_qty=coil_qty) -print('Status of COIL {}: {}'.format(coil_address, coil_status)) -time.sleep(1) - -print() - -# READ HREGS -hreg_address = register_definitions['HREGS']['EXAMPLE_HREG']['register'] -register_qty = register_definitions['HREGS']['EXAMPLE_HREG']['len'] -register_value = host.read_holding_registers( - slave_addr=slave_addr, - starting_addr=hreg_address, - register_qty=register_qty, - signed=False) -print('Status of HREG {}: {}'.format(hreg_address, register_value)) -time.sleep(1) - -# WRITE HREGS -new_hreg_val = 44 -operation_status = host.write_single_register( - slave_addr=slave_addr, - register_address=hreg_address, - register_value=new_hreg_val, - signed=False) -print('Result of setting HREG {} to {}'.format(hreg_address, operation_status)) -time.sleep(1) +run_host_tests(host=host, + slave_addr=slave_addr, + register_definitions=register_definitions) -# READ HREGS again -register_value = host.read_holding_registers( - slave_addr=slave_addr, - starting_addr=hreg_address, - register_qty=register_qty, - signed=False) -print('Status of HREG {}: {}'.format(hreg_address, register_value)) -time.sleep(1) - -print() - -# READ ISTS -ist_address = register_definitions['ISTS']['EXAMPLE_ISTS']['register'] -input_qty = register_definitions['ISTS']['EXAMPLE_ISTS']['len'] -input_status = host.read_discrete_inputs( - slave_addr=slave_addr, - starting_addr=ist_address, - input_qty=input_qty) -print('Status of IST {}: {}'.format(ist_address, input_status)) -time.sleep(1) - -print() - -# READ IREGS -ireg_address = register_definitions['IREGS']['EXAMPLE_IREG']['register'] -register_qty = register_definitions['IREGS']['EXAMPLE_IREG']['len'] -register_value = host.read_input_registers( - slave_addr=slave_addr, - starting_addr=ireg_address, - register_qty=register_qty, - signed=False) -print('Status of IREG {}: {}'.format(ireg_address, register_value)) -time.sleep(1) - -print() - -# reset all registers back to their default values on the client -# WRITE COILS -print('Resetting register data to default values...') -coil_address = \ - register_definitions['COILS']['RESET_REGISTER_DATA_COIL']['register'] -new_coil_val = True -operation_status = host.write_single_coil( - slave_addr=slave_addr, - output_address=coil_address, - output_value=new_coil_val) -print('Result of setting COIL {}: {}'.format(coil_address, operation_status)) -time.sleep(1) - -print() - -print("Finished requesting/setting data on client") - -if IS_DOCKER_MICROPYTHON: - sys.exit(0) +exit() diff --git a/examples/tcp_client_example.py b/examples/tcp_client_example.py index 5125e17..06d57fc 100644 --- a/examples/tcp_client_example.py +++ b/examples/tcp_client_example.py @@ -13,183 +13,32 @@ the client can be defined by the user. """ -# system packages -import time - # import modbus client classes from umodbus.tcp import ModbusTCP -IS_DOCKER_MICROPYTHON = False -try: - import network -except ImportError: - IS_DOCKER_MICROPYTHON = True - import json - - -# =============================================== -if IS_DOCKER_MICROPYTHON is False: - # connect to a network - station = network.WLAN(network.STA_IF) - if station.active() and station.isconnected(): - station.disconnect() - time.sleep(1) - station.active(False) - time.sleep(1) - station.active(True) - - # station.connect('SSID', 'PASSWORD') - station.connect('TP-LINK_FBFC3C', 'C1FBFC3C') - time.sleep(1) - - while True: - print('Waiting for WiFi connection...') - if station.isconnected(): - print('Connected to WiFi.') - print(station.ifconfig()) - break - time.sleep(2) - -# =============================================== -# TCP Slave setup -tcp_port = 502 # port to listen to - -if IS_DOCKER_MICROPYTHON: - local_ip = '172.24.0.2' # static Docker IP address -else: - # set IP address of the MicroPython device explicitly - # local_ip = '192.168.4.1' # IP address - # or get it from the system after a connection to the network has been made - local_ip = station.ifconfig()[0] +# import relevant auxiliary script variables +from examples.common.register_definitions import register_definitions, setup_callbacks +from examples.common.tcp_client_common import local_ip, tcp_port +from examples.common.tcp_client_common import IS_DOCKER_MICROPYTHON # ModbusTCP can get TCP requests from a host device to provide/set data client = ModbusTCP() -is_bound = False - -# check whether client has been bound to an IP and port -is_bound = client.get_bound_status() - -if not is_bound: - client.bind(local_ip=local_ip, local_port=tcp_port) - - -def my_coil_set_cb(reg_type, address, val): - print('Custom callback, called on setting {} at {} to: {}'. - format(reg_type, address, val)) - - -def my_coil_get_cb(reg_type, address, val): - print('Custom callback, called on getting {} at {}, currently: {}'. - format(reg_type, address, val)) - - -def my_holding_register_set_cb(reg_type, address, val): - print('Custom callback, called on setting {} at {} to: {}'. - format(reg_type, address, val)) - - -def my_holding_register_get_cb(reg_type, address, val): - print('Custom callback, called on getting {} at {}, currently: {}'. - format(reg_type, address, val)) - - -def my_discrete_inputs_register_get_cb(reg_type, address, val): - print('Custom callback, called on getting {} at {}, currently: {}'. - format(reg_type, address, val)) - - -def my_inputs_register_get_cb(reg_type, address, val): - # usage of global isn't great, but okay for an example - global client - - print('Custom callback, called on getting {} at {}, currently: {}'. - format(reg_type, address, val)) - - # any operation should be as short as possible to avoid response timeouts - new_val = val[0] + 1 - - # It would be also possible to read the latest ADC value at this time - # adc = machine.ADC(12) # check MicroPython port specific syntax - # new_val = adc.read() - - client.set_ireg(address=address, value=new_val) - print('Incremented current value by +1 before sending response') - - -def reset_data_registers_cb(reg_type, address, val): - # usage of global isn't great, but okay for an example - global client - global register_definitions - - print('Resetting register data to default values ...') - client.setup_registers(registers=register_definitions) - print('Default values restored') - - -# commond slave register setup, to be used with the Master example above -register_definitions = { - "COILS": { - "RESET_REGISTER_DATA_COIL": { - "register": 42, - "len": 1, - "val": 0 - }, - "EXAMPLE_COIL": { - "register": 123, - "len": 1, - "val": 1 - } - }, - "HREGS": { - "EXAMPLE_HREG": { - "register": 93, - "len": 1, - "val": 19 - } - }, - "ISTS": { - "EXAMPLE_ISTS": { - "register": 67, - "len": 1, - "val": 0 - } - }, - "IREGS": { - "EXAMPLE_IREG": { - "register": 10, - "len": 1, - "val": 60001 - } - } -} # alternatively the register definitions can also be loaded from a JSON file # this is always done if Docker is used for testing purpose in order to keep # the client registers in sync with the test registers if IS_DOCKER_MICROPYTHON: + import json with open('registers/example.json', 'r') as file: - register_definitions = json.load(file) + register_definitions = json.load(file) # noqa: F811 -# add callbacks for different Modbus functions -# each register can have a different callback -# coils and holding register support callbacks for set and get -register_definitions['COILS']['EXAMPLE_COIL']['on_set_cb'] = my_coil_set_cb -register_definitions['COILS']['EXAMPLE_COIL']['on_get_cb'] = my_coil_get_cb -register_definitions['HREGS']['EXAMPLE_HREG']['on_set_cb'] = \ - my_holding_register_set_cb -register_definitions['HREGS']['EXAMPLE_HREG']['on_get_cb'] = \ - my_holding_register_get_cb +# setup remaining callbacks after creating client +setup_callbacks(client, register_definitions) -# discrete inputs and input registers support only get callbacks as they can't -# be set externally -register_definitions['ISTS']['EXAMPLE_ISTS']['on_get_cb'] = \ - my_discrete_inputs_register_get_cb -register_definitions['IREGS']['EXAMPLE_IREG']['on_get_cb'] = \ - my_inputs_register_get_cb - -# reset all registers back to their default value with a callback -register_definitions['COILS']['RESET_REGISTER_DATA_COIL']['on_set_cb'] = \ - reset_data_registers_cb +# check whether client has been bound to an IP and port +is_bound = client.get_bound_status() +if not is_bound: + client.bind(local_ip=local_ip, local_port=tcp_port) print('Setting up registers ...') # use the defined values of each register type provided by register_definitions diff --git a/examples/tcp_host_example.py b/examples/tcp_host_example.py index edd4c11..205cc95 100644 --- a/examples/tcp_host_example.py +++ b/examples/tcp_host_example.py @@ -13,53 +13,11 @@ the client can be defined by the user. """ -# system packages -import time - # import modbus host classes from umodbus.tcp import TCP as ModbusTCPMaster - -IS_DOCKER_MICROPYTHON = False -try: - import network -except ImportError: - IS_DOCKER_MICROPYTHON = True - import sys - - -# =============================================== -if IS_DOCKER_MICROPYTHON is False: - # connect to a network - station = network.WLAN(network.STA_IF) - if station.active() and station.isconnected(): - station.disconnect() - time.sleep(1) - station.active(False) - time.sleep(1) - station.active(True) - - # station.connect('SSID', 'PASSWORD') - station.connect('TP-LINK_FBFC3C', 'C1FBFC3C') - time.sleep(1) - - while True: - print('Waiting for WiFi connection...') - if station.isconnected(): - print('Connected to WiFi.') - print(station.ifconfig()) - break - time.sleep(2) - -# =============================================== -# TCP Slave setup -slave_tcp_port = 502 # port to listen to -slave_addr = 10 # bus address of client - -# set IP address of the MicroPython device acting as client (slave) -if IS_DOCKER_MICROPYTHON: - slave_ip = '172.24.0.2' # static Docker IP address -else: - slave_ip = '192.168.178.69' # IP address +from examples.common.register_definitions import register_definitions +from examples.common.tcp_host_common import slave_ip, slave_tcp_port, slave_addr, exit +from examples.common.sync_host_tests import run_host_tests # TCP Master setup # act as host, get Modbus data via TCP from a client device @@ -70,157 +28,12 @@ slave_port=slave_tcp_port, timeout=5) # optional, default 5 -# commond slave register setup, to be used with the Master example above -register_definitions = { - "COILS": { - "RESET_REGISTER_DATA_COIL": { - "register": 42, - "len": 1, - "val": 0 - }, - "EXAMPLE_COIL": { - "register": 123, - "len": 1, - "val": 1 - } - }, - "HREGS": { - "EXAMPLE_HREG": { - "register": 93, - "len": 1, - "val": 19 - } - }, - "ISTS": { - "EXAMPLE_ISTS": { - "register": 67, - "len": 1, - "val": 0 - } - }, - "IREGS": { - "EXAMPLE_IREG": { - "register": 10, - "len": 1, - "val": 60001 - } - } -} - -""" -# alternatively the register definitions can also be loaded from a JSON file -import json - -with open('registers/example.json', 'r') as file: - register_definitions = json.load(file) -""" - print('Requesting and updating data on TCP client at {}:{}'. format(slave_ip, slave_tcp_port)) print() -# READ COILS -coil_address = register_definitions['COILS']['EXAMPLE_COIL']['register'] -coil_qty = register_definitions['COILS']['EXAMPLE_COIL']['len'] -coil_status = host.read_coils( - slave_addr=slave_addr, - starting_addr=coil_address, - coil_qty=coil_qty) -print('Status of COIL {}: {}'.format(coil_address, coil_status)) -time.sleep(1) - -# WRITE COILS -new_coil_val = 0 -operation_status = host.write_single_coil( - slave_addr=slave_addr, - output_address=coil_address, - output_value=new_coil_val) -print('Result of setting COIL {}: {}'.format(coil_address, operation_status)) -time.sleep(1) - -# READ COILS again -coil_status = host.read_coils( - slave_addr=slave_addr, - starting_addr=coil_address, - coil_qty=coil_qty) -print('Status of COIL {}: {}'.format(coil_address, coil_status)) -time.sleep(1) - -print() - -# READ HREGS -hreg_address = register_definitions['HREGS']['EXAMPLE_HREG']['register'] -register_qty = register_definitions['HREGS']['EXAMPLE_HREG']['len'] -register_value = host.read_holding_registers( - slave_addr=slave_addr, - starting_addr=hreg_address, - register_qty=register_qty, - signed=False) -print('Status of HREG {}: {}'.format(hreg_address, register_value)) -time.sleep(1) - -# WRITE HREGS -new_hreg_val = 44 -operation_status = host.write_single_register( - slave_addr=slave_addr, - register_address=hreg_address, - register_value=new_hreg_val, - signed=False) -print('Result of setting HREG {}: {}'.format(hreg_address, operation_status)) -time.sleep(1) - -# READ HREGS again -register_value = host.read_holding_registers( - slave_addr=slave_addr, - starting_addr=hreg_address, - register_qty=register_qty, - signed=False) -print('Status of HREG {}: {}'.format(hreg_address, register_value)) -time.sleep(1) - -print() - -# READ ISTS -ist_address = register_definitions['ISTS']['EXAMPLE_ISTS']['register'] -input_qty = register_definitions['ISTS']['EXAMPLE_ISTS']['len'] -input_status = host.read_discrete_inputs( - slave_addr=slave_addr, - starting_addr=ist_address, - input_qty=input_qty) -print('Status of IST {}: {}'.format(ist_address, input_status)) -time.sleep(1) - -print() - -# READ IREGS -ireg_address = register_definitions['IREGS']['EXAMPLE_IREG']['register'] -register_qty = register_definitions['IREGS']['EXAMPLE_IREG']['len'] -register_value = host.read_input_registers( - slave_addr=slave_addr, - starting_addr=ireg_address, - register_qty=register_qty, - signed=False) -print('Status of IREG {}: {}'.format(ireg_address, register_value)) -time.sleep(1) - -print() - -# reset all registers back to their default values on the client -# WRITE COILS -print('Resetting register data to default values...') -coil_address = \ - register_definitions['COILS']['RESET_REGISTER_DATA_COIL']['register'] -new_coil_val = True -operation_status = host.write_single_coil( - slave_addr=slave_addr, - output_address=coil_address, - output_value=new_coil_val) -print('Result of setting COIL {}: {}'.format(coil_address, operation_status)) -time.sleep(1) - -print() - -print("Finished requesting/setting data on client") +run_host_tests(host=host, + slave_addr=slave_addr, + register_definitions=register_definitions) -if IS_DOCKER_MICROPYTHON: - sys.exit(0) +exit() diff --git a/fakes/machine.py b/fakes/machine.py index 1053f66..46fc358 100755 --- a/fakes/machine.py +++ b/fakes/machine.py @@ -425,6 +425,9 @@ def sendbreak(self) -> None: """Send a break condition on the bus""" raise MachineError('Not yet implemented') + ''' + # flush introduced in MicroPython v1.20.0 + # use manual timing calculation for testing def flush(self) -> None: """ Waits until all data has been sent @@ -434,6 +437,7 @@ def flush(self) -> None: Only available with newer versions than 1.19 """ raise MachineError('Not yet implemented') + ''' def txdone(self) -> bool: """ diff --git a/package.json b/package.json index a9ab915..9be6fbe 100644 --- a/package.json +++ b/package.json @@ -37,8 +37,6 @@ "github:brainelectronics/micropython-modbus/umodbus/version.py" ] ], - "deps": [ - ], - "version": "2.3.4" -} - + "deps": [], + "version": "3.0.0" +} \ No newline at end of file diff --git a/requirements-deploy.txt b/requirements-deploy.txt index b9821c8..96b0350 100644 --- a/requirements-deploy.txt +++ b/requirements-deploy.txt @@ -2,4 +2,4 @@ # Avoid fixed versions # # to upload package to PyPi or other package hosts twine>=4.0.1,<5 -changelog2version>=0.5.0,<1 \ No newline at end of file +changelog2version>=0.5.0,<1 diff --git a/requirements-test.txt b/requirements-test.txt index 94945cf..2ac46f6 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1,6 +1,9 @@ # List external packages here # Avoid fixed versions -flake8>=5.0.0,<6 +changelog2version>=0.10.0,<1 coverage>=6.4.2,<7 +flake8>=5.0.0,<6 nose2>=0.12.0,<1 +setup2upypackage>=0.4.0,<1 +pre-commit>=3.3.3,<4 yamllint>=1.29,<2 diff --git a/requirements.txt b/requirements.txt index f9dfa09..0884c36 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ # adafruit-ampy>=1.1.0,<2.0.0 esptool rshell>=0.0.30,<1.0.0 -mpremote>=0.4.0,<1 \ No newline at end of file +mpremote>=0.4.0,<1 diff --git a/tests/ulogging.py b/tests/ulogging.py new file mode 100644 index 0000000..0d1c8d5 --- /dev/null +++ b/tests/ulogging.py @@ -0,0 +1,260 @@ +#!/usr/bin/env python3 +# -*- coding: UTF-8 -*- + +""" +This file has been copied from micropython-lib + +https://github.com/micropython/micropython-lib/blob/7128d423c2e7c0309ac17a1e6ba873b909b24fcc/python-stdlib/logging/logging.py +""" + +try: + from micropython import const # noqa: F401 +except ImportError: + + def const(x): + return x + + +import sys +import time + +CRITICAL = const(50) +ERROR = const(40) +WARNING = const(30) +INFO = const(20) +DEBUG = const(10) +NOTSET = const(0) + +_DEFAULT_LEVEL = const(WARNING) + +_level_dict = { + CRITICAL: "CRITICAL", + ERROR: "ERROR", + WARNING: "WARNING", + INFO: "INFO", + DEBUG: "DEBUG", + NOTSET: "NOTSET", +} + +_loggers = {} +_stream = sys.stderr +_default_fmt = "%(levelname)s:%(name)s:%(message)s" +_default_datefmt = "%Y-%m-%d %H:%M:%S" + + +class LogRecord: + def set(self, name, level, message): + self.name = name + self.levelno = level + self.levelname = _level_dict[level] + self.message = message + self.ct = time.time() + self.msecs = int((self.ct - int(self.ct)) * 1000) + self.asctime = None + + +class Handler: + def __init__(self, level=NOTSET): + self.level = level + self.formatter = None + + def close(self): + pass + + def setLevel(self, level): + self.level = level + + def setFormatter(self, formatter): + self.formatter = formatter + + def format(self, record): + return self.formatter.format(record) + + +class StreamHandler(Handler): + def __init__(self, stream=None): + self.stream = _stream if stream is None else stream + self.terminator = "\n" + + def close(self): + if hasattr(self.stream, "flush"): + self.stream.flush() + + def emit(self, record): + if record.levelno >= self.level: + self.stream.write(self.format(record) + self.terminator) + + +class FileHandler(StreamHandler): + def __init__(self, filename, mode="a", encoding="UTF-8"): + super().__init__(stream=open(filename, mode=mode, encoding=encoding)) + + def close(self): + super().close() + self.stream.close() + + +class Formatter: + def __init__(self, fmt=None, datefmt=None): + self.fmt = _default_fmt if fmt is None else fmt + self.datefmt = _default_datefmt if datefmt is None else datefmt + + def usesTime(self): + return "asctime" in self.fmt + + def formatTime(self, datefmt, record): + if hasattr(time, "strftime"): + return time.strftime(datefmt, time.localtime(record.ct)) + return None + + def format(self, record): + if self.usesTime(): + record.asctime = self.formatTime(self.datefmt, record) + return self.fmt % { + "name": record.name, + "message": record.message, + "msecs": record.msecs, + "asctime": record.asctime, + "levelname": record.levelname, + } + + +class Logger: + def __init__(self, name, level=NOTSET): + self.name = name + self.level = level + self.handlers = [] + self.record = LogRecord() + + def setLevel(self, level): + self.level = level + + def isEnabledFor(self, level): + return level >= self.getEffectiveLevel() + + def getEffectiveLevel(self): + return self.level or getLogger().level or _DEFAULT_LEVEL + + def log(self, level, msg, *args): + if self.isEnabledFor(level): + if args: + if isinstance(args[0], dict): + args = args[0] + msg = msg % args + self.record.set(self.name, level, msg) + handlers = self.handlers + if not handlers: + handlers = getLogger().handlers + for h in handlers: + h.emit(self.record) + + def debug(self, msg, *args): + self.log(DEBUG, msg, *args) + + def info(self, msg, *args): + self.log(INFO, msg, *args) + + def warning(self, msg, *args): + self.log(WARNING, msg, *args) + + def error(self, msg, *args): + self.log(ERROR, msg, *args) + + def critical(self, msg, *args): + self.log(CRITICAL, msg, *args) + + def exception(self, msg, *args): + self.log(ERROR, msg, *args) + if hasattr(sys, "exc_info"): + sys.print_exception(sys.exc_info()[1], _stream) + + def addHandler(self, handler): + self.handlers.append(handler) + + def hasHandlers(self): + return len(self.handlers) > 0 + + +def getLogger(name=None): + if name is None: + name = "root" + if name not in _loggers: + _loggers[name] = Logger(name) + if name == "root": + basicConfig() + return _loggers[name] + + +def log(level, msg, *args): + getLogger().log(level, msg, *args) + + +def debug(msg, *args): + getLogger().debug(msg, *args) + + +def info(msg, *args): + getLogger().info(msg, *args) + + +def warning(msg, *args): + getLogger().warning(msg, *args) + + +def error(msg, *args): + getLogger().error(msg, *args) + + +def critical(msg, *args): + getLogger().critical(msg, *args) + + +def exception(msg, *args): + getLogger().exception(msg, *args) + + +def shutdown(): + for k, logger in _loggers.items(): + for h in logger.handlers: + h.close() + _loggers.pop(logger, None) + + +def addLevelName(level, name): + _level_dict[level] = name + + +def basicConfig( + filename=None, + filemode="a", + format=None, + datefmt=None, + level=WARNING, + stream=None, + encoding="UTF-8", + force=False, +): + if "root" not in _loggers: + _loggers["root"] = Logger("root") + + logger = _loggers["root"] + + if force or not logger.handlers: + for h in logger.handlers: + h.close() + logger.handlers = [] + + if filename is None: + handler = StreamHandler(stream) + else: + handler = FileHandler(filename, filemode, encoding) + + handler.setLevel(level) + handler.setFormatter(Formatter(format, datefmt)) + + logger.setLevel(level) + logger.addHandler(handler) + + +if hasattr(sys, "atexit"): + sys.atexit(shutdown) diff --git a/umodbus/asynchronous/__init__.py b/umodbus/asynchronous/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/umodbus/asynchronous/async_utils.py b/umodbus/asynchronous/async_utils.py new file mode 100644 index 0000000..eedcc4d --- /dev/null +++ b/umodbus/asynchronous/async_utils.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2019, Pycom Limited. +# +# This software is licensed under the GNU GPL version 3 or any +# later version, with permitted additional terms. For more information +# see the Pycom Licence v1.0 document supplied with this file, or +# available at https://www.pycom.io/opensource/licensing +# + +# system packages +try: + import uasyncio as asyncio +except ImportError: + import asyncio +import time + + +async def hybrid_sleep(time_us: int) -> None: + """ + Sleeps for the given time using both asyncio and the time library. + + :param time_us The total time to sleep, in microseconds + :type int + """ + + sleep_ms, sleep_us = int(time_us / 1000), (time_us % 1000) + if sleep_ms > 0: + await asyncio.sleep_ms(sleep_ms) + if sleep_us > 0: + # sleep using inbuilt time library since asyncio + # too slow for switching times of this magnitude + time.sleep_us(sleep_us) diff --git a/umodbus/asynchronous/common.py b/umodbus/asynchronous/common.py new file mode 100644 index 0000000..f4893d0 --- /dev/null +++ b/umodbus/asynchronous/common.py @@ -0,0 +1,245 @@ +#!/usr/bin/env python +# +# Copyright (c) 2019, Pycom Limited. +# +# This software is licensed under the GNU GPL version 3 or any +# later version, with permitted additional terms. For more information +# see the Pycom Licence v1.0 document supplied with this file, or +# available at https://www.pycom.io/opensource/licensing +# + +# system packages +from ..typing import List, Optional, Tuple, Union + +# custom packages +from .. import functions, const as Const +from ..common import CommonModbusFunctions, Request + + +class AsyncRequest(Request): + """Asynchronously deconstruct request data received via TCP or Serial""" + + async def send_response(self, + values: Optional[list] = None, + signed: bool = True) -> None: + """ + Send a response via the configured interface. + + :param values: The values + :type values: Optional[list] + :param signed: Indicates if signed values are used + :type signed: bool + """ + + await self._itf.send_response(slave_addr=self.unit_addr, + function_code=self.function, + request_register_addr=self.register_addr, + request_register_qty=self.quantity, + request_data=self.data, + values=values, + signed=signed, + request=self) + + async def send_exception(self, exception_code: int) -> None: + """ + Send an exception response. + + :param exception_code: The exception code + :type exception_code: int + """ + await self._itf.send_exception_response(slave_addr=self.unit_addr, + function_code=self.function, + exception_code=exception_code, + request=self) + + +class CommonAsyncModbusFunctions(CommonModbusFunctions): + """Common Async Modbus functions""" + + async def read_coils(self, + slave_addr: int, + starting_addr: int, + coil_qty: int) -> List[bool]: + """@see CommonModbusFunctions.read_coils""" + + modbus_pdu = functions.read_coils(starting_address=starting_addr, + quantity=coil_qty) + + response = await self._send_receive(slave_addr=slave_addr, + modbus_pdu=modbus_pdu, + count=True) + + status_pdu = functions.bytes_to_bool(byte_list=response, + bit_qty=coil_qty) + + return status_pdu + + async def read_discrete_inputs(self, + slave_addr: int, + starting_addr: int, + input_qty: int) -> List[bool]: + """@see CommonModbusFunctions.read_discrete_inputs""" + + modbus_pdu = functions.read_discrete_inputs( + starting_address=starting_addr, + quantity=input_qty) + + response = await self._send_receive(slave_addr=slave_addr, + modbus_pdu=modbus_pdu, + count=True) + + status_pdu = functions.bytes_to_bool(byte_list=response, + bit_qty=input_qty) + + return status_pdu + + async def read_holding_registers(self, + slave_addr: int, + starting_addr: int, + register_qty: int, + signed: bool = True) -> Tuple[int, ...]: + """@see CommonModbusFunctions.read_holding_registers""" + + modbus_pdu = functions.read_holding_registers( + starting_address=starting_addr, + quantity=register_qty) + + response = await self._send_receive(slave_addr=slave_addr, + modbus_pdu=modbus_pdu, + count=True) + + register_value = functions.to_short(byte_array=response, signed=signed) + + return register_value + + async def read_input_registers(self, + slave_addr: int, + starting_addr: int, + register_qty: int, + signed: bool = True) -> Tuple[int, ...]: + """@see CommonModbusFunctions.read_input_registers""" + + modbus_pdu = functions.read_input_registers( + starting_address=starting_addr, + quantity=register_qty) + + response = await self._send_receive(slave_addr=slave_addr, + modbus_pdu=modbus_pdu, + count=True) + + register_value = functions.to_short(byte_array=response, signed=signed) + + return register_value + + async def write_single_coil(self, + slave_addr: int, + output_address: int, + output_value: Union[int, bool]) -> bool: + """@see CommonModbusFunctions.write_single_coil""" + + modbus_pdu = functions.write_single_coil(output_address=output_address, + output_value=output_value) + + response = await self._send_receive(slave_addr=slave_addr, + modbus_pdu=modbus_pdu, + count=False) + + if response is None: + return False + + operation_status = functions.validate_resp_data( + data=response, + function_code=Const.WRITE_SINGLE_COIL, + address=output_address, + value=output_value, + signed=False) + + return operation_status + + async def write_single_register(self, + slave_addr: int, + register_address: int, + register_value: int, + signed: bool = True) -> bool: + """@see CommonModbusFunctions.write_single_register""" + + modbus_pdu = functions.write_single_register( + register_address=register_address, + register_value=register_value, + signed=signed) + + response = await self._send_receive(slave_addr=slave_addr, + modbus_pdu=modbus_pdu, + count=False) + + if response is None: + return False + + operation_status = functions.validate_resp_data( + data=response, + function_code=Const.WRITE_SINGLE_REGISTER, + address=register_address, + value=register_value, + signed=signed) + + return operation_status + + async def write_multiple_coils(self, + slave_addr: int, + starting_address: int, + output_values: list) -> bool: + """@see CommonModbusFunctions.write_multiple_coils""" + + modbus_pdu = functions.write_multiple_coils( + starting_address=starting_address, + value_list=output_values) + + response = await self._send_receive(slave_addr=slave_addr, + modbus_pdu=modbus_pdu, + count=False) + + if response is None: + return False + + operation_status = functions.validate_resp_data( + data=response, + function_code=Const.WRITE_MULTIPLE_COILS, + address=starting_address, + quantity=len(output_values)) + + return operation_status + + async def write_multiple_registers(self, + slave_addr: int, + starting_address: int, + register_values: List[int], + signed: bool = True) -> bool: + """@see CommonModbusFunctions.write_multiple_registers""" + + modbus_pdu = functions.write_multiple_registers( + starting_address=starting_address, + register_values=register_values, + signed=signed) + + response = await self._send_receive(slave_addr=slave_addr, + modbus_pdu=modbus_pdu, + count=False) + + if response is None: + return False + + operation_status = functions.validate_resp_data( + data=response, + function_code=Const.WRITE_MULTIPLE_REGISTERS, + address=starting_address, + quantity=len(register_values), + signed=signed + ) + + return operation_status + + async def _send_receive(self, + slave_addr: int, + modbus_pdu: bytes, + count: bool) -> bytes: + raise NotImplementedError("Must be overridden by subclass.") diff --git a/umodbus/asynchronous/modbus.py b/umodbus/asynchronous/modbus.py new file mode 100644 index 0000000..7fc5388 --- /dev/null +++ b/umodbus/asynchronous/modbus.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +# -*- coding: UTF-8 -*- + +""" +Modbus register abstraction class + +Used to add, remove, set and get values or states of a register or coil. +Additional helper properties and functions like getters for changed registers +are available as well. + +This class is inherited by the Modbus client implementations +:py:class:`umodbus.serial.ModbusRTU` and :py:class:`umodbus.tcp.ModbusTCP` +""" + +# system packages +from ..typing import List, Optional, Union + +# custom packages +from .common import AsyncRequest +from ..modbus import Modbus + + +class AsyncModbus(Modbus): + """Modbus register abstraction.""" + + def __init__(self, + # in quotes because of circular import errors + itf: Union["AsyncTCPServer", "AsyncRTUServer"], # noqa: F821 + addr_list: Optional[List[int]] = None): + super().__init__(itf, addr_list) + self._itf.set_params(addr_list=addr_list, req_handler=self.process) + + async def process(self, request: Optional[AsyncRequest] = None) -> None: + """@see Modbus.process""" + + result = super().process(request) + if result is None: + return + # Result of get_request() if request is None, or either of the *tasks*: + # - AsyncRequest.send_exception() (invalid function code) + # - self._process_read_access() and self._process_write_access(): + # - AsyncRequest.send_response() + # - AsyncRequest.send_exception() + # - None: implies no data received + request = await result + if request is None: + return + + # below code should only execute if no request was passed, i.e. if + # process() was called manually - so that get_request() returns an + # AsyncRequest + sub_result = super().process(request) + if sub_result is not None: + await sub_result + + async def _process_read_access(self, + request: AsyncRequest, + reg_type: str) -> None: + """@see Modbus._process_read_access""" + + task = super()._process_read_access(request, reg_type) + if task is not None: + await task + + async def _process_write_access(self, + request: AsyncRequest, + reg_type: str) -> None: + """@see Modbus._process_write_access""" + + task = super()._process_write_access(request, reg_type) + if task is not None: + await task diff --git a/umodbus/asynchronous/serial.py b/umodbus/asynchronous/serial.py new file mode 100644 index 0000000..3af7899 --- /dev/null +++ b/umodbus/asynchronous/serial.py @@ -0,0 +1,354 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2019, Pycom Limited. +# +# This software is licensed under the GNU GPL version 3 or any +# later version, with permitted additional terms. For more information +# see the Pycom Licence v1.0 document supplied with this file, or +# available at https://www.pycom.io/opensource/licensing +# + +# system packages +from machine import Pin +try: + import uasyncio as asyncio +except ImportError: + import asyncio +import time + +# custom packages +from .async_utils import hybrid_sleep +from .common import CommonAsyncModbusFunctions, AsyncRequest +from ..common import ModbusException +from .modbus import AsyncModbus +from ..serial import CommonRTUFunctions, RTUServer + +# typing not natively supported on MicroPython +from ..typing import Callable, Coroutine +from ..typing import List, Tuple, Optional, Union, Any + +US_TO_S = 1 / 1_000_000 + + +class AsyncModbusRTU(AsyncModbus): + """ + Asynchronous Modbus RTU server + + @see ModbusRTU + """ + def __init__(self, + addr: int, + baudrate: int = 9600, + data_bits: int = 8, + stop_bits: int = 1, + parity: Optional[int] = None, + pins: Tuple[Union[int, Pin], Union[int, Pin]] = None, + ctrl_pin: int = None, + uart_id: int = 1): + super().__init__( + # set itf to AsyncRTUServer object, addr_list to [addr] + AsyncRTUServer(uart_id=uart_id, + baudrate=baudrate, + data_bits=data_bits, + stop_bits=stop_bits, + parity=parity, + pins=pins, + ctrl_pin=ctrl_pin), + [addr] + ) + + async def bind(self) -> None: + """@see AsyncRTUServer.bind""" + + await self._itf.bind() + + async def serve_forever(self) -> None: + """@see AsyncRTUServer.serve_forever""" + + await self._itf.serve_forever() + + def server_close(self) -> None: + """@see AsyncRTUServer.server_close""" + + self._itf.server_close() + + +class CommonAsyncRTUFunctions(CommonRTUFunctions): + """ + A mixin for functions common to both the async client and server. + """ + + async def get_request(self, + unit_addr_list: Optional[List[int]] = None, + timeout: Optional[int] = None) -> \ + Optional[AsyncRequest]: + """@see RTUServer.get_request""" + + req = await self._uart_read_frame(timeout=timeout) + req_no_crc = self._parse_request(req=req, + unit_addr_list=unit_addr_list) + try: + if req_no_crc is not None: + return AsyncRequest(interface=self, data=req_no_crc) + except ModbusException as e: + await self.send_exception_response(slave_addr=req[0], + function_code=e.function_code, + exception_code=e.exception_code) + + async def _uart_read_frame(self, + timeout: Optional[int] = None) -> bytearray: + """@see RTUServer._uart_read_frame""" + t1char_ms = max(2, self._t1char // 1000) + + # Wait here till the next frame starts + while not self._uart.any(): + await asyncio.sleep_ms(t1char_ms) + + received_bytes = bytearray() + last_read_us = time.ticks_us() + + while True: + # check amount of available characters + chars_ready = self._uart.any() + if chars_ready: + # WiPy only + # r = self._uart.readall() + r = self._uart.read(chars_ready) + if r is not None: + last_read_us = time.ticks_us() + received_bytes.extend(r) + continue + + silence_us = time.ticks_diff(time.ticks_us(), last_read_us) + if silence_us > self._inter_frame_delay: + # The Modbus Specification: The frame is complete after + # silence of 1.5 times a character. + # Here we use 'self._inter_frame_delay' which on the save side. + return received_bytes + + # Here, I am using a blocking sleep in favor of 'asyncio.sleep_ms()'. + # The communication proved to be much more stable. + time.sleep_ms(t1char_ms) + + async def _send(self, + modbus_pdu: bytes, + slave_addr: int) -> None: + """@see CommonRTUFunctions._send""" + + await super()._send(modbus_pdu=modbus_pdu, + slave_addr=slave_addr) + + async def _post_send(self, sleep_time_us: float) -> None: + """ + The async variant of CommonRTUFunctions._post_send; used + to achieve async sleep behaviour while sharing code with + the synchronous send method. + + @see CommonRTUFunctions._post_send + """ + + # Do NOT use 'hybrid_sleep()' as it may fall back to 'asyncio.sleep()'. + # The sleep MUST NOT TAKE TOO LONG as this might squelsh the subsequent + # frame sent by the client. + time.sleep_us(sleep_time_us) + if self._ctrlPin: + self._ctrlPin.off() + + +class AsyncRTUServer(CommonAsyncRTUFunctions, RTUServer): + """Asynchronous Modbus Serial host""" + + def __init__(self, + uart_id: int = 1, + baudrate: int = 9600, + data_bits: int = 8, + stop_bits: int = 1, + parity=None, + pins: Tuple[Union[int, Pin], Union[int, Pin]] = None, + ctrl_pin: int = None): + """ + Setup asynchronous Serial/RTU Modbus + + @see RTUServer + """ + super().__init__(uart_id=uart_id, + baudrate=baudrate, + data_bits=data_bits, + stop_bits=stop_bits, + parity=parity, + pins=pins, + ctrl_pin=ctrl_pin) + + self._task = None + self.event = asyncio.Event() + self.req_handler: Callable[[Optional[AsyncRequest]], + Coroutine[Any, Any, bool]] = None + + async def bind(self) -> None: + """ + Starts serving the asynchronous server on the specified host and port + specified in the constructor. + """ + + self._task = asyncio.create_task(self._uart_bind()) + + async def _uart_bind(self) -> None: + """Starts processing requests continuously. Must be run as a task.""" + + if self.req_handler is None: + raise ValueError("No req_handler detected. " + "This may be because this class object was " + "instantiated manually, and not as part of " + "a Modbus server.") + while not self.event.is_set(): + # form request and pass to process in infinite loop + await self.req_handler() + + async def serve_forever(self) -> None: + """Waits for the server to close.""" + + if self._task is None: + raise ValueError("Error: must call bind() first") + await self._task + + def server_close(self) -> None: + """Stops a running server, i.e. stops reading from UART.""" + + self.event.set() + + async def send_response(self, + slave_addr: int, + function_code: int, + request_register_addr: int, + request_register_qty: int, + request_data: list, + values: Optional[list] = None, + signed: bool = True, + request: Optional[AsyncRequest] = None) -> None: + """ + Asynchronous equivalent to Serial.send_response + @see RTUServer.send_response for common (leading) parameters + + :param request: Ignored; kept for compatibility + with AsyncRequest + :type request: AsyncRequest, optional + """ + + task = super().send_response(slave_addr=slave_addr, + function_code=function_code, + request_register_addr=request_register_addr, # noqa: E501 + request_register_qty=request_register_qty, + request_data=request_data, + values=values, + signed=signed) + if task is not None: + await task + + async def send_exception_response(self, + slave_addr: int, + function_code: int, + exception_code: int, + request: Optional[AsyncRequest] = None) \ + -> None: + """ + Asynchronous equivalent to Serial.send_exception_response + @see RTUServer.send_exception_response for common (leading) parameters + + :param request: Ignored; kept for compatibility + with AsyncRequest + :type request: AsyncRequest, optional + """ + + task = super().send_exception_response(slave_addr=slave_addr, + function_code=function_code, + exception_code=exception_code) + if task is not None: + await task + + def set_params(self, + addr_list: Optional[List[int]], + req_handler: Callable[[Optional[AsyncRequest]], + Coroutine[Any, Any, bool]]) -> None: + """ + Used to set parameters such as the unit address list + and the processing handler. + + :param addr_list: The unit address list, currently ignored + :type addr_list: List[int], optional + :param req_handler: A callback that is responsible for parsing + individual requests from a Modbus client + :type req_handler: (Optional[AsyncRequest]) -> + (() -> bool, async) + """ + + self.req_handler = req_handler + + +class AsyncSerial(CommonAsyncModbusFunctions, CommonAsyncRTUFunctions): + """Asynchronous Modbus Serial client""" + + def __init__(self, + uart_id: int = 1, + baudrate: int = 9600, + data_bits: int = 8, + stop_bits: int = 1, + parity=None, + pins: Tuple[Union[int, Pin], Union[int, Pin]] = None, + ctrl_pin: int = None, + read_timeout: int = None, + **extra_args): + """ + Setup asynchronous Serial/RTU Modbus + + @see Serial + """ + super().__init__(uart_id=uart_id, + baudrate=baudrate, + data_bits=data_bits, + stop_bits=stop_bits, + parity=parity, + pins=pins, + ctrl_pin=ctrl_pin, + read_timeout=read_timeout, + **extra_args) + + self._uart_reader = asyncio.StreamReader(self._uart) + self._uart_writer = asyncio.StreamWriter(self._uart, {}) + + async def _uart_read(self) -> bytearray: + """@see Serial._uart_read""" + + response = bytearray() + # number of repetitions = // + repetitions = self._uart_read_timeout // self._inter_frame_delay + + for _ in range(1, repetitions): + if self._uart.any(): + # WiPy only + # response.extend(await self._uart_reader.readall()) + response.extend(await self._uart_reader.read()) + + # variable length function codes may require multiple reads + if self._exit_read(response): + break + + # wait for the maximum time between two frames + await hybrid_sleep(self._inter_frame_delay) + + return response + + async def _send_receive(self, + slave_addr: int, + modbus_pdu: bytes, + count: bool) -> bytes: + """@see Serial._send_receive""" + + # flush the Rx FIFO + await self._uart_reader.read() + await self._send(modbus_pdu=modbus_pdu, slave_addr=slave_addr) + + response = await self._uart_read() + return self._validate_resp_hdr(response=response, + slave_addr=slave_addr, + function_code=modbus_pdu[0], + count=count) diff --git a/umodbus/asynchronous/tcp.py b/umodbus/asynchronous/tcp.py new file mode 100644 index 0000000..8e7e545 --- /dev/null +++ b/umodbus/asynchronous/tcp.py @@ -0,0 +1,376 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2019, Pycom Limited. +# +# This software is licensed under the GNU GPL version 3 or any +# later version, with permitted additional terms. For more information +# see the Pycom Licence v1.0 document supplied with this file, or +# available at https://www.pycom.io/opensource/licensing +# + +# system packages +import struct +import socket +try: + import uasyncio as asyncio +except ImportError: + import asyncio + +# custom packages +from .modbus import AsyncModbus +from .common import AsyncRequest, CommonAsyncModbusFunctions +from .. import functions, const as Const +from ..common import ModbusException +from ..tcp import CommonTCPFunctions, TCPServer + +# typing not natively supported on MicroPython +from ..typing import Optional, Tuple, List +from ..typing import Callable, Coroutine, Any, Dict +# in case inet_ntop not natively supported on Micropython +from ..compat_utils import inet_ntop + + +class AsyncModbusTCP(AsyncModbus): + """ + Asynchronous equivalent of ModbusTCP class. + + @see ModbusTCP + """ + def __init__(self, addr_list: Optional[List[int]] = None): + super().__init__( + # set itf to AsyncTCPServer object + AsyncTCPServer(), + addr_list + ) + + async def bind(self, + local_ip: str, + local_port: int = 502, + max_connections: int = 10) -> None: + """@see ModbusTCP.bind""" + + await self._itf.bind(local_ip, local_port, max_connections) + + def get_bound_status(self) -> bool: + """@see ModbusTCP.get_bound_status""" + + return self._itf.is_bound + + async def serve_forever(self) -> None: + """@see AsyncTCPServer.serve_forever""" + + await self._itf.serve_forever() + + def server_close(self) -> None: + """@see AsyncTCPServer.server_close""" + + self._itf.server_close() + + +class AsyncTCP(CommonTCPFunctions, CommonAsyncModbusFunctions): + """ + Asynchronous equivalent of TCP class. + + @see TCP + """ + def __init__(self, + slave_ip: str, + slave_port: int = 502, + timeout: float = 5.0): + """ + Initializes an asynchronous TCP client. + + Warning: Client does not auto-connect on initialization, + unlike the synchronous client. Call `connect()` before + calling client methods. + + @see TCP + """ + + super().__init__(slave_ip=slave_ip, + slave_port=slave_port, + timeout=timeout) + + self._sock_reader: Optional[asyncio.StreamReader] = None + self._sock_writer: Optional[asyncio.StreamWriter] = None + self.protocol = self + + async def _send_receive(self, + slave_addr: int, + modbus_pdu: bytes, + count: bool) -> bytes: + """@see TCP._send_receive""" + + mbap_hdr, trans_id = self._create_mbap_hdr(slave_addr=slave_addr, + modbus_pdu=modbus_pdu) + + if self._sock_writer is None or self._sock_reader is None: + raise ValueError("_sock_writer is None, try calling bind()" + " on the server.") + + self._sock_writer.write(mbap_hdr + modbus_pdu) + + await self._sock_writer.drain() + + response = await self._sock_reader.read(256) + + modbus_data = self._validate_resp_hdr(response=response, + trans_id=trans_id, + slave_addr=slave_addr, + function_code=modbus_pdu[0], + count=count) + + return modbus_data + + async def connect(self) -> None: + """@see TCP.connect""" + + if self._sock_writer is not None: + # clean up old writer + self._sock_writer.close() + await self._sock_writer.wait_closed() + + self._sock_reader, self._sock_writer = \ + await asyncio.open_connection(self._slave_ip, self._slave_port) + self.is_connected = True + + +class AsyncTCPServer(TCPServer): + """ + Asynchronous equivalent of TCPServer class. + + @see TCPServer + """ + def __init__(self, timeout: float = 5.0): + super().__init__() + self._is_bound: bool = False + self._handle_request: Callable[[Optional[AsyncRequest]], + Coroutine[Any, Any, bool]] = None + self._unit_addr_list: Optional[List[int]] = None + self._req_dict: Dict[AsyncRequest, Tuple[asyncio.StreamWriter, + int]] = {} + self.timeout: float = timeout + self._lock: asyncio.Lock = None + self._on_connect_cb: Optional[Callable[[str], None]] = None + self._on_disconnect_cb: Optional[Callable[[str], None]] = None + + def set_on_connect_cb(self, cb: Callable[[str], None]) -> None: + """ + Sets the callback to be called when a client has connected. + + :param callback: Callback to be called on client connect. + :type callback: Callable that takes an (addr) + """ + + self._on_connect_cb = cb + + def set_on_disconnect_cb(self, cb: Callable[[str], None]) -> None: + """ + Sets the callback to be called when a client has disconnected. + + :param callback: Callback to be called on client disconnect. + :type callback: Callable that takes an (addr) + """ + + self._on_disconnect_cb = cb + + async def bind(self, + local_ip: str, + local_port: int = 502, + max_connections: int = 1) -> None: + """@see TCPServer.bind""" + + self._lock = asyncio.Lock() + self.server = await asyncio.start_server(self._accept_request, + local_ip, + local_port) + self._is_bound = True + + async def _send(self, + writer: asyncio.StreamWriter, + req_tid: int, + modbus_pdu: bytes, + slave_addr: int) -> None: + """ + Asynchronous equivalent to TCPServer._send + @see TCPServer._send for common (trailing) parameters + + :param writer: The socket output/writer + :type writer: (u)asyncio.StreamWriter + :param req_tid: The Modbus transaction ID + :type req_tid: int + """ + + size = len(modbus_pdu) + fmt = 'B' * size + adu = struct.pack('>HHHB' + fmt, + req_tid, + 0, + size + 1, + slave_addr, + *modbus_pdu) + writer.write(adu) + await writer.drain() + + async def send_response(self, + slave_addr: int, + function_code: int, + request_register_addr: int, + request_register_qty: int, + request_data: list, + values: Optional[list] = None, + signed: bool = True, + request: AsyncRequest = None) -> None: + """ + Asynchronous equivalent to TCPServer.send_response + @see TCPServer.send_response for common (leading) parameters + + :param request: The request to send a response for + :type request: AsyncRequest + """ + + writer, req_tid = self._req_dict.pop(request) + modbus_pdu = functions.response(function_code, + request_register_addr, + request_register_qty, + request_data, + values, + signed) + + await self._send(writer, req_tid, modbus_pdu, slave_addr) + + async def send_exception_response(self, + slave_addr: int, + function_code: int, + exception_code: int, + request: AsyncRequest = None) -> None: + """ + Asynchronous equivalent to TCPServer.send_exception_response + @see TCPServer.send_exception_response for common (trailing) parameters + + :param request: The request to send a response for + :type request: AsyncRequest + """ + + writer, req_tid = self._req_dict.pop(request) + modbus_pdu = functions.exception_response(function_code, + exception_code) + + await self._send(writer, req_tid, modbus_pdu, slave_addr) + + async def _accept_request(self, + reader: asyncio.StreamReader, + writer: asyncio.StreamWriter) -> None: + """ + Accept, read and decode a socket based request. Timeout and unit + address list settings are based on values specified in constructor + + :param reader: The socket input/reader to read request from + :type reader: (u)asyncio.StreamReader + :param writer: The socket output/writer to send response to + :type writer: (u)asyncio.StreamWriter + """ + + try: + header_len = Const.MBAP_HDR_LENGTH - 1 + dest_addr = inet_ntop(socket.AF_INET, + writer.get_extra_info('peername')) + if self._on_connect_cb is not None: + self._on_connect_cb(dest_addr) + + while True: + task = reader.read(128) + if self.timeout is not None: + pass # task = asyncio.wait_for(task, self.timeout) + req: bytes = await task + if len(req) == 0: + break + + req_header_no_uid = req[:header_len] + req_tid, req_pid, req_len = struct.unpack('>HHH', + req_header_no_uid) + req_uid_and_pdu = req[header_len:header_len + req_len] + if (req_pid != 0): + raise ValueError( + "Modbus request error: expected PID of 0," + " encountered {0} instead".format(req_pid)) + + elif (self._unit_addr_list is None or + req_uid_and_pdu[0] in self._unit_addr_list): + async with self._lock: + # _handle_request = process(request) + if self._handle_request is None: + break + data = bytearray(req_uid_and_pdu) + request = AsyncRequest(self, data) + self._req_dict[request] = (writer, req_tid) + try: + await self._handle_request(request) + except ModbusException as err: + await self.send_exception_response( + request, + req[0], + err.function_code, + err.exception_code + ) + except Exception as err: + if not isinstance(err, OSError): # or err.errno != 104: + print("{0}: ".format(type(err).__name__), err) + finally: + if self._on_disconnect_cb is not None: + self._on_disconnect_cb(dest_addr) + await self._close_writer(writer) + + def get_request(self, + unit_addr_list: Optional[List[int]] = None, + timeout: float = 0) -> None: + """ + Unused function, kept for equivalent + compatibility with synchronous version + + @see TCPServer.get_request + """ + + self._unit_addr_list = unit_addr_list + self.timeout = timeout + + def set_params(self, + addr_list: Optional[List[int]], + req_handler: Callable[[Optional[AsyncRequest]], + Coroutine[Any, Any, bool]]) -> None: + """ + Used to set parameters such as the unit address + list and the socket processing callback + + :param addr_list: The unit address list + :type addr_list: List[int], optional + :param req_handler: A callback that is responsible for parsing + individual requests from a Modbus client + :type req_handler: (Optional[AsyncRequest]) -> + (() -> bool, async) + """ + + self._handle_request = req_handler + self._unit_addr_list = addr_list + + async def _close_writer(self, writer: asyncio.StreamWriter) -> None: + """ + Stops and closes the connection to a client. + + :param writer: The socket writer + :type writer: (u)asyncio.StreamWriter + """ + + writer.close() + await writer.wait_closed() + + async def serve_forever(self) -> None: + """Waits for the server to close.""" + + await self.server.wait_closed() + + def server_close(self) -> None: + """Stops a running server.""" + + if self._is_bound: + self.server.close() diff --git a/umodbus/common.py b/umodbus/common.py index a5ebe9f..1e26743 100644 --- a/umodbus/common.py +++ b/umodbus/common.py @@ -7,7 +7,6 @@ # see the Pycom Licence v1.0 document supplied with this file, or # available at https://www.pycom.io/opensource/licensing # - # system packages import struct @@ -16,7 +15,7 @@ from . import functions # typing not natively supported on MicroPython -from .typing import List, Optional, Tuple, Union +from .typing import List, Optional, Union class Request(object): @@ -81,6 +80,7 @@ def send_response(self, :param signed: Indicates if signed values are used :type signed: bool """ + self._itf.send_response(self.unit_addr, self.function, self.register_addr, @@ -176,7 +176,7 @@ def read_holding_registers(self, slave_addr: int, starting_addr: int, register_qty: int, - signed: bool = True) -> Tuple[int, ...]: + signed: bool = True) -> bytes: """ Read holding registers (HREGS). @@ -190,7 +190,7 @@ def read_holding_registers(self, :type signed: bool :returns: State of read holding register as tuple - :rtype: Tuple[int, ...] + :rtype: bytes """ modbus_pdu = functions.read_holding_registers( starting_address=starting_addr, @@ -208,7 +208,7 @@ def read_input_registers(self, slave_addr: int, starting_addr: int, register_qty: int, - signed: bool = True) -> Tuple[int, ...]: + signed: bool = True) -> bytes: """ Read input registers (IREGS). @@ -222,7 +222,7 @@ def read_input_registers(self, :type signed: bool :returns: State of read input register as tuple - :rtype: Tuple[int, ...] + :rtype: bytes """ modbus_pdu = functions.read_input_registers( starting_address=starting_addr, @@ -390,3 +390,9 @@ def write_multiple_registers(self, ) return operation_status + + def _send_receive(self, + slave_addr: int, + modbus_pdu: bytes, + count: bool) -> bytes: + raise NotImplementedError("Must be overridden by subclass") diff --git a/umodbus/compat_utils.py b/umodbus/compat_utils.py new file mode 100644 index 0000000..04bea16 --- /dev/null +++ b/umodbus/compat_utils.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python +# +# Copyright (c) 2019, Pycom Limited. +# +# This software is licensed under the GNU GPL version 3 or any +# later version, with permitted additional terms. For more information +# see the Pycom Licence v1.0 document supplied with this file, or +# available at https://www.pycom.io/opensource/licensing +# +# compatibility for ports which do not have inet_ntop available +# from https://github.com/micropython/micropython/issues/8877#issuecomment-1178674681 + +try: + from socket import inet_ntop +except ImportError: + import socket + + def inet_ntop(type: int, packed_ip: bytes) -> str: + if type == socket.AF_INET: + return ".".join(map(str, packed_ip)) + elif type == socket.AF_INET6: + iterator = zip(*[iter(packed_ip)] * 2) + ipv6_addr = [] + for high, low in iterator: + ipv6_addr.append(f"{high << 8 | low:04x}") + + return ":".join(ipv6_addr) + raise ValueError("Invalid address type") diff --git a/umodbus/const.py b/umodbus/const.py index eb940be..e5c45b6 100644 --- a/umodbus/const.py +++ b/umodbus/const.py @@ -12,6 +12,16 @@ from micropython import const +# request types +READ = 'READ' +WRITE = 'WRITE' + +# datablock names +ISTS = 'ISTS' +COILS = 'COILS' +HREGS = 'HREGS' +IREGS = 'IREGS' + # function codes # defined as const(), see https://github.com/micropython/micropython/issues/573 #: Read contiguous status of coils diff --git a/umodbus/functions.py b/umodbus/functions.py index f1a0269..923ae27 100644 --- a/umodbus/functions.py +++ b/umodbus/functions.py @@ -7,7 +7,6 @@ # see the Pycom Licence v1.0 document supplied with this file, or # available at https://www.pycom.io/opensource/licensing # - # system packages import struct @@ -352,6 +351,8 @@ def response(function_code: int, request_register_addr, request_register_qty) + return b'' + def exception_response(function_code: int, exception_code: int) -> bytes: """ diff --git a/umodbus/modbus.py b/umodbus/modbus.py index 56bd8e7..3742bec 100644 --- a/umodbus/modbus.py +++ b/umodbus/modbus.py @@ -21,7 +21,10 @@ from .common import Request # typing not natively supported on MicroPython -from .typing import Callable, dict_keys, List, Optional, Union +from .typing import Callable, List, Optional, Union +from .typing import KeysView, Literal, Dict, Awaitable, overload + +CallbackType = Callable[[str, int, List[int]], None] class Modbus(object): @@ -33,83 +36,90 @@ class Modbus(object): :param addr_list: List of addresses :type addr_list: List[int] """ - def __init__(self, itf, addr_list: List[int]) -> None: + def __init__(self, itf, addr_list: Optional[List[int]]) -> None: self._itf = itf self._addr_list = addr_list # modbus register types with their default value - self._available_register_types = ['COILS', 'HREGS', 'IREGS', 'ISTS'] - self._register_dict = dict() + self._available_register_types = (Const.COILS, Const.HREGS, Const.IREGS, Const.ISTS) + self._register_dict: Dict[str, + Dict[int, + Dict[str, Union[bool, + int, + List[bool], + List[int]]]]] = dict() for reg_type in self._available_register_types: self._register_dict[reg_type] = dict() self._default_vals = dict(zip(self._available_register_types, [False, 0, 0, False])) # registers which can be set by remote device - self._changeable_register_types = ['COILS', 'HREGS'] + self._changeable_register_types = (Const.COILS, Const.HREGS) self._changed_registers = dict() for reg_type in self._changeable_register_types: self._changed_registers[reg_type] = dict() - def process(self) -> bool: + def process(self, request: Optional[Request] = None) -> Optional[Awaitable]: """ Process the Modbus requests. - :returns: Result of processing, True on success, False otherwise - :rtype: bool + :returns: Request response - None for a synchronous server, or + an awaitable for an asynchronous server due to AsyncRequest + :rtype: Awaitable, optional """ reg_type = None req_type = None - request = self._itf.get_request(unit_addr_list=self._addr_list, - timeout=0) + # for synchronous version if request is None: - return False + request = self._itf.get_request(unit_addr_list=self._addr_list, + timeout=0) + # if get_request is an async generator or None, hands it off to the async subclass + if not isinstance(request, Request): + return request if request.function == Const.READ_COILS: # Coils (setter+getter) [0, 1] # function 01 - read single register - reg_type = 'COILS' - req_type = 'READ' + reg_type = Const.COILS + req_type = Const.READ elif request.function == Const.READ_DISCRETE_INPUTS: # Ists (only getter) [0, 1] # function 02 - read input status (discrete inputs/digital input) - reg_type = 'ISTS' - req_type = 'READ' + reg_type = Const.ISTS + req_type = Const.READ elif request.function == Const.READ_HOLDING_REGISTERS: # Hregs (setter+getter) [0, 65535] # function 03 - read holding register - reg_type = 'HREGS' - req_type = 'READ' + reg_type = Const.HREGS + req_type = Const.READ elif request.function == Const.READ_INPUT_REGISTER: # Iregs (only getter) [0, 65535] # function 04 - read input registers - reg_type = 'IREGS' - req_type = 'READ' + reg_type = Const.IREGS + req_type = Const.READ elif (request.function == Const.WRITE_SINGLE_COIL or request.function == Const.WRITE_MULTIPLE_COILS): # Coils (setter+getter) [0, 1] # function 05 - write single coil # function 15 - write multiple coil - reg_type = 'COILS' - req_type = 'WRITE' + reg_type = Const.COILS + req_type = Const.WRITE elif (request.function == Const.WRITE_SINGLE_REGISTER or request.function == Const.WRITE_MULTIPLE_REGISTERS): # Hregs (setter+getter) [0, 65535] # function 06 - write holding register # function 16 - write multiple holding register - reg_type = 'HREGS' - req_type = 'WRITE' + reg_type = Const.HREGS + req_type = Const.WRITE else: - request.send_exception(Const.ILLEGAL_FUNCTION) + return request.send_exception(Const.ILLEGAL_FUNCTION) if reg_type: - if req_type == 'READ': - self._process_read_access(request=request, reg_type=reg_type) - elif req_type == 'WRITE': - self._process_write_access(request=request, reg_type=reg_type) - - return True + if req_type == Const.READ: + return self._process_read_access(request=request, reg_type=reg_type) + elif req_type == Const.WRITE: + return self._process_write_access(request=request, reg_type=reg_type) def _create_response(self, request: Request, @@ -129,7 +139,7 @@ def _create_response(self, default_value = {'val': 0} reg_dict = self._register_dict[reg_type] - if reg_type in ['COILS', 'ISTS']: + if reg_type in (Const.COILS, Const.ISTS): default_value = {'val': False} for addr in range(request.register_addr, @@ -170,7 +180,8 @@ def _create_response(self, return data - def _process_read_access(self, request: Request, reg_type: str) -> None: + def _process_read_access(self, request: Request, reg_type: str) \ + -> Optional[Awaitable]: """ Process read access to register @@ -178,6 +189,10 @@ def _process_read_access(self, request: Request, reg_type: str) -> None: :type request: Request :param reg_type: The register type :type reg_type: str + + :returns: Request response - None for a synchronous server, or + an awaitable for an asynchronous server due to AsyncRequest + :rtype Awaitable, optional """ address = request.register_addr @@ -190,11 +205,14 @@ def _process_read_access(self, request: Request, reg_type: str) -> None: _cb(reg_type=reg_type, address=address, val=vals) vals = self._create_response(request=request, reg_type=reg_type) - request.send_response(vals) + return request.send_response(vals) else: - request.send_exception(Const.ILLEGAL_DATA_ADDRESS) + # "return" is hack to ensure that AsyncModbus can call await + # on this result if AsyncRequest is passed to its function + return request.send_exception(Const.ILLEGAL_DATA_ADDRESS) - def _process_write_access(self, request: Request, reg_type: str) -> None: + def _process_write_access(self, request: Request, reg_type: str) \ + -> Optional[Awaitable]: """ Process write access to register @@ -202,64 +220,64 @@ def _process_write_access(self, request: Request, reg_type: str) -> None: :type request: Request :param reg_type: The register type :type reg_type: str + + :returns: Request response - None for a synchronous server, or + an awaitable for an asynchronous server due to AsyncRequest + :rtype Awaitable, optional """ address = request.register_addr - val = 0 - valid_register = False - - if address in self._register_dict[reg_type]: - if request.data is None: - request.send_exception(Const.ILLEGAL_DATA_VALUE) - return - - if reg_type == 'COILS': - valid_register = True - - if request.function == Const.WRITE_SINGLE_COIL: - val = request.data[0] - if 0x00 < val < 0xFF: - valid_register = False - request.send_exception(Const.ILLEGAL_DATA_VALUE) - else: - val = [(val == 0xFF)] - elif request.function == Const.WRITE_MULTIPLE_COILS: - tmp = int.from_bytes(request.data, "big") - val = [ - bool(tmp & (1 << n)) for n in range(request.quantity) - ] - - if valid_register: - self.set_coil(address=address, value=val) - elif reg_type == 'HREGS': - valid_register = True - val = list(functions.to_short(byte_array=request.data, - signed=False)) - - if request.function in [Const.WRITE_SINGLE_REGISTER, - Const.WRITE_MULTIPLE_REGISTERS]: - self.set_hreg(address=address, value=val) - else: - # nothing except holding registers or coils can be set - request.send_exception(Const.ILLEGAL_FUNCTION) - - if valid_register: - request.send_response() - self._set_changed_register(reg_type=reg_type, - address=address, - value=val) - if self._register_dict[reg_type][address].get('on_set_cb', 0): - _cb = self._register_dict[reg_type][address]['on_set_cb'] - _cb(reg_type=reg_type, address=address, val=val) + val = False + + if address not in self._register_dict[reg_type]: + return request.send_exception(Const.ILLEGAL_DATA_ADDRESS) + elif request.data is None: + return request.send_exception(Const.ILLEGAL_DATA_VALUE) + elif reg_type == Const.COILS: + if request.function == Const.WRITE_SINGLE_COIL: + val = request.data[0] + if 0x00 < val < 0xFF: + return request.send_exception(Const.ILLEGAL_DATA_VALUE) + val = [(val == 0xFF)] + elif request.function == Const.WRITE_MULTIPLE_COILS: + tmp = int.from_bytes(request.data, "big") + val = [ + bool(tmp & (1 << n)) for n in range(request.quantity) + ] + + self.set_coil(address=address, value=val) + elif reg_type == Const.HREGS: + val = list(functions.to_short(byte_array=request.data, + signed=False)) + + if request.function in [Const.WRITE_SINGLE_REGISTER, + Const.WRITE_MULTIPLE_REGISTERS]: + self.set_hreg(address=address, value=val) else: - request.send_exception(Const.ILLEGAL_DATA_ADDRESS) + # nothing except holding registers or coils can be set + return request.send_exception(Const.ILLEGAL_FUNCTION) + + if self._register_dict[reg_type][address].get('on_pre_set_cb', 0): + _cb = self._register_dict[reg_type][address]['on_pre_set_cb'] + if _cb(reg_type=reg_type, address=address, val=val): + return request.send_exception(Const.ILLEGAL_DATA_VALUE) + + self._set_changed_register(reg_type=reg_type, + address=address, + value=val) + if self._register_dict[reg_type][address].get('on_set_cb', 0): + _cb = self._register_dict[reg_type][address]['on_set_cb'] + _cb(reg_type=reg_type, address=address, val=val) + return request.send_response() def add_coil(self, address: int, value: Union[bool, List[bool]] = False, - on_set_cb: Callable[[str, int, Union[List[bool], List[int]]], - None] = None, - on_get_cb: Callable[[str, int, Union[List[bool], List[int]]], - None] = None) -> None: + on_set_cb: Optional[Callable[[str, int, + Union[List[bool], List[int]]], + None]] = None, + on_get_cb: Optional[Callable[[str, int, + Union[List[bool], List[int]]], + None]] = None) -> None: """ Add a coil to the modbus register dictionary. @@ -278,7 +296,7 @@ def add_coil(self, None ] """ - self._set_reg_in_dict(reg_type='COILS', + self._set_reg_in_dict(reg_type=Const.COILS, address=address, value=value, on_set_cb=on_set_cb, @@ -294,7 +312,7 @@ def remove_coil(self, address: int) -> Union[None, bool, List[bool]]: :returns: Register value, None if register did not exist in dict :rtype: Union[None, bool, List[bool]] """ - return self._remove_reg_from_dict(reg_type='COILS', address=address) + return self._remove_reg_from_dict(reg_type=Const.COILS, address=address) def set_coil(self, address: int, @@ -307,7 +325,7 @@ def set_coil(self, :param value: The default value :type value: Union[bool, List[bool]], optional """ - self._set_reg_in_dict(reg_type='COILS', + self._set_reg_in_dict(reg_type=Const.COILS, address=address, value=value) @@ -321,24 +339,24 @@ def get_coil(self, address: int) -> Union[bool, List[bool]]: :returns: Coil value :rtype: Union[bool, List[bool]] """ - return self._get_reg_in_dict(reg_type='COILS', + return self._get_reg_in_dict(reg_type=Const.COILS, address=address) @property - def coils(self) -> dict_keys: + def coils(self) -> KeysView: """ Get the configured coils. :returns: The dictionary keys. - :rtype: dict_keys + :rtype: KeysView """ - return self._get_regs_of_dict(reg_type='COILS') + return self._get_regs_of_dict(reg_type=Const.COILS) def add_hreg(self, address: int, value: Union[int, List[int]] = 0, - on_set_cb: Callable[[str, int, List[int]], None] = None, - on_get_cb: Callable[[str, int, List[int]], None] = None) -> None: + on_set_cb: Optional[CallbackType] = None, + on_get_cb: Optional[CallbackType] = None) -> None: """ Add a holding register to the modbus register dictionary. @@ -347,11 +365,11 @@ def add_hreg(self, :param value: The default value :type value: Union[int, List[int]], optional :param on_set_cb: Callback on setting the holding register - :type on_set_cb: Callable[[str, int, List[int]], None] + :type on_set_cb: Callable[[str, int, List[int]], None], optional :param on_get_cb: Callback on getting the holding register - :type on_get_cb: Callable[[str, int, List[int]], None] + :type on_get_cb: Callable[[str, int, List[int]], None], optional """ - self._set_reg_in_dict(reg_type='HREGS', + self._set_reg_in_dict(reg_type=Const.HREGS, address=address, value=value, on_set_cb=on_set_cb, @@ -367,7 +385,7 @@ def remove_hreg(self, address: int) -> Union[None, int, List[int]]: :returns: Register value, None if register did not exist in dict :rtype: Union[None, int, List[int]] """ - return self._remove_reg_from_dict(reg_type='HREGS', address=address) + return self._remove_reg_from_dict(reg_type=Const.HREGS, address=address) def set_hreg(self, address: int, value: Union[int, List[int]] = 0) -> None: """ @@ -378,7 +396,7 @@ def set_hreg(self, address: int, value: Union[int, List[int]] = 0) -> None: :param value: The default value :type value: int or list of int, optional """ - self._set_reg_in_dict(reg_type='HREGS', + self._set_reg_in_dict(reg_type=Const.HREGS, address=address, value=value) @@ -392,24 +410,25 @@ def get_hreg(self, address: int) -> Union[int, List[int]]: :returns: Holding register value :rtype: Union[int, List[int]] """ - return self._get_reg_in_dict(reg_type='HREGS', + return self._get_reg_in_dict(reg_type=Const.HREGS, address=address) @property - def hregs(self) -> dict_keys: + def hregs(self) -> KeysView: """ Get the configured holding registers. :returns: The dictionary keys. - :rtype: dict_keys + :rtype: KeysView """ - return self._get_regs_of_dict(reg_type='HREGS') + return self._get_regs_of_dict(reg_type=Const.HREGS) def add_ist(self, address: int, value: Union[bool, List[bool]] = False, - on_get_cb: Callable[[str, int, Union[List[bool], List[int]]], - None] = None) -> None: + on_get_cb: Optional[Callable[[str, int, + Union[List[bool], List[int]]], + None]] = None) -> None: """ Add a discrete input register to the modbus register dictionary. @@ -423,7 +442,7 @@ def add_ist(self, None ] """ - self._set_reg_in_dict(reg_type='ISTS', + self._set_reg_in_dict(reg_type=Const.ISTS, address=address, value=value, on_get_cb=on_get_cb) @@ -438,7 +457,7 @@ def remove_ist(self, address: int) -> Union[None, bool, List[bool]]: :returns: Register value, None if register did not exist in dict :rtype: Union[None, bool, List[bool]] """ - return self._remove_reg_from_dict(reg_type='ISTS', address=address) + return self._remove_reg_from_dict(reg_type=Const.ISTS, address=address) def set_ist(self, address: int, value: bool = False) -> None: """ @@ -449,7 +468,7 @@ def set_ist(self, address: int, value: bool = False) -> None: :param value: The default value :type value: bool or list of bool, optional """ - self._set_reg_in_dict(reg_type='ISTS', + self._set_reg_in_dict(reg_type=Const.ISTS, address=address, value=value) @@ -463,24 +482,25 @@ def get_ist(self, address: int) -> Union[bool, List[bool]]: :returns: Discrete input register value :rtype: Union[bool, List[bool]] """ - return self._get_reg_in_dict(reg_type='ISTS', + return self._get_reg_in_dict(reg_type=Const.ISTS, address=address) @property - def ists(self) -> dict_keys: + def ists(self) -> KeysView: """ Get the configured discrete input registers. :returns: The dictionary keys. - :rtype: dict_keys + :rtype: KeysView """ - return self._get_regs_of_dict(reg_type='ISTS') + return self._get_regs_of_dict(reg_type=Const.ISTS) def add_ireg(self, address: int, value: Union[int, List[int]] = 0, - on_get_cb: Callable[[str, int, Union[List[bool], List[int]]], - None] = None) -> None: + on_get_cb: Optional[Callable[[str, int, + Union[List[bool], List[int]]], + None]] = None) -> None: """ Add an input register to the modbus register dictionary. @@ -494,7 +514,7 @@ def add_ireg(self, None ] """ - self._set_reg_in_dict(reg_type='IREGS', + self._set_reg_in_dict(reg_type=Const.IREGS, address=address, value=value, on_get_cb=on_get_cb) @@ -509,7 +529,7 @@ def remove_ireg(self, address: int) -> Union[None, int, List[int]]: :returns: Register value, None if register did not exist in dict :rtype: Union[None, int, List[int]] """ - return self._remove_reg_from_dict(reg_type='IREGS', address=address) + return self._remove_reg_from_dict(reg_type=Const.IREGS, address=address) def set_ireg(self, address: int, value: Union[int, List[int]] = 0) -> None: """ @@ -520,7 +540,7 @@ def set_ireg(self, address: int, value: Union[int, List[int]] = 0) -> None: :param value: The default value :type value: Union[int, List[int]], optional """ - self._set_reg_in_dict(reg_type='IREGS', + self._set_reg_in_dict(reg_type=Const.IREGS, address=address, value=value) @@ -534,29 +554,31 @@ def get_ireg(self, address: int) -> Union[int, List[int]]: :returns: Input register value :rtype: Union[int, List[int]] """ - return self._get_reg_in_dict(reg_type='IREGS', + return self._get_reg_in_dict(reg_type=Const.IREGS, address=address) @property - def iregs(self) -> dict_keys: + def iregs(self) -> KeysView: """ Get the configured input registers. :returns: The dictionary keys. - :rtype: dict_keys + :rtype: KeysView """ - return self._get_regs_of_dict(reg_type='IREGS') + return self._get_regs_of_dict(reg_type=Const.IREGS) def _set_reg_in_dict(self, reg_type: str, address: int, value: Union[bool, int, List[bool], List[int]], - on_set_cb: Callable[[str, int, Union[List[bool], - List[int]]], - None] = None, - on_get_cb: Callable[[str, int, Union[List[bool], - List[int]]], - None] = None) -> None: + on_set_cb: Optional[Callable[[str, int, + Union[List[bool], + List[int]]], + None]] = None, + on_get_cb: Optional[Callable[[str, int, + Union[List[bool], + List[int]]], + None]] = None) -> None: """ Set the register value in the dictionary of registers. @@ -603,14 +625,12 @@ def _set_single_reg_in_dict(self, reg_type: str, address: int, value: Union[bool, int], - on_set_cb: Callable[ + on_set_cb: Optional[Callable[ [str, int, Union[List[bool], List[int]]], - None - ] = None, - on_get_cb: Callable[ + None]] = None, + on_get_cb: Optional[Callable[ [str, int, Union[List[bool], List[int]]], - None - ] = None) -> None: + None]] = None) -> None: """ Set a register value in the dictionary of registers. @@ -653,7 +673,19 @@ def _set_single_reg_in_dict(self, self._register_dict[reg_type][address] = data + @overload def _remove_reg_from_dict(self, + reg_type: Literal["COILS", "ISTS"], + address: int) -> Union[bool, List[bool]]: + pass + + @overload + def _remove_reg_from_dict(self, # noqa: F811 + reg_type: Literal["HREGS", "IREGS"], + address: int) -> Union[int, List[int]]: + pass + + def _remove_reg_from_dict(self, # noqa: F811 reg_type: str, address: int) -> Union[None, bool, int, List[bool], List[int]]: """ @@ -674,7 +706,19 @@ def _remove_reg_from_dict(self, return self._register_dict[reg_type].pop(address, None) + @overload def _get_reg_in_dict(self, + reg_type: Literal["HREGS", "IREGS"], + address: int) -> Union[int, List[int]]: + pass + + @overload + def _get_reg_in_dict(self, # noqa: F811 + reg_type: Literal["COILS", "ISTS"], + address: int) -> Union[bool, List[bool]]: + pass + + def _get_reg_in_dict(self, # noqa: F811 reg_type: str, address: int) -> Union[bool, int, List[bool], List[int]]: """ @@ -699,7 +743,7 @@ def _get_reg_in_dict(self, raise KeyError('No {} available for the register address {}'. format(reg_type, address)) - def _get_regs_of_dict(self, reg_type: str) -> dict_keys: + def _get_regs_of_dict(self, reg_type: str) -> KeysView: """ Get all configured registers of specified register type. @@ -708,7 +752,7 @@ def _get_regs_of_dict(self, reg_type: str) -> dict_keys: :raise KeyError: No register at specified address found :returns: The configured registers of the specified register type. - :rtype: dict_keys + :rtype: KeysView """ if not self._check_valid_register(reg_type=reg_type): raise KeyError('{} is not a valid register type of {}'. @@ -726,10 +770,7 @@ def _check_valid_register(self, reg_type: str) -> bool: :returns: Flag whether register type is valid :rtype: bool """ - if reg_type in self._available_register_types: - return True - else: - return False + return reg_type in self._available_register_types @property def changed_registers(self) -> dict: @@ -749,7 +790,7 @@ def changed_coils(self) -> dict: :returns: The changed coil registers. :rtype: dict """ - return self._changed_registers['COILS'] + return self._changed_registers[Const.COILS] @property def changed_hregs(self) -> dict: @@ -759,7 +800,7 @@ def changed_hregs(self) -> dict: :returns: The changed holding registers. :rtype: dict """ - return self._changed_registers['HREGS'] + return self._changed_registers[Const.HREGS] def _set_changed_register(self, reg_type: str, @@ -831,43 +872,58 @@ def setup_registers(self, :param use_default_vals: Flag to use dummy default values :type use_default_vals: Optional[bool] """ - if len(registers): - for reg_type, default_val in self._default_vals.items(): - if reg_type in registers: - for reg, val in registers[reg_type].items(): - address = val['register'] - - if use_default_vals: - if 'len' in val: - value = [default_val] * val['len'] - else: - value = default_val - else: - value = val['val'] - - on_set_cb = val.get('on_set_cb', None) - on_get_cb = val.get('on_get_cb', None) - - if reg_type == 'COILS': - self.add_coil(address=address, - value=value, - on_set_cb=on_set_cb, - on_get_cb=on_get_cb) - elif reg_type == 'HREGS': - self.add_hreg(address=address, - value=value, - on_set_cb=on_set_cb, - on_get_cb=on_get_cb) - elif reg_type == 'ISTS': - self.add_ist(address=address, - value=value, - on_get_cb=on_get_cb) # only getter - elif reg_type == 'IREGS': - self.add_ireg(address=address, - value=value, - on_get_cb=on_get_cb) # only getter - else: - # invalid register type - pass + if not len(registers): + return + + for reg_type, default_val in self._default_vals.items(): + if reg_type not in registers: + # invalid register type + continue + for reg, val in registers[reg_type].items(): + address = val['register'] + + if use_default_vals: + if 'len' in val: + value = [default_val] * val['len'] + else: + value = default_val else: - pass + value = val['val'] + + on_set_cb = val.get('on_set_cb', None) + on_get_cb = val.get('on_get_cb', None) + + if reg_type == Const.COILS: + self.add_coil(address=address, + value=value, + on_set_cb=on_set_cb, + on_get_cb=on_get_cb) + elif reg_type == Const.HREGS: + self.add_hreg(address=address, + value=value, + on_set_cb=on_set_cb, + on_get_cb=on_get_cb) + elif reg_type == Const.ISTS: + self.add_ist(address=address, + value=value, + on_get_cb=on_get_cb) # only getter + elif reg_type == Const.IREGS: + self.add_ireg(address=address, + value=value, + on_get_cb=on_get_cb) # only getter + + try: + extra_callbacks = registers["META"] + on_connect_cb = extra_callbacks["on_connect_cb"] + self._itf.set_on_connect_cb(on_connect_cb) + + on_disconnect_cb = extra_callbacks["on_disconnect_cb"] + self._itf.set_on_disconnect_cb(on_disconnect_cb) + except KeyError: + # either meta, connect or disconnect cb + # undefined in definitions; can ignore + pass + except AttributeError: + # interface does not support on_connect_cb + # e.g. RTU; can ignore + pass diff --git a/umodbus/serial.py b/umodbus/serial.py index d24b981..05bcd8d 100644 --- a/umodbus/serial.py +++ b/umodbus/serial.py @@ -13,7 +13,6 @@ from machine import Pin import struct import time -import machine # custom packages from . import const as Const @@ -23,12 +22,12 @@ from .modbus import Modbus # typing not natively supported on MicroPython -from .typing import List, Optional, Union +from .typing import List, Optional, Union, Awaitable class ModbusRTU(Modbus): """ - Modbus RTU client class + Modbus RTU server class :param addr: The address of this device on the bus :type addr: int @@ -55,21 +54,25 @@ def __init__(self, parity: Optional[int] = None, pins: List[Union[int, Pin], Union[int, Pin]] = None, ctrl_pin: int = None, - uart_id: int = 1): + uart_id: int = 1, + **extra_args): super().__init__( - # set itf to Serial object, addr_list to [addr] - Serial(uart_id=uart_id, - baudrate=baudrate, - data_bits=data_bits, - stop_bits=stop_bits, - parity=parity, - pins=pins, - ctrl_pin=ctrl_pin), + # set itf to RTUServer object, addr_list to [addr] + RTUServer(uart_id=uart_id, + baudrate=baudrate, + data_bits=data_bits, + stop_bits=stop_bits, + parity=parity, + pins=pins, + ctrl_pin=ctrl_pin, + **extra_args), [addr] ) -class Serial(CommonModbusFunctions): +class CommonRTUFunctions(object): + """Common Functions for Modbus RTU servers""" + def __init__(self, uart_id: int = 1, baudrate: int = 9600, @@ -77,47 +80,62 @@ def __init__(self, stop_bits: int = 1, parity=None, pins: List[Union[int, Pin], Union[int, Pin]] = None, - ctrl_pin: int = None): + ctrl_pin: int = None, + read_timeout: int = None, + **extra_args): """ - Setup Serial/RTU Modbus - - :param uart_id: The ID of the used UART - :type uart_id: int - :param baudrate: The baudrate, default 9600 - :type baudrate: int - :param data_bits: The data bits, default 8 - :type data_bits: int - :param stop_bits: The stop bits, default 1 - :type stop_bits: int - :param parity: The parity, default None - :type parity: Optional[int] - :param pins: The pins as list [TX, RX] - :type pins: List[Union[int, Pin], Union[int, Pin]] - :param ctrl_pin: The control pin - :type ctrl_pin: int + Setup Serial/RTU Modbus (common to client and server) + + :param uart_id: The ID of the used UART + :type uart_id: int + :param baudrate: The baudrate, default 9600 + :type baudrate: int + :param data_bits: The data bits, default 8 + :type data_bits: int + :param stop_bits: The stop bits, default 1 + :type stop_bits: int + :param parity: The parity, default None + :type parity: Optional[int] + :param pins: The pins as list [TX, RX] + :type pins: List[Union[int, Pin], Union[int, Pin]] + :param ctrl_pin: The control pin + :type ctrl_pin: int + :param read_timeout: The read timeout in ms. + :type read_timeout: int """ + + super().__init__() + # UART flush function is introduced in Micropython v1.20.0 + self._has_uart_flush = callable(getattr(UART, "flush", None)) self._uart = UART(uart_id, baudrate=baudrate, bits=data_bits, parity=parity, stop=stop_bits, - # timeout_chars=2, # WiPy only # pins=pins # WiPy only tx=pins[0], - rx=pins[1] - ) + rx=pins[1], + **extra_args) if ctrl_pin is not None: self._ctrlPin = Pin(ctrl_pin, mode=Pin.OUT) else: self._ctrlPin = None - self._t1char = (1000000 * (data_bits + stop_bits + 2)) // baudrate + # timing of 1 character in microseconds (us) + self._t1char = (1_000_000 * (data_bits + stop_bits + 2)) // baudrate + + # inter-frame delay in microseconds (us) + # - <= 19200 bps: 3.5x timing of 1 character + # - > 19200 bps: 1750 us if baudrate <= 19200: - # 4010us (approx. 4ms) @ 9600 baud - self._t35chars = (3500000 * (data_bits + stop_bits + 2)) // baudrate + self._inter_frame_delay = (self._t1char * 3500) // 1000 else: - self._t35chars = 1750 # 1750us (approx. 1.75ms) + self._inter_frame_delay = 1750 + + # no specific reason for 120, taken from _uart_read + # convert to us by multiplying by 1000 + self._uart_read_timeout = (read_timeout or 0.120) * 1000 def _calculate_crc16(self, data: bytearray) -> bytes: """ @@ -136,51 +154,120 @@ def _calculate_crc16(self, data: bytearray) -> bytes: return struct.pack(' bool: + def _form_serial_adu(self, modbus_pdu: bytes, slave_addr: int) -> bytearray: """ - Return on modbus read error + Adds the slave address to the beginning of the Modbus PDU and appends + the checksum of the resulting payload to form the Modbus Serial ADU. - :param response: The response - :type response: bytearray + :param modbus_pdu: The modbus Protocol Data Unit + :type modbus_pdu: bytes + :param slave_addr: The slave address + :type slave_addr: int - :returns: State of basic read response evaluation - :rtype: bool + :returns: The modbus serial PDU. + :rtype bytearray """ - if response[1] >= Const.ERROR_BIAS: - if len(response) < Const.ERROR_RESP_LEN: - return False - elif (Const.READ_COILS <= response[1] <= Const.READ_INPUT_REGISTER): - expected_len = Const.RESPONSE_HDR_LENGTH + 1 + response[2] + Const.CRC_LENGTH - if len(response) < expected_len: - return False - elif len(response) < Const.FIXED_RESP_LEN: - return False - return True + modbus_adu = bytearray() + modbus_adu.append(slave_addr) + modbus_adu.extend(modbus_pdu) + modbus_adu.extend(self._calculate_crc16(modbus_adu)) + return modbus_adu - def _uart_read(self) -> bytearray: + def _send(self, modbus_pdu: bytes, slave_addr: int) -> Optional[Awaitable]: """ - Read up to 40 bytes from UART + Send Modbus frame via UART - :returns: Read content - :rtype: bytearray + If a flow control pin has been setup, it will be controlled accordingly + + :param modbus_pdu: The modbus Protocol Data Unit + :type modbus_pdu: bytes + :param slave_addr: The slave address + :type slave_addr: int + + :returns: None if called by the synchronous variant, or else an + Awaitable is returned that represents the actions to take + after the request is sent (e.g. sleeping) + :rtype: Optional[Awaitable] """ - response = bytearray() - for x in range(1, 40): - if self._uart.any(): - # WiPy only - # response.extend(self._uart.readall()) - response.extend(self._uart.read()) + # modbus_adu: Modbus Application Data Unit + # consists of the Modbus PDU, with slave address prepended and checksum appended + modbus_adu = self._form_serial_adu(modbus_pdu, slave_addr) - # variable length function codes may require multiple reads - if self._exit_read(response): - break + if self._ctrlPin: + self._ctrlPin.on() + # wait until the control pin really changed + # 85-95us (ESP32 @ 160/240MHz) + time.sleep_us(200) + + # the timing of this part is critical: + # - if we disable output too early, + # the command will not be received in full + # - if we disable output too late, + # the incoming response will lose some data at the beginning + # easiest to just wait for the bytes to be sent out on the wire + + send_start_time = time.ticks_us() + # 360-400us @ 9600-115200 baud (measured) (ESP32 @ 160/240MHz) + self._uart.write(modbus_adu) + send_finish_time = time.ticks_us() + + sleep_time_us = self._t1char + if self._has_uart_flush: + self._uart.flush() + else: + sleep_time_us = ( + self._t1char * len(modbus_adu) - # total frame time in us + time.ticks_diff(send_finish_time, send_start_time) + + 100 # only required at baudrates above 57600, but hey 100us + ) - # wait for the maximum time between two frames - time.sleep_us(self._t35chars) + return self._post_send(sleep_time_us) - return response + def _post_send(self, sleep_time_us: float) -> None: + """ + Sleeps after sending a request, along with other post-send actions. + """ + + time.sleep_us(sleep_time_us) + if self._ctrlPin: + self._ctrlPin.off() + + +class RTUServer(CommonRTUFunctions): + """Common Functions for Modbus RTU servers""" + + def _parse_request(self, + req: bytearray, + unit_addr_list: Optional[List[int]]) \ + -> Optional[bytearray]: + """ + Parses a request and, if valid, returns the request body. + + :param req: The request to parse + :type req: bytearray + :param unit_addr_list: The unit address list + :type unit_addr_list: Optional[list] + + :returns: The request body (i.e. excluding CRC) if it is valid, + or None otherwise. + :rtype bytearray, optional + """ + + if len(req) < 8: + return None + + if req[0] not in unit_addr_list: + return None + + req_crc = req[-Const.CRC_LENGTH:] + req_no_crc = req[:-Const.CRC_LENGTH] + expected_crc = self._calculate_crc16(req_no_crc) + + if (req_crc[0] != expected_crc[0]) or (req_crc[1] != expected_crc[1]): + return None + return req_no_crc def _uart_read_frame(self, timeout: Optional[int] = None) -> bytearray: """ @@ -194,10 +281,9 @@ def _uart_read_frame(self, timeout: Optional[int] = None) -> bytearray: """ received_bytes = bytearray() - # set timeout to at least twice the time between two frames in case the - # timeout was set to zero or None + # set default timeout to at twice the inter-frame delay if timeout == 0 or timeout is None: - timeout = 2 * self._t35chars # in milliseconds + timeout = 2 * self._inter_frame_delay # in microseconds start_us = time.ticks_us() @@ -210,13 +296,13 @@ def _uart_read_frame(self, timeout: Optional[int] = None) -> bytearray: # do not stop reading and appending the result to the buffer # until the time between two frames elapsed - while time.ticks_diff(time.ticks_us(), last_byte_ts) <= self._t35chars: + while time.ticks_diff(time.ticks_us(), last_byte_ts) <= self._inter_frame_delay: # WiPy only # r = self._uart.readall() r = self._uart.read() # if something has been read after the first iteration of - # this inner while loop (during self._t35chars time) + # this inner while loop (within self._inter_frame_delay) if r is not None: # append the new read stuff to the buffer received_bytes.extend(r) @@ -231,63 +317,148 @@ def _uart_read_frame(self, timeout: Optional[int] = None) -> bytearray: # return the result in case the overall timeout has been reached return received_bytes - def _send(self, modbus_pdu: bytes, slave_addr: int) -> None: + def get_request(self, + unit_addr_list: Optional[List[int]] = None, + timeout: Optional[int] = None) -> Optional[Request]: """ - Send Modbus frame via UART + Check for request within the specified timeout - If a flow control pin has been setup, it will be controller accordingly + :param unit_addr_list: The unit address list + :type unit_addr_list: Optional[list] + :param timeout: The timeout + :type timeout: Optional[int] - :param modbus_pdu: The modbus Protocol Data Unit - :type modbus_pdu: bytes - :param slave_addr: The slave address - :type slave_addr: int + :returns: A request object or None. + :rtype: Union[Request, None] """ - serial_pdu = bytearray() - serial_pdu.append(slave_addr) - serial_pdu.extend(modbus_pdu) + req = self._uart_read_frame(timeout=timeout) + req_no_crc = self._parse_request(req, unit_addr_list) + try: + if req_no_crc is not None: + return Request(interface=self, data=req_no_crc) + except ModbusException as e: + self.send_exception_response( + slave_addr=req[0], + function_code=e.function_code, + exception_code=e.exception_code) + return None - crc = self._calculate_crc16(serial_pdu) - serial_pdu.extend(crc) + def send_response(self, + slave_addr: int, + function_code: int, + request_register_addr: int, + request_register_qty: int, + request_data: list, + values: Optional[list] = None, + signed: bool = True) -> Optional[Awaitable]: + """ + Send a response to a client. - if self._ctrlPin: - self._ctrlPin(1) - time.sleep_us(1000) # wait until the control pin really changed - send_start_time = time.ticks_us() + :param slave_addr: The slave address + :type slave_addr: int + :param function_code: The function code + :type function_code: int + :param request_register_addr: The request register address + :type request_register_addr: int + :param request_register_qty: The request register qty + :type request_register_qty: int + :param request_data: The request data + :type request_data: list + :param values: The values + :type values: Optional[list] + :param signed: Indicates if signed + :type signed: bool - self._uart.write(serial_pdu) + :returns: Request response - None for a synchronous server, or + an awaitable for an asynchronous server due to AsyncRequest + :rtype Awaitable, optional + """ + modbus_pdu = functions.response( + function_code=function_code, + request_register_addr=request_register_addr, + request_register_qty=request_register_qty, + request_data=request_data, + value_list=values, + signed=signed + ) + return self._send(modbus_pdu=modbus_pdu, slave_addr=slave_addr) - if self._ctrlPin: - total_frame_time_us = self._t1char * len(serial_pdu) - while time.ticks_us() <= send_start_time + total_frame_time_us: - machine.idle() - self._ctrlPin(0) + def send_exception_response(self, + slave_addr: int, + function_code: int, + exception_code: int) -> Optional[Awaitable]: + """ + Send an exception response to a client. - def _send_receive(self, - modbus_pdu: bytes, - slave_addr: int, - count: bool) -> bytes: + :param slave_addr: The slave address + :type slave_addr: int + :param function_code: The function code + :type function_code: int + :param exception_code: The exception code + :type exception_code: int + + :returns: Request response - None for a synchronous server, or + an awaitable for an asynchronous server due to AsyncRequest + :rtype Awaitable, optional """ - Send a modbus message and receive the reponse. + modbus_pdu = functions.exception_response( + function_code=function_code, + exception_code=exception_code) + return self._send(modbus_pdu=modbus_pdu, slave_addr=slave_addr) - :param modbus_pdu: The modbus Protocol Data Unit - :type modbus_pdu: bytes - :param slave_addr: The slave address - :type slave_addr: int - :param count: The count - :type count: bool - :returns: Validated response content - :rtype: bytes +class Serial(CommonRTUFunctions, CommonModbusFunctions): + """Modbus Serial/RTU client""" + + def _exit_read(self, response: bytearray) -> bool: """ - # flush the Rx FIFO - self._uart.read() + Return on modbus read error - self._send(modbus_pdu=modbus_pdu, slave_addr=slave_addr) + :param response: The response + :type response: bytearray - return self._validate_resp_hdr(response=self._uart_read(), - slave_addr=slave_addr, - function_code=modbus_pdu[0], - count=count) + :returns: State of basic read response evaluation, + True if entire response has been read + :rtype: bool + """ + response_len = len(response) + if response_len >= 2 and response[1] >= Const.ERROR_BIAS: + if response_len < Const.ERROR_RESP_LEN: + return False + elif response_len >= 3 and (Const.READ_COILS <= response[1] <= Const.READ_INPUT_REGISTER): + expected_len = Const.RESPONSE_HDR_LENGTH + 1 + response[2] + Const.CRC_LENGTH + if response_len < expected_len: + return False + elif response_len < Const.FIXED_RESP_LEN: + return False + + return True + + def _uart_read(self) -> bytearray: + """ + Read incoming slave response from UART + + :returns: Read content + :rtype: bytearray + """ + response = bytearray() + # number of repetitions = // + repetitions = self._uart_read_timeout // self._inter_frame_delay + + for _ in range(1, repetitions): + if self._uart.any(): + # WiPy only + # response.extend(self._uart.readall()) + response.extend(self._uart.read()) + + # variable length function codes may require multiple reads + if self._exit_read(response): + break + + # wait for the maximum time between two frames + time.sleep_us(self._inter_frame_delay) + + return response def _validate_resp_hdr(self, response: bytearray, @@ -333,97 +504,29 @@ def _validate_resp_hdr(self, return response[hdr_length:len(response) - Const.CRC_LENGTH] - def send_response(self, + def _send_receive(self, + modbus_pdu: bytes, slave_addr: int, - function_code: int, - request_register_addr: int, - request_register_qty: int, - request_data: list, - values: Optional[list] = None, - signed: bool = True) -> None: + count: bool) -> bytes: """ - Send a response to a client. + Send a modbus message and receive the reponse. - :param slave_addr: The slave address - :type slave_addr: int - :param function_code: The function code - :type function_code: int - :param request_register_addr: The request register address - :type request_register_addr: int - :param request_register_qty: The request register qty - :type request_register_qty: int - :param request_data: The request data - :type request_data: list - :param values: The values - :type values: Optional[list] - :param signed: Indicates if signed - :type signed: bool - """ - modbus_pdu = functions.response( - function_code=function_code, - request_register_addr=request_register_addr, - request_register_qty=request_register_qty, - request_data=request_data, - value_list=values, - signed=signed - ) - self._send(modbus_pdu=modbus_pdu, slave_addr=slave_addr) + :param modbus_pdu: The modbus Protocol Data Unit + :type modbus_pdu: bytes + :param slave_addr: The slave address + :type slave_addr: int + :param count: The count + :type count: bool - def send_exception_response(self, - slave_addr: int, - function_code: int, - exception_code: int) -> None: + :returns: Validated response content + :rtype: bytes """ - Send an exception response to a client. + # flush the Rx FIFO buffer + self._uart.read() - :param slave_addr: The slave address - :type slave_addr: int - :param function_code: The function code - :type function_code: int - :param exception_code: The exception code - :type exception_code: int - """ - modbus_pdu = functions.exception_response( - function_code=function_code, - exception_code=exception_code) self._send(modbus_pdu=modbus_pdu, slave_addr=slave_addr) - def get_request(self, - unit_addr_list: List[int], - timeout: Optional[int] = None) -> Union[Request, None]: - """ - Check for request within the specified timeout - - :param unit_addr_list: The unit address list - :type unit_addr_list: Optional[list] - :param timeout: The timeout - :type timeout: Optional[int] - - :returns: A request object or None. - :rtype: Union[Request, None] - """ - req = self._uart_read_frame(timeout=timeout) - - if len(req) < 8: - return None - - if req[0] not in unit_addr_list: - return None - - req_crc = req[-Const.CRC_LENGTH:] - req_no_crc = req[:-Const.CRC_LENGTH] - expected_crc = self._calculate_crc16(req_no_crc) - - if (req_crc[0] != expected_crc[0]) or (req_crc[1] != expected_crc[1]): - return None - - try: - request = Request(interface=self, data=req_no_crc) - except ModbusException as e: - self.send_exception_response( - slave_addr=req[0], - function_code=e.function_code, - exception_code=e.exception_code) - return None - - return request + return self._validate_resp_hdr(response=self._uart_read(), + slave_addr=slave_addr, + function_code=modbus_pdu[0], + count=count) diff --git a/umodbus/tcp.py b/umodbus/tcp.py index 00239b3..60c65b2 100644 --- a/umodbus/tcp.py +++ b/umodbus/tcp.py @@ -10,8 +10,8 @@ # system packages # import random -import struct import socket +import struct import time # custom packages @@ -22,16 +22,18 @@ from .modbus import Modbus # typing not natively supported on MicroPython -from .typing import Optional, Tuple, Union +from .typing import Optional, Tuple, List, Union, Callable +# in case inet_ntop not natively supported on Micropython +from .compat_utils import inet_ntop class ModbusTCP(Modbus): """Modbus TCP client class""" - def __init__(self): + def __init__(self, addr_list: Optional[List[int]] = None): super().__init__( - # set itf to TCPServer object, addr_list to None + # set itf to TCPServer object TCPServer(), - None + addr_list ) def bind(self, @@ -63,29 +65,21 @@ def get_bound_status(self) -> bool: return False -class TCP(CommonModbusFunctions): - """ - TCP class handling socket connections and parsing the Modbus data +class CommonTCPFunctions(object): + """Common Functions for Modbus TCP Servers""" - :param slave_ip: IP of this device listening for requests - :type slave_ip: str - :param slave_port: Port of this device - :type slave_port: int - :param timeout: Socket timeout in seconds - :type timeout: float - """ def __init__(self, slave_ip: str, slave_port: int = 502, timeout: float = 5.0): - self._sock = socket.socket() + self._slave_ip, self._slave_port = slave_ip, slave_port self.trans_id_ctr = 0 + self.timeout = timeout + self.is_connected = False - # print(socket.getaddrinfo(slave_ip, slave_port)) - # [(2, 1, 0, '192.168.178.47', ('192.168.178.47', 502))] - self._sock.connect(socket.getaddrinfo(slave_ip, slave_port)[0][-1]) - - self._sock.settimeout(timeout) + @property + def connected(self) -> bool: + return self.is_connected def _create_mbap_hdr(self, slave_addr: int, @@ -158,6 +152,39 @@ def _validate_resp_hdr(self, return response[hdr_length:] + +class TCP(CommonTCPFunctions, CommonModbusFunctions): + """ + TCP class handling socket connections and parsing the Modbus data + + :param slave_ip: IP of this device listening for requests + :type slave_ip: str + :param slave_port: Port of this device + :type slave_port: int + :param timeout: Socket timeout in seconds + :type timeout: float + """ + def __init__(self, + slave_ip: str, + slave_port: int = 502, + timeout: float = 5.0): + super().__init__(slave_ip=slave_ip, + slave_port=slave_port, + timeout=timeout) + + self._sock = socket.socket() + self.connect() + + def connect(self) -> None: + """Binds the IP and port for incoming requests.""" + # print(socket.getaddrinfo(slave_ip, slave_port)) + # [(2, 1, 0, '192.168.178.47', ('192.168.178.47', 502))] + self._sock.settimeout(self.timeout) + + self._sock.connect(socket.getaddrinfo(self._slave_ip, + self._slave_port)[0][-1]) + self.is_connected = True + def _send_receive(self, slave_addr: int, modbus_pdu: bytes, @@ -192,9 +219,32 @@ def _send_receive(self, class TCPServer(object): """Modbus TCP host class""" def __init__(self): - self._sock = None - self._client_sock = None + self._sock: socket.socket = None + self._client_sock: socket.socket = None self._is_bound = False + self._client_address: Tuple[str, int] = None + self._on_connect_cb: Optional[Callable[[str], None]] = None + self._on_disconnect_cb: Optional[Callable[[str], None]] = None + + def set_on_connect_cb(self, cb: Callable[[str], None]) -> None: + """ + Sets the callback to be called when a client has connected. + + :param callback: Callback to be called on client connect. + :type callback: Callable that takes an (addr) + """ + + self._on_connect_cb = cb + + def set_on_disconnect_cb(self, cb: Callable[[str], None]) -> None: + """ + Sets the callback to be called when a client has disconnected. + + :param callback: Callback to be called on client disconnect. + :type callback: Callable that takes an (addr) + """ + + self._on_disconnect_cb = cb @property def is_bound(self) -> bool: @@ -229,8 +279,7 @@ def bind(self, :param max_connections: Number of maximum connections :type max_connections: int """ - if self._client_sock: - self._client_sock.close() + self._close_client_sockets() if self._sock: self._sock.close() @@ -311,9 +360,25 @@ def send_exception_response(self, exception_code) self._send(modbus_pdu, slave_addr) + def _close_client_sockets(self) -> None: + """ + Closes the old client sockets (if any) and + calls the on_disconnect callback (if applicable). + """ + + if self._client_sock is None: + return + + if self._on_disconnect_cb is not None: + self._on_disconnect_cb(self._client_address) + self._client_address = None + + self._client_sock.close() + self._client_sock = None + def _accept_request(self, accept_timeout: float, - unit_addr_list: list) -> Union[Request, None]: + unit_addr_list: Optional[List[int]]) -> Optional[Request]: """ Accept, read and decode a socket based request @@ -327,14 +392,17 @@ def _accept_request(self, try: new_client_sock, client_address = self._sock.accept() + client_address = inet_ntop(socket.AF_INET, + client_address) + self._client_address = client_address + if self._on_connect_cb is not None: + self._on_connect_cb(client_address) except OSError as e: if e.args[0] != 11: # 11 = timeout expired raise e if new_client_sock is not None: - if self._client_sock is not None: - self._client_sock.close() - + self._close_client_sockets() self._client_sock = new_client_sock # recv() timeout, setting to 0 might lead to the following error @@ -356,16 +424,14 @@ def _accept_request(self, # MicroPython raises an OSError instead of socket.timeout # print("Socket OSError aka TimeoutError: {}".format(e)) return None - except Exception: + except Exception as e: # print("Modbus request error:", e) - self._client_sock.close() - self._client_sock = None + self._close_client_sockets() return None if (req_pid != 0): # print("Modbus request error: PID not 0") - self._client_sock.close() - self._client_sock = None + self._close_client_sockets() return None if ((unit_addr_list is not None) and (req_uid_and_pdu[0] not in unit_addr_list)): @@ -380,7 +446,7 @@ def _accept_request(self, return None def get_request(self, - unit_addr_list: Optional[list] = None, + unit_addr_list: Optional[List[int]] = None, timeout: int = None) -> Union[Request, None]: """ Check for request within the specified timeout diff --git a/umodbus/typing.py b/umodbus/typing.py index ba64efa..f202c4b 100644 --- a/umodbus/typing.py +++ b/umodbus/typing.py @@ -52,10 +52,6 @@ class Awaitable: pass -class Coroutine: - pass - - class AsyncIterable: pass @@ -93,6 +89,9 @@ class Collection: Callable = _subscriptable +Coroutine = _subscriptable + + class AbstractSet: pass @@ -127,6 +126,9 @@ class ByteString: List = _subscriptable +Literal = _subscriptable + + class Deque: pass @@ -208,5 +210,5 @@ def _overload_dummy(*args, **kwds): ) -def overload(): +def overload(fun): return _overload_dummy diff --git a/umodbus/version.py b/umodbus/version.py index 3856329..5bda8e8 100644 --- a/umodbus/version.py +++ b/umodbus/version.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 # -*- coding: UTF-8 -*- -__version_info__ = ("0", "0", "0") +__version_info__ = ("3", "0", "0") __version__ = '.'.join(__version_info__)