diff --git a/.flake8 b/.flake8 index 57a3f58..33c99f3 100644 --- a/.flake8 +++ b/.flake8 @@ -20,6 +20,7 @@ ignore = D107, D400, E501, W504, D204 # Specify a list of mappings of files and the codes that should be ignored for the entirety of the file. per-file-ignores = tests/*:D101,D102,D104 + umodbus/const.py:F821 # Provide a comma-separated list of glob patterns to exclude from checks. exclude = @@ -32,7 +33,7 @@ exclude = # There's no value in checking cache directories __pycache__, # The conf file is mostly autogenerated, ignore it - docs/source/conf.py, + docs/conf.py, # This contains our built documentation build, # This contains builds that we don't want to check @@ -42,6 +43,7 @@ exclude = # example testing folder before going live thinking .idea + .bak # custom scripts, not being part of the distribution libs_external modules diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fe760a9..e07ac30 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -33,6 +33,15 @@ jobs: run: | python -m pip install --upgrade pip if [ -f requirements-deploy.txt ]; then pip install -r requirements-deploy.txt; fi + - name: Execute tests + run: | + docker build --tag micropython-test --file Dockerfile.tests . + - name: Run Client/Host TCP example + run: | + docker compose up --build --exit-code-from micropython-host + - name: Run Client/Host TCP test + run: | + docker compose -f docker-compose-tcp-test.yaml up --build --exit-code-from micropython-host - name: Build package run: | changelog2version \ diff --git a/.gitignore b/.gitignore index 513d659..47dd3b1 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,6 @@ .DS_Store .DS_Store? pymakr.conf -tests/ config/config*.py thinking/ *.bin diff --git a/.gitmodules b/.gitmodules index c918708..839d04e 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,6 @@ [submodule "modules"] path = modules - url = git@github.com:brainelectronics/python-modules.git + # 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 diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 0000000..aeea64b --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,20 @@ +# Read the Docs configuration file +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details + +# Required +version: 2 + +# Set the version of Python and other tools you might need +build: + os: ubuntu-22.04 + tools: + python: "3.9" + +# Build documentation in the docs/ directory with Sphinx +sphinx: + configuration: docs/conf.py + +# Optionally declare the Python requirements required to build your docs +python: + install: + - requirements: docs/requirements.txt diff --git a/Dockerfile.client b/Dockerfile.client new file mode 100644 index 0000000..faa03b3 --- /dev/null +++ b/Dockerfile.client @@ -0,0 +1,16 @@ +# Build image +# $ docker build -t micropython-client -f Dockerfile.client . +# +# Run image +# $ docker run -it --rm --name micropython-client micropython-client + +FROM micropython/unix:v1.18 + +# use "volumes" in docker-compose file to remove need of rebuilding +# COPY ./ /home +# COPY umodbus /root/.micropython/lib/umodbus + +RUN micropython-dev -m upip install micropython-ulogging +RUN micropython-dev -m upip install micropython-urequests + +CMD [ "micropython-dev", "-m", "examples/tcp_client_example.py" ] diff --git a/Dockerfile.host b/Dockerfile.host new file mode 100644 index 0000000..145739e --- /dev/null +++ b/Dockerfile.host @@ -0,0 +1,16 @@ +# Build image +# $ docker build -t micropython-host -f Dockerfile.host . +# +# Run image +# $ docker run -it --rm --name micropython-host micropython-host + +FROM micropython/unix:v1.18 + +# use "volumes" in docker-compose file to remove need of rebuilding +# COPY ./ /home +# COPY umodbus /root/.micropython/lib/umodbus + +RUN micropython-dev -m upip install micropython-ulogging +RUN micropython-dev -m upip install micropython-urequests + +CMD [ "micropython-dev", "-m", "examples/tcp_host_example.py" ] diff --git a/Dockerfile.test_tcp_example b/Dockerfile.test_tcp_example new file mode 100644 index 0000000..e37b139 --- /dev/null +++ b/Dockerfile.test_tcp_example @@ -0,0 +1,16 @@ +# Build image +# $ docker build -t micropython-tcp-example -f Dockerfile.test_tcp_example . +# if a command fails, it will exit with non-zero code +# +# Run image, only possible if all tests passed +# $ docker run -it --rm --name micropython-tcp-example micropython-tcp-example + +FROM micropython/unix:v1.18 + +# use "volumes" in docker-compose file to remove need of rebuilding +# 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 +RUN micropython-dev -m upip install micropython-urequests diff --git a/Dockerfile.tests b/Dockerfile.tests new file mode 100644 index 0000000..c4d37c2 --- /dev/null +++ b/Dockerfile.tests @@ -0,0 +1,20 @@ +# Build image +# $ docker build -t micropython-test -f Dockerfile.tests . +# if a unittest fails, it will exit with non-zero code +# +# Run image, only possible if all tests passed +# $ docker run -it --rm --name micropython-test micropython-test + +FROM micropython/unix:v1.18 + +COPY ./ /home +# keep examples and tests registers JSON file easily in sync +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 +RUN micropython-dev -m upip install micropython-urequests +RUN micropython-dev -c "import mpy_unittest as unittest; unittest.main('tests')" + +ENTRYPOINT ["/bin/bash"] diff --git a/README.md b/README.md index 08fa6a9..aaf335e 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,8 @@ ![MicroPython](https://img.shields.io/badge/micropython-Ok-green.svg) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![CI](https://github.com/brainelectronics/micropython-modbus/actions/workflows/release.yml/badge.svg)](https://github.com/brainelectronics/micropython-modbus/actions/workflows/release.yml) +[![Test Python package](https://github.com/brainelectronics/micropython-modbus/actions/workflows/test.yml/badge.svg)](https://github.com/brainelectronics/micropython-modbus/actions/workflows/test.yml) +[![Documentation Status](https://readthedocs.org/projects/micropython-modbus/badge/?version=latest)](https://micropython-modbus.readthedocs.io/en/latest/?badge=latest) MicroPython ModBus TCP and RTU library supporting client and host mode @@ -16,15 +18,18 @@ Forked from [Exo Sense Py][ref-sferalabs-exo-sense], based on [PyCom Modbus][ref-pycom-modbus] and extended with other functionalities to become a powerfull MicroPython library +The latest documentation is available at +[MicroPython Modbus ReadTheDocs][ref-rtd-micropython-modbus] + <!-- MarkdownTOC --> - [Quickstart](#quickstart) - [Install package on board with pip](#install-package-on-board-with-pip) + - [Request coil status](#request-coil-status) + - [TCP](#tcp) + - [RTU](#rtu) - [Install additional MicroPython packages](#install-additional-micropython-packages) - [Usage](#usage) - - [Master implementation](#master-implementation) - - [Slave implementation](#slave-implementation) - - [Register configuration](#register-configuration) - [Supported Modbus functions](#supported-modbus-functions) - [Credits](#credits) @@ -36,7 +41,8 @@ This is a quickstart to install the `micropython-modbus` library on a MicroPython board. A more detailed guide of the development environment can be found in -[SETUP](SETUP.md) +[SETUP](SETUP.md). Further details about the usage can be found in +[USAGE](USAGE.md) ```bash python3 -m venv .venv @@ -51,15 +57,8 @@ pip install 'rshell>=0.0.30,<1.0.0' rshell -p /dev/tty.SLAB_USBtoUART --editor nano ``` -Inside the rshell - -```bash -cp main.py /pyboard -cp boot.py /pyboard -repl -``` - -Inside the REPL +Inside the [rshell][ref-remote-upy-shell] open a REPL and execute these +commands inside the REPL ```python import machine @@ -76,135 +75,83 @@ print('Installation completed') machine.soft_reset() ``` -### Install additional MicroPython packages - -To use this package with the provided [`boot.py`](boot.py) and -[`main.py`](main.py) file, additional modules are required, which are not part -of this repo/package. To install these modules on the device, connect to a -network and install them via `upip` as follows - -```python -import upip -upip.install('micropython-brainelectronics-helpers') -``` +### Request coil status -or check the README of the -[brainelectronics MicroPython modules][ref-github-be-mircopython-modules] +After a successful installation of the package and reboot of the system as +described in the [installation section](#install-package-on-board-with-pip) +the following commands can be used to request a coil state of a target/client +device. Further usage examples can be found in the +[examples folder][ref-examples-folder] and in the +[Micropython section of USAGE](USAGE.md#micropython) -## Usage +#### TCP -See also [USAGE](USAGE.md) +```python +from ummodbus.tcp import ModbusTCPMaster -Start a REPL (may perform a soft reboot), wait for network connection and -start performing Modbus requests to the device. +tcp_device = ModbusTCPMaster( + slave_ip='172.24.0.2', # IP address of the target/client/slave device + slave_port=502, # TCP port of the target/client/slave device + # timeout=5.0 # optional, timeout in seconds, default 5.0 +) -For further details about a TCP-RTU bridge implementation check the header -comment of [`main.py`](main.py). +# address of the target/client/slave device on the bus +slave_addr = 10 +coil_address = 123 +coil_qty = 1 -### Master implementation +coil_status = host.read_coils( + slave_addr=slave_addr, + starting_addr=coil_address, + coil_qty=coil_qty) +print('Status of coil {}: {}'.format(coil_status, coil_address)) +``` -Act as host, get Modbus data via RTU or TCP from a client device +#### RTU ```python -# import modbus host classes -from umodbus.tcp import TCP as ModbusTCPMaster -from umodbus.serial import Serial as ModbusRTUMaster - -# RTU Master setup -# act as host, get Modbus data via RTU from a client device -# ModbusRTUMaster can make serial requests to a client device to get/set data -rtu_pins = (25, 26) # (TX, RX) -slave_addr = 10 # bus address of client -host = ModbusRTUMaster( - baudrate=9600, # optional, default 9600 - data_bits=8, # optional, default 8 - stop_bits=1, # optional, default 1 - parity=None, # optional, default None - pins=rtu_pins) - -# 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 -host = ModbusTCPMaster( - slave_ip=192.168.178.34, - slave_port=180, - timeout=5) # optional, default 5 - -# READ COILS +from umodbus.serial import ModbusRTU + +host = ModbusRTU( + addr=1, # address of this Master/Host on bus + # baudrate=9600, # optional, default 9600 + # data_bits=8, # optional, default 8 + # stop_bits=1, # optional, default 1 + # parity=None, # optional, default None + pins=(25, 26) # (TX, RX) +) + +# address of the target/client/slave device on the bus +slave_addr = 10 coil_address = 123 +coil_qty = 1 + coil_status = host.read_coils( slave_addr=slave_addr, starting_addr=coil_address, - coil_qty=1) + coil_qty=coil_qty) print('Status of coil {}: {}'.format(coil_status, coil_address)) - -# 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)) - -# READ HREGS -hreg_address = 93 -register_value = host.read_holding_registers( - slave_addr=slave_addr, - starting_addr=hreg_address, - register_qty=1, - signed=False) -print('Status of hreg {}: {}'.format(hreg_address, register_value)) - -# 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)) - -# READ ISTS -ist_address = 67 -input_status = host.read_discrete_inputs( - slave_addr=slave_addr, - starting_addr=ist_address, - input_qty=1) -print('Status of ist {}: {}'.format(ist_address, input_status)) - -# READ IREGS -ireg_address = 10 -register_value = host.read_input_registers( - slave_addr=slave_addr, - starting_addr=ireg_address, - register_qty=2, - signed=False) -print('Status of ireg {}: {}'.format(ireg_address, register_value)) ``` -### Slave implementation - -Act as client, provide Modbus data via RTU or TCP to a host device. - -See [Modbus TCP Client example](examples/tcp_client_example.py) and -[Modbus RTU Client example](examples/rtu_client_example.py) +### Install additional MicroPython packages -Both examples are using [example register definitions](examples/example.json) +To use this package with the provided [`boot.py`][ref-package-boot-file] and +[`main.py`][ref-package-boot-file] file, additional modules are required, +which are not part of this repo/package. To install these modules on the +device, connect to a network and install them via `upip` as follows -Use the provided example scripts [read RTU](examples/read_registers_rtu.sh) or -[read TCP](examples/read_registers_tcp.sh) to read the data from the devices. -This requires the [modules submodule][ref-github-be-python-modules] to be -cloned as well and the required packages being installed as described in the -modules README file. For further details read the [SETUP](SETUP.md) guide. +```python +import upip +upip.install('micropython-brainelectronics-helpers') +``` -### Register configuration +Check also the README of the +[brainelectronics MicroPython modules][ref-github-be-mircopython-modules] +and the [SETUP guide](SETUP.md) -The available registers are defined by a JSON file, placed inside the -`/pyboard/registers` folder on the board and selected in [`main.py`](main.py). +## Usage -As an [example the registers](registers/modbusRegisters-MyEVSE.json) of a -[brainelectronics MyEVSE][ref-myevse-be], [MyEVSE on Tindie][ref-myevse-tindie] -board is provided with this repo. +See [USAGE](USAGE.md) ## Supported Modbus functions @@ -228,13 +175,16 @@ of this library. * **sfera-labs** - *Initial work* - [giampiero7][ref-sferalabs-exo-sense] * **pycom** - *Initial Modbus work* - [pycom-modbus][ref-pycom-modbus] +* **pfalcon** - *Initial MicroPython unittest module* - [micropython-unittest][ref-pfalcon-unittest]: <!-- Links --> [ref-sferalabs-exo-sense]: https://github.com/sfera-labs/exo-sense-py-modbus [ref-pycom-modbus]: https://github.com/pycom/pycom-modbus +[ref-rtd-micropython-modbus]: https://micropython-modbus.readthedocs.io/en/latest/ [ref-remote-upy-shell]: https://github.com/dhylands/rshell +[ref-examples-folder]: https://github.com/brainelectronics/micropython-modbus/tree/develop/examples +[ref-package-boot-file]: https://github.com/brainelectronics/micropython-modbus/blob/c45d6cc334b4adf0e0ffd9152c8f08724e1902d9/boot.py +[ref-package-main-file]: https://github.com/brainelectronics/micropython-modbus/blob/c45d6cc334b4adf0e0ffd9152c8f08724e1902d9/main.py [ref-github-be-mircopython-modules]: https://github.com/brainelectronics/micropython-modules -[ref-github-be-python-modules]: https://github.com/brainelectronics/python-modules -[ref-myevse-be]: https://brainelectronics.de/ -[ref-myevse-tindie]: https://www.tindie.com/stores/brainelectronics/ [ref-giampiero7]: https://github.com/giampiero7 +[ref-pfalcon-unittest]: https://github.com/pfalcon/pycopy-lib/blob/56ebf2110f3caa63a3785d439ce49b11e13c75c0/unittest/unittest.py diff --git a/USAGE.md b/USAGE.md deleted file mode 100644 index c497f94..0000000 --- a/USAGE.md +++ /dev/null @@ -1,198 +0,0 @@ -# Usage - -Using and testing this `micropython-modbus` library - ---------------- - -<!-- MarkdownTOC --> - -- [Development environment](#development-environment) - - [TCP](#tcp) - - [Read data](#read-data) - - [Write data](#write-data) -- [MicroPython](#micropython) - - [TCP](#tcp-1) - - [Client](#client) - - [Host](#host) - - [RTU](#rtu) - -<!-- /MarkdownTOC --> - -The onwards described steps assume a successful setup as described in -[SETUP.md](SETUP.md) - -## Development environment - -This section describes the necessary steps on the computer to get ready to -test and run the examples. - -```bash -# Linux/Mac -source .venv/bin/activate -``` - -On a Windows based system activate the virtual environment like this - -``` -.venv\Scripts\activate.bat -``` - -The onwards mentioned commands shall be performed inside the previously -activated virtual environment. - -### TCP - -Read and write the Modbus register data from a MicroPython device with the -[brainelectronics ModbusWrapper][ref-github-be-modbus-wrapper] provided with -the [modules submodule](modules) - -#### Read data - -```bash -python modules/read_device_info_registers.py \ ---file=examples/example.json \ ---connection=tcp \ ---address=192.168.178.69 \ ---port=502 \ ---print \ ---pretty \ ---debug \ ---verbose=3 -``` - -Or use the even more convenient wrapper script for the wrapper. - -```bash -cd examples -sh read_registers_tcp.sh 192.168.178.69 example.json 502 -``` - -#### Write data - -```bash -python modules/write_device_info_registers.py \ ---file=examples/set-example.json \ ---connection=tcp \ ---address=192.168.178.69 \ ---port=502 \ ---print \ ---pretty \ ---debug \ ---verbose=3 -``` - -Or use the even more convenient wrapper script for the wrapper. - -```bash -cd examples -sh write_registers_tcp.sh 192.168.178.69 set-example.json 502 -``` - -## MicroPython - -This section describes the necessary steps on the MicroPython device to get -ready to test and run the examples. - -```bash -# Linux/Mac -source .venv/bin/activate - -rshell -p /dev/tty.SLAB_USBtoUART --editor nano -``` - -On a Windows based system activate the virtual environment and enter the -remote shell like this - -``` -.venv\Scripts\activate.bat - -rshell -p COM9 -``` - -The onwards mentioned commands shall be performed inside the previously entered -remote shell. - -### TCP - -Get two network capable boards up and running, collecting and setting data on -each other. - -Adjust the WiFi network name (SSID) and password to be able to connect to your -personal network or remove that section if a wired network connection is used. - -#### Client - -The client, former known as slave, provides some dummy registers which can be -read and updated by another device. - -```bash -cp examples/tcp_client_example.py /pyboard/main.py -cp examples/boot.py /pyboard/boot.py -repl -``` - -Inside the REPL press CTRL+D to perform a soft reboot. The device will serve -several registers now. The log output might look similar to this - -``` -MPY: soft reboot -System booted successfully! -Waiting for WiFi connection... -Waiting for WiFi connection... -Connected to WiFi. -('192.168.178.69', '255.255.255.0', '192.168.178.1', '192.168.178.1') -Setting up registers ... -Register setup done -Serving as TCP client on 192.168.178.69:502 -``` - -#### Host - -The host, former known as master, requests and updates some dummy registers of -another device. - -```bash -cp examples/tcp_host_example.py /pyboard/main.py -cp examples/boot.py /pyboard/boot.py -repl -``` - -Inside the REPL press CTRL+D to perform a soft reboot. The device will request -and update registers of the Client after a few seconds. The log output might -look similar to this - -``` -MPY: soft reboot -System booted successfully! -Waiting for WiFi connection... -Waiting for WiFi connection... -Connected to WiFi. -('192.168.178.42', '255.255.255.0', '192.168.178.1', '192.168.178.1') -Requesting and updating data on TCP client at 192.168.178.69:502 - -Status of COIL 123: [True, False, False, False, False, False, False, False] -Result of setting COIL 123: True -Status of COIL 123: [False, False, False, False, False, False, False, False] - -Status of HREG 93: (44,) -Result of setting HREG 93: True -Status of HREG 93: (44,) - -Status of IST 67: [False, False, False, False, False, False, False, False] -Status of IREG 10: (60001,) - -Finished requesting/setting data on client -MicroPython v1.18 on 2022-01-17; ESP32 module (spiram) with ESP32 -Type "help()" for more information. ->>> -``` - -<!-- -### RTU - -Get two UART/RS485 capable boards up and running, collecting and setting data -on each other. ---> - -<!-- Links --> -[ref-github-be-modbus-wrapper]: https://github.com/brainelectronics/be-modbus-wrapper diff --git a/changelog.md b/changelog.md index 4683ee5..f83909e 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 <!-- ## [Unreleased] --> ## Released +## [2.0.0] - 2022-12-03 +### Added +- Perform MicroPython based unittests on every `Test` workflow run +- Add usage description of docker based MicroPython unittest framework in [USAGE](USAGE.md) +- Add [docker compose file](docker-compose.yaml) based in MicroPython 1.18 image +- Add [TCP client Dockerfile](Dockerfile.client), [TCP host Dockerfile](Dockerfile.host), [unittest Dockerfile](Dockerfile.tests) and [TCP unittest specific Dockerfile](Dockerfile.test_tcp_example). All based on MicroPython 1.18 image +- Add initial test, testing the unittest itself +- Add [unittest](mpy_unittest.py) implementation based on pfalcon's [micropython-unittest](https://github.com/pfalcon/pycopy-lib/blob/56ebf2110f3caa63a3785d439ce49b11e13c75c0/unittest/unittest.py) +- Docstrings available for all functions of [functions.py](umodbus/functions.py), see #27 +- Typing hints available for all functions of [functions.py](umodbus/functions.py), [serial.py](umodbus/serial.py) and [tcp.py](umodbus/tcp.py), see #27 +- Unittest for [functions.py](umodbus/functions.py), see #16 +- Unittest for [const.py](umodbus/const.py), see #16 +- [.readthedocs.yaml](.readthedocs.yaml) for Read The Docs, contributes to #26 + +### Changed +- Use default values for all registers defined in the [example JSON](registers/example.json) +- [TCP host example](examples/tcp_host_example.py) and [TCP client example](examples/tcp_client_example.py) define a static IP address and skip further WiFi setup steps in case a Docker usage is detected by a failing import of the `network` module, contributes to #16 +- Define all Modbus function codes as `const()` to avoid external modifications, contributes to #18 +- Remove dependency to `Serial` and `requests` from `umodbus.modbus`, see #18 +- `ModbusRTU` class is part of [serial.py](umodbus/serial.py), see #18 +- `ModbusTCP` class is part of [tcp.py](umodbus/tcp.py), see #18 +- `ModbusRTU` and `ModbusTCP` classes and related functions removed from [modbus.py](umodbus/modbus.py), see #18 +- Imports changed from: + - `from umodbus.modbus import ModbusRTU` to `from umodbus.serial import ModbusRTU` + - `from umodbus.modbus import ModbusTCP` to `from umodbus.tcp import ModbusTCP` +- `read_coils` and `read_discrete_inputs` return a list with the same length as the requested quantity instead of always 8, see #12 and #25 +- Common functions `bytes_to_bool` and `to_short` moved to [functions.py](umodbus/functions.py) +- Use HTTPS URL instead of SSH for submodule +- Cleanup of root [README](README.md), content moved to [SETUP](docs/SETUP.md) and [USAGE](docs/USAGE.md), contributes to #30 +- Moved [SETUP](docs/SETUP.md) and [USAGE](docs/USAGE.md) into [docs](docs) folder, see #26 contributes to #30 +- Use `False` or `0` as default values for registers without a specific initial value in [modbus.py](umodbus/modbus.py) + +### Fixed +- `read_coils` returns list with amount of requested coils, see #12 +- `read_holding_registers` returns list with amount of requested registers, see #25 + ## [1.2.0] - 2022-11-13 ### Added - [TCP host example script](examples/tcp_host_example.py) @@ -28,7 +64,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Add more info to [TCP client example script](examples/tcp_client_example.py) -- Update [modules submodule](modules) to `1.3.0` +- Update [modules submodule](https://github.com/brainelectronics/python-modules/tree/43bad716b7db27db07c94c2d279cee57d0c8c753) to `1.3.0` - Line breaks are no longer used in this changelog for enumerations - Issues are referenced as `#123` instead of `[#123][ref-issue-123]` to avoid explicit references at the bottom or some other location in the file - Scope of contents permissions in release and test release workflow is now `write` to use auto release creation @@ -115,8 +151,9 @@ 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) <!-- Links --> -[Unreleased]: https://github.com/brainelectronics/micropython-modbus/compare/1.2.0...develop +[Unreleased]: https://github.com/brainelectronics/micropython-modbus/compare/2.0.0...develop +[2.0.0]: https://github.com/brainelectronics/micropython-modbus/tree/2.0.0 [1.2.0]: https://github.com/brainelectronics/micropython-modbus/tree/1.2.0 [1.1.1]: https://github.com/brainelectronics/micropython-modbus/tree/1.1.1 [1.1.0]: https://github.com/brainelectronics/micropython-modbus/tree/1.1.0 diff --git a/docker-compose-tcp-test.yaml b/docker-compose-tcp-test.yaml new file mode 100644 index 0000000..25589dd --- /dev/null +++ b/docker-compose-tcp-test.yaml @@ -0,0 +1,62 @@ +# +# build all non-image containers +# $ docker-compose -f docker-compose-tcp-test.yaml build +# can be combined into one command to also start it afterwards +# $ docker-compose -f docker-compose-tcp-test.yaml up --build +# + +version: "3.8" + +services: + micropython-client: + build: + context: . + dockerfile: Dockerfile.client + container_name: micropython-client + volumes: + - ./:/home + - ./registers:/home/registers + - ./umodbus:/root/.micropython/lib/umodbus + expose: + - "502" + ports: + - "502:502" # reach "micropython-client" at 172.24.0.2:502, see networks + networks: + my_bridge: + # fix IPv4 address to be known and in the MicroPython scripts + # https://docs.docker.com/compose/compose-file/#ipv4_address + ipv4_address: 172.24.0.2 + + micropython-host: + build: + context: . + dockerfile: Dockerfile.test_tcp_example + container_name: micropython-host + volumes: + - ./:/home + - ./umodbus:/root/.micropython/lib/umodbus + - ./mpy_unittest.py:/root/.micropython/lib/mpy_unittest.py + depends_on: + - micropython-client + command: + - /bin/bash + - -c + - | + micropython-dev -c "import mpy_unittest as unittest; unittest.main(name='tests.test_tcp_example', fromlist=['TestTcpExample'])" + networks: + my_bridge: + # fix IPv4 address to be known and in the MicroPython scripts + # https://docs.docker.com/compose/compose-file/#ipv4_address + ipv4_address: 172.24.0.3 + +networks: + my_bridge: + # use "external: true" if the network already exists + # check available networks with "docker network ls" + # external: true + driver: bridge + # https://docs.docker.com/compose/compose-file/#ipam + ipam: + config: + - subnet: 172.24.0.0/16 + gateway: 172.24.0.1 diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..549527f --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,55 @@ +# +# build all non-image containers +# $ docker-compose build +# can be combined into one command to also start it afterwards +# $ docker-compose up --build +# + +version: "3.8" + +services: + micropython-client: + build: + context: . + dockerfile: Dockerfile.client + container_name: micropython-client + volumes: + - ./:/home + - ./umodbus:/root/.micropython/lib/umodbus + expose: + - "502" + ports: + - "502:502" # reach "micropython-client" at 172.24.0.2:502, see networks + networks: + my_bridge: + # fix IPv4 address to be known and in the MicroPython scripts + # https://docs.docker.com/compose/compose-file/#ipv4_address + ipv4_address: 172.24.0.2 + + micropython-host: + build: + context: . + dockerfile: Dockerfile.host + container_name: micropython-host + volumes: + - ./:/home + - ./umodbus:/root/.micropython/lib/umodbus + depends_on: + - micropython-client + networks: + my_bridge: + # fix IPv4 address to be known and in the MicroPython scripts + # https://docs.docker.com/compose/compose-file/#ipv4_address + ipv4_address: 172.24.0.3 + +networks: + my_bridge: + # use "external: true" if the network already exists + # check available networks with "docker network ls" + # external: true + driver: bridge + # https://docs.docker.com/compose/compose-file/#ipam + ipam: + config: + - subnet: 172.24.0.0/16 + gateway: 172.24.0.1 diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md new file mode 100755 index 0000000..a6e4a9a --- /dev/null +++ b/docs/CONTRIBUTING.md @@ -0,0 +1,3 @@ +# CONTRIBUTING + +Guideline to contribute to this package diff --git a/SETUP.md b/docs/SETUP.md similarity index 52% rename from SETUP.md rename to docs/SETUP.md index 3b4b068..f5ea1f2 100644 --- a/SETUP.md +++ b/docs/SETUP.md @@ -4,18 +4,6 @@ Setup the development environment and the MicroPython board --------------- -<!-- MarkdownTOC --> - -- [Development environment](#development-environment) - - [Update submodule](#update-submodule) - - [Install required tools](#install-required-tools) -- [MicroPython](#micropython) - - [Flash firmware](#flash-firmware) - - [Install package with pip](#install-package-with-pip) - - [Without network connection](#without-network-connection) - -<!-- /MarkdownTOC --> - ## Development environment This section describes the necessary steps on the computer to get ready to @@ -68,7 +56,7 @@ ready to test and run the examples. ### Flash firmware Flash the [MicroPython firmware][ref-upy-firmware-download] to the MicroPython -board with this call +board with this call in case a ESP32 is used. ```bash esptool.py --chip esp32 --port /dev/tty.SLAB_USBtoUART erase_flash @@ -86,7 +74,8 @@ station.connect('SSID', 'PASSWORD') station.isconnected() ``` -and install this lib on the MicroPython device like this +and install this lib with all its dependencies on the MicroPython device like +this ```python import upip @@ -95,8 +84,8 @@ upip.install('micropython-modbus') ### Without network connection -Copy the module to the MicroPython board and import them as shown below -using [Remote MicroPython shell][ref-remote-upy-shell] +Copy all files of the [umodbus module][ref-umodbus-module] to the MicroPython +board using [Remote MicroPython shell][ref-remote-upy-shell] Open the remote shell with the following command. Additionally use `-b 115200` in case no CP210x is used but a CH34x. @@ -114,7 +103,55 @@ mkdir /pyboard/lib/umodbus cp umodbus/* /pyboard/lib/umodbus ``` +As this package depends on [`micropython-urequests`][ref-urequests] to perform +TCP requests those files have to be copied as well to the MicroPython board. +This is of course only necessary if TCP connection are used, in case only +serial (RTU )Modbus communication is used this step can be skipped. + +### Additional MicroPython packages + +To use this package with the provided [`boot.py`][ref-package-boot-file] and +[`main.py`][ref-package-boot-file] file, additional modules are required, +which are not part of this repo/package. + +```bash +rshell -p /dev/tty.SLAB_USBtoUART --editor nano +``` + +#### Install additional package with pip + +Again connect to a network and install the additional package on the +MicroPython device with + +```python +import upip +upip.install('micropython-modbus') +``` + +#### Without network connection + +To install the additional modules on the device, download the +[brainelectronics MicroPython Helpers repo][ref-github-be-mircopython-modules] +and copy them to the device. + +Perform the following command to copy all files and folders to the device + +```bash +mkdir /pyboard/lib/be_helpers + +cp be_helpers/* /pyboard/lib/be_helpers +``` + +Additionally check the latest instructions of the +[brainelectronics MicroPython modules][ref-github-be-mircopython-modules] +README for further instructions. + <!-- Links --> [ref-github-be-python-modules]: https://github.com/brainelectronics/python-modules [ref-upy-firmware-download]: https://micropython.org/download/ [ref-remote-upy-shell]: https://github.com/dhylands/rshell +[ref-umodbus-module]: https://github.com/brainelectronics/micropython-modbus/tree/develop/umodbus +[ref-urequests]: https://micropython.org/pi/urequests/urequests-0.6.tar.gz +[ref-package-boot-file]: https://github.com/brainelectronics/micropython-modbus/blob/c45d6cc334b4adf0e0ffd9152c8f08724e1902d9/boot.py +[ref-package-main-file]: https://github.com/brainelectronics/micropython-modbus/blob/c45d6cc334b4adf0e0ffd9152c8f08724e1902d9/main.py +[ref-github-be-mircopython-modules]: https://github.com/brainelectronics/micropython-modules diff --git a/docs/UPGRADE.md b/docs/UPGRADE.md new file mode 100644 index 0000000..8f8c57c --- /dev/null +++ b/docs/UPGRADE.md @@ -0,0 +1,145 @@ +# Upgrade Guide + +Detailed upgrade guide for upgrading between breaking versions + +--------------- + +## Intro + +As this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html) +this document thereby describes the necessary steps to upgrade between two +major versions. + +## Upgrade from major version 1 to 2 + +### Overview + +This is a compressed extraction of the changelog + +> - Remove dependency to `Serial` and `requests` from `umodbus.modbus`, see [#18](https://github.com/brainelectronics/micropython-modbus/issues/18) +> - `ModbusRTU` class is part of [serial.py](umodbus/serial.py), see [#18](https://github.com/brainelectronics/micropython-modbus/issues/18) +> - `ModbusTCP` class is part of [tcp.py](umodbus/tcp.py), see [#18](https://github.com/brainelectronics/micropython-modbus/issues/18) +> - `ModbusRTU` and `ModbusTCP` classes and related functions removed from [modbus.py](umodbus/modbus.py), see [#18](https://github.com/brainelectronics/micropython-modbus/issues/18) +> - Imports changed from: +> - `from umodbus.modbus import ModbusRTU` to `from umodbus.serial import ModbusRTU` +> - `from umodbus.modbus import ModbusTCP` to `from umodbus.tcp import ModbusTCP` +> - `read_coils` and `read_discrete_inputs` return a list with the same length as the requested quantity instead of always 8, see [#12](https://github.com/brainelectronics/micropython-modbus/issues/12) and [#25](https://github.com/brainelectronics/micropython-modbus/issues/25) +> - `read_holding_registers` returns list with amount of requested registers, see [#25](https://github.com/brainelectronics/micropython-modbus/issues/25) + +### Steps to be performed + +#### Update imports + +The way of importing `ModbusRTU` and `ModbusTCP` changed. Update the imports +according to the following table. For further details check [#18](https://github.com/brainelectronics/micropython-modbus/issues/18) + +| Version 1 | Version 2 | +| --------- | --------- | +| `from umodbus.modbus import ModbusRTU` | `from umodbus.serial import ModbusRTU` | +| `from umodbus.modbus import ModbusTCP` | `from umodbus.tcp import ModbusTCP` | + +#### Return values changed + +The functions `read_coils`, `read_discrete_inputs` and `read_holding_registers` +return now a list with the same length as the requested register quantity. + +##### Coil registers + +All major version 1 releases of this package returned a list with 8 elements +on a coil register request. + +```python +# example usage only, non productive code example + +# reading one coil returned a list of 8 boolean elements +>>> host.read_coils(slave_addr=10, starting_addr=123, coil_qty=1) +[True, False, False, False, False, False, False, False] +# expectation is [True] + +# reading 3 coils returned a list of 8 boolean elements +>>> host.read_coils(slave_addr=10, starting_addr=126, coil_qty=3) +[False, False, False, False, False, False, False, False] +# expectation is [False, True, False] +``` + +With the fixes of major version 2 a list with the expected length is returned + +```python +# example usage only, non productive code example + +# reading one coil returns a list of 1 boolean element +>>> host.read_coils(slave_addr=10, starting_addr=123, coil_qty=1) +[True] + +# reading 3 coils returns a list of 3 boolean elements +>>> host.read_coils(slave_addr=10, starting_addr=126, coil_qty=3) +[False, True, False] +``` + +##### Discrete input registers + +All major version 1 releases of this package returned a list with 8 elements +on a discrete input register request. + +```python +# example usage only, non productive code example + +# reading one discrete input register returned a list of 8 boolean elements +>>> host.read_discrete_inputs(slave_addr=10, starting_addr=123, input_qty=1) +[True, False, False, False, False, False, False, False] +# expectation is [True] + +# reading 3 discrete input register returned a list of 8 boolean elements +>>> host.read_discrete_inputs(slave_addr=10, starting_addr=126, input_qty=3) +[False, False, False, False, False, False, False, False] +# expectation is [False, True, False] +``` + +With the fixes of major version 2 a list with the expected length is returned + +```python +# example usage only, non productive code example + +# reading one discrete input register returns a list of 1 boolean element +>>> host.read_discrete_inputs(slave_addr=10, starting_addr=123, input_qty=1) +[True] + +# reading 3 discrete input registers returns a list of 3 boolean elements +>>> host.read_discrete_inputs(slave_addr=10, starting_addr=126, input_qty=3) +[False, True, False] +``` + +##### Holding registers + +In all major version 1 releases of this package returned a tuple with only one +element on a holding register request. + +```python +# example usage only, non productive code example + +# reading one register only worked as expected +>>> host.read_holding_registers(slave_addr=10, starting_addr=93, register_qty=1, signed=False) +(19,) +# expectation is (19,) + +# reading multiple registers did not work as expected +# register values of register 93 + 94 should be returned +>>> host.read_holding_registers(slave_addr=10, starting_addr=93, register_qty=2, signed=False) +(19,) +# expectation is (19, 29) +``` + +With the fixes of major version 2 a list with the expected length is returned + +```python +# example usage only, non productive code example + +# reading one register only worked as expected +>>> host.read_holding_registers(slave_addr=10, starting_addr=93, register_qty=1, signed=False) +(19,) + +# reading multiple registers did not work as expected +# register values of register 93 + 94 should be returned +>>> host.read_holding_registers(slave_addr=10, starting_addr=93, register_qty=2, signed=False) +(19, 29) +``` diff --git a/docs/USAGE.md b/docs/USAGE.md new file mode 100644 index 0000000..5a08c1d --- /dev/null +++ b/docs/USAGE.md @@ -0,0 +1,506 @@ +# Usage + +Overview to use and test this `micropython-modbus` library + +--------------- + +The onwards described steps assume a successful setup as described in +[SETUP.md](SETUP.md) + +## MicroPython + +This section describes the necessary steps on the MicroPython device to get +ready to test and run the examples. + +```bash +# Linux/Mac +source .venv/bin/activate + +rshell -p /dev/tty.SLAB_USBtoUART --editor nano +``` + +On a Windows based system activate the virtual environment and enter the +remote shell like this + +``` +.venv\Scripts\activate.bat + +rshell -p COM9 +``` + +The onwards mentioned commands shall be performed inside the previously entered +remote shell. + +### Register configuration + +The available registers can be defined by a JSON file, placed inside the +`/pyboard/registers` folder or any other location on the board and loaded in +`main.py` or by defining a dictionary. + +As an [example the registers][ref-registers-MyEVSE] of a +[brainelectronics MyEVSE][ref-myevse-be], [MyEVSE on Tindie][ref-myevse-tindie] +board and others are provided with this repo. + +#### Structure + +If only an interaction with a single register is intended no dictionary needs +to be defined of course. The onwards explanations assume a bigger setup of +registers on the same target/client/slave device. + +The JSON file/dictionary shall follow the following pattern/structure + +```python +{ + "COILS": { # this key shall contain all coils + "COIL_NAME": { # custom name of a coil + "register": 42, # register address of the coil + "len": 1, # amount of registers to request aka quantity + "val": 0, # used to set a register + # the onwards mentioned keys are optional + "description": "Optional description of the coil", + "range": "[0, 1]", # may provide a range of the value, only for documentation purpose + "unit": "BOOL" # may provide a unit of the value, only for documentation purpose + } + }, + "HREGS": { # this key shall contain all holding registers + "HREG_NAME": { # custom name of a holding register + "register": 93, # register address of the holding register + "len": 1, # amount of registers to request aka quantity + "val": 19, # used to set a register + "description": "Optional description of the holding register", + "range": "[0, 65535]", + "unit": "Hz" + }, + }, + "ISTS": { # this key shall contain all static input registers + "ISTS_NAME": { # custom name of a static input register + "register": 67, # register address of the static input register + "len": 1, # amount of registers to request aka quantity + "val": 0, # used to set a register, not possible for ISTS + "description": "Optional description of the static input register", + "range": "[0, 1]", + "unit": "activated" + } + }, + "IREGS": { # this key shall contain all input registers + "IREG_NAME": { # custom name of an input register + "register": 10, # register address of the input register + "len": 1, # amount of registers to request aka quantity + "val": 60001, # used to set a register, not possible for IREGS + "description": "Optional description of the static input register", + "range": "[0, 65535]", + "unit": "millivolt" + } + } +} +``` + +If not all register types are used they can be of course removed from the +JSON file/dictionary. The smallest possible definition for reading a coil +would look like + +```python +{ + "COILS": { # this key shall contain all coils + "COIL_NAME": { # custom name of a coil + "register": 42, # register address of the coil + "len": 1 # amount of registers to request aka quantity + } + } +} +``` + +In order to act as client/slave device the same structure can be used. If no +`val` element is found in the structure the default values are + +| Type | Function Code | Default value | +| ---- | ------------- | ------------- | +| COILS | 0x01 | False (0x0) | +| ISTS | 0x02 | False (0x0) | +| HREGS | 0x03 | 0 | +| IREGS | 0x04 | 0 | + +The value of multiple registers can be set like this + +```python +{ + "HREGS": { # this key shall contain all holding registers + "HREG_NAME": { # custom name of a holding register + "register": 93, # register address of the holding register + "len": 3, # amount of registers to request aka quantity + "val": [29, 38, 0] # used to set a register + } + } +} +``` + +> :warning: As of version 2.0.0 of this package it is not possible to request +only the holding register 94, which would hold `38` in the above example. +This is a bug (non implemented feature) of the client/slave implementation. +For further details check [#35](https://github.com/brainelectronics/micropython-modbus/issues/35) + +#### Detailed key explanation + +The onwards described key explanations are valid for COIL, HREG, IST and IREG + +##### Register + +The key `register` defines the register to request or manipulate. + +According to the Modbus specification the register address has to be in the +range of 0x0000 to 0xFFFF (65535) to be valid. + +##### Length + +The key `len` defines the amout of registers to be requested starting from/with +the defined `register` address. + +According to the Modbus specification the length or amount depends on the type +of the register as summarized in the table below. + +| Type | Function Code | Valid range | +| ---- | ------------- | ----------- | +| COILS | 0x01 | 0x1 to 0x7D0 (2000) | +| ISTS | 0x02 | 0x1 to 0x7D0 (2000) | +| HREGS | 0x03 | 0x1 to 0x7D (125) | +| IREGS | 0x04 | 0x1 to 0x7D (125) | + +In order to read 5 coils starting at 124 use the following dictionary aka +config + +```python +{ + "COILS": { # this key shall contain all coils + "COIL_NAME": { # custom name of a coil + "register": 124, # register address of the coil + "len": 5 # amount of registers to request aka quantity + } + } +} +``` + +The output will be a list of 5 elements like `[True, False, False, True, True]` +depending on the actual device coil states of course. + + + +##### Value + +The key `val` defines the value of registers to be set on the target/client +device. + +According to the Modbus specification the value (range) depends on the type +of the register as summarized in the table below. + +| Type | Function Code | Valid value | Comment | +| ---- | ------------- | ----------- | ------- | +| COILS | 0x05 | 0x0000 or 0xFF00 | This package maps `0` or `False` to `0x0000` and `1` or `True` to `0xFF00` | +| HREGS | 0x06 | 0x0000 to 0xFFFF (65535) | | + +##### Optional description + +The optional key `description` can be used to provide an additional +description of the register. This might be helpful if the register name is not +meaninful enough or for any other reason of course. + +##### Optional range + +The optional key `range` can be used to indicate the possible value range of +this specific target. For example a holding register for setting a PWM output +might only support a range of 0 to 100. This might be especially helpful with +the optional [`unit`](#optional-unit) key. + +###### Optional unit + +The optional key `unit` can be used to provide further details about the unit +of the register. In case of the PWM output register example of the +[optional range key](#optional-range) the recommended value for this key could +be `percent`. + +### TCP + +Get two network capable boards up and running, collecting and setting data on +each other. + +Adjust the WiFi network name (SSID) and password to be able to connect to your +personal network or remove that section if a wired network connection is used. + +#### Client + +The client, former known as slave, provides some dummy registers which can be +read and updated by another device. + +```bash +cp examples/tcp_client_example.py /pyboard/main.py +cp examples/boot.py /pyboard/boot.py +repl +``` + +Inside the REPL press CTRL+D to perform a soft reboot. The device will serve +several registers now. The log output might look similar to this + +``` +MPY: soft reboot +System booted successfully! +Waiting for WiFi connection... +Waiting for WiFi connection... +Connected to WiFi. +('192.168.178.69', '255.255.255.0', '192.168.178.1', '192.168.178.1') +Setting up registers ... +Register setup done +Serving as TCP client on 192.168.178.69:502 +``` + +#### Host + +The host, former known as master, requests and updates some dummy registers of +another device. + +```bash +cp examples/tcp_host_example.py /pyboard/main.py +cp examples/boot.py /pyboard/boot.py +repl +``` + +Inside the REPL press CTRL+D to perform a soft reboot. The device will request +and update registers of the Client after a few seconds. The log output might +look similar to this + +``` +MPY: soft reboot +System booted successfully! +Waiting for WiFi connection... +Waiting for WiFi connection... +Connected to WiFi. +('192.168.178.42', '255.255.255.0', '192.168.178.1', '192.168.178.1') +Requesting and updating data on TCP client at 192.168.178.69:502 + +Status of COIL 123: [True, False, False, False, False, False, False, False] +Result of setting COIL 123: True +Status of COIL 123: [False, False, False, False, False, False, False, False] + +Status of HREG 93: (44,) +Result of setting HREG 93: True +Status of HREG 93: (44,) + +Status of IST 67: [False, False, False, False, False, False, False, False] +Status of IREG 10: (60001,) + +Finished requesting/setting data on client +MicroPython v1.18 on 2022-01-17; ESP32 module (spiram) with ESP32 +Type "help()" for more information. +>>> +``` + +<!-- +### RTU + +Get two UART/RS485 capable boards up and running, collecting and setting data +on each other. +--> + +### TCP-RTU bridge + +This example implementation shows how to act as bridge between an RTU (serial) +connected device and another external TCP device. + +For further details about a TCP-RTU bridge implementation check the header +comment of [`main.py`][ref-package-main-file]. + +## Classic development environment + +This section describes the necessary steps on the computer to get ready to +test and run the examples. + +```bash +# Linux/Mac +source .venv/bin/activate +``` + +On a Windows based system activate the virtual environment like this + +``` +.venv\Scripts\activate.bat +``` + +The onwards mentioned commands shall be performed inside the previously +activated virtual environment. + +### TCP + +Read and write the Modbus register data from a MicroPython device with the +[brainelectronics ModbusWrapper][ref-github-be-modbus-wrapper] provided with +the [modules submodule][ref-modules-folder] + +#### Read data + +```bash +python modules/read_device_info_registers.py \ +--file=registers/example.json \ +--connection=tcp \ +--address=192.168.178.69 \ +--port=502 \ +--print \ +--pretty \ +--debug \ +--verbose=3 +``` + +Or use the even more convenient wrapper script for the wrapper. + +```bash +cd examples +sh read_registers_tcp.sh 192.168.178.69 ../registers/example.json 502 +``` + +#### Write data + +```bash +python modules/write_device_info_registers.py \ +--file=registers/set-example.json \ +--connection=tcp \ +--address=192.168.178.69 \ +--port=502 \ +--print \ +--pretty \ +--debug \ +--verbose=3 +``` + +Or use the even more convenient wrapper script for the wrapper. + +```bash +cd examples +sh write_registers_tcp.sh 192.168.178.69 ../registers/set-example.json 502 +``` + +## Docker development environment + +### Pull container + +Checkout the available +[MicroPython containers](https://hub.docker.com/r/micropython/unix/tags) + +```bash +docker pull micropython/unix:v1.18 +``` + +### Spin up container + +#### Simple container + +Use this command for your first tests or to run some MicroPython commands in +a simple REPL + +```bash +docker run -it \ +--name micropython-1.18 \ +--network=host \ +--entrypoint bash \ +micropython/unix:v1.18 +``` + +#### Enter MicroPython REPL + +Inside the container enter the REPL by running `micropython-dev`. The console +should now look similar to this + +``` +root@debian:/home# +MicroPython v1.18 on 2022-01-17; linux version +Use Ctrl-D to exit, Ctrl-E for paste mode +>>> +``` + +#### Manually run unittests + +In order to manually execute only a specific set of tests use the following +command inside the container + +```bash +# run all unittests defined in "tests" directory and exit with status result +micropython-dev -c "import unittest; unittest.main('tests')" + +# run all tests of "TestAbsoluteTruth" defined in tests/test_absolute_truth.py +# and exit with status result +micropython-dev -c "import unittest; unittest.main(name='tests.test_absolute_truth', fromlist=['TestAbsoluteTruth'])" +``` + +#### Custom container for unittests + +```bash +docker build \ +--tag micropython-test \ +--file Dockerfile.tests . +``` + +The unittests are executed during the building process. It will exit with a +non-zero status in case of a unittest failure. + +The return value can be collected by `echo $?` (on Linux based systems), which +will be either `0` in case all tests passed, or `1` if one or multiple tests +failed. + +#### Docker compose + +The following command uses the setup defined in the `docker-compose.yaml` file +to act as two MicroPython devices communicating via TCP. The container +`micropython-host` defined by `Dockerfile.host` acts as host and sets/gets +data at/from the client as defined by `tcp_host_example.py`. On the other hand +the container `micropython-client` defined by `Dockerfile.client` acts as +client and provides data for the host as defined by `tcp_client_example.py`. +The port defined in `tcp_host_example.py` and `tcp_client_example.py` has to +be open and optionally exposed in the `docker-compose.yaml` file. + +```bash +docker compose up --build --exit-code-from micropython-host +``` + +The option `--build` can be skipped on the second run, to avoid rebuilds of +the containers. All "dynamic" data is shared via `volumes` + +##### Test for TCP example + +```bash +docker compose -f docker-compose-tcp-test.yaml up --build --exit-code-from micropython-host +``` + +## Documentation + +The documentation is automatically generated on every merge to the develop +branch and available [here][ref-rtd-micropython-modbus] + +### Install required packages + +```bash +# create and activate virtual environment +python3 -m venv .venv +source .venv/bin/activate + +# install and upgrade required packages +pip install -U -r docs/requirements.txt +``` + +### Create documentation + +Some usefull checks have been disabled in the `docs/conf.py` file. Please +check the documentation build output locally before opening a PR. + +```bash +# perform link checks +sphinx-build docs/ docs/build/linkcheck -d docs/build/docs_doctree/ --color -blinkcheck -j auto -W + +# create documentation +sphinx-build docs/ docs/build/html/ -d docs/build/docs_doctree/ --color -bhtml -j auto -W +``` + +The created documentation can be found at [`docs/build/html`](docs/build/html). + +<!-- Links --> +[ref-registers-MyEVSE]: https://github.com/brainelectronics/micropython-modbus/blob/c45d6cc334b4adf0e0ffd9152c8f08724e1902d9/registers/modbusRegisters-MyEVSE.json +[ref-myevse-be]: https://brainelectronics.de/ +[ref-myevse-tindie]: https://www.tindie.com/stores/brainelectronics/ +[ref-package-main-file]: https://github.com/brainelectronics/micropython-modbus/blob/c45d6cc334b4adf0e0ffd9152c8f08724e1902d9/main.py +[ref-github-be-modbus-wrapper]: https://github.com/brainelectronics/be-modbus-wrapper +[ref-modules-folder]: https://github.com/brainelectronics/python-modules/tree/43bad716b7db27db07c94c2d279cee57d0c8c753 +[ref-rtd-micropython-modbus]: https://micropython-modbus.readthedocs.io/en/latest/ diff --git a/docs/changelog_link.rst b/docs/changelog_link.rst new file mode 100755 index 0000000..e446ab8 --- /dev/null +++ b/docs/changelog_link.rst @@ -0,0 +1,3 @@ + +.. include:: ../changelog.md + :parser: myst_parser.sphinx_ \ No newline at end of file diff --git a/docs/conf.py b/docs/conf.py new file mode 100755 index 0000000..a225ebb --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,92 @@ +# Configuration file for the Sphinx documentation builder. + +import os +import sys +from pathlib import Path + +# -- Path setup -------------------------------------------------------------- + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# +sys.path.insert(0, os.path.abspath('../')) +here = Path(__file__).parent.resolve() + +try: + import umodbus +except ImportError: + raise SystemExit("umodbus has to be importable") +else: + # Inject mock modules so that we can build the + # documentation without having the real stuff available + from mock import Mock + + for mod_name in ['micropython', 'machine', 'urequests']: + sys.modules[mod_name] = Mock() + print("Mocked {}".format(mod_name)) + +# load elements of version.py +exec(open(here / '..' / 'umodbus' / 'version.py').read()) + +# -- Project information + +project = 'micropython-modbus' +copyright = '2022, brainelectronics' +author = 'brainelectronics' + +version = __version__ +release = version + +# -- General configuration + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + 'myst_parser', + 'sphinx.ext.autodoc', + 'sphinx.ext.autosectionlabel', + 'sphinx.ext.autosummary', + 'sphinx.ext.doctest', + 'sphinx.ext.duration', + 'sphinx.ext.intersphinx', + 'sphinx.ext.viewcode', +] +autosectionlabel_prefix_document = True + +# The suffix of source filenames. +source_suffix = ['.rst', '.md'] + +intersphinx_mapping = { + 'python': ('https://docs.python.org/3/', None), + 'sphinx': ('https://www.sphinx-doc.org/en/master/', None), +} +intersphinx_disabled_domains = ['std'] +suppress_warnings = [ + # throws an error due to not found reference targets to files not in docs/ + 'ref.myst', + # throws an error due to multiple "Added" labels in "changelog.md" + 'autosectionlabel.*' +] + +# A list of regular expressions that match URIs that should not be checked +# when doing a linkcheck build. +linkcheck_ignore = [ + # tag 2.0.0 did not exist during docs introduction + 'https://github.com/brainelectronics/micropython-modbus/tree/2.0.0', + # RTD page did not exist during docs introduction + 'https://micropython-modbus.readthedocs.io/en/latest/', +] + +templates_path = ['_templates'] + +# -- Options for HTML output + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +html_theme = 'sphinx_rtd_theme' + +# -- Options for EPUB output +epub_show_urls = 'footnote' diff --git a/docs/index.rst b/docs/index.rst new file mode 100755 index 0000000..d45fdcc --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,22 @@ +micropython-modbus +=================================== + +Contents +-------- + +.. toctree:: + :maxdepth: 1 + + readme_link + SETUP + USAGE + CONTRIBUTING + umodbus + UPGRADE + changelog_link + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` diff --git a/docs/readme_link.rst b/docs/readme_link.rst new file mode 100755 index 0000000..dd42123 --- /dev/null +++ b/docs/readme_link.rst @@ -0,0 +1,3 @@ + +.. include:: ../README.md + :parser: myst_parser.sphinx_ \ No newline at end of file diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100755 index 0000000..40e473f --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,16 @@ +# use fixed versions +# +# fix docutils to a version working for all +docutils >=0.14,<0.18 + +# sphinx 5.3.0 requires Jinja2 >=3.0 and docutils >=0.14,<0.20 +sphinx >=5.0.0,<6 + +# sphinx-rtd-theme >=1.0.0 would require docutils <0.18 +sphinx-rtd-theme >=1.0.0,<2 + +# replaces outdated and no longer maintained m2rr +myst-parser >= 0.18.1,<1 + +# mock imports of "micropython" +mock >=4.0.3,<5 diff --git a/docs/umodbus.rst b/docs/umodbus.rst new file mode 100755 index 0000000..8ad7a94 --- /dev/null +++ b/docs/umodbus.rst @@ -0,0 +1,47 @@ +micropython-modbus API +======================= + +.. autosummary:: + :toctree: generated + +umodbus.common module +--------------------------------- + +.. automodule:: umodbus.common + :members: + :show-inheritance: + +umodbus.const module +--------------------------------- + +.. automodule:: umodbus.const + :members: + :show-inheritance: + +umodbus.functions module +--------------------------------- + +.. automodule:: umodbus.functions + :members: + :show-inheritance: + +umodbus.modbus module +--------------------------------- + +.. automodule:: umodbus.modbus + :members: + :show-inheritance: + +umodbus.serial module +--------------------------------- + +.. automodule:: umodbus.serial + :members: + :show-inheritance: + +umodbus.tcp module +--------------------------------- + +.. automodule:: umodbus.tcp + :members: + :show-inheritance: diff --git a/examples/example.json b/examples/example.json deleted file mode 100644 index b819f70..0000000 --- a/examples/example.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "COILS": { - "EXAMPLE_COIL": { - "register": 123, - "len": 1, - "description": "Example COILS register, Coils (setter+getter) [0, 1]", - "range": "", - "unit": "" - } - }, - "HREGS": { - "EXAMPLE_HREG": { - "register": 93, - "len": 1, - "description": "Example HREGS register, Hregs (setter+getter) [0, 65535]", - "range": "", - "unit": "" - } - }, - "ISTS": { - "EXAMPLE_ISTS": { - "register": 67, - "len": 1, - "description": "Example ISTS register, Ists (only getter) [0, 1]", - "range": "", - "unit": "" - } - }, - "IREGS": { - "EXAMPLE_IREG": { - "register": 10, - "len": 1, - "description": "Example IREGS register, Iregs (only getter) [0, 65535]", - "range": "", - "unit": "" - } - } -} \ No newline at end of file diff --git a/examples/read_registers_rtu.sh b/examples/read_registers_rtu.sh index f96a518..a5a19ec 100644 --- a/examples/read_registers_rtu.sh +++ b/examples/read_registers_rtu.sh @@ -2,8 +2,8 @@ # title :read_registers_rtu.sh # description :Read Modbus registers via Serial RTU based on register JSON # author :brainelectronics -# date :20220708 -# version :0.1.0 +# date :20221203 +# version :0.1.1 # usage :sh read_registers_rtu.sh \ # /dev/tty.SLAB_USBtoUART \ # example.json \ @@ -18,7 +18,7 @@ register_file_path=$2 modbus_unit=$3 if [[ -z "$register_file_path" ]]; then - register_file_path=example.json + register_file_path=../registers/example.json echo "No register file given, using default at $register_file_path" fi diff --git a/examples/read_registers_tcp.sh b/examples/read_registers_tcp.sh index 030f434..2994e9a 100644 --- a/examples/read_registers_tcp.sh +++ b/examples/read_registers_tcp.sh @@ -2,8 +2,8 @@ # title :read_registers_tcp.sh # description :Read Modbus registers via TCP based on register JSON # author :brainelectronics -# date :20220708 -# version :0.1.0 +# date :20221203 +# version :0.1.1 # usage :sh read_registers_tcp.sh \ # 192.168.178.188 \ # example.json \ @@ -18,7 +18,7 @@ register_file_path=$2 modbus_port=$3 if [[ -z "$register_file_path" ]]; then - register_file_path=example.json + register_file_path=../registers/example.json echo "No register file given, using default at $register_file_path" fi diff --git a/examples/rtu_client_example.py b/examples/rtu_client_example.py index b0016bb..e15d568 100644 --- a/examples/rtu_client_example.py +++ b/examples/rtu_client_example.py @@ -15,7 +15,7 @@ """ # import modbus client classes -from umodbus.modbus import ModbusRTU +from umodbus.serial import ModbusRTU # =============================================== # RTU Slave setup diff --git a/examples/tcp_client_example.py b/examples/tcp_client_example.py index 48793e9..789cf11 100644 --- a/examples/tcp_client_example.py +++ b/examples/tcp_client_example.py @@ -14,40 +14,53 @@ """ # system packages -import network import time # import modbus client classes -from umodbus.modbus import ModbusTCP +from umodbus.tcp import ModbusTCP + +IS_DOCKER_MICROPYTHON = False +try: + import network +except ImportError: + IS_DOCKER_MICROPYTHON = True + import json + # =============================================== -# connect to a network -station = network.WLAN(network.STA_IF) -if station.active() and station.isconnected(): - station.disconnect() +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(False) -time.sleep(1) -station.active(True) + station.active(True) -station.connect('SSID', 'PASSWORD') -time.sleep(1) + # 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) + 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 -# 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] + +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] # ModbusTCP can get TCP requests from a host device to provide/set data client = ModbusTCP() @@ -62,6 +75,11 @@ # 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, @@ -91,13 +109,12 @@ } } -""" # 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) -""" +# 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) print('Setting up registers ...') # use the defined values of each register type provided by register_definitions @@ -108,9 +125,17 @@ print('Serving as TCP client on {}:{}'.format(local_ip, tcp_port)) +reset_data_register = \ + register_definitions['COILS']['RESET_REGISTER_DATA_COIL']['register'] + while True: try: result = client.process() + if reset_data_register in client.coils: + if client.get_coil(address=reset_data_register): + print('Resetting register data to default values ...') + client.setup_registers(registers=register_definitions) + print('Default values restored') except KeyboardInterrupt: print('KeyboardInterrupt, stopping TCP client...') break diff --git a/examples/tcp_host_example.py b/examples/tcp_host_example.py index 8932818..80c5577 100644 --- a/examples/tcp_host_example.py +++ b/examples/tcp_host_example.py @@ -14,40 +14,52 @@ """ # system packages -import network import time # import modbus host classes -# from umodbus.modbus import ModbusTCP from umodbus.tcp import TCP as ModbusTCPMaster +IS_DOCKER_MICROPYTHON = False +try: + import network +except ImportError: + IS_DOCKER_MICROPYTHON = True + import sys + + # =============================================== -# connect to a network -station = network.WLAN(network.STA_IF) -if station.active() and station.isconnected(): - station.disconnect() +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(False) -time.sleep(1) -station.active(True) + station.active(True) -station.connect('SSID', 'PASSWORD') -time.sleep(1) + # 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) + 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) -slave_ip = '192.168.178.69' # IP address +if IS_DOCKER_MICROPYTHON: + slave_ip = '172.24.0.2' # static Docker IP address +else: + slave_ip = '192.168.178.69' # IP address # TCP Master setup # act as host, get Modbus data via TCP from a client device @@ -61,6 +73,11 @@ # 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, @@ -184,6 +201,22 @@ print('Status of IREG {}: {}'.format(ireg_address, register_value)) time.sleep(1) +# 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) diff --git a/examples/write_registers_tcp.sh b/examples/write_registers_tcp.sh index eb3e1bc..16278e0 100644 --- a/examples/write_registers_tcp.sh +++ b/examples/write_registers_tcp.sh @@ -2,8 +2,8 @@ # title :write_registers_tcp.sh # description :Write Modbus registers via TCP based on register JSON # author :brainelectronics -# date :20221112 -# version :0.1.0 +# date :20221203 +# version :0.1.1 # usage :sh write_registers_tcp.sh \ # 192.168.178.69 \ # set-example.json \ @@ -18,7 +18,7 @@ register_file_path=$2 modbus_port=$3 if [[ -z "$register_file_path" ]]; then - register_file_path=set-example.json + register_file_path=../registers/set-example.json echo "No register file given, using default at $register_file_path" fi diff --git a/mpy_unittest.py b/mpy_unittest.py new file mode 100644 index 0000000..1472d9f --- /dev/null +++ b/mpy_unittest.py @@ -0,0 +1,433 @@ +#!/usr/bin/env python3 +# -*- coding: UTF-8 -*- + +# Taken from pfalcon's pycopy-lib, see +# https://github.com/pfalcon/pycopy-lib/blob/56ebf2110f3caa63a3785d439ce49b11e13c75c0/unittest/unittest.py +# +# Copyright (c) 2014-2021 Paul Sokolovsky +# Copyright (c) 2014-2020 pycopy-lib contributors + +# Copyright (c) 2022 brainelectronics +# Added: +# - New properties in TestResult class +# - errors +# - failures +# - skipped +# - testsRun +# - All tests or a specific TestCase can be executed +# - sys exit status can be enabled (default) or disabled +# - assertNotIn, assertNotIsInstance, assertLess, assertGreater +# - Shebang header +# +# Fixed: +# - All flake8 warnings +# + +import sys +try: + import io + import traceback +except ImportError: + import uio as io + traceback = None + + +class SkipTest(Exception): + pass + + +class AssertRaisesContext: + + def __init__(self, exc): + self.expected = exc + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, tb): + self.exception = exc_value + if exc_type is None: + assert False, "%r not raised" % self.expected + if issubclass(exc_type, self.expected): + return True + return False + + +class NullContext: + + def __enter__(self): + pass + + def __exit__(self, a, b, c): + pass + + +class TestCase: + """ + This class describes a test case. + + https://docs.python.org/3/library/unittest.html + """ + def __init__(self): + pass + + def addCleanup(self, func, *args, **kwargs): + if not hasattr(self, "_cleanups"): + self._cleanups = [] + self._cleanups.append((func, args, kwargs)) + + def doCleanups(self): + if hasattr(self, "_cleanups"): + while self._cleanups: + func, args, kwargs = self._cleanups.pop() + func(*args, **kwargs) + + def subTest(self, msg=None, **params): + return NullContext() + + def skipTest(self, reason): + raise SkipTest(reason) + + def fail(self, msg=''): + assert False, msg + + def assertEqual(self, x, y, msg=''): + if not msg: + msg = "%r vs (expected) %r" % (x, y) + assert x == y, msg + + def assertNotEqual(self, x, y, msg=''): + if not msg: + msg = "%r not expected to be equal %r" % (x, y) + assert x != y, msg + + def assertLess(self, x, y, msg=None): + if msg is None: + msg = "%r is expected to be < %r" % (x, y) + assert x < y, msg + + def assertLessEqual(self, x, y, msg=None): + if msg is None: + msg = "%r is expected to be <= %r" % (x, y) + assert x <= y, msg + + def assertGreater(self, x, y, msg=None): + if msg is None: + msg = "%r is expected to be > %r" % (x, y) + assert x > y, msg + + def assertGreaterEqual(self, x, y, msg=None): + if msg is None: + msg = "%r is expected to be >= %r" % (x, y) + assert x >= y, msg + + def assertAlmostEqual(self, x, y, places=None, msg='', delta=None): + if x == y: + return + if delta is not None and places is not None: + raise TypeError("specify delta or places not both") + + if delta is not None: + if abs(x - y) <= delta: + return + if not msg: + msg = '%r != %r within %r delta' % (x, y, delta) + else: + if places is None: + places = 7 + if round(abs(y - x), places) == 0: + return + if not msg: + msg = '%r != %r within %r places' % (x, y, places) + + assert False, msg + + def assertNotAlmostEqual(self, x, y, places=None, msg='', delta=None): + if delta is not None and places is not None: + raise TypeError("specify delta or places not both") + + if delta is not None: + if not (x == y) and abs(x - y) > delta: + return + if not msg: + msg = '%r == %r within %r delta' % (x, y, delta) + else: + if places is None: + places = 7 + if not (x == y) and round(abs(y - x), places) != 0: + return + if not msg: + msg = '%r == %r within %r places' % (x, y, places) + + assert False, msg + + def assertIs(self, x, y, msg=''): + if not msg: + msg = "%r is not %r" % (x, y) + assert x is y, msg + + def assertIsNot(self, x, y, msg=''): + if not msg: + msg = "%r is %r" % (x, y) + assert x is not y, msg + + def assertIsNone(self, x, msg=''): + if not msg: + msg = "%r is not None" % x + assert x is None, msg + + def assertIsNotNone(self, x, msg=''): + if not msg: + msg = "%r is None" % x + assert x is not None, msg + + def assertTrue(self, x, msg=''): + if not msg: + msg = "Expected %r to be True" % x + assert x, msg + + def assertFalse(self, x, msg=''): + if not msg: + msg = "Expected %r to be False" % x + assert not x, msg + + def assertIn(self, x, y, msg=''): + if not msg: + msg = "Expected %r to be in %r" % (x, y) + assert x in y, msg + + def assertNotIn(self, x, y, msg=''): + if not msg: + msg = "Expected %r to be in %r" % (x, y) + assert x not in y, msg + + def assertIsInstance(self, x, y, msg=''): + assert isinstance(x, y), msg + + def assertNotIsInstance(self, x, y, msg=''): + assert not isinstance(x, y), msg + + def assertRaises(self, exc, func=None, *args, **kwargs): + if func is None: + return AssertRaisesContext(exc) + + try: + func(*args, **kwargs) + except Exception as e: + if isinstance(e, exc): + return + raise + + assert False, "%r not raised" % exc + + def assertWarns(self, warn): + return NullContext() + + +def skip(msg): + def _decor(fun): + # We just replace original fun with _inner + def _inner(self): + raise SkipTest(msg) + return _inner + return _decor + + +def skipIf(cond, msg): + if not cond: + return lambda x: x + return skip(msg) + + +def skipUnless(cond, msg): + if cond: + return lambda x: x + return skip(msg) + + +def expectedFailure(test): + + def test_exp_fail(*args, **kwargs): + try: + test(*args, **kwargs) + except: # noqa: E722 + pass + else: + assert False, "unexpected success" + + return test_exp_fail + + +class TestSuite: + def __init__(self): + self._tests = [] + + def addTest(self, cls): + self._tests.append(cls) + + def run(self, result): + for c in self._tests: + run_suite(c, result) + return result + + +class TestRunner: + def run(self, suite): + res = TestResult() + suite.run(res) + + res.printErrors() + print("----------------------------------------------------------------------") # noqa: E501 + print("Ran {} tests\n".format(res.testsRun)) + if res.failuresNum > 0 or res.errorsNum > 0: + print("FAILED (failures={}, errors={})".format(res.failuresNum, + res.errorsNum)) + else: + msg = "OK" + if res.skippedNum > 0: + msg += " (skipped={})".format(res.skippedNum) + print(msg) + + return res + + +TextTestRunner = TestRunner + + +class TestResult: + def __init__(self): + self.errorsNum = 0 + self.failuresNum = 0 + self.skippedNum = 0 + self._testsRun = 0 + self._errors = [] + self._failures = [] + self._skipped = [] + + @property + def errors(self): + return self._errors + + @property + def failures(self): + return self._failures + + @property + def skipped(self): + return self._skipped + + @property + def testsRun(self): + return self._testsRun + + def wasSuccessful(self): + return self.errorsNum == 0 and self.failuresNum == 0 + + def printErrors(self): + # print() + self.printErrorList(self.errors) + self.printErrorList(self.failures) + + def printErrorList(self, lst): + sep = "----------------------------------------------------------------------" # noqa: E501 + for c, e in lst: + print("======================================================================") # noqa: E501 + print(c) + print(sep) + print(e) + + def __repr__(self): + # Format is compatible with CPython. + return ("<unittest.result.TestResult run={} errors={} failures={}>". + format(self._testsRun, self.errorsNum, self.failuresNum)) + + +def capture_exc(e): + buf = io.StringIO() + if hasattr(sys, "print_exception"): + sys.print_exception(e, buf) + elif traceback is not None: + traceback.print_exception(None, e, sys.exc_info()[2], file=buf) + return buf.getvalue() + + +# TODO: Uncompliant +def run_suite(c, test_result): + if isinstance(c, TestSuite): + c.run(test_result) + return + + if isinstance(c, type): + o = c() + else: + o = c + set_up = getattr(o, "setUp", lambda: None) + tear_down = getattr(o, "tearDown", lambda: None) + exceptions = [] + + def run_one(m): + print("{} ({}) ...".format(name, c.__qualname__), end="") + set_up() + try: + test_result._testsRun += 1 + m() + print(" ok") + except SkipTest as e: + print(" skipped:", e.args[0]) + test_result.skippedNum += 1 + test_result._skipped.append((name, e.args[0])) + except Exception as ex: + ex_str = capture_exc(ex) + if isinstance(ex, AssertionError): + test_result.failuresNum += 1 + test_result._failures.append(((name, c), ex_str)) + print(" FAIL") + else: + test_result.errorsNum += 1 + test_result._errors.append(((name, c), ex_str)) + print(" ERROR") + # Uncomment to investigate failure in detail + # raise + finally: + tear_down() + o.doCleanups() + + if hasattr(o, "runTest"): + name = str(o) + run_one(o.runTest) + return + + for name in dir(o): + if name.startswith("test"): + m = getattr(o, name) + if not callable(m): + continue + run_one(m) + return exceptions + + +def test_cases(m): + for tn in dir(m): + c = getattr(m, tn) + if (isinstance(c, object) and + isinstance(c, type) and + issubclass(c, TestCase)): + yield c + + +def main(name="__main__", fromlist: bool = list(), do_exit: bool = True): + # Import the complete module of only a subset, see + # https://docs.python.org/3/library/functions.html#__import__ + if len(fromlist): + m = __import__(name, globals(), locals(), fromlist) + else: + m = __import__(name) if isinstance(name, str) else name + suite = TestSuite() + for c in test_cases(m): + suite.addTest(c) + runner = TestRunner() + result = runner.run(suite) + + if do_exit: + # Terminate with non zero return code in case of failures + sys.exit(result.failuresNum or result.errorsNum) diff --git a/registers/example.json b/registers/example.json new file mode 100644 index 0000000..a353010 --- /dev/null +++ b/registers/example.json @@ -0,0 +1,106 @@ +{ + "COILS": { + "RESET_REGISTER_DATA_COIL": { + "register": 42, + "len": 1, + "val": 0, + "description": "Set this COIL to true to reset all register values back to the default state/value", + "range": "", + "unit": "" + }, + "EXAMPLE_COIL": { + "register": 123, + "len": 1, + "val": 1, + "description": "Example COILS register, Coils (setter+getter) [0, 1]", + "range": "", + "unit": "" + }, + "EXAMPLE_COIL_OFF": { + "register": 124, + "len": 1, + "val": 0, + "description": "Example COILS register, Coils (setter+getter) [0, 1]", + "range": "", + "unit": "" + }, + "EXAMPLE_COIL_MIXED": { + "register": 125, + "len": 2, + "val": [1, 0], + "description": "Example COILS registers with length of 2, Coils (setter+getter) [0, 1]", + "range": "", + "unit": "" + }, + "ANOTHER_EXAMPLE_COIL": { + "register": 126, + "len": 3, + "val": [0, 1, 0], + "description": "Example COILS registers with length of 3, Coils (setter+getter) [0, 1]", + "range": "", + "unit": "" + } + }, + "HREGS": { + "EXAMPLE_HREG": { + "register": 93, + "len": 1, + "val": 19, + "description": "Example HREGS register, Hregs (setter+getter) [0, 65535]", + "range": "", + "unit": "" + }, + "ANOTHER_EXAMPLE_HREG": { + "register": 94, + "len": 3, + "val": [29, 38, 0], + "description": "Example HREGS registers with length of 3, Hregs (setter+getter) [0, 65535]", + "range": "", + "unit": "" + } + }, + "ISTS": { + "EXAMPLE_ISTS": { + "register": 67, + "len": 1, + "val": 0, + "description": "Example ISTS register, Ists (only getter) [0, 1]", + "range": "", + "unit": "" + }, + "EXAMPLE_ISTS_MIXED": { + "register": 68, + "len": 2, + "val": [1, 0], + "description": "Example ISTS registers with length of 2, Ists (only getter) [0, 1]", + "range": "", + "unit": "" + }, + "ANOTHER_EXAMPLE_ISTS": { + "register": 69, + "len": 3, + "val": [0, 1, 0], + "description": "Example ISTS registers with length of 3, Ists (only getter) [0, 1]", + "range": "", + "unit": "" + } + }, + "IREGS": { + "EXAMPLE_IREG": { + "register": 10, + "len": 1, + "val": 60001, + "description": "Example IREGS register, Iregs (only getter) [0, 65535]", + "range": "", + "unit": "" + }, + "ANOTHER_EXAMPLE_IREG": { + "register": 11, + "len": 3, + "val": [59123, 0, 390], + "description": "Example IREGS registers with length of 3, Iregs (only getter) [0, 65535]", + "range": "", + "unit": "" + } + } +} \ No newline at end of file diff --git a/examples/set-example.json b/registers/set-example.json similarity index 100% rename from examples/set-example.json rename to registers/set-example.json diff --git a/setup.py b/setup.py index 4b07149..eb3c907 100644 --- a/setup.py +++ b/setup.py @@ -35,5 +35,5 @@ license='MIT', cmdclass={'sdist': sdist_upip.sdist}, packages=['umodbus'], - install_requires=[] + install_requires=['micropython-urequests'] ) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..efb9b9b --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,9 @@ +#!/usr/bin/env python3 +# -*- coding: UTF-8 -*- + +from .test_absolute_truth import * +from .test_const import * +from .test_functions import * + +# TestTcpExample is a non static test and requires a running TCP client +# from .test_tcp_example import * diff --git a/tests/test_absolute_truth.py b/tests/test_absolute_truth.py new file mode 100644 index 0000000..dc38082 --- /dev/null +++ b/tests/test_absolute_truth.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +# -*- coding: UTF-8 -*- +"""Unittest for testing the absolute truth""" + +# import sys +import ulogging as logging +import mpy_unittest as unittest + + +class TestAbsoluteTruth(unittest.TestCase): + def setUp(self) -> None: + """Run before every test method""" + # set basic config and level for the logger + logging.basicConfig(level=logging.INFO) + + # create a logger for this TestSuite + self.test_logger = logging.getLogger(__name__) + + # set the test logger level + self.test_logger.setLevel(logging.DEBUG) + + # enable/disable the log output of the device logger for the tests + # if enabled log data inside this test will be printed + self.test_logger.disabled = False + + def test_absolute_truth(self) -> None: + """Test the unittest itself""" + x = 0 + y = 1 + z = 2 + none_thing = None + some_dict = dict() + some_list = [x, y, 40, "asdf", z] + + self.assertTrue(True) + self.assertFalse(False) + + self.assertEqual(y, 1) + assert y == 1 + with self.assertRaises(AssertionError): + self.assertEqual(x, y) + + self.assertNotEqual(x, y) + assert x != y + with self.assertRaises(AssertionError): + self.assertNotEqual(x, x) + + self.assertIs(some_list, some_list) + self.assertIsNot(some_list, some_dict) + + self.assertIsNone(none_thing) + self.assertIsNotNone(some_dict) + + self.assertIn(y, some_list) + self.assertNotIn(12, some_list) + + # self.assertRaises(exc, fun, args, *kwds) + with self.assertRaises(ZeroDivisionError): + 1 / 0 + + self.assertIsInstance(some_dict, dict) + self.assertNotIsInstance(some_list, dict) + + self.assertGreater(y, x) + self.assertGreaterEqual(y, x) + self.assertLess(x, y) + self.assertLessEqual(x, y) + + self.test_logger.warning('Dummy logger warning') + + def testAssert(self): + e1 = None + try: + def func_under_test(a): + assert a > 10 + + self.assertRaises(AssertionError, func_under_test, 20) + except AssertionError as e: + e1 = e + + if not e1 or "not raised" not in e1.args[0]: + self.fail("Expected to catch lack of AssertionError from assert \ + in func_under_test") + + @unittest.skip('Reasoning for skipping this test') + def testSkip(self): + self.fail('this should be skipped') + + def testSkipNoDecorator(self): + do_skip = True + + if do_skip: + self.skipTest("External resource triggered skipping this test") + + self.fail('this should be skipped') + + @unittest.skipIf('a' in ['a', 'b'], 'Reasoning for skipping another test') + def testSkipIf(self): + self.fail('this should be skipped') + + @unittest.skipUnless(42 == 24, 'Reasoning for skipping test 42') + def testSkipUnless(self): + self.fail('this should be skipped') + + @unittest.expectedFailure + def testExpectedFailure(self): + self.assertEqual(1, 0) + + def testExpectedFailureNot(self): + @unittest.expectedFailure + def testInner(): + self.assertEqual(1, 1) + try: + testInner() + except: # noqa: E722 + pass + else: + self.fail("Unexpected success was not detected") + + @unittest.expectedFailure + def testSubTest(self): + for i in range(0, 6): + with self.subTest(i=i): + # will fail on 1, 3, 5 + # but expect the failure by using the expectedFailure decorator + self.assertEqual(i % 2, 0) + + def tearDown(self) -> None: + """Run after every test method""" + pass + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_const.py b/tests/test_const.py new file mode 100644 index 0000000..203637a --- /dev/null +++ b/tests/test_const.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +# -*- coding: UTF-8 -*- +"""Unittest for testing const definitions of umodbus""" + +from umodbus.typing import List +import ulogging as logging +import mpy_unittest as unittest +from umodbus import const as Const + + +class TestConst(unittest.TestCase): + def setUp(self) -> None: + """Run before every test method""" + # set basic config and level for the logger + logging.basicConfig(level=logging.INFO) + + # create a logger for this TestSuite + self.test_logger = logging.getLogger(__name__) + + # set the test logger level + self.test_logger.setLevel(logging.DEBUG) + + # enable/disable the log output of the device logger for the tests + # if enabled log data inside this test will be printed + self.test_logger.disabled = False + + def test_function_codes(self) -> None: + """Test Modbus function codes""" + self.assertEqual(Const.READ_COILS, 0x01) + self.assertEqual(Const.READ_DISCRETE_INPUTS, 0x02) + self.assertEqual(Const.READ_HOLDING_REGISTERS, 0x03) + self.assertEqual(Const.READ_INPUT_REGISTER, 0x04) + + self.assertEqual(Const.WRITE_SINGLE_COIL, 0x05) + self.assertEqual(Const.WRITE_SINGLE_REGISTER, 0x06) + self.assertEqual(Const.WRITE_MULTIPLE_COILS, 0x0F) + self.assertEqual(Const.WRITE_MULTIPLE_REGISTERS, 0x10) + + self.assertEqual(Const.MASK_WRITE_REGISTER, 0x16) + self.assertEqual(Const.READ_WRITE_MULTIPLE_REGISTERS, 0x17) + + self.assertEqual(Const.READ_FIFO_QUEUE, 0x18) + + self.assertEqual(Const.READ_FILE_RECORD, 0x14) + self.assertEqual(Const.WRITE_FILE_RECORD, 0x15) + + self.assertEqual(Const.READ_EXCEPTION_STATUS, 0x07) + self.assertEqual(Const.DIAGNOSTICS, 0x08) + self.assertEqual(Const.GET_COM_EVENT_COUNTER, 0x0B) + self.assertEqual(Const.GET_COM_EVENT_LOG, 0x0C) + self.assertEqual(Const.REPORT_SERVER_ID, 0x11) + self.assertEqual(Const.READ_DEVICE_IDENTIFICATION, 0x2B) + + def test_exception_codes(self) -> None: + """Test Modbus exception codes""" + self.assertEqual(Const.ILLEGAL_FUNCTION, 0x01) + self.assertEqual(Const.ILLEGAL_DATA_ADDRESS, 0x02) + self.assertEqual(Const.ILLEGAL_DATA_VALUE, 0x03) + self.assertEqual(Const.SERVER_DEVICE_FAILURE, 0x04) + self.assertEqual(Const.ACKNOWLEDGE, 0x05) + self.assertEqual(Const.SERVER_DEVICE_BUSY, 0x06) + self.assertEqual(Const.MEMORY_PARITY_ERROR, 0x08) + self.assertEqual(Const.GATEWAY_PATH_UNAVAILABLE, 0x0A) + self.assertEqual(Const.DEVICE_FAILED_TO_RESPOND, 0x0B) + + def test_pdu_constants(self) -> None: + """Test Modbus Protocol Data Unit constants""" + self.assertEqual(Const.CRC_LENGTH, 0x02) + self.assertEqual(Const.ERROR_BIAS, 0x80) + self.assertEqual(Const.RESPONSE_HDR_LENGTH, 0x02) + self.assertEqual(Const.ERROR_RESP_LEN, 0x05) + self.assertEqual(Const.FIXED_RESP_LEN, 0x08) + self.assertEqual(Const.MBAP_HDR_LENGTH, 0x07) + + def test_crc16_table(self): + """Test CRC16-Modbus table""" + def generate_crc16_table() -> List[int, ...]: + crc_table = [] + for byte in range(256): + crc = 0x0000 + for _ in range(8): + if (byte ^ crc) & 0x0001: + crc = (crc >> 1) ^ 0xa001 + else: + crc >>= 1 + byte >>= 1 + crc_table.append(crc) + return crc_table + + crc16_table = generate_crc16_table() + self.assertEqual(len(crc16_table), 256) + self.assertEqual(len(Const.CRC16_TABLE), 256) + for idx, ele in enumerate(crc16_table): + with self.subTest(ele=ele, idx=idx): + self.assertEqual(ele, Const.CRC16_TABLE[idx]) + + def tearDown(self) -> None: + """Run after every test method""" + pass + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_functions.py b/tests/test_functions.py new file mode 100644 index 0000000..a70d4af --- /dev/null +++ b/tests/test_functions.py @@ -0,0 +1,501 @@ +#!/usr/bin/env python3 +# -*- coding: UTF-8 -*- +"""Unittest for testing functions of umodbus""" + +import ulogging as logging +import mpy_unittest as unittest +from umodbus import functions +from umodbus import const as Const + + +class TestFunctions(unittest.TestCase): + def setUp(self) -> None: + """Run before every test method""" + # set basic config and level for the logger + logging.basicConfig(level=logging.INFO) + + # create a logger for this TestSuite + self.test_logger = logging.getLogger(__name__) + + # set the test logger level + self.test_logger.setLevel(logging.DEBUG) + + # enable/disable the log output of the device logger for the tests + # if enabled log data inside this test will be printed + self.test_logger.disabled = False + + def test_read_coils(self) -> None: + """Test creation of Modbus Protocol Data Unit for coil reading""" + modbus_pdu = functions.read_coils(starting_address=19, quantity=11) + + self.assertIsInstance(modbus_pdu, bytes) + self.assertEqual(len(modbus_pdu), 5) + self.assertEqual(modbus_pdu, b'\x01\x00\x13\x00\x0b') + + with self.assertRaises(ValueError): + functions.read_coils(starting_address=42, quantity=0) + + with self.assertRaises(ValueError): + functions.read_coils(starting_address=69, quantity=2001) + + def test_read_discrete_inputs(self) -> None: + """ + Test creation of Modbus Protocol Data Unit for discrete inputs reading + """ + modbus_pdu = functions.read_discrete_inputs(starting_address=196, + quantity=22) + + self.assertIsInstance(modbus_pdu, bytes) + self.assertEqual(len(modbus_pdu), 5) + self.assertEqual(modbus_pdu, b'\x02\x00\xC4\x00\x16') + + with self.assertRaises(ValueError): + functions.read_discrete_inputs(starting_address=42, quantity=0) + + with self.assertRaises(ValueError): + functions.read_discrete_inputs(starting_address=69, quantity=2001) + + def test_read_holding_registers(self) -> None: + """ + Test creation of Modbus Protocol Data Unit for holding register reading + """ + modbus_pdu = functions.read_holding_registers(starting_address=107, + quantity=3) + + self.assertIsInstance(modbus_pdu, bytes) + self.assertEqual(len(modbus_pdu), 5) + self.assertEqual(modbus_pdu, b'\x03\x00\x6B\x00\x03') + + with self.assertRaises(ValueError): + functions.read_holding_registers(starting_address=42, quantity=0) + + with self.assertRaises(ValueError): + functions.read_holding_registers(starting_address=69, quantity=126) + + def test_read_input_registers(self) -> None: + """ + Test creation of Modbus Protocol Data Unit for input registers reading + """ + modbus_pdu = functions.read_input_registers(starting_address=8, + quantity=1) + + self.assertIsInstance(modbus_pdu, bytes) + self.assertEqual(len(modbus_pdu), 5) + self.assertEqual(modbus_pdu, b'\x04\x00\x08\x00\x01') + + with self.assertRaises(ValueError): + functions.read_input_registers(starting_address=42, quantity=0) + + with self.assertRaises(ValueError): + functions.read_input_registers(starting_address=69, quantity=126) + + def test_write_single_coil(self) -> None: + """ + Test creation of Modbus Protocol Data Unit for single coil writing + """ + for on_state in [1, 0xFF00, True]: + with self.subTest(on_state=on_state): + modbus_pdu = functions.write_single_coil(output_address=172, + output_value=on_state) + + self.assertIsInstance(modbus_pdu, bytes) + self.assertEqual(len(modbus_pdu), 5) + self.assertEqual(modbus_pdu, b'\x05\x00\xAC\xFF\x00') + + for off_state in [0, 0x0000, False]: + with self.subTest(off_state=off_state): + modbus_pdu = functions.write_single_coil(output_address=199, + output_value=off_state) + + self.assertIsInstance(modbus_pdu, bytes) + self.assertEqual(len(modbus_pdu), 5) + self.assertEqual(modbus_pdu, b'\x05\x00\xC7\x00\x00') + + for valid_state in [0, 1, 0x0000, 0xFF00, False, True]: + with self.subTest(valid_state=valid_state): + try: + modbus_pdu = functions.write_single_coil( + output_address=142, + output_value=valid_state) + + self.assertIsInstance(modbus_pdu, bytes) + self.assertEqual(len(modbus_pdu), 5) + except ValueError: + self.fail("write_single_coil() raised unexpected \ + ValueError") + + with self.assertRaises(ValueError): + functions.write_single_coil(output_address=69, output_value='on') + + with self.assertRaises(ValueError): + functions.write_single_coil(output_address=69, output_value=0xFF01) + + def test_write_single_register(self) -> None: + """ + Test creation of Modbus Protocol Data Unit for single registers writing + """ + # test signed value + modbus_pdu = functions.write_single_register(register_address=1, + register_value=3) + + self.assertIsInstance(modbus_pdu, bytes) + self.assertEqual(len(modbus_pdu), 5) + self.assertEqual(modbus_pdu, b'\x06\x00\x01\x00\x03') + + # test signed value + modbus_pdu = functions.write_single_register(register_address=1, + register_value=-3) + + self.assertIsInstance(modbus_pdu, bytes) + self.assertEqual(len(modbus_pdu), 5) + self.assertEqual(modbus_pdu, b'\x06\x00\x01\xFF\xFD') + + # test unsigned value + modbus_pdu = functions.write_single_register(register_address=1, + register_value=3, + signed=False) + + self.assertIsInstance(modbus_pdu, bytes) + self.assertEqual(len(modbus_pdu), 5) + self.assertEqual(modbus_pdu, b'\x06\x00\x01\x00\x03') + + def test_write_multiple_coils(self) -> None: + """ + Test creation of Modbus Protocol Data Unit for multiple coils writing + """ + # write 11 coils starting at coil 20 + value_list_int = [1, 1, 0, 0, 1, 1, 0, 1, 0, 1, 1] + # 27 26 25 24 23 22 21 20 - - - - - - 29 28 + # value_list_int = [1, 1, 0, 0, 1, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1] + value_list_bool = [ + True, True, False, False, True, True, False, True, False, True, + True + ] + + for value_list in [value_list_int, value_list_bool]: + with self.subTest(value_list=value_list): + modbus_pdu = functions.write_multiple_coils( + starting_address=19, + value_list=value_list) + + self.assertIsInstance(modbus_pdu, bytes) + self.assertEqual(len(modbus_pdu), 8) + self.assertEqual(modbus_pdu, + b'\x0F\x00\x13\x00\x0B\x02\xCD\x03') + + def test_write_multiple_registers(self) -> None: + """ + Test creation of Modbus Protocol Data Unit for single registers writing + """ + # test signed value + register_values = [10, 258] + modbus_pdu = functions.write_multiple_registers( + starting_address=1, + register_values=register_values) + + self.assertIsInstance(modbus_pdu, bytes) + self.assertEqual(len(modbus_pdu), 10) + self.assertEqual(modbus_pdu, + b'\x10\x00\x01\x00\x02\x04\x00\x0A\x01\x02') + + # test signed value + register_values = [10, -258] + modbus_pdu = functions.write_multiple_registers( + starting_address=1, + register_values=register_values) + + self.assertIsInstance(modbus_pdu, bytes) + self.assertEqual(len(modbus_pdu), 10) + self.assertEqual(modbus_pdu, + b'\x10\x00\x01\x00\x02\x04\x00\x0A\xFE\xFE') + + # test unsigned value + register_values = [10, 258] + modbus_pdu = functions.write_multiple_registers( + starting_address=1, + register_values=register_values, + signed=False) + + self.assertIsInstance(modbus_pdu, bytes) + self.assertEqual(len(modbus_pdu), 10) + self.assertEqual(modbus_pdu, + b'\x10\x00\x01\x00\x02\x04\x00\x0A\x01\x02') + + register_values = [7] * 124 + with self.assertRaises(ValueError): + functions.write_multiple_registers(starting_address=42, + register_values=register_values) + + def test_validate_resp_data_single_coil(self) -> None: + """Test response data validation of writing single coil""" + # test response of writing single coil to ON + starting_address = 172 + output_value = 0xFF00 + resp_data = b'\x05\x00\xAC\xFF\x00'[1:] + result = functions.validate_resp_data( + data=resp_data, + function_code=Const.WRITE_SINGLE_COIL, + address=starting_address, + value=output_value, + signed=False + ) + + self.assertIsInstance(result, bool) + self.assertTrue(result) + + # test response of writing single coil to OFF + starting_address = 199 + output_value = 0x0000 + resp_data = b'\x05\x00\xC7\x00\x00'[1:] + result = functions.validate_resp_data( + data=resp_data, + function_code=Const.WRITE_SINGLE_COIL, + address=starting_address, + value=output_value, + signed=False + ) + + self.assertIsInstance(result, bool) + self.assertTrue(result) + + def test_validate_resp_data_single_register(self) -> None: + """Test response data validation of writing single register""" + # test response of writing single register to 3 + starting_address = 1 + output_value = 3 + resp_data = b'\x06\x00\x01\x00\x03'[1:] + result = functions.validate_resp_data( + data=resp_data, + function_code=Const.WRITE_SINGLE_REGISTER, + address=starting_address, + value=output_value, + signed=True + ) + + self.assertIsInstance(result, bool) + self.assertTrue(result) + + # test response of writing single register to -3 + starting_address = 1 + output_value = -3 + resp_data = b'\x06\x00\x01\xFF\xFD'[1:] + result = functions.validate_resp_data( + data=resp_data, + function_code=Const.WRITE_SINGLE_REGISTER, + address=starting_address, + value=output_value, + signed=True + ) + + self.assertIsInstance(result, bool) + self.assertTrue(result) + + # test response of writing single register to -3 unsigned + starting_address = 1 + output_value = 3 + resp_data = b'\x06\x00\x01\x00\x03'[1:] + result = functions.validate_resp_data( + data=resp_data, + function_code=Const.WRITE_SINGLE_REGISTER, + address=starting_address, + value=output_value, + signed=False + ) + + self.assertIsInstance(result, bool) + self.assertTrue(result) + + def test_validate_resp_data_multiple_coils(self) -> None: + """Test response data validation of writing multiple coils""" + # test response of writing multiple coils + value_list_int = [1, 1, 0, 0, 1, 1, 0, 1, 0, 1, 1] + # 27 26 25 24 23 22 21 20 - - - - - - 29 28 + # value_list_int = [1, 1, 0, 0, 1, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1] + value_list_bool = [ + True, True, False, False, True, True, False, True, False, True, + True + ] + starting_address = 19 + resp_data = b'\x0F\x00\x13\x00\x0B'[1:] + + for value_list in [value_list_int, value_list_bool]: + with self.subTest(value_list=value_list): + result = functions.validate_resp_data( + data=resp_data, + function_code=Const.WRITE_MULTIPLE_COILS, + address=starting_address, + quantity=len(value_list) + ) + + self.assertIsInstance(result, bool) + self.assertTrue(result) + + def test_validate_resp_data_multiple_register(self) -> None: + """Test response data validation of writing multiple registers""" + starting_address = 1 + + # test response of writing multiple registers with signed value + register_values = [10, 258] + resp_data = b'\x10\x00\x01\x00\x02\x04\x00\x0A\x01\x02'[1:] + result = functions.validate_resp_data( + data=resp_data, + function_code=Const.WRITE_MULTIPLE_REGISTERS, + address=starting_address, + quantity=len(register_values) + ) + + self.assertIsInstance(result, bool) + self.assertTrue(result) + + # test response of writing multiple registers with signed value + register_values = [10, -258] + resp_data = b'\x10\x00\x01\x00\x02\x04\x00\x0A\xFE\xFE'[1:] + result = functions.validate_resp_data( + data=resp_data, + function_code=Const.WRITE_MULTIPLE_REGISTERS, + address=starting_address, + quantity=len(register_values) + ) + + self.assertIsInstance(result, bool) + self.assertTrue(result) + + # test response of writing multiple registers with unsigned value + register_values = [10, 258] + resp_data = b'\x10\x00\x01\x00\x02\x04\x00\x0A\x01\x02'[1:] + result = functions.validate_resp_data( + data=resp_data, + function_code=Const.WRITE_MULTIPLE_REGISTERS, + address=starting_address, + quantity=len(register_values), + signed=False + ) + + self.assertIsInstance(result, bool) + self.assertTrue(result) + + @unittest.skip('Test not yet implemented') + def test_response(self) -> None: + pass + + def test_exception_response(self) -> None: + """Test exception responses""" + function_code = Const.READ_COILS + exception_code = Const.ILLEGAL_DATA_ADDRESS + + result = functions.exception_response(function_code=function_code, + exception_code=exception_code) + + self.assertIsInstance(result, bytes) + self.assertEqual(len(result), 2) + self.assertEqual(result, b'\x81\x02') + + def test_bytes_to_bool(self) -> None: + """Convert bytes list to boolean list""" + possibilities = [ + # response, qty, expectation + (b'\x00', 1, [False]), + (b'\x01', 1, [True]), + + (b'\x00', 2, [False, False]), + (b'\x01', 2, [True, False]), + (b'\x02', 2, [False, True]), + (b'\x03', 2, [True, True]), + + (b'\x00', 3, [False, False, False]), + (b'\x01', 3, [True, False, False]), + (b'\x02', 3, [False, True, False]), + (b'\x03', 3, [True, True, False]), + (b'\x04', 3, [False, False, True]), + (b'\x05', 3, [True, False, True]), + (b'\x06', 3, [False, True, True]), + (b'\x07', 3, [True, True, True]), + + (b'\x00', 4, [False, False, False, False]), + # (b'\x05', 4, [False, True, False, True]), + (b'\x05', 4, [True, False, True, False]), + (b'\x0A', 4, [False, True, False, True]), + (b'\x0F', 4, [True, True, True, True]), + + (b'\x0A', 5, [False, True, False, True, False]), + ] + for pair in possibilities: + with self.subTest(pair=pair): + byte_list = pair[0] + bit_qty = pair[1] + expectation = pair[2] + + result = functions.bytes_to_bool(byte_list=byte_list, + bit_qty=bit_qty) + self.assertIsInstance(result, list) + self.assertEqual(len(result), len(expectation)) + self.assertTrue(all(isinstance(x, bool) for x in result)) + self.assertEqual(result, expectation) + + def test_to_short(self) -> None: + """Convert bytes list to integer tuple""" + possibilities = [ + # response, signed, expectation + (b'\x00\x00', True, (0,)), + (b'\x00\x14', True, (20,)), + + (b'\x00\x00\x00\x00', True, (0, 0)), + (b'\x00\x00\x00\x07', True, (0, 7)), + (b'\x00\x17\x00\x00', True, (23, 0)), + (b'\x00\x0c\x00\x13', True, (12, 19)), + + (b'\x00\x00\x00\x00\x00\x00', True, (0, 0, 0)), + (b'\x00\x09\x00\x00\x00\x00', True, (9, 0, 0)), + (b'\x00\x00\x00\x01\x00\x00', True, (0, 1, 0)), + (b'\x00\x00\x00\x00\x00\x07', True, (0, 0, 7)), + (b'\x00\x1d\x00\x26\x00\x00', True, (29, 38, 0)), + (b'\x00\x11\x00\x00\x00\x04', True, (17, 0, 4)), + (b'\x09\x29\x00\x25\x11\x5c', True, (2345, 37, 4444)), + ] + for pair in possibilities: + with self.subTest(pair=pair): + byte_array = pair[0] + signed = pair[1] + expectation = pair[2] + + result = functions.to_short(byte_array=byte_array, + signed=signed) + self.assertIsInstance(result, tuple) + self.assertEqual(len(result), len(expectation)) + self.assertTrue(all(isinstance(x, int) for x in result)) + self.assertEqual(result, expectation) + + def test_float_to_bin(self) -> None: + """Test conversion of float to bin according to IEEE 754""" + float_val = 10.27 + expectation = '01000001001001000101000111101100' + + result = functions.float_to_bin(num=float_val) + self.assertIsInstance(result, str) + self.assertEqual(result, expectation) + + def test_bin_to_float(self) -> None: + """Test conversion of binary string to float""" + binary = '01000001001001000101000111101100' + expectation = 10.27 + + result = functions.bin_to_float(binary=binary) + self.assertIsInstance(result, float) + self.assertAlmostEqual(result, expectation, delta=0.01) + + def test_int_to_bin(self) -> None: + """Test conversion of integer to binary""" + number = 123 + expectation = '1111011' + + result = functions.int_to_bin(num=number) + self.assertIsInstance(result, str) + self.assertEqual(result, expectation) + + def tearDown(self) -> None: + """Run after every test method""" + pass + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_tcp_example.py b/tests/test_tcp_example.py new file mode 100644 index 0000000..32cdd5e --- /dev/null +++ b/tests/test_tcp_example.py @@ -0,0 +1,643 @@ +#!/usr/bin/env python3 +# -*- coding: UTF-8 -*- +"""Unittest for testing functions of umodbus""" + +import json +from random import randint +import struct +import ulogging as logging +import mpy_unittest as unittest +from umodbus.tcp import TCP as ModbusTCPMaster + + +class TestTcpExample(unittest.TestCase): + def setUp(self) -> None: + """Run before every test method""" + # set basic config and level for the logger + logging.basicConfig(level=logging.INFO) + + # create a logger for this TestSuite + self.test_logger = logging.getLogger(__name__) + + # set the test logger level + self.test_logger.setLevel(logging.DEBUG) + + # enable/disable the log output of the device logger for the tests + # if enabled log data inside this test will be printed + self.test_logger.disabled = False + + self._client_tcp_port = 502 # port of client + self._client_addr = 10 # bus address of client + self._client_ip = '172.24.0.2' # static Docker IP address + self._host = ModbusTCPMaster( + slave_ip=self._client_ip, + slave_port=self._client_tcp_port, + timeout=5.0) + + test_register_file = 'registers/example.json' + try: + with open(test_register_file, 'r') as file: + self._register_definitions = json.load(file) + except Exception as e: + self.test_logger.error( + 'Is the test register file available at {}?'.format( + test_register_file)) + raise e + + def test_setup(self) -> None: + """Test successful setup of ModbusTCPMaster and the defined register""" + self.assertEqual(self._host.trans_id_ctr, 0) + self.assertIsInstance(self._register_definitions, dict) + + for reg_type in ['COILS', 'HREGS', 'ISTS', 'IREGS']: + with self.subTest(reg_type=reg_type): + self.assertIn(reg_type, self._register_definitions.keys()) + self.assertIsInstance(self._register_definitions[reg_type], + dict) + self.assertGreaterEqual( + len(self._register_definitions[reg_type]), 1) + + def test__create_mbap_hdr(self) -> None: + """Test creating a Modbus header""" + trans_id = randint(1, 1000) # create a random transaction ID + modbus_pdu = b'\x05\x00\x7b\xff\x00' # WRITE_SINGLE_COIL 123 to True + self._host.trans_id_ctr = trans_id + + # 0x00 0x06 is the lenght of the Modbus Protocol Data Unit +1 + # 0x0A is the cliend address + expectation = (struct.pack('>H', trans_id) + b'\x00\x00\x00\x06\x0A', + trans_id) + + result = self._host._create_mbap_hdr(slave_id=self._client_addr, + modbus_pdu=modbus_pdu) + + self.assertIsInstance(result, tuple) + self.assertEqual(len(result), len(expectation)) + self.assertEqual(result, expectation) + self.assertEqual(self._host.trans_id_ctr, trans_id + 1) + + def test__validate_resp_hdr(self) -> None: + """Test response header validation""" + # positive path + # similar to @params in python + parameters = [ + # (response, transaction ID, function_code, expectation) + # reading a single coil + (b'\x00\x01\x00\x00\x00\x04\x0a\x01\x01\x01', 1, 1, b'\x01\x01'), + (b'\x00\x02\x00\x00\x00\x04\x0a\x01\x01\x01', 2, 1, b'\x01\x01'), + # reading an input register + (b'\x00\x03\x00\x00\x00\x04\x0a\x02\x01\x00', 3, 2, b'\x01\x00'), + # reading a holding register + ( + b'\x00\x04\x00\x00\x00\x05\x0a\x03\x02\x00\x13', + 4, # transaction ID + 3, # function code + b'\x02\x00\x13' + ), + # setting an input register + ( + b'\x00\x05\x00\x00\x00\x05\x0a\x04\x02\xea\x0a', + 5, # transaction ID + 4, # function code + b'\x02\xea\x0a' + ), + # setting a single coil + ( + b'\x00\x06\x00\x00\x00\x06\x0a\x05\x00\x7b\x00\x00', + 6, # transaction ID + 5, # function code + b'\x00\x7b\x00\x00' + ), + # setting a holding register + ( + b'\x00\x07\x00\x00\x00\x06\x0a\x06\x00\x5d\x00\x14', + 7, # transaction ID + 6, # function code + b'\x00\x5d\x00\x14' + ), + ] + + for pair in parameters: + with self.subTest(pair=pair): + response = pair[0] + trans_id = pair[1] + function_code = pair[2] + expectation = pair[3] + + result = self._host._validate_resp_hdr( + response=response, + trans_id=trans_id, + slave_id=self._client_addr, + function_code=function_code) + self.test_logger.debug('result: {}, expectation: {}'.format( + result, expectation)) + + self.assertIsInstance(result, bytes) + self.assertEqual(result, expectation) + + # negative path, trigger asserts + data = { + # TID SID FC + 'input': b'\x00\x09\x00\x00\x00\x05\x0a\x03\x02\x00\x13', + 'tid': 9, # transaction ID + 'sid': 10, # slave ID + 'fid': 3, # function code, read holding register + 'response': b'\x02\x00\x13' + } + # trigger wrong transaction ID assert + with self.assertRaises(ValueError): + self._host._validate_resp_hdr( + response=response, + trans_id=data['tid'] + 1, + slave_id=data['sid'], + function_code=data['fid']) + + # trigger wrong function ID/throw Modbus exception code assert + with self.assertRaises(ValueError): + self._host._validate_resp_hdr( + response=response, + trans_id=data['tid'], + slave_id=data['sid'], + function_code=data['fid'] + 1) + + # trigger wrong slave ID assert + with self.assertRaises(ValueError): + self._host._validate_resp_hdr( + response=response, + trans_id=data['tid'], + slave_id=data['sid'] + 1, + function_code=data['fid']) + + @unittest.skip('Test not yet implemented') + def test__send_receive(self) -> None: + pass + + def test_read_coils_single(self) -> None: + """Test reading sinlge coil of client""" + # read coil with state ON/True + coil_address = \ + self._register_definitions['COILS']['EXAMPLE_COIL']['register'] + coil_qty = self._register_definitions['COILS']['EXAMPLE_COIL']['len'] + expectation_list = [ + bool(self._register_definitions['COILS']['EXAMPLE_COIL']['val']) + ] + + coil_status = self._host.read_coils( + slave_addr=self._client_addr, + starting_addr=coil_address, + coil_qty=coil_qty) + + self.test_logger.debug('Status of COIL {}: {}, expectation: {}'. + format(coil_address, + coil_status, + expectation_list)) + self.assertIsInstance(coil_status, list) + self.assertEqual(len(coil_status), coil_qty) + self.assertTrue(all(isinstance(x, bool) for x in coil_status)) + self.assertEqual(coil_status, expectation_list) + + # read coil with state OFF/False + coil_address = \ + self._register_definitions['COILS']['EXAMPLE_COIL_OFF']['register'] + coil_qty = \ + self._register_definitions['COILS']['EXAMPLE_COIL_OFF']['len'] + expectation_list = [bool( + self._register_definitions['COILS']['EXAMPLE_COIL_OFF']['val'] + )] + + coil_status = self._host.read_coils( + slave_addr=self._client_addr, + starting_addr=coil_address, + coil_qty=coil_qty) + + self.test_logger.debug('Status of COIL {}: {}, expectation: {}'. + format(coil_address, + coil_status, + expectation_list)) + self.assertIsInstance(coil_status, list) + self.assertEqual(len(coil_status), coil_qty) + self.assertTrue(all(isinstance(x, bool) for x in coil_status)) + self.assertEqual(coil_status, expectation_list) + + def test_read_coils_multiple(self) -> None: + """Test reading multiple coils of client""" + coil_address = \ + self._register_definitions['COILS']['EXAMPLE_COIL_MIXED']['register'] # noqa: E501 + coil_qty = \ + self._register_definitions['COILS']['EXAMPLE_COIL_MIXED']['len'] + expectation_list = list( + map(bool, + self._register_definitions['COILS']['EXAMPLE_COIL_MIXED']['val'] # noqa: E501 + ) + ) + + coil_status = self._host.read_coils( + slave_addr=self._client_addr, + starting_addr=coil_address, + coil_qty=coil_qty) + + self.test_logger.debug( + 'Status of COIL {} lenght {}: {}, expectation: {}'.format( + coil_address, coil_qty, coil_status, expectation_list)) + self.assertIsInstance(coil_status, list) + self.assertEqual(len(coil_status), coil_qty) + self.assertTrue(all(isinstance(x, bool) for x in coil_status)) + # self.assertEqual(coil_status, expectation_list) + + coil_address = \ + self._register_definitions['COILS']['ANOTHER_EXAMPLE_COIL']['register'] # noqa: E501 + coil_qty = \ + self._register_definitions['COILS']['ANOTHER_EXAMPLE_COIL']['len'] + expectation_list = list( + map(bool, + self._register_definitions['COILS']['ANOTHER_EXAMPLE_COIL']['val'] # noqa: E501 + ) + ) + + coil_status = self._host.read_coils( + slave_addr=self._client_addr, + starting_addr=coil_address, + coil_qty=coil_qty) + + self.test_logger.debug( + 'Status of COIL {} lenght {}: {}, expectation: {}'.format( + coil_address, coil_qty, coil_status, expectation_list)) + self.assertIsInstance(coil_status, list) + self.assertEqual(len(coil_status), coil_qty) + self.assertTrue(all(isinstance(x, bool) for x in coil_status)) + self.assertEqual(coil_status, expectation_list) + + def test_read_discrete_inputs_single(self) -> None: + """Test reading discrete inputs of client""" + ist_address = \ + self._register_definitions['ISTS']['EXAMPLE_ISTS']['register'] + input_qty = self._register_definitions['ISTS']['EXAMPLE_ISTS']['len'] + expectation_list = [ + bool(self._register_definitions['ISTS']['EXAMPLE_ISTS']['val']) + ] + + input_status = self._host.read_discrete_inputs( + slave_addr=self._client_addr, + starting_addr=ist_address, + input_qty=input_qty) + + self.test_logger.debug('Status of IST {}: {}, expectation: {}'. + format(ist_address, + input_status, + expectation_list)) + self.assertIsInstance(input_status, list) + self.assertEqual(len(input_status), input_qty) + self.assertTrue(all(isinstance(x, bool) for x in input_status)) + self.assertEqual(input_status, expectation_list) + + def test_read_discrete_inputs_multiple(self) -> None: + """Test reading multiple discrete inputs of client""" + ist_address = \ + self._register_definitions['ISTS']['EXAMPLE_ISTS_MIXED']['register'] # noqa: E501 + input_qty = \ + self._register_definitions['ISTS']['EXAMPLE_ISTS_MIXED']['len'] + expectation_list = \ + self._register_definitions['ISTS']['EXAMPLE_ISTS_MIXED']['val'] + + input_status = self._host.read_discrete_inputs( + slave_addr=self._client_addr, + starting_addr=ist_address, + input_qty=input_qty) + + self.test_logger.debug( + 'Status of IST {} length {}: {}, expectation: {}'.format( + ist_address, input_qty, input_status, expectation_list)) + self.assertIsInstance(input_status, list) + self.assertEqual(len(input_status), input_qty) + self.assertTrue(all(isinstance(x, bool) for x in input_status)) + # self.assertEqual(input_status, expectation_list) + + ist_address = \ + self._register_definitions['ISTS']['ANOTHER_EXAMPLE_ISTS']['register'] # noqa: E501 + input_qty = \ + self._register_definitions['ISTS']['ANOTHER_EXAMPLE_ISTS']['len'] + expectation_list = \ + self._register_definitions['ISTS']['ANOTHER_EXAMPLE_ISTS']['val'] + + input_status = self._host.read_discrete_inputs( + slave_addr=self._client_addr, + starting_addr=ist_address, + input_qty=input_qty) + + self.test_logger.debug( + 'Status of IST {} length {}: {}, expectation: {}'.format( + ist_address, input_qty, input_status, expectation_list)) + self.assertIsInstance(input_status, list) + self.assertEqual(len(input_status), input_qty) + self.assertTrue(all(isinstance(x, bool) for x in input_status)) + self.assertEqual(input_status, expectation_list) + + def test_read_holding_registers_single(self) -> None: + """Test reading holding registers of client""" + hreg_address = \ + self._register_definitions['HREGS']['EXAMPLE_HREG']['register'] + register_qty = \ + self._register_definitions['HREGS']['EXAMPLE_HREG']['len'] + expectation = \ + (self._register_definitions['HREGS']['EXAMPLE_HREG']['val'], ) + + register_value = self._host.read_holding_registers( + slave_addr=self._client_addr, + starting_addr=hreg_address, + register_qty=register_qty) + + self.test_logger.debug('Status of HREG {}: {}, expectation: {}'. + format(hreg_address, + register_value, + expectation)) + self.assertIsInstance(register_value, tuple) + self.assertEqual(len(register_value), register_qty) + self.assertTrue(all(isinstance(x, int) for x in register_value)) + self.assertEqual(register_value, expectation) + + def test_read_holding_registers_multiple(self) -> None: + """Test reading multiple holding registers of client""" + hreg_address = \ + self._register_definitions['HREGS']['ANOTHER_EXAMPLE_HREG']['register'] # noqa: E501 + register_qty = \ + self._register_definitions['HREGS']['ANOTHER_EXAMPLE_HREG']['len'] + expectation = tuple( + self._register_definitions['HREGS']['ANOTHER_EXAMPLE_HREG']['val'] + ) + + register_value = self._host.read_holding_registers( + slave_addr=self._client_addr, + starting_addr=hreg_address, + register_qty=register_qty) + + self.test_logger.debug( + 'Status of HREG {} length {}: {}, expectation: {}'.format( + hreg_address, register_qty, register_value, expectation)) + self.assertIsInstance(register_value, tuple) + self.assertEqual(len(register_value), register_qty) + self.assertTrue(all(isinstance(x, int) for x in register_value)) + self.assertEqual(register_value, expectation) + + def test_read_input_registers_single(self) -> None: + """Test reading input registers of client""" + ireg_address = \ + self._register_definitions['IREGS']['EXAMPLE_IREG']['register'] + register_qty = \ + self._register_definitions['IREGS']['EXAMPLE_IREG']['len'] + expectation = \ + (self._register_definitions['IREGS']['EXAMPLE_IREG']['val'], ) + + register_value = self._host.read_input_registers( + slave_addr=self._client_addr, + starting_addr=ireg_address, + register_qty=register_qty, + signed=False) + + self.test_logger.debug('Status of IREG {}: {}, expectation: {}'. + format(ireg_address, + register_value, + expectation)) + self.assertIsInstance(register_value, tuple) + self.assertEqual(len(register_value), register_qty) + self.assertTrue(all(isinstance(x, int) for x in register_value)) + self.assertEqual(register_value, expectation) + + def test_read_input_registers_multiple(self) -> None: + """Test reading multiple input registers of client""" + ireg_address = \ + self._register_definitions['IREGS']['ANOTHER_EXAMPLE_IREG']['register'] # noqa: E501 + register_qty = \ + self._register_definitions['IREGS']['ANOTHER_EXAMPLE_IREG']['len'] + expectation = tuple( + self._register_definitions['IREGS']['ANOTHER_EXAMPLE_IREG']['val'] + ) + + register_value = self._host.read_input_registers( + slave_addr=self._client_addr, + starting_addr=ireg_address, + register_qty=register_qty, + signed=False) + + self.test_logger.debug( + 'Status of IREG {} length {}: {}, expectation: {}'.format( + ireg_address, register_qty, register_value, expectation)) + self.assertIsInstance(register_value, tuple) + self.assertEqual(len(register_value), register_qty) + self.assertTrue(all(isinstance(x, int) for x in register_value)) + self.assertEqual(register_value, expectation) + + def test_reset_client_data(self) -> None: + """Test resettig client data to default""" + coil_address = \ + self._register_definitions['COILS']['RESET_REGISTER_DATA_COIL']['register'] # noqa: E501 + coil_qty = \ + self._register_definitions['COILS']['RESET_REGISTER_DATA_COIL']['len'] # noqa: E501 + + operation_status = self._host.write_single_coil( + slave_addr=self._client_addr, + output_address=coil_address, + output_value=True) + + self.test_logger.debug( + 'Result of setting COIL {} to {}: {}, expectation: {}'.format( + coil_address, True, operation_status, [True])) + self.assertIsInstance(operation_status, bool) + self.assertTrue(operation_status) + + # The coil value is actually True for a very short time + + # verify setting of state by reading data back again + coil_status = self._host.read_coils( + slave_addr=self._client_addr, + starting_addr=coil_address, + coil_qty=coil_qty) + + self.test_logger.debug( + 'Status of COIL {}: {}, expectation: {}'.format( + coil_address, coil_status, [False])) + self.assertIsInstance(coil_status, list) + self.assertEqual(len(coil_status), coil_qty) + self.assertTrue(all(isinstance(x, bool) for x in coil_status)) + self.assertEqual(coil_status, [False]) + + def test_write_single_coil(self) -> None: + """Test updating single coil of client""" + coil_address = \ + self._register_definitions['COILS']['EXAMPLE_COIL']['register'] + coil_qty = self._register_definitions['COILS']['EXAMPLE_COIL']['len'] + expectation_list = [ + bool(self._register_definitions['COILS']['EXAMPLE_COIL']['val']) + ] + + # + # Check clean system (client register data is as initially defined) + # + # verify current state by reading coil states + coil_status = self._host.read_coils( + slave_addr=self._client_addr, + starting_addr=coil_address, + coil_qty=coil_qty) + + self.test_logger.debug( + 'Initial status of COIL {}: {}, expectation: {}'.format( + coil_address, + coil_status, + expectation_list)) + self.assertIsInstance(coil_status, list) + self.assertEqual(len(coil_status), coil_qty) + self.assertTrue(all(isinstance(x, bool) for x in coil_status)) + self.assertEqual(coil_status, expectation_list) + + # + # Test setting coil to True + # + # update coil state of client with a different than the current state + new_coil_val = not expectation_list[0] + expectation_list[0] = new_coil_val + + operation_status = self._host.write_single_coil( + slave_addr=self._client_addr, + output_address=coil_address, + output_value=new_coil_val) + + self.test_logger.debug( + '1. Result of setting COIL {} to {}: {}, expectation: {}'.format( + coil_address, new_coil_val, operation_status, True)) + self.assertIsInstance(operation_status, bool) + self.assertTrue(operation_status) + + # verify setting of state by reading data back again + coil_status = self._host.read_coils( + slave_addr=self._client_addr, + starting_addr=coil_address, + coil_qty=coil_qty) + + self.test_logger.debug('1. Status of COIL {}: {}, expectation: {}'. + format(coil_address, + coil_status, + expectation_list)) + self.assertIsInstance(coil_status, list) + self.assertEqual(len(coil_status), coil_qty) + self.assertTrue(all(isinstance(x, bool) for x in coil_status)) + self.assertEqual(coil_status, expectation_list) + + # + # Test setting coil to False + # + # update coil state of client again with/to original state + new_coil_val = not expectation_list[0] + expectation_list[0] = new_coil_val + + operation_status = self._host.write_single_coil( + slave_addr=self._client_addr, + output_address=coil_address, + output_value=new_coil_val) + + self.test_logger.debug( + '2. Result of setting COIL {} to {}: {}, expectation: {}'.format( + coil_address, new_coil_val, operation_status, True)) + self.assertIsInstance(operation_status, bool) + self.assertTrue(operation_status) + + # verify setting of state by reading data back again + coil_status = self._host.read_coils( + slave_addr=self._client_addr, + starting_addr=coil_address, + coil_qty=coil_qty) + + self.test_logger.debug('2. Status of COIL {}: {}, expectation: {}'. + format(coil_address, + coil_status, + expectation_list)) + self.assertIsInstance(coil_status, list) + self.assertEqual(len(coil_status), coil_qty) + self.assertTrue(all(isinstance(x, bool) for x in coil_status)) + self.assertEqual(coil_status, expectation_list) + + def test_write_single_register(self) -> None: + """Test updating single holding register of client""" + hreg_address = \ + self._register_definitions['HREGS']['EXAMPLE_HREG']['register'] + register_qty = \ + self._register_definitions['HREGS']['EXAMPLE_HREG']['len'] + expectation = \ + (self._register_definitions['HREGS']['EXAMPLE_HREG']['val'], ) + + # + # Check clean system (client register data is as initially defined) + # + # verify current state by reading holding register data + register_value = self._host.read_holding_registers( + slave_addr=self._client_addr, + starting_addr=hreg_address, + register_qty=register_qty) + + self.test_logger.debug( + 'Initial status of HREG {}: {}, expectation: {}'.format( + hreg_address, + register_value, + expectation)) + self.assertIsInstance(register_value, tuple) + self.assertEqual(len(register_value), register_qty) + self.assertTrue(all(isinstance(x, int) for x in register_value)) + self.assertEqual(register_value, expectation) + + # + # Test setting holding register to x+1 + # + # update holding register of client with a different than the current + # value + new_hreg_val = \ + self._register_definitions['HREGS']['EXAMPLE_HREG']['val'] + 1 + + operation_status = self._host.write_single_register( + slave_addr=self._client_addr, + register_address=hreg_address, + register_value=new_hreg_val, + signed=False) + self.test_logger.debug( + '1. Result of setting HREG {} to {}: {}, expectation: {}'.format( + hreg_address, new_hreg_val, operation_status, (new_hreg_val, ))) + self.assertIsInstance(operation_status, bool) + self.assertTrue(operation_status) + + # verify setting of state by reading data back again + register_value = self._host.read_holding_registers( + slave_addr=self._client_addr, + starting_addr=hreg_address, + register_qty=register_qty) + + self.test_logger.debug('1. Status of HREG {}: {}, expectation: {}'. + format(hreg_address, + register_value, + new_hreg_val)) + self.assertIsInstance(register_value, tuple) + self.assertEqual(len(register_value), register_qty) + self.assertTrue(all(isinstance(x, int) for x in register_value)) + self.assertEqual(register_value, (new_hreg_val, )) + + @unittest.skip('Test not yet implemented') + def test_write_multiple_coils(self) -> None: + pass + + @unittest.skip('Test not yet implemented') + def test_write_multiple_registers(self) -> None: + pass + + def tearDown(self) -> None: + """Run after every test method""" + # reset the client data back to the default values + coil_address = \ + self._register_definitions['COILS']['RESET_REGISTER_DATA_COIL']['register'] # noqa: E501 + + operation_status = self._host.write_single_coil( + slave_addr=self._client_addr, + output_address=coil_address, + output_value=True) + + self.assertIsInstance(operation_status, bool) + self.assertTrue(operation_status) + + +if __name__ == '__main__': + unittest.main() diff --git a/umodbus/const.py b/umodbus/const.py index 3b38e21..3ebb228 100644 --- a/umodbus/const.py +++ b/umodbus/const.py @@ -8,50 +8,53 @@ # available at https://www.pycom.io/opensource/licensing # +from micropython import const + # function codes -READ_COILS = 0x01 # COILS, [0, 1] -READ_DISCRETE_INPUTS = 0x02 # ISTS, [0, 1] -READ_HOLDING_REGISTERS = 0x03 # HREGS, [0, 65535] -READ_INPUT_REGISTER = 0x04 # IREGS, [0, 65535] +# defined as const(), see https://github.com/micropython/micropython/issues/573 +READ_COILS = const(0x01) # COILS, [0, 1] +READ_DISCRETE_INPUTS = const(0x02) # ISTS, [0, 1] +READ_HOLDING_REGISTERS = const(0x03) # HREGS, [0, 65535] +READ_INPUT_REGISTER = const(0x04) # IREGS, [0, 65535] -WRITE_SINGLE_COIL = 0x05 # COILS, [0, 1] -WRITE_SINGLE_REGISTER = 0x06 # HREGS, [0, 65535] -WRITE_MULTIPLE_COILS = 0x0F # COILS, [0, 1] -WRITE_MULTIPLE_REGISTERS = 0x10 # HREGS, [0, 65535] +WRITE_SINGLE_COIL = const(0x05) # COILS, [0, 1] +WRITE_SINGLE_REGISTER = const(0x06) # HREGS, [0, 65535] +WRITE_MULTIPLE_COILS = const(0x0F) # COILS, [0, 1] +WRITE_MULTIPLE_REGISTERS = const(0x10) # HREGS, [0, 65535] -MASK_WRITE_REGISTER = 0x16 -READ_WRITE_MULTIPLE_REGISTERS = 0x17 +MASK_WRITE_REGISTER = const(0x16) +READ_WRITE_MULTIPLE_REGISTERS = const(0x17) -READ_FIFO_QUEUE = 0x18 +READ_FIFO_QUEUE = const(0x18) -READ_FILE_RECORD = 0x14 -WRITE_FILE_RECORD = 0x15 +READ_FILE_RECORD = const(0x14) +WRITE_FILE_RECORD = const(0x15) -READ_EXCEPTION_STATUS = 0x07 -DIAGNOSTICS = 0x08 -GET_COM_EVENT_COUNTER = 0x0B -GET_COM_EVENT_LOG = 0x0C -REPORT_SERVER_ID = 0x11 -READ_DEVICE_IDENTIFICATION = 0x2B +READ_EXCEPTION_STATUS = const(0x07) +DIAGNOSTICS = const(0x08) +GET_COM_EVENT_COUNTER = const(0x0B) +GET_COM_EVENT_LOG = const(0x0C) +REPORT_SERVER_ID = const(0x11) +READ_DEVICE_IDENTIFICATION = const(0x2B) # exception codes -ILLEGAL_FUNCTION = 0x01 -ILLEGAL_DATA_ADDRESS = 0x02 -ILLEGAL_DATA_VALUE = 0x03 -SERVER_DEVICE_FAILURE = 0x04 -ACKNOWLEDGE = 0x05 -SERVER_DEVICE_BUSY = 0x06 -MEMORY_PARITY_ERROR = 0x08 -GATEWAY_PATH_UNAVAILABLE = 0x0A -DEVICE_FAILED_TO_RESPOND = 0x0B +ILLEGAL_FUNCTION = const(0x01) +ILLEGAL_DATA_ADDRESS = const(0x02) +ILLEGAL_DATA_VALUE = const(0x03) +SERVER_DEVICE_FAILURE = const(0x04) +ACKNOWLEDGE = const(0x05) +SERVER_DEVICE_BUSY = const(0x06) +MEMORY_PARITY_ERROR = const(0x08) +GATEWAY_PATH_UNAVAILABLE = const(0x0A) +DEVICE_FAILED_TO_RESPOND = const(0x0B) # PDU constants -CRC_LENGTH = 0x02 -ERROR_BIAS = 0x80 -RESPONSE_HDR_LENGTH = 0x02 -ERROR_RESP_LEN = 0x05 -FIXED_RESP_LEN = 0x08 -MBAP_HDR_LENGTH = 0x07 +CRC_LENGTH = const(0x02) +ERROR_BIAS = const(0x80) +RESPONSE_HDR_LENGTH = const(0x02) +ERROR_RESP_LEN = const(0x05) +FIXED_RESP_LEN = const(0x08) +MBAP_HDR_LENGTH = const(0x07) CRC16_TABLE = ( 0x0000, 0xC0C1, 0xC181, 0x0140, 0xC301, 0x03C0, 0x0280, 0xC241, 0xC601, @@ -85,17 +88,17 @@ 0x4100, 0x81C1, 0x8081, 0x4040 ) -""" Code to generate the CRC-16 lookup table: -def generate_crc16_table(): - crc_table = [] - for byte in range(256): - crc = 0x0000 - for _ in range(8): - if (byte ^ crc) & 0x0001: - crc = (crc >> 1) ^ 0xa001 - else: - crc >>= 1 - byte >>= 1 - crc_table.append(crc) - return crc_table -""" + +# Code to generate the CRC-16 lookup table: +# def generate_crc16_table(): +# crc_table = [] +# for byte in range(256): +# crc = 0x0000 +# for _ in range(8): +# if (byte ^ crc) & 0x0001: +# crc = (crc >> 1) ^ 0xa001 +# else: +# crc >>= 1 +# byte >>= 1 +# crc_table.append(crc) +# return crc_table diff --git a/umodbus/functions.py b/umodbus/functions.py index 1309826..c55d006 100644 --- a/umodbus/functions.py +++ b/umodbus/functions.py @@ -14,37 +14,105 @@ # custom packages from . import const as Const +# typing not natively supported on MicroPython +from .typing import List, Optional, Union -def read_coils(starting_address, quantity): + +def read_coils(starting_address: int, quantity: int) -> bytes: + """ + Create Modbus Protocol Data Unit for reading coils. + + :param starting_address: The starting address + :type starting_address: int + :param quantity: Quantity of coils + :type quantity: int + + :returns: Packed Modbus message + :rtype: bytes + """ if not (1 <= quantity <= 2000): - raise ValueError('invalid number of coils') + raise ValueError('Invalid number of coils') return struct.pack('>BHH', Const.READ_COILS, starting_address, quantity) -def read_discrete_inputs(starting_address, quantity): +def read_discrete_inputs(starting_address: int, quantity: int) -> bytes: + """ + Create Modbus Protocol Data Unit for reading discrete inputs. + + :param starting_address: The starting address + :type starting_address: int + :param quantity: Quantity of coils + :type quantity: int + + :returns: Packed Modbus message + :rtype: bytes + """ if not (1 <= quantity <= 2000): - raise ValueError('invalid number of discrete inputs') + raise ValueError('Invalid number of discrete inputs') - return struct.pack('>BHH', Const.READ_DISCRETE_INPUTS, starting_address, quantity) + return struct.pack('>BHH', + Const.READ_DISCRETE_INPUTS, + starting_address, + quantity) -def read_holding_registers(starting_address, quantity): +def read_holding_registers(starting_address: int, quantity: int) -> bytes: + """ + Create Modbus Protocol Data Unit for reading holding registers. + + :param starting_address: The starting address + :type starting_address: int + :param quantity: Quantity of coils + :type quantity: int + + :returns: Packed Modbus message + :rtype: bytes + """ if not (1 <= quantity <= 125): - raise ValueError('invalid number of holding registers') + raise ValueError('Invalid number of holding registers') + + return struct.pack('>BHH', + Const.READ_HOLDING_REGISTERS, + starting_address, + quantity) - return struct.pack('>BHH', Const.READ_HOLDING_REGISTERS, starting_address, quantity) +def read_input_registers(starting_address: int, quantity: int) -> bytes: + """ + Create Modbus Protocol Data Unit for reading input registers. -def read_input_registers(starting_address, quantity): + :param starting_address: The starting address + :type starting_address: int + :param quantity: Quantity of coils + :type quantity: int + + :returns: Packed Modbus message + :rtype: bytes + """ if not (1 <= quantity <= 125): - raise ValueError('invalid number of input registers') + raise ValueError('Invalid number of input registers') + + return struct.pack('>BHH', + Const.READ_INPUT_REGISTER, + starting_address, + quantity) - return struct.pack('>BHH', Const.READ_INPUT_REGISTER, starting_address, quantity) +def write_single_coil(output_address: int, + output_value: Union[int, bool]) -> bytes: + """ + Create Modbus message to update single coil -def write_single_coil(output_address, output_value): - if output_value not in [0x0000, 0xFF00, True]: + :param output_address: The output address + :type output_address: int + :param output_value: The output value + :type output_value: Union[int, bool] + + :returns: Packed Modbus message + :rtype: bytes + """ + if output_value not in [0x0000, 0xFF00, False, True, 0, 1]: raise ValueError('Illegal coil value') if output_value not in [0x0000, 0xFF00]: @@ -53,53 +121,148 @@ def write_single_coil(output_address, output_value): else: output_value = 0x0000 - return struct.pack('>BHH', Const.WRITE_SINGLE_COIL, output_address, output_value) + return struct.pack('>BHH', + Const.WRITE_SINGLE_COIL, + output_address, + output_value) + + +def write_single_register(register_address: int, + register_value: int, + signed: bool = True) -> bytes: + """ + Create Modbus message to writes a single register + :param register_address: The register address + :type register_address: int + :param register_value: The register value + :type register_value: int + :param signed: Flag whether data is signed or not + :type signed: bool -def write_single_register(register_address, register_value, signed=True): + :returns: Packed Modbus message + :rtype: bytes + """ fmt = 'h' if signed else 'H' - return struct.pack('>BH' + fmt, Const.WRITE_SINGLE_REGISTER, register_address, register_value) + return struct.pack('>BH' + fmt, + Const.WRITE_SINGLE_REGISTER, + register_address, + register_value) + + +def write_multiple_coils(starting_address: int, + value_list: List[int, bool]) -> bytes: + """ + Create Modbus message to update multiple coils + :param starting_address: The starting address + :type starting_address: int + :param value_list: The list of output values + :type value_list: List[int, bool] -def write_multiple_coils(starting_address, value_list): - sectioned_list = [value_list[i:i + 8] for i in range(0, len(value_list), 8)] + :returns: Packed Modbus message + :rtype: bytes + """ + if not (1 <= len(value_list) <= 0x07B0): + raise ValueError('Invalid quantity of outputs') + + sectioned_list = [value_list[i:i + 8] for i in range(0, len(value_list), 8)] # noqa: E501 output_value = [] for index, byte in enumerate(sectioned_list): - output = sum(v << i for i, v in enumerate(byte)) + # see https://github.com/brainelectronics/micropython-modbus/issues/22 + # output = sum(v << i for i, v in enumerate(byte)) + output = 0 + for bit in byte: + output = (output << 1) | bit output_value.append(output) fmt = 'B' * len(output_value) - return struct.pack('>BHHB' + fmt, Const.WRITE_MULTIPLE_COILS, starting_address, len(value_list), ((len(value_list) - 1) // 8) + 1, *output_value) - - -def write_multiple_registers(starting_address, register_values, signed=True): + return struct.pack('>BHHB' + fmt, + Const.WRITE_MULTIPLE_COILS, + starting_address, + len(value_list), # quantity of outputs + ((len(value_list) - 1) // 8) + 1, # byte count + *output_value) + + +def write_multiple_registers(starting_address: int, + register_values: List[int], + signed: bool = True) -> bytes: + """ + Create Modbus message to update multiple coils + + :param starting_address: The starting address + :type starting_address: int + :param register_values: The list of output value + :type register_values: List[int, bool] + :param signed: Flag whether data is signed or not + :type signed: bool + + :returns: Packed Modbus message + :rtype: bytes + """ quantity = len(register_values) if not (1 <= quantity <= 123): - raise ValueError('invalid number of registers') + raise ValueError('Invalid number of registers') fmt = ('h' if signed else 'H') * quantity - return struct.pack('>BHHB' + fmt, Const.WRITE_MULTIPLE_REGISTERS, starting_address, quantity, quantity * 2, *register_values) + return struct.pack('>BHHB' + fmt, + Const.WRITE_MULTIPLE_REGISTERS, + starting_address, + quantity, + quantity * 2, + *register_values) + + +def validate_resp_data(data: bytes, + function_code: int, + address: int, + value: int = None, + quantity: int = None, + signed: bool = True) -> bool: + """ + Validate the response data. + + :param data: The data + :type data: bytes + :param function_code: The function code + :type function_code: int + :param address: The address + :type address: int + :param value: The value + :type value: int + :param quantity: The quantity + :type quantity: int + :param signed: Indicates if signed + :type signed: bool + + :returns: True if valid, False otherwise + :rtype: bool + """ + fmt = '>H' + ('h' if signed else 'H') - -def validate_resp_data(data, - function_code, - address, - value=None, - quantity=None, - signed=True): if function_code in [Const.WRITE_SINGLE_COIL, Const.WRITE_SINGLE_REGISTER]: - fmt = '>H' + ('h' if signed else 'H') resp_addr, resp_value = struct.unpack(fmt, data) + # if bool(True) or int(1) is used as "output_value" of + # "write_single_coil" it will be internally converted to int(0xFF00), + # see Modbus specification, which is actually int(65280). + # Due to the non binary, but real value comparison of "value" and + # "resp_value", it would never match without the next two lines + # see #21 + if function_code == Const.WRITE_SINGLE_COIL: + resp_value = bool(resp_value) + value = bool(value) + if (address == resp_addr) and (value == resp_value): return True - - elif function_code in [Const.WRITE_MULTIPLE_COILS, Const.WRITE_MULTIPLE_REGISTERS]: - resp_addr, resp_qty = struct.unpack('>HH', data) + elif function_code in [Const.WRITE_MULTIPLE_COILS, + Const.WRITE_MULTIPLE_REGISTERS]: + resp_addr, resp_qty = struct.unpack(fmt, data) if (address == resp_addr) and (quantity == resp_qty): return True @@ -107,24 +270,32 @@ def validate_resp_data(data, return False -def response(function_code, - request_register_addr, - request_register_qty, +def response(function_code: int, + request_register_addr: int, + request_register_qty: int, request_data, value_list=None, signed=True): if function_code in [Const.READ_COILS, Const.READ_DISCRETE_INPUTS]: - output_value = [] - sectioned_list = [value_list[i:i + 8] for i in range(0, len(value_list), 8)] + sectioned_list = [value_list[i:i + 8] for i in range(0, len(value_list), 8)] # noqa: E501 + output_value = [] for index, byte in enumerate(sectioned_list): - output = sum(v << i for i, v in enumerate(byte)) + # see https://github.com/brainelectronics/micropython-modbus/issues/22 + # output = sum(v << i for i, v in enumerate(byte)) + output = 0 + for bit in byte: + output = (output << 1) | bit output_value.append(output) fmt = 'B' * len(output_value) - return struct.pack('>BB' + fmt, function_code, ((len(value_list) - 1) // 8) + 1, *output_value) + return struct.pack('>BB' + fmt, + function_code, + ((len(value_list) - 1) // 8) + 1, + *output_value) - elif function_code in [Const.READ_HOLDING_REGISTERS, Const.READ_INPUT_REGISTER]: + elif function_code in [Const.READ_HOLDING_REGISTERS, + Const.READ_INPUT_REGISTER]: quantity = len(value_list) if not (0x0001 <= quantity <= 0x007D): @@ -137,26 +308,121 @@ def response(function_code, for s in signed: fmt += 'h' if s else 'H' - return struct.pack('>BB' + fmt, function_code, quantity * 2, *value_list) + return struct.pack('>BB' + fmt, + function_code, + quantity * 2, + *value_list) + + elif function_code in [Const.WRITE_SINGLE_COIL, + Const.WRITE_SINGLE_REGISTER]: + return struct.pack('>BHBB', + function_code, + request_register_addr, + *request_data) + + elif function_code in [Const.WRITE_MULTIPLE_COILS, + Const.WRITE_MULTIPLE_REGISTERS]: + return struct.pack('>BHH', + function_code, + request_register_addr, + request_register_qty) + + +def exception_response(function_code: int, exception_code: int) -> bytes: + """ + Create Modbus exception response + + :param function_code: The function code + :type function_code: int + :param exception_code: The exception code + :type exception_code: int + + :returns: Packed Modbus message + :rtype: bytes + """ + return struct.pack('>BB', Const.ERROR_BIAS + function_code, exception_code) - elif function_code in [Const.WRITE_SINGLE_COIL, Const.WRITE_SINGLE_REGISTER]: - return struct.pack('>BHBB', function_code, request_register_addr, *request_data) - elif function_code in [Const.WRITE_MULTIPLE_COILS, Const.WRITE_MULTIPLE_REGISTERS]: - return struct.pack('>BHH', function_code, request_register_addr, request_register_qty) +def bytes_to_bool(byte_list: bytes, bit_qty: Optional[int] = 1) -> List[bool]: + """ + Convert bytes to list of boolean values + :param byte_list: The byte list + :type byte_list: bytes + :param bit_qty: Amount of bits received + :type bit_qty: Optional[int] -def exception_response(function_code, exception_code): - return struct.pack('>BB', Const.ERROR_BIAS + function_code, exception_code) + :returns: Boolean representation + :rtype: List[bool] + """ + # evil hack for missing keyword support in MicroPython format() + fmt = '{:0' + str(bit_qty) + 'b}' + + bool_list = [bool(int(x)) for x in fmt.format(list(byte_list)[0])] + + # invert list due to byte order + return bool_list[::-1] + + +def to_short(byte_array: bytes, signed: bool = True) -> bytes: + """ + Convert bytes to tuple of integer values + + :param byte_array: The byte array + :type byte_array: bytes + :param signed: Indicates if signed + :type signed: bool + :returns: Integer representation + :rtype: bytes + """ + response_quantity = int(len(byte_array) / 2) + fmt = '>' + (('h' if signed else 'H') * response_quantity) -def float_to_bin(num): - return bin(struct.unpack('!I', struct.pack('!f', num))[0])[2:].zfill(32) + return struct.unpack(fmt, byte_array) -def bin_to_float(binary): +def float_to_bin(num: float) -> bin: + """ + Convert floating point value to binary + + See IEEE 754 + + :param num: The number + :type num: float + + :returns: Binary representation + :rtype: bin + """ + # no "zfill" available in MicroPython + # return bin(struct.unpack('!I', struct.pack('!f', num))[0])[2:].zfill(32) + + return '{:0>{w}}'.format( + bin(struct.unpack('!I', struct.pack('!f', num))[0])[2:], + w=32) + + +def bin_to_float(binary: str) -> float: + """ + Convert binary string to floating point value + + :param binary: The binary string + :type binary: str + + :returns: Converted floating point value + :rtype: float + """ return struct.unpack('!f', struct.pack('!I', int(binary, 2)))[0] -def int_to_bin(num): +def int_to_bin(num: int) -> str: + """ + Convert integer to binary + + :param num: The number + :type num: int + + :returns: Binary representation of given input + :rtype: str + """ return "{0:b}".format(num) diff --git a/umodbus/modbus.py b/umodbus/modbus.py index 7f425ac..2ffbc49 100644 --- a/umodbus/modbus.py +++ b/umodbus/modbus.py @@ -9,10 +9,6 @@ import time # custom packages -from .serial import Serial -from .tcp import TCPServer -from . import const as ModbusConst -from urequests import request # typing not natively supported on MicroPython from .typing import List, Optional @@ -31,7 +27,7 @@ def __init__(self, itf, addr_list: list): for reg_type in self._available_register_types: self._register_dict[reg_type] = dict() self._default_vals = dict(zip(self._available_register_types, - [True, 999, 999, True])) + [False, 0, 0, False])) # registers which can be set by remote device self._changeable_register_types = ['COILS', 'HREGS'] @@ -475,143 +471,6 @@ def _remove_changed_register(self, return result - def process(self) -> bool: - """ - Process the modbus requests. - - :returns: Result of processing, True on success, False otherwise - :rtype: bool - """ - reg_type = None - req_type = None - - request = self._itf.get_request(unit_addr_list=self._addr_list, - timeout=0) - if request is None: - return False - - if request.function == ModbusConst.READ_COILS: - # Coils (setter+getter) [0, 1] - # function 01 - read single register - reg_type = 'COILS' - req_type = 'READ' - elif request.function == ModbusConst.READ_DISCRETE_INPUTS: - # Ists (only getter) [0, 1] - # function 02 - read input status (discrete inputs/digital input) - reg_type = 'ISTS' - req_type = 'READ' - elif request.function == ModbusConst.READ_HOLDING_REGISTERS: - # Hregs (setter+getter) [0, 65535] - # function 03 - read holding register - reg_type = 'HREGS' - req_type = 'READ' - elif request.function == ModbusConst.READ_INPUT_REGISTER: - # Iregs (only getter) [0, 65535] - # function 04 - read input registers - reg_type = 'IREGS' - req_type = 'READ' - elif request.function == ModbusConst.WRITE_SINGLE_COIL: - # Coils (setter+getter) [0, 1] - # function 05 - write single register - reg_type = 'COILS' - req_type = 'WRITE' - elif request.function == ModbusConst.WRITE_SINGLE_REGISTER: - # Hregs (setter+getter) [0, 65535] - # function 06 - write holding register - reg_type = 'HREGS' - req_type = 'WRITE' - else: - request.send_exception(ModbusConst.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 - - def _create_response(self, request: request, reg_type: str): - """ - Create a response. - - :param request: The request - :type request: request - :param reg_type: The register type - :type reg_type: str - - :returns: Values of this register - :rtype: Union[bool, int, List[int], List[bool]] - """ - if type(self._register_dict[reg_type][request.register_addr]) is list: - return self._register_dict[reg_type][request.register_addr] - else: - return [self._register_dict[reg_type][request.register_addr]] - - def _process_read_access(self, request: request, reg_type: str) -> None: - """ - Process read access to register - - :param request: The request - :type request: request - :param reg_type: The register type - :type reg_type: str - """ - if request.register_addr in self._register_dict[reg_type]: - vals = self._create_response(request=request, reg_type=reg_type) - request.send_response(vals) - else: - request.send_exception(ModbusConst.ILLEGAL_DATA_ADDRESS) - - def _process_write_access(self, request: request, reg_type: str) -> None: - """ - Process write access to register - - :param request: The request - :type request: request - :param reg_type: The register type - :type reg_type: str - """ - address = request.register_addr - val = 0 - valid_register = False - - if address in self._register_dict[reg_type]: - if reg_type == 'COILS': - val = request.data[0] - if val == 0x00: - val = False - valid_register = True - - request.send_response() - - self.set_coil(address=address, value=val) - elif val == 0xFF: - val = True - valid_register = True - - request.send_response() - - self.set_coil(address=address, value=val) - else: - request.send_exception(ModbusConst.ILLEGAL_DATA_VALUE) - elif reg_type == 'HREGS': - valid_register = True - val = request.data_as_registers(signed=False)[0] - - request.send_response() - - self.set_hreg(address=address, value=val) - else: - pass - - if valid_register: - self._set_changed_register(reg_type=reg_type, - address=address, - value=val) - else: - request.send_exception(ModbusConst.ILLEGAL_DATA_ADDRESS) - def setup_registers(self, registers: dict = dict(), use_default_vals: Optional[bool] = False) -> None: @@ -654,46 +513,3 @@ def setup_registers(self, pass else: pass - - -class ModbusRTU(Modbus): - def __init__(self, - addr, - baudrate=9600, - data_bits=8, - stop_bits=1, - parity=None, - pins=None, - ctrl_pin=None): - super().__init__( - # set itf to Serial object, addr_list to [addr] - Serial(uart_id=1, - baudrate=baudrate, - data_bits=data_bits, - stop_bits=stop_bits, - parity=parity, - pins=pins, - ctrl_pin=ctrl_pin), - [addr] - ) - - -class ModbusTCP(Modbus): - def __init__(self): - super().__init__( - # set itf to TCPServer object, addr_list to None - TCPServer(), - None - ) - - def bind(self, - local_ip: str, - local_port: int = 502, - max_connections: int = 10) -> None: - self._itf.bind(local_ip, local_port, max_connections) - - def get_bound_status(self) -> bool: - try: - return self._itf.get_is_bound() - except Exception: - return False diff --git a/umodbus/serial.py b/umodbus/serial.py index cf070aa..0d19a44 100644 --- a/umodbus/serial.py +++ b/umodbus/serial.py @@ -20,17 +20,43 @@ from . import functions from .common import Request from .common import ModbusException +from .modbus import Modbus +# typing not natively supported on MicroPython +from .typing import List, Optional, Union -class Serial(object): + +class ModbusRTU(Modbus): def __init__(self, - uart_id=1, + addr, baudrate=9600, data_bits=8, stop_bits=1, parity=None, pins=None, ctrl_pin=None): + super().__init__( + # set itf to Serial object, addr_list to [addr] + Serial(uart_id=1, + baudrate=baudrate, + data_bits=data_bits, + stop_bits=stop_bits, + parity=parity, + pins=pins, + ctrl_pin=ctrl_pin), + [addr] + ) + + +class Serial(object): + def __init__(self, + uart_id: int = 1, + baudrate: int = 9600, + data_bits: int = 8, + stop_bits: int = 1, + parity=None, + pins: List[int, int] = None, + ctrl_pin: int = None): self._uart = UART(uart_id, baudrate=baudrate, bits=data_bits, @@ -61,21 +87,6 @@ def _calculate_crc16(self, data): return struct.pack('<H', crc) - def _bytes_to_bool(self, byte_list): - bool_list = [] - - for index, byte in enumerate(byte_list): - bool_list.extend([bool(byte & (1 << n)) for n in range(8)]) - - return bool_list - - def _to_short(self, byte_array, signed=True): - response_quantity = int(len(byte_array) / 2) - - fmt = '>' + (('h' if signed else 'H') * response_quantity) - - return struct.unpack(fmt, byte_array) - def _exit_read(self, response): if response[1] >= Const.ERROR_BIAS: if len(response) < Const.ERROR_RESP_LEN: @@ -89,7 +100,13 @@ def _exit_read(self, response): return True - def _uart_read(self): + def _uart_read(self) -> bytearray: + """ + Read up to 40 bytes from UART + + :returns: Read content + :rtype: bytearray + """ response = bytearray() for x in range(1, 40): @@ -105,7 +122,7 @@ def _uart_read(self): return response - def _uart_read_frame(self, timeout=None): + def _uart_read_frame(self, timeout: Optional[int] = None): received_bytes = bytearray() # set timeout to at least twice the time between two frames in case the @@ -145,7 +162,17 @@ def _uart_read_frame(self, timeout=None): # return the result in case the overall timeout has been reached return received_bytes - def _send(self, modbus_pdu, slave_addr): + def _send(self, modbus_pdu: bytes, slave_addr: int) -> None: + """ + Send Modbus frame via UART + + If a flow control pin has been setup, it will be controller accordingly + + :param modbus_pdu: The modbus Protocol Data Unit + :type modbus_pdu: bytes + :param slave_addr: The slave address + :type slave_addr: int + """ serial_pdu = bytearray() serial_pdu.append(slave_addr) serial_pdu.extend(modbus_pdu) @@ -164,22 +191,63 @@ def _send(self, modbus_pdu, slave_addr): time.sleep_us(self._t35chars) self._ctrlPin(0) - def _send_receive(self, modbus_pdu, slave_addr, count): + def _send_receive(self, + modbus_pdu: bytes, + slave_addr: int, + count: bool) -> bytes: + """ + Send a modbus message and receive the reponse. + + :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 + """ # flush the Rx FIFO self._uart.read() - self._send(modbus_pdu, slave_addr) - - return self._validate_resp_hdr(self._uart_read(), slave_addr, modbus_pdu[0], count) - - def _validate_resp_hdr(self, response, slave_addr, function_code, count): + self._send(modbus_pdu=modbus_pdu, slave_addr=slave_addr) + + return self._validate_resp_hdr(response=self._uart_read(), + slave_addr=slave_addr, + function_code=modbus_pdu[0], + count=count) + + def _validate_resp_hdr(self, + response: bytearray, + slave_addr: int, + function_code: int, + count: bool) -> bytes: + """ + Validate the response header. + + :param response: The response + :type response: bytearray + :param slave_addr: The slave address + :type slave_addr: int + :param function_code: The function code + :type function_code: int + :param count: The count + :type count: bool + + :returns: Modbus response content + :rtype: bytes + """ if len(response) == 0: raise OSError('no data received from slave') resp_crc = response[-Const.CRC_LENGTH:] - expected_crc = self._calculate_crc16(response[0:len(response) - Const.CRC_LENGTH]) + expected_crc = self._calculate_crc16( + response[0:len(response) - Const.CRC_LENGTH] + ) - if ((resp_crc[0] is not expected_crc[0]) or (resp_crc[1] is not expected_crc[1])): + if ((resp_crc[0] is not expected_crc[0]) or + (resp_crc[1] is not expected_crc[1])): raise OSError('invalid response CRC') if (response[0] != slave_addr): @@ -189,139 +257,199 @@ def _validate_resp_hdr(self, response, slave_addr, function_code, count): raise ValueError('slave returned exception code: {:d}'. format(response[2])) - hdr_length = (Const.RESPONSE_HDR_LENGTH + 1) if count else Const.RESPONSE_HDR_LENGTH + hdr_length = (Const.RESPONSE_HDR_LENGTH + 1) if count else \ + Const.RESPONSE_HDR_LENGTH return response[hdr_length:len(response) - Const.CRC_LENGTH] - def read_coils(self, slave_addr, starting_addr, coil_qty): - modbus_pdu = functions.read_coils(starting_addr, coil_qty) + def read_coils(self, + slave_addr: int, + starting_addr: int, + coil_qty: int) -> List[bool]: + modbus_pdu = functions.read_coils(starting_address=starting_addr, + quantity=coil_qty) - resp_data = self._send_receive(modbus_pdu, slave_addr, True) - status_pdu = self._bytes_to_bool(resp_data) + resp_data = self._send_receive(modbus_pdu=modbus_pdu, + slave_addr=slave_addr, + count=True) + status_pdu = functions.bytes_to_bool(byte_list=resp_data, + bit_qty=coil_qty) return status_pdu - def read_discrete_inputs(self, slave_addr, starting_addr, input_qty): - modbus_pdu = functions.read_discrete_inputs(starting_addr, input_qty) + def read_discrete_inputs(self, + slave_addr: int, + starting_addr: int, + input_qty: int) -> List[bool]: + modbus_pdu = functions.read_discrete_inputs( + starting_address=starting_addr, + quantity=input_qty) - resp_data = self._send_receive(modbus_pdu, slave_addr, True) - status_pdu = self._bytes_to_bool(resp_data) + resp_data = self._send_receive(modbus_pdu=modbus_pdu, + slave_addr=slave_addr, + count=True) + status_pdu = functions.bytes_to_bool(byte_list=resp_data, + bit_qty=input_qty) return status_pdu def read_holding_registers(self, - slave_addr, - starting_addr, - register_qty, - signed=True): - modbus_pdu = functions.read_holding_registers(starting_addr, register_qty) - - resp_data = self._send_receive(modbus_pdu, slave_addr, True) - register_value = self._to_short(resp_data, signed) + slave_addr: int, + starting_addr: int, + register_qty: int, + signed: bool = True) -> List[int]: + modbus_pdu = functions.read_holding_registers( + starting_address=starting_addr, + quantity=register_qty) + + resp_data = self._send_receive(modbus_pdu=modbus_pdu, + slave_addr=slave_addr, + count=True) + register_value = functions.to_short(byte_array=resp_data, + signed=signed) return register_value def read_input_registers(self, - slave_addr, - starting_addr, - register_qty, - signed=True): - modbus_pdu = functions.read_input_registers(starting_addr, - register_qty) - - resp_data = self._send_receive(modbus_pdu, slave_addr, True) - register_value = self._to_short(resp_data, signed) + slave_addr: int, + starting_addr: int, + register_qty: int, + signed: bool = True) -> List[int]: + modbus_pdu = functions.read_input_registers( + starting_address=starting_addr, + quantity=register_qty) + + resp_data = self._send_receive(modbus_pdu=modbus_pdu, + slave_addr=slave_addr, + count=True) + register_value = functions.to_short(byte_array=resp_data, + signed=signed) return register_value - def write_single_coil(self, slave_addr, output_address, output_value): - modbus_pdu = functions.write_single_coil(output_address, output_value) - - resp_data = self._send_receive(modbus_pdu, slave_addr, False) - operation_status = functions.validate_resp_data(resp_data, - Const.WRITE_SINGLE_COIL, - output_address, - value=output_value, - signed=False) + def write_single_coil(self, + slave_addr: int, + output_address: int, + output_value: Union[int, bool]) -> bool: + """ + Update a single coil. + + :param slave_addr: The slave address + :type slave_addr: int + :param output_address: The output address + :type output_address: int + :param output_value: The output value + :type output_value: Union[int, bool] + + :returns: Result of operation + :rtype: bool + """ + modbus_pdu = functions.write_single_coil(output_address=output_address, + output_value=output_value) + + resp_data = self._send_receive(modbus_pdu=modbus_pdu, + slave_addr=slave_addr, + count=False) + operation_status = functions.validate_resp_data( + data=resp_data, + function_code=Const.WRITE_SINGLE_COIL, + address=output_address, + value=output_value, + signed=False) return operation_status def write_single_register(self, - slave_addr, - register_address, - register_value, - signed=True): + slave_addr: int, + register_address: int, + register_value: int, + signed=True) -> bool: modbus_pdu = functions.write_single_register(register_address, register_value, signed) - resp_data = self._send_receive(modbus_pdu, slave_addr, False) - operation_status = functions.validate_resp_data(resp_data, - Const.WRITE_SINGLE_REGISTER, - register_address, - value=register_value, - signed=signed) + resp_data = self._send_receive(modbus_pdu=modbus_pdu, + slave_addr=slave_addr, + count=False) + operation_status = functions.validate_resp_data( + data=resp_data, + function_code=Const.WRITE_SINGLE_REGISTER, + address=register_address, + value=register_value, + signed=signed) return operation_status def write_multiple_coils(self, - slave_addr, - starting_address, - output_values): - modbus_pdu = functions.write_multiple_coils(starting_address, - output_values) - - resp_data = self._send_receive(modbus_pdu, slave_addr, False) - operation_status = functions.validate_resp_data(resp_data, - Const.WRITE_MULTIPLE_COILS, - starting_address, - quantity=len(output_values)) + slave_addr: int, + starting_address: int, + output_values: List[int, bool]) -> bool: + modbus_pdu = functions.write_multiple_coils( + starting_address=starting_address, + value_list=output_values) + + resp_data = self._send_receive(modbus_pdu=modbus_pdu, + slave_addr=slave_addr, + count=False) + operation_status = functions.validate_resp_data( + data=resp_data, + function_code=Const.WRITE_MULTIPLE_COILS, + address=starting_address, + quantity=len(output_values)) return operation_status def write_multiple_registers(self, - slave_addr, - starting_address, - register_values, - signed=True): - modbus_pdu = functions.write_multiple_registers(starting_address, - register_values, - signed) - - resp_data = self._send_receive(modbus_pdu, slave_addr, False) - operation_status = functions.validate_resp_data(resp_data, - Const.WRITE_MULTIPLE_REGISTERS, - starting_address, - quantity=len(register_values)) + slave_addr: int, + starting_address: int, + register_values: List[int], + signed=True) -> bool: + modbus_pdu = functions.write_multiple_registers( + starting_address=starting_address, + register_values=register_values, + signed=signed) + + resp_data = self._send_receive(modbus_pdu=modbus_pdu, + slave_addr=slave_addr, + count=False) + operation_status = functions.validate_resp_data( + data=resp_data, + function_code=Const.WRITE_MULTIPLE_REGISTERS, + address=starting_address, + quantity=len(register_values) + ) return operation_status def send_response(self, - slave_addr, - function_code, - request_register_addr, - request_register_qty, - request_data, - values=None, - signed=True): - modbus_pdu = functions.response(function_code, - request_register_addr, - request_register_qty, - request_data, - values, - signed) - self._send(modbus_pdu, slave_addr) + 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: + 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) def send_exception_response(self, - slave_addr, - function_code, - exception_code): - modbus_pdu = functions.exception_response(function_code, - exception_code) - self._send(modbus_pdu, slave_addr) + slave_addr: int, + function_code: int, + exception_code: int) -> None: + 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, timeout=None): - req = self._uart_read_frame(timeout) + def get_request(self, unit_addr_list: list, timeout: Optional[int] = None): + req = self._uart_read_frame(timeout=timeout) if len(req) < 8: return None @@ -337,11 +465,12 @@ def get_request(self, unit_addr_list, timeout=None): return None try: - request = Request(self, req_no_crc) + request = Request(interface=self, data=req_no_crc) except ModbusException as e: - self.send_exception_response(req[0], - e.function_code, - e.exception_code) + self.send_exception_response( + slave_addr=req[0], + function_code=e.function_code, + exception_code=e.exception_code) return None return request diff --git a/umodbus/tcp.py b/umodbus/tcp.py index fd9235d..3979b95 100644 --- a/umodbus/tcp.py +++ b/umodbus/tcp.py @@ -9,7 +9,7 @@ # # system packages -import random +# import random import struct import socket import time @@ -19,11 +19,178 @@ from . import const as Const from .common import Request from .common import ModbusException +from .modbus import Modbus +from urequests import request + +# typing not natively supported on MicroPython +from .typing import List, Optional, Tuple, Union + + +class ModbusTCP(Modbus): + def __init__(self): + super().__init__( + # set itf to TCPServer object, addr_list to None + TCPServer(), + None + ) + + def bind(self, + local_ip: str, + local_port: int = 502, + max_connections: int = 10) -> None: + self._itf.bind(local_ip, local_port, max_connections) + + def get_bound_status(self) -> bool: + try: + return self._itf.get_is_bound() + except Exception: + return False + + def process(self) -> bool: + """ + Process the modbus requests. + + :returns: Result of processing, True on success, False otherwise + :rtype: bool + """ + reg_type = None + req_type = None + + request = self._itf.get_request(unit_addr_list=self._addr_list, + timeout=0) + if request is None: + return False + + if request.function == Const.READ_COILS: + # Coils (setter+getter) [0, 1] + # function 01 - read single register + reg_type = 'COILS' + req_type = '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' + elif request.function == Const.READ_HOLDING_REGISTERS: + # Hregs (setter+getter) [0, 65535] + # function 03 - read holding register + reg_type = 'HREGS' + req_type = 'READ' + elif request.function == Const.READ_INPUT_REGISTER: + # Iregs (only getter) [0, 65535] + # function 04 - read input registers + reg_type = 'IREGS' + req_type = 'READ' + elif request.function == Const.WRITE_SINGLE_COIL: + # Coils (setter+getter) [0, 1] + # function 05 - write single register + reg_type = 'COILS' + req_type = 'WRITE' + elif request.function == Const.WRITE_SINGLE_REGISTER: + # Hregs (setter+getter) [0, 65535] + # function 06 - write holding register + reg_type = 'HREGS' + req_type = 'WRITE' + else: + 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 + + def _create_response(self, request: request, reg_type: str): + """ + Create a response. + + :param request: The request + :type request: request + :param reg_type: The register type + :type reg_type: str + + :returns: Values of this register + :rtype: Union[bool, int, List[int], List[bool]] + """ + if type(self._register_dict[reg_type][request.register_addr]) is list: + return self._register_dict[reg_type][request.register_addr] + else: + return [self._register_dict[reg_type][request.register_addr]] + + def _process_read_access(self, request: request, reg_type: str) -> None: + """ + Process read access to register + + :param request: The request + :type request: request + :param reg_type: The register type + :type reg_type: str + """ + if request.register_addr in self._register_dict[reg_type]: + vals = self._create_response(request=request, reg_type=reg_type) + request.send_response(vals) + else: + request.send_exception(Const.ILLEGAL_DATA_ADDRESS) + + def _process_write_access(self, request: request, reg_type: str) -> None: + """ + Process write access to register + + :param request: The request + :type request: request + :param reg_type: The register type + :type reg_type: str + """ + address = request.register_addr + val = 0 + valid_register = False + + if address in self._register_dict[reg_type]: + if reg_type == 'COILS': + val = request.data[0] + if val == 0x00: + val = False + valid_register = True + + request.send_response() + + self.set_coil(address=address, value=val) + elif val == 0xFF: + val = True + valid_register = True + + request.send_response() + + self.set_coil(address=address, value=val) + else: + request.send_exception(Const.ILLEGAL_DATA_VALUE) + elif reg_type == 'HREGS': + valid_register = True + val = request.data_as_registers(signed=False)[0] + + request.send_response() + + self.set_hreg(address=address, value=val) + else: + pass + + if valid_register: + self._set_changed_register(reg_type=reg_type, + address=address, + value=val) + else: + request.send_exception(Const.ILLEGAL_DATA_ADDRESS) class TCP(object): - def __init__(self, slave_ip, slave_port=502, timeout=5): + def __init__(self, + slave_ip: str, + slave_port: int = 502, + timeout: float = 5.0): self._sock = socket.socket() + self.trans_id_ctr = 0 # print(socket.getaddrinfo(slave_ip, slave_port)) # [(2, 1, 0, '192.168.178.47', ('192.168.178.47', 502))] @@ -31,167 +198,340 @@ def __init__(self, slave_ip, slave_port=502, timeout=5): self._sock.settimeout(timeout) - def _create_mbap_hdr(self, slave_id, modbus_pdu): + def _create_mbap_hdr(self, + slave_id: int, + modbus_pdu: bytes) -> Tuple[bytes, int]: + """ + Create a Modbus header. + + :param slave_id: The slave identifier + :type slave_id: int + :param modbus_pdu: The modbus Protocol Data Unit + :type modbus_pdu: bytes + + :returns: Modbus header and unique transaction ID + :rtype: Tuple[bytes, int] + """ # only available on WiPy # trans_id = machine.rng() & 0xFFFF # use builtin function to generate random 24 bit integer - trans_id = random.getrandbits(24) & 0xFFFF + # trans_id = random.getrandbits(24) & 0xFFFF + # use incrementing counter as it's faster + trans_id = self.trans_id_ctr + self.trans_id_ctr += 1 - mbap_hdr = struct.pack('>HHHB', trans_id, 0, len(modbus_pdu) + 1, slave_id) + mbap_hdr = struct.pack( + '>HHHB', trans_id, 0, len(modbus_pdu) + 1, slave_id) return mbap_hdr, trans_id - def _bytes_to_bool(self, byte_list): - bool_list = [] - for index, byte in enumerate(byte_list): - bool_list.extend([bool(byte & (1 << n)) for n in range(8)]) - - return bool_list - - def _to_short(self, byte_array, signed=True): - response_quantity = int(len(byte_array) / 2) - fmt = '>' + (('h' if signed else 'H') * response_quantity) - - return struct.unpack(fmt, byte_array) - def _validate_resp_hdr(self, - response, - trans_id, - slave_id, - function_code, - count=False): - rec_tid, rec_pid, rec_len, rec_uid, rec_fc = struct.unpack('>HHHBB', response[:Const.MBAP_HDR_LENGTH + 1]) + response: bytearray, + trans_id: int, + slave_id: int, + function_code: int, + count: bool = False) -> bytes: + """ + Validate the response header. + + :param response: The response + :type response: bytearray + :param trans_id: The transaction identifier + :type trans_id: int + :param slave_id: The slave identifier + :type slave_id: int + :param function_code: The function code + :type function_code: int + :param count: The count + :type count: bool + + :returns: Modbus response content + :rtype: bytes + """ + rec_tid, rec_pid, rec_len, rec_uid, rec_fc = struct.unpack( + '>HHHBB', response[:Const.MBAP_HDR_LENGTH + 1]) + if (trans_id != rec_tid): - raise ValueError('wrong transaction Id') + raise ValueError('wrong transaction ID') if (rec_pid != 0): - raise ValueError('invalid protocol Id') + raise ValueError('invalid protocol ID') if (slave_id != rec_uid): - raise ValueError('wrong slave Id') + raise ValueError('wrong slave ID') if (rec_fc == (function_code + Const.ERROR_BIAS)): raise ValueError('slave returned exception code: {:d}'. format(rec_fc)) - hdr_length = (Const.MBAP_HDR_LENGTH + 2) if count else (Const.MBAP_HDR_LENGTH + 1) + hdr_length = (Const.MBAP_HDR_LENGTH + 2) if count else \ + (Const.MBAP_HDR_LENGTH + 1) return response[hdr_length:] - def _send_receive(self, slave_id, modbus_pdu, count): - mbap_hdr, trans_id = self._create_mbap_hdr(slave_id, modbus_pdu) + def _send_receive(self, + slave_id: int, + modbus_pdu: bytes, + count: bool) -> bytes: + """ + Send a modbus message and receive the reponse. + + :param slave_id: The slave identifier + :type slave_id: int + :param modbus_pdu: The modbus PDU + :type modbus_pdu: bytes + :param count: The count + :type count: bool + + :returns: Modbus data + :rtype: bytes + """ + mbap_hdr, trans_id = self._create_mbap_hdr(slave_id=slave_id, + modbus_pdu=modbus_pdu) self._sock.send(mbap_hdr + modbus_pdu) response = self._sock.recv(256) - modbus_data = self._validate_resp_hdr(response, - trans_id, - slave_id, - modbus_pdu[0], - count) + modbus_data = self._validate_resp_hdr(response=response, + trans_id=trans_id, + slave_id=slave_id, + function_code=modbus_pdu[0], + count=count) return modbus_data - def read_coils(self, slave_addr, starting_addr, coil_qty): - modbus_pdu = functions.read_coils(starting_addr, coil_qty) - - response = self._send_receive(slave_addr, modbus_pdu, True) - status_pdu = self._bytes_to_bool(response) + def read_coils(self, + slave_addr: int, + starting_addr: int, + coil_qty: int) -> List[bool]: + """ + Read coils (COILS). + + :param slave_addr: The slave address + :type slave_addr: int + :param starting_addr: The coil starting address + :type starting_addr: int + :param coil_qty: The amount of coils to read + :type coil_qty: int + + :returns: State of read coils as list + :rtype: List[bool] + """ + modbus_pdu = functions.read_coils( + starting_address=starting_addr, + quantity=coil_qty) + + response = self._send_receive(slave_id=slave_addr, + modbus_pdu=modbus_pdu, + count=True) + status_pdu = functions.bytes_to_bool(byte_list=response, + bit_qty=coil_qty) return status_pdu - def read_discrete_inputs(self, slave_addr, starting_addr, input_qty): - modbus_pdu = functions.read_discrete_inputs(starting_addr, input_qty) - - response = self._send_receive(slave_addr, modbus_pdu, True) - status_pdu = self._bytes_to_bool(response) + def read_discrete_inputs(self, + slave_addr: int, + starting_addr: int, + input_qty: int) -> List[bool]: + """ + Read discrete inputs (ISTS). + + :param slave_addr: The slave address + :type slave_addr: int + :param starting_addr: The discrete input starting address + :type starting_addr: int + :param input_qty: The amount of discrete inputs to read + :type input_qty: int + + :returns: State of read discrete inputs as list + :rtype: List[bool] + """ + modbus_pdu = functions.read_discrete_inputs( + starting_address=starting_addr, + quantity=input_qty) + + response = self._send_receive(slave_id=slave_addr, + modbus_pdu=modbus_pdu, + count=True) + status_pdu = functions.bytes_to_bool(byte_list=response, + bit_qty=input_qty) return status_pdu def read_holding_registers(self, - slave_addr, - starting_addr, - register_qty, - signed=True): - modbus_pdu = functions.read_holding_registers(starting_addr, - register_qty) - - response = self._send_receive(slave_addr, modbus_pdu, True) - register_value = self._to_short(response, signed) + slave_addr: int, + starting_addr: int, + register_qty: int, + signed: bool = True) -> Tuple[int, ...]: + """ + Read holding registers (HREGS). + + :param slave_addr: The slave address + :type slave_addr: int + :param starting_addr: The holding register starting address + :type starting_addr: int + :param register_qty: The amount of holding registers to read + :type register_qty: int + :param signed: Indicates if signed + :type signed: bool + + :returns: State of read holding register as tuple + :rtype: Tuple[int, ...] + """ + modbus_pdu = functions.read_holding_registers( + starting_address=starting_addr, + quantity=register_qty) + + response = self._send_receive(slave_id=slave_addr, + modbus_pdu=modbus_pdu, + count=True) + register_value = functions.to_short(byte_array=response, signed=signed) return register_value def read_input_registers(self, - slave_addr, - starting_addr, - register_qty, - signed=True): - modbus_pdu = functions.read_input_registers(starting_addr, - register_qty) - - response = self._send_receive(slave_addr, modbus_pdu, True) - register_value = self._to_short(response, signed) + slave_addr: int, + starting_addr: int, + register_qty: int, + signed: bool = True) -> Tuple[int, ...]: + """ + Read input registers (IREGS). + + :param slave_addr: The slave address + :type slave_addr: int + :param starting_addr: The input register starting address + :type starting_addr: int + :param register_qty: The amount of input registers to read + :type register_qty: int + :param signed: Indicates if signed + :type signed: bool + + :returns: State of read input register as tuple + :rtype: Tuple[int, ...] + """ + modbus_pdu = functions.read_input_registers( + starting_address=starting_addr, + quantity=register_qty) + + response = self._send_receive(slave_id=slave_addr, + modbus_pdu=modbus_pdu, + count=True) + register_value = functions.to_short(byte_array=response, signed=signed) return register_value - def write_single_coil(self, slave_addr, output_address, output_value): - modbus_pdu = functions.write_single_coil(output_address, output_value) - - response = self._send_receive(slave_addr, modbus_pdu, False) - operation_status = functions.validate_resp_data(response, - Const.WRITE_SINGLE_COIL, - output_address, - value=output_value, - signed=False) + def write_single_coil(self, + slave_addr: int, + output_address: int, + output_value: Union[int, bool]) -> bool: + """ + Update a single coil. + + :param slave_addr: The slave address + :type slave_addr: int + :param output_address: The output address + :type output_address: int + :param output_value: The output value + :type output_value: Union[int, bool] + + :returns: Result of operation + :rtype: bool + """ + modbus_pdu = functions.write_single_coil(output_address=output_address, + output_value=output_value) + + response = self._send_receive(slave_id=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 def write_single_register(self, - slave_addr, - register_address, - register_value, - signed=True): - modbus_pdu = functions.write_single_register(register_address, - register_value, - signed) - - response = self._send_receive(slave_addr, modbus_pdu, False) - operation_status = functions.validate_resp_data(response, - Const.WRITE_SINGLE_REGISTER, - register_address, - value=register_value, - signed=signed) + slave_addr: int, + register_address: int, + register_value: int, + signed: bool = True) -> bool: + """ + Update a single register. + + :param slave_addr: The slave address + :type slave_addr: int + :param register_address: The register address + :type register_address: int + :param register_value: The register value + :type register_value: int + :param signed: Indicates if signed + :type signed: bool + + :returns: Result of operation + :rtype: bool + """ + modbus_pdu = functions.write_single_register( + register_address=register_address, + register_value=register_value, + signed=signed) + + response = self._send_receive(slave_id=slave_addr, + modbus_pdu=modbus_pdu, + count=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 def write_multiple_coils(self, - slave_addr, - starting_address, - output_values): - modbus_pdu = functions.write_multiple_coils(starting_address, - output_values) - - response = self._send_receive(slave_addr, modbus_pdu, False) - operation_status = functions.validate_resp_data(response, - Const.WRITE_MULTIPLE_COILS, - starting_address, - quantity=len(output_values)) + slave_addr: int, + starting_address: int, + output_values: List[int, bool]) -> bool: + modbus_pdu = functions.write_multiple_coils( + starting_address=starting_address, + value_list=output_values) + + response = self._send_receive(slave_id=slave_addr, + modbus_pdu=modbus_pdu, + count=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 def write_multiple_registers(self, - slave_addr, - starting_address, - register_values, - signed=True): - modbus_pdu = functions.write_multiple_registers(starting_address, - register_values, - signed) - - response = self._send_receive(slave_addr, modbus_pdu, False) - operation_status = functions.validate_resp_data(response, - Const.WRITE_MULTIPLE_REGISTERS, - starting_address, - quantity=len(register_values)) + slave_addr: int, + starting_address: int, + register_values: List[int], + signed=True) -> bool: + modbus_pdu = functions.write_multiple_registers( + starting_address=starting_address, + register_values=register_values, + signed=signed) + + response = self._send_receive(slave_id=slave_addr, + modbus_pdu=modbus_pdu, + count=False) + operation_status = functions.validate_resp_data( + data=response, + function_code=Const.WRITE_MULTIPLE_REGISTERS, + address=starting_address, + quantity=len(register_values), + # this fixes + # https://github.com/brainelectronics/micropython-modbus/issues/23 + # signed=signed + ) return operation_status @@ -202,10 +542,13 @@ def __init__(self): self._client_sock = None self._is_bound = False - def get_is_bound(self): + def get_is_bound(self) -> bool: return self._is_bound - def bind(self, local_ip, local_port=502, max_connections=10): + def bind(self, + local_ip: str, + local_port: int = 502, + max_connections: int = 10): if self._client_sock: self._client_sock.close() @@ -222,20 +565,20 @@ def bind(self, local_ip, local_port=502, max_connections=10): self._is_bound = True - def _send(self, modbus_pdu, slave_addr): + def _send(self, modbus_pdu: bytes, slave_addr: int) -> None: size = len(modbus_pdu) fmt = 'B' * size adu = struct.pack('>HHHB' + fmt, self._req_tid, 0, size + 1, slave_addr, *modbus_pdu) self._client_sock.send(adu) def send_response(self, - slave_addr, - function_code, - request_register_addr, - request_register_qty, - request_data, - values=None, - signed=True): + 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: modbus_pdu = functions.response(function_code, request_register_addr, request_register_qty, @@ -245,14 +588,16 @@ def send_response(self, self._send(modbus_pdu, slave_addr) def send_exception_response(self, - slave_addr, - function_code, - exception_code): + slave_addr: int, + function_code: int, + exception_code: int) -> None: modbus_pdu = functions.exception_response(function_code, exception_code) self._send(modbus_pdu, slave_addr) - def _accept_request(self, accept_timeout, unit_addr_list): + def _accept_request(self, + accept_timeout: float, + unit_addr_list: list) -> None: self._sock.settimeout(accept_timeout) new_client_sock = None @@ -310,7 +655,7 @@ def _accept_request(self, accept_timeout, unit_addr_list): e.exception_code) return None - def get_request(self, unit_addr_list=None, timeout=None): + def get_request(self, unit_addr_list=None, timeout=None) -> None: if self._sock is None: raise Exception('Modbus TCP server not bound') diff --git a/umodbus/typing.py b/umodbus/typing.py index 9719929..7b14274 100644 --- a/umodbus/typing.py +++ b/umodbus/typing.py @@ -10,6 +10,14 @@ """ +class _Subscriptable(): + def __getitem__(self, item): + return None + + +_subscriptable = _Subscriptable() + + class Any: pass @@ -22,12 +30,10 @@ class ClassVar: pass -class Union: - pass +Union = _subscriptable -class Optional: - pass +Optional = _subscriptable class Generic: @@ -114,12 +120,10 @@ class ByteString: pass -class Tuple: - pass +Tuple = _subscriptable -class List: - pass +List = _subscriptable class Deque: @@ -162,8 +166,7 @@ class AsyncContextManager: pass -class Dict: - pass +Dict = _subscriptable class DefaultDict: