Developer documentation¶
The details printed in debug mode (requests and responses) are very useful
for using the included dummy_serial port for unit testing purposes.
For examples, see the file test/test_minimalmodbus.py
.
Design considerations¶
My take on the design is that is should be as simple as possible, hence the name MinimalModbus, but it should implement the smallest number of functions needed for it to be useful. The target audience for this driver simply wants to talk to Modbus clients using a serial interface using some simple driver.
Only a few functions are implemented. It is very easy to implement lots of (seldom used) functions, resulting in buggy code with large fractions of it almost never used. It is instead much better to implement the features when needed/requested. There are many Modbus function codes, but I guess that most are not used.
There should be unittests for all functions, and mock communication data.
Errors should be caught as early as possible, and the error messages should be informative. For this reason there is type checking for for the parameters in most functions. This is rather un-pythonic, but is intended to give more clear error messages (for easier remote support).
Note that the term ‘address’ is ambiguous, why it is better to use the terms ‘register address’ or ‘slave address’.
Use only external links in the README.rst, otherwise they will not work on Python Package Index (PyPI). No Sphinx-specific constructs are allowed in that file.
Design priorities:
- Easy to use
- Catch errors early
- Informative error messages
- Good unittest coverage
General driver structure¶
The general structure of the program is shown here:
Function | Description |
---|---|
read_register() |
One of the facades for _generic_command() . |
_generic_command() |
Generates payload, then calls _perform_command() . |
_perform_command() |
Embeds payload into error-checking codes etc, then calls _communicate() . |
_communicate() |
Handles raw bytes for communication via pySerial. |
Most of the logic is located in separate (easy to test) functions on module level. For a description of them, see Internal documentation for MinimalModbus.
Number conversion to and from bytes¶
The Python module struct
is used for conversion. See https://docs.python.org/3/library/struct.html
Several wrapper functions are defined for easy use of the conversion. These functions also do argument validity checking.
Data type | To bytes | From bytes |
---|---|---|
(internal usage) | _num_to_two_bytes() |
|
Bit | _bit_to_bytes() |
Same as for bits |
Several bits | _bits_to_bytes() |
_bytes_to_bits() |
Integer (char, short) | _num_to_two_bytes() |
_two_bytes_to_num() |
Several registers | _valuelist_to_bytes() |
_bytes_to_valuelist() |
Long integer | _long_to_bytes() |
_bytes_to_long() |
Floating point number | _float_to_bytes() |
_bytes_to_float() |
String | _textstring_to_bytes() |
_bytes_to_textstring() |
For compability with both Python2 and Python3, earlier versions of this module did store data internally as bytestrings. Now that Python2 support has been dropped, the internal representation of data is using Python bytes objects.
Unit testing¶
Unit tests are provided in the tests subfolder. To run them:
make test
The unittests uses previosly recorded communication data for the testing.
A dummy/mock/stub for the serial port, dummy_serial
, is provided for
test purposes. See Documentation for dummy_serial (which is a serial port mock).
The test coverage analysis is found at https://codecov.io/github/pyhys/minimalmodbus?branch=master.
Hardware tests are performed using a Delta DTB4824 process controller together with a USB-to-RS485 converter. See Internal documentation for hardware testing of MinimalModbus using DTB4824 for more information.
Run it with:
python3 tests/test_deltaDTB4824.py
The baudrate, portname and mode can optionally be set from command line. For more details on testing with this hardware, see Internal documentation for hardware testing of MinimalModbus using DTB4824.
Making sure that error messages are informative for the user¶
To have a look on the error messages raised during unit testing of minimalmodbus
,
monkey-patch test_minimalmodbus.SHOW_ERROR_MESSAGES_FOR_ASSERTRAISES
as seen here:
>>> import unittest
>>> import test_minimalmodbus
>>> test_minimalmodbus.SHOW_ERROR_MESSAGES_FOR_ASSERTRAISES = True
>>> suite = unittest.TestLoader().loadTestsFromModule(test_minimalmodbus)
>>> unittest.TextTestRunner(verbosity=2).run(suite)
This is part of the output:
testFunctioncodeNotInteger (test_minimalmodbus.TestEmbedPayload) ...
TypeError('The functioncode must be an integer. Given: 1.0',)
TypeError("The functioncode must be an integer. Given: '1'",)
TypeError('The functioncode must be an integer. Given: [1]',)
TypeError('The functioncode must be an integer. Given: None',)
ok
testKnownValues (test_minimalmodbus.TestEmbedPayload) ... ok
testPayloadNotString (test_minimalmodbus.TestEmbedPayload) ...
TypeError('The payload should be a string. Given: 1',)
TypeError('The payload should be a string. Given: 1.0',)
TypeError("The payload should be a string. Given: ['ABC']",)
TypeError('The payload should be a string. Given: None',)
ok
testSlaveaddressNotInteger (test_minimalmodbus.TestEmbedPayload) ...
TypeError('The slaveaddress must be an integer. Given: 1.0',)
TypeError("The slaveaddress must be an integer. Given: 'DEF'",)
ok
testWrongFunctioncodeValue (test_minimalmodbus.TestEmbedPayload) ...
ValueError('The functioncode is too large: 222, but maximum value is 127.',)
ValueError('The functioncode is too small: -1, but minimum value is 1.',)
ok
testWrongSlaveaddressValue (test_minimalmodbus.TestEmbedPayload) ...
ValueError('The slaveaddress is too large: 248, but maximum value is 247.',)
ValueError('The slaveaddress is too small: -1, but minimum value is 0.',)
ok
See test_minimalmodbus
for details on how this is implemented.
It is possible to run just a few tests. To load a single class of test cases:
suite = unittest.TestLoader().loadTestsFromTestCase(test_minimalmodbus.TestSetBitOn)
If necessary:
reload(test_minimalmodbus.minimalmodbus)
Recording communication data for unittesting¶
With the known data output from an instrument, we can fine tune the inner details of the driver (code refactoring) without worrying that we change the output from the code. This data will be the ‘golden standard’ to which we test the code. Use as many as possible of the commands, and paste all the output in a text document. From this it is pretty easy to reshuffle it into unittest code.
Here is an example how to record communication data, which then is pasted into the test code (for use with a mock/dummy serial port). See for example Internal documentation for unit testing of MinimalModbus (click ‘[source]’ on right side, see RESPONSES at end of the page). Do like this:
>>> import minimalmodbus
>>> instrument_1 = minimalmodbus.Instrument('/dev/ttyUSB0',10)
>>> instrument_1.debug = True
>>> instrument_1.read_register(4097,1)
MinimalModbus debug mode. Writing to instrument: '\n\x03\x10\x01\x00\x01\xd0q'
MinimalModbus debug mode. Response from instrument: '\n\x03\x02\x07\xd0\x1e)'
200.0
>>> instrument_1.write_register(4097,325.8,1)
MinimalModbus debug mode. Writing to instrument: '\n\x10\x10\x01\x00\x01\x02\x0c\xbaA\xc3'
MinimalModbus debug mode. Response from instrument: '\n\x10\x10\x01\x00\x01U\xb2'
>>> instrument_1.read_register(4097,1)
MinimalModbus debug mode. Writing to instrument: '\n\x03\x10\x01\x00\x01\xd0q'
MinimalModbus debug mode. Response from instrument: '\n\x03\x02\x0c\xba\x996'
325.8
>>> instrument_1.read_bit(2068)
MinimalModbus debug mode. Writing to instrument: '\n\x02\x08\x14\x00\x01\xfa\xd5'
MinimalModbus debug mode. Response from instrument: '\n\x02\x01\x00\xa3\xac'
0
>>> instrument_1.write_bit(2068,1)
MinimalModbus debug mode. Writing to instrument: '\n\x05\x08\x14\xff\x00\xcf%'
MinimalModbus debug mode. Response from instrument: '\n\x05\x08\x14\xff\x00\xcf%'
This is also very useful for debugging drivers built on top of MinimalModbus.
Using the dummy serial port¶
A dummy serial port is included for testing purposes, see dummy_serial
. Use it like this:
>>> import dummy_serial
>>> import test_minimalmodbus
>>> dummy_serial.RESPONSES = test_minimalmodbus.RESPONSES # Load previously recorded responses
>>> import minimalmodbus
>>> minimalmodbus.serial.Serial = dummy_serial.Serial # Monkey-patch a dummy serial port
>>> instrument = minimalmodbus.Instrument('DUMMYPORTNAME', 1) # port name, slave address (in decimal)
>>> instrument.read_register(4097, 1)
823.6
In the example above there is recorded data available for read_register(4097, 1)
. If no
recorded data is available, an error message is displayed:
>>> instrument.read_register(4098, 1)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "/home/jonas/pythonprogrammering/minimalmodbus/trunk/minimalmodbus.py", line 174, in read_register
return self._genericCommand(functioncode, registeraddress, numberOfDecimals=numberOfDecimals)
File "/home/jonas/pythonprogrammering/minimalmodbus/trunk/minimalmodbus.py", line 261, in _genericCommand
payloadFromSlave = self._performCommand(functioncode, payloadToSlave)
File "/home/jonas/pythonprogrammering/minimalmodbus/trunk/minimalmodbus.py", line 317, in _performCommand
response = self._communicate(message)
File "/home/jonas/pythonprogrammering/minimalmodbus/trunk/minimalmodbus.py", line 395, in _communicate
raise IOError('No communication with the instrument (no answer)')
IOError: No communication with the instrument (no answer)
The dummy serial port can be used also with instrument drivers built on top of MinimalModbus:
>>> import dummy_serial
>>> import test_omegacn7500
>>> dummy_serial.RESPONSES = test_omegacn7500.RESPONSES # Load previously recorded responses
>>> import omegacn7500
>>> omegacn7500.minimalmodbus.serial.Serial = dummy_serial.Serial # Monkey-patch a dummy serial port
>>> instrument = omegacn7500.OmegaCN7500('DUMMYPORTNAME', 1) # port name, slave address
>>> instrument.get_pv()
24.6
To see the generated request data (without bothering about the response):
>>> import dummy_serial
>>> import minimalmodbus
>>> minimalmodbus.serial.Serial = dummy_serial.Serial # Monkey-patch a dummy serial port
>>> instrument = minimalmodbus.Instrument('DUMMYPORTNAME', 1)
>>> instrument.debug = True
>>> instrument.read_bit(2068)
MinimalModbus debug mode. Writing to instrument: '\x01\x02\x08\x14\x00\x01\xfb\xae'
MinimalModbus debug mode. Response from instrument: ''
(Then an error message appears)
Data encoding in Python2 and Python3¶
The string type has changed in Python3 compared to Python2. In Python3 the type bytes is used when communicating via pySerial.
Dependent on the Python version number, the data sent from MinimalModbus to pySerial has different types.
String constants¶
This is a string constant both in Python2 and Python3:
st = 'abc\x69\xe6\x03'
This is a bytes constant in Python3, but a string constant in Python2 (allowed for 2.6 and higher):
by = b'abc\x69\xe6\x03'
Type conversion in Python3¶
To convert a string to bytes, use one of these:
bytes(st, 'latin1') # Note that 'ascii' encoding gives error for some values.
st.encode('latin1')
To convert bytes to string, use one of these:
str(by, encoding='latin1')
by.decode('latin1')
Encoding | Allowed range |
---|---|
ascii | 0-127 |
latin-1 | 0-255 |
Corresponding in Python2¶
Ideally, we would like to use the same source code for Python2 and Python3. In Python 2.6 and higher
there is the bytes()
function for forward compatibility, but it is merely a
synonym for str()
.
To convert from ‘bytes’(string) to string:
str(by) # not possible to give encoding
by.decode('latin1') # Gives unicode
To convert from string to ‘bytes’(string):
bytes(st) # not possible to give encoding
st.encode('latin1') # Can not be used for values larger than 127
It is thus not possible to use exactly the same code for both Python2 and Python3. Where it is unavoidable, use:
if sys.version_info[0] > 2:
whatever
Extending MinimalModbus¶
It is straight-forward to extend MinimalModbus to handle more Modbus function codes.
Use the method _perform_command()
to send data to the slave, and to receive
the response. Note that the API might change, as this is outside the official API.
This is easily tested in interactive mode. For example the method read_register()
generates payload, which internally is sent to the instrument using _perform_command()
:
>>> instr.debug = True
>>> instr.read_register(5,1)
MinimalModbus debug mode. Writing to instrument: '\x01\x03\x00\x05\x00\x01\x94\x0b'
MinimalModbus debug mode. Response from instrument: '\x01\x03\x02\x00º9÷'
18.6
It is possible to use _perform_command()
directly. You can use any Modbus function code (1-127),
but you need to generate the payload yourself. Note that the same data is sent:
>>> instr._perform_command(3, b'\x00\x05\x00\x01')
MinimalModbus debug mode. Writing to instrument: '\x01\x03\x00\x05\x00\x01\x94\x0b'
MinimalModbus debug mode. Response from instrument: '\x01\x03\x02\x00º9÷'
'\x02\x00º'
Use this if you are to implement other Modbus function codes, as it takes care of CRC generation etc.
Other useful internal functions¶
There are several useful (module level) helper functions available in
the minimalmodbus
module.
The module level helper functions can be used without any hardware connected.
See Internal documentation for MinimalModbus. These can be handy when developing your
own Modbus instrument hardware.
For example:
>>> minimalmodbus._calculate_crc(b'\x01\x03\x00\x05\x00\x01')
b'\x94\x0b'
And to embed the payload b'\x10\x11\x12'
to slave address 1, with functioncode 16:
>>> minimalmodbus._embed_payload(1, MODE_RTU, 16, b'\x10\x11\x12')
b'\x01\x10\x10\x11\x12\x90\x98'
Playing with two’s complement:
>>> minimalmodbus._twos_complement(-1, bits=8)
255
Calculating the minimum silent interval (seconds) at a baudrate of 19200 bits/s:
>>> minimalmodbus._calculate_minimum_silent_period(19200)
0.0020052083333333332
Note that the API might change, as this is outside the official API.
Generate documentation¶
Use the top-level Make to generate HTML documentation:
make docs
Do linkchecking:
make linkcheck
Webpage¶
The HTML theme used is the Sphinx ‘sphinx_rtd_theme’ theme.
Codecov.io¶
Log in to https://codecov.io/ using your Github account.
Enable the webhook from GitHub to Codecov.io.
Notes on distribution¶
Installing the module from local files¶
In the top directory:
make install
To uninstall it:
make uninstall
How to generate a source distribution from the present development code¶
This will create a subfolder dist
with the source in wheel format and in
.tar.gz format:
make dist
Preparation for release¶
Change version number etc¶
- Manually change the
__version__
field in theminimalmodbus.py
source file. - Manually change the release date in
HISTORY.rst
- Manually change the date and version in the
CITATION.cff
(Note that the version number in the Sphinx configuration file doc/conf.py
and in the file pyproject.toml
are changed automatically.
Also the copyright year in doc/conf.py
is changed automatically).
How to number releases are described in PEP 440.
Code style checking etc¶
Automatically modify the formatting of the code:
make black
Check the code:
make pylint
make flake8
Check typing:
make mypy
Unittesting¶
Run unit tests:
make test
Show test coverage report:
make coverage
Also make tests using Delta DTB4824 hardware. See Internal documentation for hardware testing of MinimalModbus using DTB4824.
Test the source distribution generation (look in the resulting PKG-INFO
file):
make dist
Also make sure that these are functional (see sections below):
- Documentation generation
- Test coverage report generation
Git¶
Merge to the master branch locally. Make a tag in the git repository. Push the master branch (including tags) to Github.
If you push to another branch, a pull request will be generated.
See below for details.
GitHub¶
Releases are automatically generated on GitHub from tags in the repo.
Test the documentation¶
Test links on the PyPI page. If adjustments are required on the PyPI page, log in and manually adjust the text. This might be for example parsing problems with the ReST text (allows no Sphinx-specific constructs).
Force documentation rebuild on readthedocs¶
Log in to https://readthedocs.org (using Github credentials) and force rebuild on the master branch.
Enable the “master” and “stable” documentation versions. In the advanced settings select Python3.
Test the installers¶
Make sure that the installer works, and the dependencies are handled correctly. Try at least Linux and Windows.
On windows you might need to use:
py -m pip install minimalmodbus
Test on hardware¶
Test the package on hardware slave via Linux, Windows and Mac OS.
Download the file test_deltaDTB4824.py
.
To run the hardware test on Windows:
C:\Python27>python.exe C:\Users\jonas\Documents\Pythonprogram\testmodbus\test_deltaDTB4824.py -d COM7 -b 2400 -a
For python3 you might need to use the py
command.
Begin a new development version¶
Check in a new version on GitHub master branch.
If the previous release was X.Y.Z
, then use X.Y.(Z+1)a1
.
Useful development tools¶
Each of these have some additional information below on this page.
- Git
- Version control software. See https://git-scm.com/
- Sphinx
- For generating HTML documentation. See https://www.sphinx-doc.org/
- Coverage.py
- Unittest coverage tool. See https://coverage.readthedocs.io/
- PyChecker
- This is a tool for finding bugs in python source code. See http://pychecker.sourceforge.net/
- pycodestyle
- Code style checker. See https://github.com/PyCQA/pycodestyle#readme
Git usage¶
Clone the repository from GitHub (it will create a directory):
git clone https://github.com/pyhys/minimalmodbus.git
Show details:
git remote -v
git status
git branch
Stage changes:
git add testb.txt
Commit locally:
git commit -m "test1"
Commit remotely (will ask for GitHub username and password):
git push origin
Git branches¶
Create a new branch:
git branch develop
List branches:
git branch
Change branch:
git checkout develop
Commit other branch remotely:
git push origin develop
Make a tag in Git¶
See the section on Git usage.
The release is done in the ‘master’ branch, not the ‘develop’ branch. List tags:
git tag
Make a tag in Git:
git tag -a 0.7 -m 'Release 0.7'
Show info about a tag:
git show 0.7
Commit tags to remote server:
git push origin --tags
Sphinx usage¶
This documentation is generated with the Sphinx tool: https://www.sphinx-doc.org/
It is used to automatically generate HTML documentation from docstrings in the source code. See for example Internal documentation for MinimalModbus. To see the source code of the Python file, click [source] on the right part of that page. To see the source of the Sphinx page definition file, click ‘View page Source’ (or possibly ‘Edit on Github’) in the upper right corner.
To install, use:
sudo pip3 install sphinx sphinx_rtd_theme
Check installed version by typing:
sphinx-build --version
Spinx formatting conventions¶
What | Usage | Result |
---|---|---|
Inline web link | `Link text <http://example.com/>`_ |
Link text |
Internal link | :ref:`testminimalmodbus` |
Internal documentation for unit testing of MinimalModbus |
Inline code | ``code text`` |
code text |
String | ‘A’ | ‘A’ |
String w escape ch. | (string within inline code) | 'ABC\x00' |
(less good) | (string within inline code, double backslash) | 'ABC\\x00' For use in Python docstrings. |
(less good) | (string with double backslash) | ‘ABC\x00’ Avoid |
Environment var | :envvar:`PYTHONPATH` |
PYTHONPATH |
OS-level command | :command:`make` |
make |
File | :file:`minimalmodbus.py` |
minimalmodbus.py |
Path | :file:`path/to/myfile.txt` |
path/to/myfile.txt |
Type | **bytes** |
bytes |
Module | :mod:`minimalmodbus` |
minimalmodbus |
Data | :data:`.BAUDRATE` |
BAUDRATE |
Data (full) | :data:`minimalmodbus.BAUDRATE` |
minimalmodbus.BAUDRATE |
Constant | :const:`False` |
False |
Function | :func:`._checkInt` |
_checkInt() |
Function (full) | :func:`minimalmodbus._checkInt` |
minimalmodbus._checkInt() |
Argument | *payload* |
payload |
Class | :class:`.Instrument` |
Instrument |
Class (full) | :class:`minimalmodbus.Instrument` |
minimalmodbus.Instrument |
Method | :meth:`.read_bit` |
read_bit() |
Method (full) | :meth:`minimalmodbus.Instrument.read_bit` |
minimalmodbus.Instrument.read_bit() |
Note that only the functions and methods that are listed in the index will show as links.
- Headings
- Top level heading underlining symbol:
=
(equals) - Next lower level:
-
(minus) - A third level if necessary (avoid this):
`
(backquote)
- Top level heading underlining symbol:
- Internal links
- Add an internal marker
.. _my-reference-label:
before a heading. - Then make an internal link to it using
:ref:`my-reference-label`
.
- Add an internal marker
- Strings with backslash
- In Python docstrings, use raw strings (a
r
before the tripplequote), to have the backslashes reach Sphinx.
- In Python docstrings, use raw strings (a
- Informative boxes
.. seealso:: Example of a **seealso** box.
.. note:: Example of a **note** box.
.. warning:: Example of a **warning** box.
See also
Example of a seealso box.
Note
Example of a note box.
Warning
Example of a warning box.
Sphinx build commands¶
To build the documentation, in the top project directory run:
make docs
That should generate HTML files to the directory docs/_build/html
.
TODO¶
See also GitHub issues: https://github.com/pyhys/minimalmodbus/issues
- Possibly use pytest instead
- Improve installation troubleshooting
- Test virtual serial port on Windows using com0com
- Unittests for measuring the sleep time in
_communicate()
. - Tool for interpretation of Modbus messages