From 1866aaaaf389537748772517cb032d1190bb4aee Mon Sep 17 00:00:00 2001 From: Jose Tiago Macara Coutinho Date: Sat, 7 Nov 2020 20:31:52 +0100 Subject: [PATCH 01/13] Add async version of send_message for both tcp and rtu --- README.rst | 34 +++++++ dev_requirements.txt | 1 + scripts/examples/simple_async_tcp_client.py | 27 ++++++ tests/system/conftest.py | 24 ++++- .../test_exception_async_responses.py | 92 ++++++++++++++++++ .../test_exception_async_rtu_responses.py | 95 +++++++++++++++++++ .../test_succesful_async_responses.py | 78 +++++++++++++++ .../test_succesful_async_rtu_responses.py | 82 ++++++++++++++++ tests/system/rtu_server.py | 28 ++++++ umodbus/client/serial/rtu.py | 24 +++++ umodbus/client/tcp.py | 23 +++++ 11 files changed, 507 insertions(+), 1 deletion(-) create mode 100755 scripts/examples/simple_async_tcp_client.py create mode 100644 tests/system/responses/test_exception_async_responses.py create mode 100644 tests/system/responses/test_exception_async_rtu_responses.py create mode 100644 tests/system/responses/test_succesful_async_responses.py create mode 100644 tests/system/responses/test_succesful_async_rtu_responses.py diff --git a/README.rst b/README.rst index 7ffd213..b126df2 100644 --- a/README.rst +++ b/README.rst @@ -126,6 +126,39 @@ Doing a Modbus request requires even less code: response = tcp.send_message(message, sock) + +The same request can also be made using any asyncio_ compatible StreamReader +and StreamWriter objects:: + + #!/usr/bin/env python + # scripts/examples/simple_async_tcp_client.py + import asyncio + + from umodbus import conf + from umodbus.client import tcp + + # Enable values to be signed (default is False). + conf.SIGNED_VALUES = True + + + async def main(): + reader, writer = await asyncio.open_connection('localhost', 15020) + + # Returns a message or Application Data Unit (ADU) specific for doing + # Modbus TCP/IP. + message = tcp.write_multiple_coils(slave_id=1, starting_address=1, values=[1, 0, 1, 1]) + + # Response depends on Modbus function code. This particular returns the + # amount of coils written, in this case it is. + response = await tcp.async_send_message(message, reader, writer) + + writer.close() + await writer.wait_closed() + + + asyncio.run(main()) + + Features -------- @@ -156,3 +189,4 @@ Climate Systems`_. .. _MODBUS Application Protocol Specification V1.1b3: http://modbus.org/docs/Modbus_Application_Protocol_V1_1b3.pdf .. _Mozilla Public License: https://github.com/AdvancedClimateSystems/uModbus/blob/develop/LICENSE .. _Read the Docs: http://umodbus.readthedocs.org/en/latest/ +.. _asyncio: https://docs.python.org/3/library/asyncio.html diff --git a/dev_requirements.txt b/dev_requirements.txt index b00073a..1664ae4 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -4,6 +4,7 @@ mock==3.0.5;python_version<"3.3" pytest==5.3.1;python_version>="3.5" pytest==4.6.6;python_version<"3.5" pytest-cov==2.8.1 +pytest-asyncio==0.14.0 Sphinx==2.2.2;python_version>="3.5" Sphinx==1.8.5;python_version<"3.5" sphinx-rtd-theme==0.4.3 diff --git a/scripts/examples/simple_async_tcp_client.py b/scripts/examples/simple_async_tcp_client.py new file mode 100755 index 0000000..6f7a514 --- /dev/null +++ b/scripts/examples/simple_async_tcp_client.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python +# scripts/examples/simple_async_tcp_client.py +import asyncio + +from umodbus import conf +from umodbus.client import tcp + +# Enable values to be signed (default is False). +conf.SIGNED_VALUES = True + + +async def main(): + reader, writer = await asyncio.open_connection('localhost', 502) + + # Returns a message or Application Data Unit (ADU) specific for doing + # Modbus TCP/IP. + message = tcp.write_multiple_coils(slave_id=1, starting_address=1, values=[1, 0, 1, 1]) + + # Response depends on Modbus function code. This particular returns the + # amount of coils written, in this case it is. + response = await tcp.async_send_message(message, reader, writer) + + writer.close() + await writer.wait_closed() + + +asyncio.run(main()) diff --git a/tests/system/conftest.py b/tests/system/conftest.py index 84fa772..ec4132a 100644 --- a/tests/system/conftest.py +++ b/tests/system/conftest.py @@ -1,10 +1,11 @@ import struct import pytest import socket +import asyncio from threading import Thread from .tcp_server import app as tcp -from .rtu_server import app as rtu +from .rtu_server import app as rtu, StreamReader, StreamWriter @pytest.fixture(autouse=True, scope="session") @@ -31,11 +32,32 @@ def sock(tcp_server): sock.close() +@pytest.yield_fixture +async def async_tcp_streams(tcp_server): + host, port = tcp_server.socket.getsockname() + reader, writer = await asyncio.open_connection(host, port) + + yield reader, writer + + writer.close() + if hasattr(writer, 'wait_closed'): + await writer.wait_closed() + + + @pytest.fixture def rtu_server(): return rtu +@pytest.fixture +async def async_serial_streams(rtu_server): + reader = StreamReader(rtu_server.serial_port) + writer = StreamWriter(rtu_server.serial_port) + return reader, writer + + + @pytest.fixture def mbap(): return struct.pack('>HHHB', 0, 0, 6, 1) diff --git a/tests/system/responses/test_exception_async_responses.py b/tests/system/responses/test_exception_async_responses.py new file mode 100644 index 0000000..3c97063 --- /dev/null +++ b/tests/system/responses/test_exception_async_responses.py @@ -0,0 +1,92 @@ +import pytest +import struct +from functools import partial + +from ..validators import validate_response_mbap +from umodbus.client import tcp + + +pytestmark = pytest.mark.asyncio + + +@pytest.mark.parametrize('function_code, quantity', [ + (1, 0), + (2, 0), + (3, 0), + (4, 0), + (1, 0x07D0 + 1), + (2, 0x07D0 + 1), + (3, 0x007D + 1), + (4, 0x007D + 1), +]) +async def test_request_returning_invalid_data_value_error(async_tcp_streams, mbap, function_code, + quantity): + """ Validate response PDU of request returning excepetion response with + error code 3. + """ + function_code, starting_address, quantity = (function_code, 0, quantity) + adu = mbap + struct.pack('>BHH', function_code, starting_address, quantity) + + reader, writer = async_tcp_streams + writer.write(adu) + await writer.drain() + resp = await reader.read(1024) + + validate_response_mbap(mbap, resp) + assert struct.unpack('>BB', resp[-2:]) == (0x80 + function_code, 3) + + +@pytest.mark.parametrize('function', [ + (partial(tcp.read_coils, 1, 9, 2)), + (partial(tcp.read_discrete_inputs, 1, 9, 2)), + (partial(tcp.read_holding_registers, 1, 9, 2)), + (partial(tcp.read_input_registers, 1, 9, 2)), + (partial(tcp.write_single_coil, 1, 11, 0)), + (partial(tcp.write_single_register, 1, 11, 1337)), + (partial(tcp.write_multiple_coils, 1, 9, [1, 1])), + (partial(tcp.write_multiple_registers, 1, 9, [1337, 15])), +]) +async def test_request_returning_invalid_data_address_error(async_tcp_streams, function): + """ Validate response PDU of request returning excepetion response with + error code 2. + """ + adu = function() + + mbap = adu[:7] + function_code = struct.unpack('>B', adu[7:8])[0] + + reader, writer = async_tcp_streams + writer.write(adu) + await writer.drain() + resp = await reader.read(1024) + + validate_response_mbap(mbap, resp) + assert struct.unpack('>BB', resp[-2:]) == (0x80 + function_code, 2) + + +@pytest.mark.parametrize('function', [ + (partial(tcp.read_coils, 1, 666, 1)), + (partial(tcp.read_discrete_inputs, 1, 666, 1)), + (partial(tcp.read_holding_registers, 1, 666, 1)), + (partial(tcp.read_input_registers, 1, 666, 1)), + (partial(tcp.write_single_coil, 1, 666, 0)), + (partial(tcp.write_single_register, 1, 666, 1337)), + (partial(tcp.write_multiple_coils, 1, 666, [1])), + (partial(tcp.write_multiple_registers, 1, 666, [1337])), +]) +async def test_request_returning_server_device_failure_error(async_tcp_streams, function): + """ Validate response PDU of request returning exception response with + error code 4. + """ + adu = function() + + mbap = adu[:7] + function_code = struct.unpack('>B', adu[7:8])[0] + + reader, writer = async_tcp_streams + writer.write(adu) + await writer.drain() + resp = await reader.read(1024) + + validate_response_mbap(mbap, resp) + assert struct.unpack('>BB', resp[-2:]) == (0x80 + function_code, 4) diff --git a/tests/system/responses/test_exception_async_rtu_responses.py b/tests/system/responses/test_exception_async_rtu_responses.py new file mode 100644 index 0000000..dc531dd --- /dev/null +++ b/tests/system/responses/test_exception_async_rtu_responses.py @@ -0,0 +1,95 @@ +import pytest +import struct +from functools import partial + +from ..validators import validate_response_mbap + +from umodbus.client.serial import rtu +from umodbus.client.serial.redundancy_check import (get_crc, validate_crc, + add_crc, CRCError) + + +pytestmark = pytest.mark.asyncio + + +@pytest.mark.parametrize('function_code, quantity', [ + (1, 0), + (2, 0), + (3, 0), + (4, 0), + (1, 0x07D0 + 1), + (2, 0x07D0 + 1), + (3, 0x007D + 1), + (4, 0x007D + 1), +]) +async def test_request_returning_invalid_data_value_error( + rtu_server, async_serial_streams, function_code, quantity): + """ Validate response PDU of request returning excepetion response with + error code 3. + """ + starting_address = 0 + slave_id = 1 + adu = add_crc(struct.pack('>BBHH', slave_id, function_code, + starting_address, quantity)) + + reader, writer = async_serial_streams + writer.write(adu) + await writer.drain() + resp = await reader.read(rtu_server.serial_port.in_waiting) + + validate_crc(resp) + assert struct.unpack('>BB', resp[1:-2]) == (0x80 + function_code, 3) + + +@pytest.mark.parametrize('function', [ + (partial(rtu.read_coils, 1, 9, 2)), + (partial(rtu.read_discrete_inputs, 1, 9, 2)), + (partial(rtu.read_holding_registers, 1, 9, 2)), + (partial(rtu.read_input_registers, 1, 9, 2)), + (partial(rtu.write_single_coil, 1, 11, 0)), + (partial(rtu.write_single_register, 1, 11, 1337)), + (partial(rtu.write_multiple_coils, 1, 9, [1, 1])), + (partial(rtu.write_multiple_registers, 1, 9, [1337, 15])), +]) +async def test_request_returning_invalid_data_address_error(rtu_server, async_serial_streams, function): + """ Validate response PDU of request returning excepetion response with + error code 2. + """ + adu = function() + + function_code = struct.unpack('>B', adu[1:2])[0] + + reader, writer = async_serial_streams + writer.write(adu) + await writer.drain() + resp = await reader.read(rtu_server.serial_port.in_waiting) + + validate_crc(resp) + assert struct.unpack('>BB', resp[1:-2]) == (0x80 + function_code, 2) + + +@pytest.mark.parametrize('function', [ + (partial(rtu.read_coils, 1, 666, 1)), + (partial(rtu.read_discrete_inputs, 1, 666, 1)), + (partial(rtu.read_holding_registers, 1, 666, 1)), + (partial(rtu.read_input_registers, 1, 666, 1)), + (partial(rtu.write_single_coil, 1, 666, 0)), + (partial(rtu.write_single_register, 1, 666, 1337)), + (partial(rtu.write_multiple_coils, 1, 666, [1])), + (partial(rtu.write_multiple_registers, 1, 666, [1337])), +]) +async def test_request_returning_server_device_failure_error(rtu_server, async_serial_streams, function): + """ Validate response PDU of request returning excepetion response with + error code 4. + """ + adu = function() + + function_code = struct.unpack('>B', adu[1:2])[0] + + reader, writer = async_serial_streams + writer.write(adu) + await writer.drain() + resp = await reader.read(rtu_server.serial_port.in_waiting) + + validate_crc(resp) + assert struct.unpack('>BB', resp[1:-2]) == (0x80 + function_code, 4) diff --git a/tests/system/responses/test_succesful_async_responses.py b/tests/system/responses/test_succesful_async_responses.py new file mode 100644 index 0000000..d68e33e --- /dev/null +++ b/tests/system/responses/test_succesful_async_responses.py @@ -0,0 +1,78 @@ +import pytest + +from umodbus import conf +from umodbus.client import tcp + + +pytestmark = pytest.mark.asyncio + + +@pytest.fixture(scope='module', autouse=True) +def enable_signed_values(request): + """ Use signed values when running tests it this module. """ + tmp = conf.SIGNED_VALUES + conf.SIGNED_VALUES = True + + def fin(): + conf.SIGNED_VALUES = tmp + + request.addfinalizer(fin) + + +@pytest.mark.parametrize('function', [ + tcp.read_coils, + tcp.read_discrete_inputs, +]) +async def test_response_on_single_bit_value_read_requests(async_tcp_streams, function): + """ Validate response of a succesful Read Coils or Read Discrete Inputs + request. + """ + slave_id, starting_address, quantity = (1, 0, 10) + req_adu = function(slave_id, starting_address, quantity) + + assert await tcp.async_send_message(req_adu, *async_tcp_streams) == [0, 1, 0, 1, 0, 1, 0, 1, 0, 1] + + +@pytest.mark.parametrize('function', [ + tcp.read_holding_registers, + tcp.read_input_registers, +]) +async def test_response_on_multi_bit_value_read_requests(async_tcp_streams, function): + """ Validate response of a succesful Read Holding Registers or Read + Input Registers request. + """ + slave_id, starting_address, quantity = (1, 0, 10) + req_adu = function(slave_id, starting_address, quantity) + + assert await tcp.async_send_message(req_adu, *async_tcp_streams) ==\ + [0, -1, -2, -3, -4, -5, -6, -7, -8, -9] + + +@pytest.mark.parametrize('function, value', [ + (tcp.write_single_coil, 1), + (tcp.write_single_register, -1337), +]) +async def test_response_single_value_write_request(async_tcp_streams, function, value): + """ Validate responde of succesful Read Single Coil and Read Single + Register request. + """ + slave_id, starting_address, value = (1, 0, value) + req_adu = function(slave_id, starting_address, value) + + assert await tcp.async_send_message(req_adu, *async_tcp_streams) == value + + +@pytest.mark.parametrize('function, values', [ + (tcp.write_multiple_coils, [1, 1]), + (tcp.write_multiple_registers, [1337, 15]), +]) +async def test_async_response_multi_value_write_request(async_tcp_streams, function, values): + """ Validate response of succesful Write Multiple Coils and Write Multiple + Registers request. + + Both requests write 2 values, starting address is 0. + """ + slave_id, starting_address = (1, 0) + req_adu = function(slave_id, starting_address, values) + + assert await tcp.async_send_message(req_adu, *async_tcp_streams) == 2 diff --git a/tests/system/responses/test_succesful_async_rtu_responses.py b/tests/system/responses/test_succesful_async_rtu_responses.py new file mode 100644 index 0000000..9950a88 --- /dev/null +++ b/tests/system/responses/test_succesful_async_rtu_responses.py @@ -0,0 +1,82 @@ +import pytest + +from umodbus import conf +from umodbus.client.serial import rtu + + +pytestmark = pytest.mark.asyncio + + +@pytest.fixture(scope='module', autouse=True) +def enable_signed_values(request): + """ Use signed values when running tests it this module. """ + tmp = conf.SIGNED_VALUES + conf.SIGNED_VALUES = True + + def fin(): + conf.SIGNED_VALUES = tmp + + request.addfinalizer(fin) + + +@pytest.mark.parametrize('function', [ + rtu.read_coils, + rtu.read_discrete_inputs, +]) +async def test_response_on_single_bit_value_read_requests(async_serial_streams, function): + """ Validate response of a succesful Read Coils or Read Discrete Inputs + request. + """ + slave_id, starting_address, quantity = (1, 0, 10) + req_adu = function(slave_id, starting_address, quantity) + + reply = await rtu.async_send_message(req_adu, *async_serial_streams) + assert reply == [0, 1, 0, 1, 0, 1, 0, 1, 0, 1] + + + +@pytest.mark.parametrize('function', [ + rtu.read_holding_registers, + rtu.read_input_registers, +]) +async def test_response_on_multi_bit_value_read_requests(async_serial_streams, function): + """ Validate response of a succesful Read Holding Registers or Read + Input Registers request. + """ + slave_id, starting_address, quantity = (1, 0, 10) + req_adu = function(slave_id, starting_address, quantity) + + reply = await rtu.async_send_message(req_adu, *async_serial_streams) + assert reply == [0, -1, -2, -3, -4, -5, -6, -7, -8, -9] + + +@pytest.mark.parametrize('function, value', [ + (rtu.write_single_coil, 0), + (rtu.write_single_register, -1337), +]) +async def test_response_single_value_write_request(async_serial_streams, function, value): + """ Validate responde of succesful Read Single Coil and Read Single + Register request. + """ + slave_id, starting_address, quantity = (1, 0, value) + req_adu = function(slave_id, starting_address, quantity) + + reply = await rtu.async_send_message(req_adu, *async_serial_streams) + assert reply == value + + +@pytest.mark.parametrize('function, values', [ + (rtu.write_multiple_coils, [1, 1]), + (rtu.write_multiple_registers, [1337, 15]), +]) +async def test_response_multi_value_write_request(async_serial_streams, function, values): + """ Validate response of succesful Write Multiple Coils and Write Multiple + Registers request. + + Both requests write 2 values, starting address is 0. + """ + slave_id, starting_address = (1, 0) + req_adu = function(slave_id, starting_address, values) + + reply = await rtu.async_send_message(req_adu, *async_serial_streams) + assert reply == 2 diff --git a/tests/system/rtu_server.py b/tests/system/rtu_server.py index 898375d..1cdefa9 100644 --- a/tests/system/rtu_server.py +++ b/tests/system/rtu_server.py @@ -1,6 +1,7 @@ from serial import serial_for_url from umodbus import conf +from umodbus.utils import recv_exactly from umodbus.server.serial import get_server from umodbus.server.serial.rtu import RTUServer @@ -8,6 +9,33 @@ conf.SIGNED_VALUES = True + +class StreamReader: + + def __init__(self, serial_port): + self.serial_port = serial_port + + async def readexactly(self, n): + return recv_exactly(self.serial_port.read, n) + + async def read(self, n): + return self.serial_port.read(n) + + +class StreamWriter: + + def __init__(self, serial_port): + self.serial_port = serial_port + + def write(self, data): + self.serial_port.write(data) + self.serial_port.flush() + app.serve_once() + + async def drain(self): + pass + + s = serial_for_url('loop://') app = get_server(RTUServer, s) diff --git a/umodbus/client/serial/rtu.py b/umodbus/client/serial/rtu.py index 4c01580..e5808a7 100644 --- a/umodbus/client/serial/rtu.py +++ b/umodbus/client/serial/rtu.py @@ -223,3 +223,27 @@ def send_message(adu, serial_port): serial_port.read, expected_response_size - exception_adu_size) return parse_response_adu(response_error_adu + response_remainder, adu) + + +async def async_send_message(adu, reader, writer): + """ Send ADU over asyncio reader/writer and return parsed response. + + :param adu: Request ADU. + :param reader: stream reader (ex: serial_asyncio.StreamReader) + :param writer: stream writer (ex: serial_asyncio.StreamWriter) + :return: Parsed response from server. + """ + writer.write(adu) + await writer.drain() + + # Check exception ADU (which is shorter than all other responses) first. + exception_adu_size = 5 + response_error_adu = await reader.readexactly(exception_adu_size) + raise_for_exception_adu(response_error_adu) + + expected_response_size = \ + expected_response_pdu_size_from_request_pdu(adu[1:-2]) + 3 + response_remainder = await reader.readexactly( + expected_response_size - exception_adu_size) + + return parse_response_adu(response_error_adu + response_remainder, adu) diff --git a/umodbus/client/tcp.py b/umodbus/client/tcp.py index 215b325..27de458 100644 --- a/umodbus/client/tcp.py +++ b/umodbus/client/tcp.py @@ -267,3 +267,26 @@ def send_message(adu, sock): sock.recv, expected_response_size - exception_adu_size) return parse_response_adu(response_error_adu + response_remainder, adu) + + +async def async_send_message(adu, reader, writer): + """ Send ADU over asyncio reader/writer and return parsed response. + + :param adu: Request ADU. + :param reader: stream reader (ex: asyncio.StreamReader) + :param writer: stream writer (ex: asyncio.StreamWriter) + :return: Parsed response from server. + """ + writer.write(adu) + await writer.drain() + + exception_adu_size = 9 + response_error_adu = await reader.readexactly(exception_adu_size) + raise_for_exception_adu(response_error_adu) + + expected_response_size = \ + expected_response_pdu_size_from_request_pdu(adu[7:]) + 7 + response_remainder = await reader.readexactly( + expected_response_size - exception_adu_size) + + return parse_response_adu(response_error_adu + response_remainder, adu) From b7bb10026de05521f1b5aedeae47c6eadcbdc4fe Mon Sep 17 00:00:00 2001 From: Jose Tiago Macara Coutinho Date: Fri, 13 Nov 2020 15:25:49 +0100 Subject: [PATCH 02/13] Fix typos Co-authored-by: Jaap Broekhuizen --- tests/system/responses/test_exception_async_responses.py | 4 ++-- .../system/responses/test_exception_async_rtu_responses.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/system/responses/test_exception_async_responses.py b/tests/system/responses/test_exception_async_responses.py index 3c97063..1f8f1ec 100644 --- a/tests/system/responses/test_exception_async_responses.py +++ b/tests/system/responses/test_exception_async_responses.py @@ -21,7 +21,7 @@ ]) async def test_request_returning_invalid_data_value_error(async_tcp_streams, mbap, function_code, quantity): - """ Validate response PDU of request returning excepetion response with + """ Validate response PDU of request returning exception response with error code 3. """ function_code, starting_address, quantity = (function_code, 0, quantity) @@ -47,7 +47,7 @@ async def test_request_returning_invalid_data_value_error(async_tcp_streams, mba (partial(tcp.write_multiple_registers, 1, 9, [1337, 15])), ]) async def test_request_returning_invalid_data_address_error(async_tcp_streams, function): - """ Validate response PDU of request returning excepetion response with + """ Validate response PDU of request returning exception response with error code 2. """ adu = function() diff --git a/tests/system/responses/test_exception_async_rtu_responses.py b/tests/system/responses/test_exception_async_rtu_responses.py index dc531dd..de2ac4e 100644 --- a/tests/system/responses/test_exception_async_rtu_responses.py +++ b/tests/system/responses/test_exception_async_rtu_responses.py @@ -24,7 +24,7 @@ ]) async def test_request_returning_invalid_data_value_error( rtu_server, async_serial_streams, function_code, quantity): - """ Validate response PDU of request returning excepetion response with + """ Validate response PDU of request returning exception response with error code 3. """ starting_address = 0 @@ -52,7 +52,7 @@ async def test_request_returning_invalid_data_value_error( (partial(rtu.write_multiple_registers, 1, 9, [1337, 15])), ]) async def test_request_returning_invalid_data_address_error(rtu_server, async_serial_streams, function): - """ Validate response PDU of request returning excepetion response with + """ Validate response PDU of request returning exception response with error code 2. """ adu = function() @@ -79,7 +79,7 @@ async def test_request_returning_invalid_data_address_error(rtu_server, async_se (partial(rtu.write_multiple_registers, 1, 666, [1337])), ]) async def test_request_returning_server_device_failure_error(rtu_server, async_serial_streams, function): - """ Validate response PDU of request returning excepetion response with + """ Validate response PDU of request returning exception response with error code 4. """ adu = function() From 21e62f137c95538b11a47b1093e7029b27b0fb7c Mon Sep 17 00:00:00 2001 From: Jose Tiago Macara Coutinho Date: Fri, 13 Nov 2020 15:21:02 +0100 Subject: [PATCH 03/13] Fix asyncio client example tcp port --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index b126df2..097cb8c 100644 --- a/README.rst +++ b/README.rst @@ -142,7 +142,7 @@ and StreamWriter objects:: async def main(): - reader, writer = await asyncio.open_connection('localhost', 15020) + reader, writer = await asyncio.open_connection('localhost', 502) # Returns a message or Application Data Unit (ADU) specific for doing # Modbus TCP/IP. From 87c2bcee80a7f6dd46092a11a31d41592057e342 Mon Sep 17 00:00:00 2001 From: Jose Tiago Macara Coutinho Date: Fri, 13 Nov 2020 15:21:36 +0100 Subject: [PATCH 04/13] Rename asyncio client example --- .../{simple_async_tcp_client.py => simple_asyncio_tcp_client.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename scripts/examples/{simple_async_tcp_client.py => simple_asyncio_tcp_client.py} (100%) diff --git a/scripts/examples/simple_async_tcp_client.py b/scripts/examples/simple_asyncio_tcp_client.py similarity index 100% rename from scripts/examples/simple_async_tcp_client.py rename to scripts/examples/simple_asyncio_tcp_client.py From 67e1209e3bd32bf166a6ec96784074f2b37ab063 Mon Sep 17 00:00:00 2001 From: Jose Tiago Macara Coutinho Date: Fri, 13 Nov 2020 16:01:06 +0100 Subject: [PATCH 05/13] Refactor system tests with helpers --- .../test_exception_async_responses.py | 33 +++++++++---------- .../test_exception_async_rtu_responses.py | 27 +++++++-------- .../responses/test_exception_responses.py | 8 ++--- .../responses/test_exception_rtu_responses.py | 9 +++-- .../test_succesful_async_responses.py | 16 ++++++--- tests/system/validators.py | 5 +++ 6 files changed, 54 insertions(+), 44 deletions(-) diff --git a/tests/system/responses/test_exception_async_responses.py b/tests/system/responses/test_exception_async_responses.py index 1f8f1ec..7511869 100644 --- a/tests/system/responses/test_exception_async_responses.py +++ b/tests/system/responses/test_exception_async_responses.py @@ -2,13 +2,19 @@ import struct from functools import partial -from ..validators import validate_response_mbap +from ..validators import validate_response_mbap, validate_response_error from umodbus.client import tcp pytestmark = pytest.mark.asyncio +async def req_rep(adu, reader, writer): + writer.write(adu) + await writer.drain() + return await reader.read(1024) + + @pytest.mark.parametrize('function_code, quantity', [ (1, 0), (2, 0), @@ -20,20 +26,17 @@ (4, 0x007D + 1), ]) async def test_request_returning_invalid_data_value_error(async_tcp_streams, mbap, function_code, - quantity): + quantity): """ Validate response PDU of request returning exception response with error code 3. """ - function_code, starting_address, quantity = (function_code, 0, quantity) + starting_address = 0 adu = mbap + struct.pack('>BHH', function_code, starting_address, quantity) - reader, writer = async_tcp_streams - writer.write(adu) - await writer.drain() - resp = await reader.read(1024) + resp = await req_rep(adu, *async_tcp_streams) validate_response_mbap(mbap, resp) - assert struct.unpack('>BB', resp[-2:]) == (0x80 + function_code, 3) + validate_response_error(resp, function_code, 3) @pytest.mark.parametrize('function', [ @@ -55,13 +58,10 @@ async def test_request_returning_invalid_data_address_error(async_tcp_streams, f mbap = adu[:7] function_code = struct.unpack('>B', adu[7:8])[0] - reader, writer = async_tcp_streams - writer.write(adu) - await writer.drain() - resp = await reader.read(1024) + resp = await req_rep(adu, *async_tcp_streams) validate_response_mbap(mbap, resp) - assert struct.unpack('>BB', resp[-2:]) == (0x80 + function_code, 2) + validate_response_error(resp, function_code, 2) @pytest.mark.parametrize('function', [ @@ -83,10 +83,7 @@ async def test_request_returning_server_device_failure_error(async_tcp_streams, mbap = adu[:7] function_code = struct.unpack('>B', adu[7:8])[0] - reader, writer = async_tcp_streams - writer.write(adu) - await writer.drain() - resp = await reader.read(1024) + resp = await req_rep(adu, *async_tcp_streams) validate_response_mbap(mbap, resp) - assert struct.unpack('>BB', resp[-2:]) == (0x80 + function_code, 4) + validate_response_error(resp, function_code, 4) diff --git a/tests/system/responses/test_exception_async_rtu_responses.py b/tests/system/responses/test_exception_async_rtu_responses.py index de2ac4e..db09580 100644 --- a/tests/system/responses/test_exception_async_rtu_responses.py +++ b/tests/system/responses/test_exception_async_rtu_responses.py @@ -2,7 +2,7 @@ import struct from functools import partial -from ..validators import validate_response_mbap +from ..validators import validate_response_error from umodbus.client.serial import rtu from umodbus.client.serial.redundancy_check import (get_crc, validate_crc, @@ -12,6 +12,12 @@ pytestmark = pytest.mark.asyncio +async def req_rep(adu, reader, writer, serial_port): + writer.write(adu) + await writer.drain() + return await reader.read(serial_port.in_waiting) + + @pytest.mark.parametrize('function_code, quantity', [ (1, 0), (2, 0), @@ -33,12 +39,10 @@ async def test_request_returning_invalid_data_value_error( starting_address, quantity)) reader, writer = async_serial_streams - writer.write(adu) - await writer.drain() - resp = await reader.read(rtu_server.serial_port.in_waiting) + resp = await req_rep(adu, reader, writer, rtu_server.serial_port) validate_crc(resp) - assert struct.unpack('>BB', resp[1:-2]) == (0x80 + function_code, 3) + validate_response_error(resp[:-2], function_code, 3) @pytest.mark.parametrize('function', [ @@ -60,12 +64,10 @@ async def test_request_returning_invalid_data_address_error(rtu_server, async_se function_code = struct.unpack('>B', adu[1:2])[0] reader, writer = async_serial_streams - writer.write(adu) - await writer.drain() - resp = await reader.read(rtu_server.serial_port.in_waiting) + resp = await req_rep(adu, reader, writer, rtu_server.serial_port) validate_crc(resp) - assert struct.unpack('>BB', resp[1:-2]) == (0x80 + function_code, 2) + validate_response_error(resp[:-2], function_code, 2) @pytest.mark.parametrize('function', [ @@ -87,9 +89,8 @@ async def test_request_returning_server_device_failure_error(rtu_server, async_s function_code = struct.unpack('>B', adu[1:2])[0] reader, writer = async_serial_streams - writer.write(adu) - await writer.drain() - resp = await reader.read(rtu_server.serial_port.in_waiting) + resp = await req_rep(adu, reader, writer, rtu_server.serial_port) validate_crc(resp) - assert struct.unpack('>BB', resp[1:-2]) == (0x80 + function_code, 4) + validate_response_error(resp[:-2], function_code, 4) + diff --git a/tests/system/responses/test_exception_responses.py b/tests/system/responses/test_exception_responses.py index 2df2cdc..377c4df 100644 --- a/tests/system/responses/test_exception_responses.py +++ b/tests/system/responses/test_exception_responses.py @@ -2,7 +2,7 @@ import struct from functools import partial -from ..validators import validate_response_mbap +from ..validators import validate_response_mbap, validate_response_error from umodbus.client import tcp @@ -28,7 +28,7 @@ def test_request_returning_invalid_data_value_error(sock, mbap, function_code, resp = sock.recv(1024) validate_response_mbap(mbap, resp) - assert struct.unpack('>BB', resp[-2:]) == (0x80 + function_code, 3) + validate_response_error(resp, function_code, 3) @pytest.mark.parametrize('function', [ @@ -54,7 +54,7 @@ def test_request_returning_invalid_data_address_error(sock, function): resp = sock.recv(1024) validate_response_mbap(mbap, resp) - assert struct.unpack('>BB', resp[-2:]) == (0x80 + function_code, 2) + validate_response_error(resp, function_code, 2) @pytest.mark.parametrize('function', [ @@ -80,4 +80,4 @@ def test_request_returning_server_device_failure_error(sock, function): resp = sock.recv(1024) validate_response_mbap(mbap, resp) - assert struct.unpack('>BB', resp[-2:]) == (0x80 + function_code, 4) + validate_response_error(resp, function_code, 4) diff --git a/tests/system/responses/test_exception_rtu_responses.py b/tests/system/responses/test_exception_rtu_responses.py index e418611..5f07780 100644 --- a/tests/system/responses/test_exception_rtu_responses.py +++ b/tests/system/responses/test_exception_rtu_responses.py @@ -2,8 +2,7 @@ import struct from functools import partial -from ..validators import validate_response_mbap - +from ..validators import validate_response_mbap, validate_response_error from umodbus.client.serial import rtu from umodbus.client.serial.redundancy_check import (get_crc, validate_crc, add_crc, CRCError) @@ -45,7 +44,7 @@ def test_request_returning_invalid_data_value_error(rtu_server, function_code, resp = rtu_server.serial_port.read(rtu_server.serial_port.in_waiting) validate_crc(resp) - assert struct.unpack('>BB', resp[1:-2]) == (0x80 + function_code, 3) + validate_response_error(resp[:-2], function_code, 3) @pytest.mark.parametrize('function', [ @@ -71,7 +70,7 @@ def test_request_returning_invalid_data_address_error(rtu_server, function): resp = rtu_server.serial_port.read(rtu_server.serial_port.in_waiting) validate_crc(resp) - assert struct.unpack('>BB', resp[1:-2]) == (0x80 + function_code, 2) + validate_response_error(resp[:-2], function_code, 2) @pytest.mark.parametrize('function', [ @@ -97,4 +96,4 @@ def test_request_returning_server_device_failure_error(rtu_server, function): resp = rtu_server.serial_port.read(rtu_server.serial_port.in_waiting) validate_crc(resp) - assert struct.unpack('>BB', resp[1:-2]) == (0x80 + function_code, 4) + validate_response_error(resp[:-2], function_code, 4) diff --git a/tests/system/responses/test_succesful_async_responses.py b/tests/system/responses/test_succesful_async_responses.py index d68e33e..31c6136 100644 --- a/tests/system/responses/test_succesful_async_responses.py +++ b/tests/system/responses/test_succesful_async_responses.py @@ -27,7 +27,9 @@ async def test_response_on_single_bit_value_read_requests(async_tcp_streams, fun """ Validate response of a succesful Read Coils or Read Discrete Inputs request. """ - slave_id, starting_address, quantity = (1, 0, 10) + slave_id = 1 + starting_address = 0 + quantity = 10 req_adu = function(slave_id, starting_address, quantity) assert await tcp.async_send_message(req_adu, *async_tcp_streams) == [0, 1, 0, 1, 0, 1, 0, 1, 0, 1] @@ -41,7 +43,9 @@ async def test_response_on_multi_bit_value_read_requests(async_tcp_streams, func """ Validate response of a succesful Read Holding Registers or Read Input Registers request. """ - slave_id, starting_address, quantity = (1, 0, 10) + slave_id = 1 + starting_address = 0 + quantity = 10 req_adu = function(slave_id, starting_address, quantity) assert await tcp.async_send_message(req_adu, *async_tcp_streams) ==\ @@ -56,7 +60,9 @@ async def test_response_single_value_write_request(async_tcp_streams, function, """ Validate responde of succesful Read Single Coil and Read Single Register request. """ - slave_id, starting_address, value = (1, 0, value) + slave_id = 1 + starting_address = 0 + quantity = 10 req_adu = function(slave_id, starting_address, value) assert await tcp.async_send_message(req_adu, *async_tcp_streams) == value @@ -72,7 +78,9 @@ async def test_async_response_multi_value_write_request(async_tcp_streams, funct Both requests write 2 values, starting address is 0. """ - slave_id, starting_address = (1, 0) + slave_id = 1 + starting_address = 0 + quantity = 10 req_adu = function(slave_id, starting_address, values) assert await tcp.async_send_message(req_adu, *async_tcp_streams) == 2 diff --git a/tests/system/validators.py b/tests/system/validators.py index dab74b8..1849ca2 100644 --- a/tests/system/validators.py +++ b/tests/system/validators.py @@ -32,6 +32,11 @@ def validate_response_mbap(request_mbap, response): validate_unit_id(request_mbap, response) +def validate_response_error(resp, function_code, error_code): + assert struct.unpack('>BB', resp[-2:]) == \ + (0x80 + function_code, error_code) + + def validate_function_code(request, response): """ Validate if Function code in request and response equal. """ assert struct.unpack('>B', request[7:8])[0] == \ From 8ebe20900e7b832b683cb21e68c9e1982adb2c47 Mon Sep 17 00:00:00 2001 From: Jose Tiago Macara Coutinho Date: Sat, 14 Nov 2020 16:22:13 +0100 Subject: [PATCH 06/13] Add simple asyncio RTU serial client exmaple --- scripts/examples/simple_asyncio_rtu_client.py | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100755 scripts/examples/simple_asyncio_rtu_client.py diff --git a/scripts/examples/simple_asyncio_rtu_client.py b/scripts/examples/simple_asyncio_rtu_client.py new file mode 100755 index 0000000..b80e1d7 --- /dev/null +++ b/scripts/examples/simple_asyncio_rtu_client.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python +# scripts/example/simple_async_rtu_client.py +import asyncio + +from serial_asyncio import open_serial_connection + +from umodbus.client.serial import rtu + + +async def main(): + reader, writer = await open_serial_connection(url='/dev/ttyS1', timeout=1) + + # Returns a message or Application Data Unit (ADU) specific for doing + # Modbus TCP/IP. + message = tcp.write_multiple_coils(slave_id=1, starting_address=1, values=[1, 0, 1, 1]) + + # Response depends on Modbus function code. This particular returns the + # amount of coils written, in this case it is. + response = await tcp.async_send_message(message, reader, writer) + + writer.close() + await writer.wait_closed() + + +asyncio.run(main()) From da81fa804911a61d899931902d8ae87b80754793 Mon Sep 17 00:00:00 2001 From: Jose Tiago Macara Coutinho Date: Mon, 16 Nov 2020 14:31:05 +0100 Subject: [PATCH 07/13] Move asynch methods to separate module --- README.rst | 4 ++- scripts/examples/simple_asyncio_rtu_client.py | 5 +-- scripts/examples/simple_asyncio_tcp_client.py | 4 ++- .../test_succesful_async_responses.py | 10 +++--- .../test_succesful_async_rtu_responses.py | 9 ++--- tests/unit/client/test_tcp.py | 2 +- tests/unit/test_tcp.py | 2 +- umodbus/client/serial/asynch.py | 34 +++++++++++++++++++ umodbus/client/serial/rtu.py | 24 ------------- umodbus/client/tcp/__init__.py | 1 + umodbus/client/tcp/asynch.py | 29 ++++++++++++++++ umodbus/client/{ => tcp}/tcp.py | 23 ------------- 12 files changed, 85 insertions(+), 62 deletions(-) create mode 100644 umodbus/client/serial/asynch.py create mode 100644 umodbus/client/tcp/__init__.py create mode 100644 umodbus/client/tcp/asynch.py rename umodbus/client/{ => tcp}/tcp.py (91%) diff --git a/README.rst b/README.rst index 097cb8c..b85615a 100644 --- a/README.rst +++ b/README.rst @@ -102,6 +102,8 @@ Doing a Modbus request requires even less code: from umodbus import conf from umodbus.client import tcp + from umodbus.client.tcp.asynch import send_message + # Enable values to be signed (default is False). conf.SIGNED_VALUES = True @@ -150,7 +152,7 @@ and StreamWriter objects:: # Response depends on Modbus function code. This particular returns the # amount of coils written, in this case it is. - response = await tcp.async_send_message(message, reader, writer) + response = await send_message(message, reader, writer) writer.close() await writer.wait_closed() diff --git a/scripts/examples/simple_asyncio_rtu_client.py b/scripts/examples/simple_asyncio_rtu_client.py index b80e1d7..49787d0 100755 --- a/scripts/examples/simple_asyncio_rtu_client.py +++ b/scripts/examples/simple_asyncio_rtu_client.py @@ -5,6 +5,7 @@ from serial_asyncio import open_serial_connection from umodbus.client.serial import rtu +from umodbus.client.serial.asynch import send_message async def main(): @@ -12,11 +13,11 @@ async def main(): # Returns a message or Application Data Unit (ADU) specific for doing # Modbus TCP/IP. - message = tcp.write_multiple_coils(slave_id=1, starting_address=1, values=[1, 0, 1, 1]) + message = rtu.write_multiple_coils(slave_id=1, starting_address=1, values=[1, 0, 1, 1]) # Response depends on Modbus function code. This particular returns the # amount of coils written, in this case it is. - response = await tcp.async_send_message(message, reader, writer) + response = await send_message(message, reader, writer) writer.close() await writer.wait_closed() diff --git a/scripts/examples/simple_asyncio_tcp_client.py b/scripts/examples/simple_asyncio_tcp_client.py index 6f7a514..48be689 100755 --- a/scripts/examples/simple_asyncio_tcp_client.py +++ b/scripts/examples/simple_asyncio_tcp_client.py @@ -4,6 +4,8 @@ from umodbus import conf from umodbus.client import tcp +from umodbus.client.asynch import send_message + # Enable values to be signed (default is False). conf.SIGNED_VALUES = True @@ -18,7 +20,7 @@ async def main(): # Response depends on Modbus function code. This particular returns the # amount of coils written, in this case it is. - response = await tcp.async_send_message(message, reader, writer) + response = await send_message(message, reader, writer) writer.close() await writer.wait_closed() diff --git a/tests/system/responses/test_succesful_async_responses.py b/tests/system/responses/test_succesful_async_responses.py index 31c6136..c2f070d 100644 --- a/tests/system/responses/test_succesful_async_responses.py +++ b/tests/system/responses/test_succesful_async_responses.py @@ -2,7 +2,7 @@ from umodbus import conf from umodbus.client import tcp - +from umodbus.client.tcp.asynch import send_message pytestmark = pytest.mark.asyncio @@ -32,7 +32,7 @@ async def test_response_on_single_bit_value_read_requests(async_tcp_streams, fun quantity = 10 req_adu = function(slave_id, starting_address, quantity) - assert await tcp.async_send_message(req_adu, *async_tcp_streams) == [0, 1, 0, 1, 0, 1, 0, 1, 0, 1] + assert await send_message(req_adu, *async_tcp_streams) == [0, 1, 0, 1, 0, 1, 0, 1, 0, 1] @pytest.mark.parametrize('function', [ @@ -48,7 +48,7 @@ async def test_response_on_multi_bit_value_read_requests(async_tcp_streams, func quantity = 10 req_adu = function(slave_id, starting_address, quantity) - assert await tcp.async_send_message(req_adu, *async_tcp_streams) ==\ + assert await send_message(req_adu, *async_tcp_streams) ==\ [0, -1, -2, -3, -4, -5, -6, -7, -8, -9] @@ -65,7 +65,7 @@ async def test_response_single_value_write_request(async_tcp_streams, function, quantity = 10 req_adu = function(slave_id, starting_address, value) - assert await tcp.async_send_message(req_adu, *async_tcp_streams) == value + assert await send_message(req_adu, *async_tcp_streams) == value @pytest.mark.parametrize('function, values', [ @@ -83,4 +83,4 @@ async def test_async_response_multi_value_write_request(async_tcp_streams, funct quantity = 10 req_adu = function(slave_id, starting_address, values) - assert await tcp.async_send_message(req_adu, *async_tcp_streams) == 2 + assert await send_message(req_adu, *async_tcp_streams) == 2 diff --git a/tests/system/responses/test_succesful_async_rtu_responses.py b/tests/system/responses/test_succesful_async_rtu_responses.py index 9950a88..da9c1e0 100644 --- a/tests/system/responses/test_succesful_async_rtu_responses.py +++ b/tests/system/responses/test_succesful_async_rtu_responses.py @@ -2,6 +2,7 @@ from umodbus import conf from umodbus.client.serial import rtu +from umodbus.client.serial.asynch import send_message pytestmark = pytest.mark.asyncio @@ -30,7 +31,7 @@ async def test_response_on_single_bit_value_read_requests(async_serial_streams, slave_id, starting_address, quantity = (1, 0, 10) req_adu = function(slave_id, starting_address, quantity) - reply = await rtu.async_send_message(req_adu, *async_serial_streams) + reply = await send_message(req_adu, *async_serial_streams) assert reply == [0, 1, 0, 1, 0, 1, 0, 1, 0, 1] @@ -46,7 +47,7 @@ async def test_response_on_multi_bit_value_read_requests(async_serial_streams, f slave_id, starting_address, quantity = (1, 0, 10) req_adu = function(slave_id, starting_address, quantity) - reply = await rtu.async_send_message(req_adu, *async_serial_streams) + reply = await send_message(req_adu, *async_serial_streams) assert reply == [0, -1, -2, -3, -4, -5, -6, -7, -8, -9] @@ -61,7 +62,7 @@ async def test_response_single_value_write_request(async_serial_streams, functio slave_id, starting_address, quantity = (1, 0, value) req_adu = function(slave_id, starting_address, quantity) - reply = await rtu.async_send_message(req_adu, *async_serial_streams) + reply = await send_message(req_adu, *async_serial_streams) assert reply == value @@ -78,5 +79,5 @@ async def test_response_multi_value_write_request(async_serial_streams, function slave_id, starting_address = (1, 0) req_adu = function(slave_id, starting_address, values) - reply = await rtu.async_send_message(req_adu, *async_serial_streams) + reply = await send_message(req_adu, *async_serial_streams) assert reply == 2 diff --git a/tests/unit/client/test_tcp.py b/tests/unit/client/test_tcp.py index ac3034b..c3e88f1 100644 --- a/tests/unit/client/test_tcp.py +++ b/tests/unit/client/test_tcp.py @@ -1,6 +1,6 @@ import struct -from umodbus.client.tcp import _create_request_adu, _create_mbap_header +from umodbus.client.tcp.tcp import _create_request_adu, _create_mbap_header def test_create_request_adu(): diff --git a/tests/unit/test_tcp.py b/tests/unit/test_tcp.py index 7dbf076..e4c62dc 100644 --- a/tests/unit/test_tcp.py +++ b/tests/unit/test_tcp.py @@ -1,6 +1,6 @@ import struct -from umodbus.client.tcp import _create_request_adu, _create_mbap_header +from umodbus.client.tcp.tcp import _create_request_adu, _create_mbap_header def validate_mbap_fields(mbap, slave_id, pdu): diff --git a/umodbus/client/serial/asynch.py b/umodbus/client/serial/asynch.py new file mode 100644 index 0000000..3aca89f --- /dev/null +++ b/umodbus/client/serial/asynch.py @@ -0,0 +1,34 @@ +""" +Async I/O. Send ModBus RTU message over any asynchonous communication +transport. +""" + +from .rtu import ( + raise_for_exception_adu, + expected_response_pdu_size_from_request_pdu, + parse_response_adu, +) + + +async def send_message(adu, reader, writer): + """ Send ADU over asyncio reader/writer and return parsed response. + + :param adu: Request ADU. + :param reader: stream reader (ex: serial_asyncio.StreamReader) + :param writer: stream writer (ex: serial_asyncio.StreamWriter) + :return: Parsed response from server. + """ + writer.write(adu) + await writer.drain() + + # Check exception ADU (which is shorter than all other responses) first. + exception_adu_size = 5 + response_error_adu = await reader.readexactly(exception_adu_size) + raise_for_exception_adu(response_error_adu) + + expected_response_size = expected_response_pdu_size_from_request_pdu(adu[1:-2]) + 3 + response_remainder = await reader.readexactly( + expected_response_size - exception_adu_size + ) + + return parse_response_adu(response_error_adu + response_remainder, adu) diff --git a/umodbus/client/serial/rtu.py b/umodbus/client/serial/rtu.py index e5808a7..4c01580 100644 --- a/umodbus/client/serial/rtu.py +++ b/umodbus/client/serial/rtu.py @@ -223,27 +223,3 @@ def send_message(adu, serial_port): serial_port.read, expected_response_size - exception_adu_size) return parse_response_adu(response_error_adu + response_remainder, adu) - - -async def async_send_message(adu, reader, writer): - """ Send ADU over asyncio reader/writer and return parsed response. - - :param adu: Request ADU. - :param reader: stream reader (ex: serial_asyncio.StreamReader) - :param writer: stream writer (ex: serial_asyncio.StreamWriter) - :return: Parsed response from server. - """ - writer.write(adu) - await writer.drain() - - # Check exception ADU (which is shorter than all other responses) first. - exception_adu_size = 5 - response_error_adu = await reader.readexactly(exception_adu_size) - raise_for_exception_adu(response_error_adu) - - expected_response_size = \ - expected_response_pdu_size_from_request_pdu(adu[1:-2]) + 3 - response_remainder = await reader.readexactly( - expected_response_size - exception_adu_size) - - return parse_response_adu(response_error_adu + response_remainder, adu) diff --git a/umodbus/client/tcp/__init__.py b/umodbus/client/tcp/__init__.py new file mode 100644 index 0000000..cdaffca --- /dev/null +++ b/umodbus/client/tcp/__init__.py @@ -0,0 +1 @@ +from .tcp import * diff --git a/umodbus/client/tcp/asynch.py b/umodbus/client/tcp/asynch.py new file mode 100644 index 0000000..933ae3a --- /dev/null +++ b/umodbus/client/tcp/asynch.py @@ -0,0 +1,29 @@ +""" +Async I/O. Send ModBus TCP message over any asynchonous communication +transport. +""" + +from .tcp import raise_for_exception_adu, expected_response_pdu_size_from_request_pdu, parse_response_adu + + +async def send_message(adu, reader, writer): + """ Send ADU over asyncio reader/writer and return parsed response. + + :param adu: Request ADU. + :param reader: stream reader (ex: asyncio.StreamReader) + :param writer: stream writer (ex: asyncio.StreamWriter) + :return: Parsed response from server. + """ + writer.write(adu) + await writer.drain() + + exception_adu_size = 9 + response_error_adu = await reader.readexactly(exception_adu_size) + raise_for_exception_adu(response_error_adu) + + expected_response_size = \ + expected_response_pdu_size_from_request_pdu(adu[7:]) + 7 + response_remainder = await reader.readexactly( + expected_response_size - exception_adu_size) + + return parse_response_adu(response_error_adu + response_remainder, adu) diff --git a/umodbus/client/tcp.py b/umodbus/client/tcp/tcp.py similarity index 91% rename from umodbus/client/tcp.py rename to umodbus/client/tcp/tcp.py index 27de458..215b325 100644 --- a/umodbus/client/tcp.py +++ b/umodbus/client/tcp/tcp.py @@ -267,26 +267,3 @@ def send_message(adu, sock): sock.recv, expected_response_size - exception_adu_size) return parse_response_adu(response_error_adu + response_remainder, adu) - - -async def async_send_message(adu, reader, writer): - """ Send ADU over asyncio reader/writer and return parsed response. - - :param adu: Request ADU. - :param reader: stream reader (ex: asyncio.StreamReader) - :param writer: stream writer (ex: asyncio.StreamWriter) - :return: Parsed response from server. - """ - writer.write(adu) - await writer.drain() - - exception_adu_size = 9 - response_error_adu = await reader.readexactly(exception_adu_size) - raise_for_exception_adu(response_error_adu) - - expected_response_size = \ - expected_response_pdu_size_from_request_pdu(adu[7:]) + 7 - response_remainder = await reader.readexactly( - expected_response_size - exception_adu_size) - - return parse_response_adu(response_error_adu + response_remainder, adu) From 20be348abdae70fba4628de09614098b814c1932 Mon Sep 17 00:00:00 2001 From: Jose Tiago Macara Coutinho Date: Mon, 16 Nov 2020 15:00:39 +0100 Subject: [PATCH 08/13] Add I/O agnostic statement to README --- README.rst | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/README.rst b/README.rst index b85615a..90cd4a6 100644 --- a/README.rst +++ b/README.rst @@ -91,7 +91,7 @@ Doing a Modbus request requires even less code: .. Because GitHub doesn't support the include directive the source of - scripts/examples/simple_data_store.py has been copied to this file. + scripts/examples/simple_tcp_client.py has been copied to this file. .. code:: python @@ -128,9 +128,22 @@ Doing a Modbus request requires even less code: response = tcp.send_message(message, sock) +uModbus client I/O model is designed to work well with many asynchronous +concurrency libraries including asyncio_, curio_, trio_, anyio_ and even +greenlet based libraries like gevent_. -The same request can also be made using any asyncio_ compatible StreamReader -and StreamWriter objects:: +As an example, the above client snippet can be made to work in a gevent +context simply by replacing the ``import socket`` line with +``from gevent import socket``. + +Here is the same request using any asyncio_ compatible StreamReader and +StreamWriter objects: + +.. + Because GitHub doesn't support the include directive the source of + scripts/examples/simple_tcp_client.py has been copied to this file. + +.. code:: python #!/usr/bin/env python # scripts/examples/simple_async_tcp_client.py @@ -192,3 +205,7 @@ Climate Systems`_. .. _Mozilla Public License: https://github.com/AdvancedClimateSystems/uModbus/blob/develop/LICENSE .. _Read the Docs: http://umodbus.readthedocs.org/en/latest/ .. _asyncio: https://docs.python.org/3/library/asyncio.html +.. _curio: https://curio.rtfd.io/ +.. _trio: https://trio.rtfd.io/ +.. _anyio: https://anyio.rtfd.io/ +.. _gevent: http://www.gevent.org/ From ea2b2a75dd1ce4d2727848ab92a95e9ef02a41a7 Mon Sep 17 00:00:00 2001 From: Jose Tiago Macara Coutinho Date: Mon, 16 Nov 2020 15:10:31 +0100 Subject: [PATCH 09/13] Add curio based tcp client example --- scripts/examples/simple_curio_tcp_client.py | 48 +++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100755 scripts/examples/simple_curio_tcp_client.py diff --git a/scripts/examples/simple_curio_tcp_client.py b/scripts/examples/simple_curio_tcp_client.py new file mode 100755 index 0000000..f5591a6 --- /dev/null +++ b/scripts/examples/simple_curio_tcp_client.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python +# scripts/examples/simple_curio_tcp_client.py +import curio + +from umodbus import conf +from umodbus.client import tcp +from umodbus.client.tcp.asynch import send_message + + +# Enable values to be signed (default is False). +conf.SIGNED_VALUES = True + + +class CurioStream: + """Adapter from curio socket to StreamReader/Writer""" + + def __init__(self, sock): + self.stream = sock.as_stream() + self.data = None + + def write(self, data): + self._data = data + + async def drain(self): + if self._data: + await self.stream.write(self._data) + self._data = None + + async def readexactly(self, n): + return await self.stream.read_exactly(n) + + +async def main(): + sock = await curio.open_connection('localhost', 502) + stream = CurioStream(sock) + + # Returns a message or Application Data Unit (ADU) specific for doing + # Modbus TCP/IP. + message = tcp.write_multiple_coils(slave_id=1, starting_address=1, values=[1, 0, 1, 1]) + + # Response depends on Modbus function code. This particular returns the + # amount of coils written, in this case it is. + response = await send_message(message, stream, stream) + + await sock.close() + + +curio.run(main) From bb7cbbc37ed937515cb517d80a1f428ece701a35 Mon Sep 17 00:00:00 2001 From: Jose Tiago Macara Coutinho Date: Mon, 16 Nov 2020 15:23:01 +0100 Subject: [PATCH 10/13] Add argument parser to new examples --- README.rst | 12 +++++++++++- scripts/examples/simple_asyncio_tcp_client.py | 15 +++++++++++++-- scripts/examples/simple_curio_tcp_client.py | 13 ++++++++++++- 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/README.rst b/README.rst index 90cd4a6..78ef8b3 100644 --- a/README.rst +++ b/README.rst @@ -157,7 +157,17 @@ StreamWriter objects: async def main(): - reader, writer = await asyncio.open_connection('localhost', 502) + # Parse command line arguments + parser = ArgumentParser() + parser.add_argument("-a", "--address", default="localhost:502") + + args = parser.parse_args() + if ":" not in args.address: + args.address += ":502" + host, port = args.address.rsplit(":", 1) + port = int(port) + + reader, writer = await asyncio.open_connection(host, port) # Returns a message or Application Data Unit (ADU) specific for doing # Modbus TCP/IP. diff --git a/scripts/examples/simple_asyncio_tcp_client.py b/scripts/examples/simple_asyncio_tcp_client.py index 48be689..1277239 100755 --- a/scripts/examples/simple_asyncio_tcp_client.py +++ b/scripts/examples/simple_asyncio_tcp_client.py @@ -1,10 +1,11 @@ #!/usr/bin/env python # scripts/examples/simple_async_tcp_client.py import asyncio +from argparse import ArgumentParser from umodbus import conf from umodbus.client import tcp -from umodbus.client.asynch import send_message +from umodbus.client.tcp.asynch import send_message # Enable values to be signed (default is False). @@ -12,7 +13,17 @@ async def main(): - reader, writer = await asyncio.open_connection('localhost', 502) + # Parse command line arguments + parser = ArgumentParser() + parser.add_argument("-a", "--address", default="localhost:502") + + args = parser.parse_args() + if ":" not in args.address: + args.address += ":502" + host, port = args.address.rsplit(":", 1) + port = int(port) + + reader, writer = await asyncio.open_connection(host, port) # Returns a message or Application Data Unit (ADU) specific for doing # Modbus TCP/IP. diff --git a/scripts/examples/simple_curio_tcp_client.py b/scripts/examples/simple_curio_tcp_client.py index f5591a6..0b83efe 100755 --- a/scripts/examples/simple_curio_tcp_client.py +++ b/scripts/examples/simple_curio_tcp_client.py @@ -1,6 +1,7 @@ #!/usr/bin/env python # scripts/examples/simple_curio_tcp_client.py import curio +from argparse import ArgumentParser from umodbus import conf from umodbus.client import tcp @@ -31,7 +32,17 @@ async def readexactly(self, n): async def main(): - sock = await curio.open_connection('localhost', 502) + # Parse command line arguments + parser = ArgumentParser() + parser.add_argument("-a", "--address", default="localhost:502") + + args = parser.parse_args() + if ":" not in args.address: + args.address += ":502" + host, port = args.address.rsplit(":", 1) + port = int(port) + + sock = await curio.open_connection(host, port) stream = CurioStream(sock) # Returns a message or Application Data Unit (ADU) specific for doing From 35fa1e5464482cd71908ebb6d35e69da074f6ad2 Mon Sep 17 00:00:00 2001 From: Jose Tiago Macara Coutinho Date: Mon, 16 Nov 2020 15:32:07 +0100 Subject: [PATCH 11/13] Add missing module to setup.py --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index 1c36cb5..64d5976 100755 --- a/setup.py +++ b/setup.py @@ -22,6 +22,7 @@ packages=[ 'umodbus', 'umodbus.client', + 'umodbus.client.tcp', 'umodbus.client.serial', 'umodbus.server', 'umodbus.server.serial', From e1f489d2d9b478d5b0e9909b952c520f5c2572b0 Mon Sep 17 00:00:00 2001 From: Jose Tiago Macara Coutinho Date: Tue, 17 Nov 2020 17:23:50 +0100 Subject: [PATCH 12/13] Fix reference to example filename --- README.rst | 4 ++-- scripts/examples/simple_asyncio_tcp_client.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.rst b/README.rst index 78ef8b3..af342e5 100644 --- a/README.rst +++ b/README.rst @@ -141,12 +141,12 @@ StreamWriter objects: .. Because GitHub doesn't support the include directive the source of - scripts/examples/simple_tcp_client.py has been copied to this file. + scripts/examples/simple_asyncio_tcp_client.py has been copied to this file. .. code:: python #!/usr/bin/env python - # scripts/examples/simple_async_tcp_client.py + # scripts/examples/simple_asyncio_tcp_client.py import asyncio from umodbus import conf diff --git a/scripts/examples/simple_asyncio_tcp_client.py b/scripts/examples/simple_asyncio_tcp_client.py index 1277239..0db30d5 100755 --- a/scripts/examples/simple_asyncio_tcp_client.py +++ b/scripts/examples/simple_asyncio_tcp_client.py @@ -1,5 +1,5 @@ #!/usr/bin/env python -# scripts/examples/simple_async_tcp_client.py +# scripts/examples/simple_asyncio_tcp_client.py import asyncio from argparse import ArgumentParser From 89b405b06796ab85fa30efe7a03748ba17701f9a Mon Sep 17 00:00:00 2001 From: Jose Tiago Macara Coutinho Date: Tue, 17 Nov 2020 17:46:58 +0100 Subject: [PATCH 13/13] Move client tcp internals --- tests/unit/client/test_tcp.py | 2 +- tests/unit/test_tcp.py | 2 +- umodbus/client/tcp/__init__.py | 270 ++++++++++++++++++++++++++++++++- umodbus/client/tcp/asynch.py | 12 +- umodbus/client/tcp/tcp.py | 269 -------------------------------- 5 files changed, 279 insertions(+), 276 deletions(-) delete mode 100644 umodbus/client/tcp/tcp.py diff --git a/tests/unit/client/test_tcp.py b/tests/unit/client/test_tcp.py index c3e88f1..ac3034b 100644 --- a/tests/unit/client/test_tcp.py +++ b/tests/unit/client/test_tcp.py @@ -1,6 +1,6 @@ import struct -from umodbus.client.tcp.tcp import _create_request_adu, _create_mbap_header +from umodbus.client.tcp import _create_request_adu, _create_mbap_header def test_create_request_adu(): diff --git a/tests/unit/test_tcp.py b/tests/unit/test_tcp.py index e4c62dc..7dbf076 100644 --- a/tests/unit/test_tcp.py +++ b/tests/unit/test_tcp.py @@ -1,6 +1,6 @@ import struct -from umodbus.client.tcp.tcp import _create_request_adu, _create_mbap_header +from umodbus.client.tcp import _create_request_adu, _create_mbap_header def validate_mbap_fields(mbap, slave_id, pdu): diff --git a/umodbus/client/tcp/__init__.py b/umodbus/client/tcp/__init__.py index cdaffca..215b325 100644 --- a/umodbus/client/tcp/__init__.py +++ b/umodbus/client/tcp/__init__.py @@ -1 +1,269 @@ -from .tcp import * +""" + +.. note:: This section is based on `MODBUS Messaging on TCP/IP + Implementation Guide V1.0b`_. + +The Application Data Unit (ADU) for Modbus messages carried over a TCP/IP are +build out of two components: a MBAP header and a PDU. The Modbus Application +Header (MBAP) is what makes Modbus TCP/IP requests and responses different from +their counterparts send over a serial line. Below the components of the Modbus +TCP/IP are listed together with their size in bytes: + ++---------------+-----------------+ +| **Component** | **Size** (bytes)| ++---------------+-----------------+ +| MBAP Header | 7 | ++---------------+-----------------+ +| PDU | N | ++---------------+-----------------+ + +Below you see a hexadecimal presentation of request over TCP/IP with Modbus +function code 1. It requests data of slave with 1, starting at coil 100, for +the length of 3 coils: + +.. + Note: the backslash in the bytes below are escaped using an extra back + slash. Without escaping the bytes aren't printed correctly in the HTML + output of this docs. + + To work with the bytes in Python you need to remove the escape sequences. + `b'\\x01\\x00d` -> `b\x01\x00d` + +.. code-block:: python + + >>> # Read coils, starting from coil 100 for the length of 3 coils. + >>> adu = b'\\x00\\x08\\x00\\x00\\x00\\x06\\x01\\x01\\x00d\\x00\\x03' + +The length of the ADU is 12 bytes:: + + >>> len(adu) + 12 + +The MBAP header is 7 bytes long:: + + >>> mbap = adu[:7] + >>> mbap + b'\\x00\\x08\\x00\\x00\\x00\\x06\\x01' + +The MBAP header contains the following fields: + ++------------------------+--------------------+--------------------------------------+ +| **Field** | **Length** (bytes) | **Description** | ++------------------------+--------------------+--------------------------------------+ +| Transaction identifier | 2 | Identification of a | +| | | Modbus request/response transaction. | ++------------------------+--------------------+--------------------------------------+ +| Protocol identifier | 2 | Protocol ID, is 0 for Modbus. | ++------------------------+--------------------+--------------------------------------+ +| Length | 2 | Number of following bytes | ++------------------------+--------------------+--------------------------------------+ +| Unit identifier | 1 | Identification of a | +| | | remote slave | ++------------------------+--------------------+--------------------------------------+ + +When unpacked, these fields have the following values:: + + >>> transaction_id = mbap[:2] + >>> transaction_id + b'\\x00\\x08' + >>> protocol_id = mbap[2:4] + >>> protocol_id + b'\\x00\\x00' + >>> length = mbap[4:6] + >>> length + b'\\x00\\x06' + >>> unit_id = mbap[6:] + >>> unit_id + b'\\0x01' + +The request in words: a request with Transaction ID 8 for slave 1. The +request uses Protocol ID 0, which is the Modbus protocol. The length of the +bytes after the Length field is 6 bytes. These 6 bytes are Unit Identifier (1 +byte) + PDU (5 bytes). + +""" +import struct +from random import randint + +from umodbus.functions import (create_function_from_response_pdu, + expected_response_pdu_size_from_request_pdu, + pdu_to_function_code_or_raise_error, ReadCoils, + ReadDiscreteInputs, ReadHoldingRegisters, + ReadInputRegisters, WriteSingleCoil, + WriteSingleRegister, WriteMultipleCoils, + WriteMultipleRegisters) +from umodbus.utils import recv_exactly + + +def _create_request_adu(slave_id, pdu): + """ Create MBAP header and combine it with PDU to return ADU. + + :param slave_id: Number of slave. + :param pdu: Byte array with PDU. + :return: Byte array with ADU. + """ + return _create_mbap_header(slave_id, pdu) + pdu + + +def _create_mbap_header(slave_id, pdu): + """ Return byte array with MBAP header for PDU. + + :param slave_id: Number of slave. + :param pdu: Byte array with PDU. + :return: Byte array of 7 bytes with MBAP header. + """ + # 65535 = (2**16)-1 aka maximum number that fits in 2 bytes. + transaction_id = randint(0, 65535) + length = len(pdu) + 1 + + return struct.pack('>HHHB', transaction_id, 0, length, slave_id) + + +def read_coils(slave_id, starting_address, quantity): + """ Return ADU for Modbus function code 01: Read Coils. + + :param slave_id: Number of slave. + :return: Byte array with ADU. + """ + function = ReadCoils() + function.starting_address = starting_address + function.quantity = quantity + + return _create_request_adu(slave_id, function.request_pdu) + + +def read_discrete_inputs(slave_id, starting_address, quantity): + """ Return ADU for Modbus function code 02: Read Discrete Inputs. + + :param slave_id: Number of slave. + :return: Byte array with ADU. + """ + function = ReadDiscreteInputs() + function.starting_address = starting_address + function.quantity = quantity + + return _create_request_adu(slave_id, function.request_pdu) + + +def read_holding_registers(slave_id, starting_address, quantity): + """ Return ADU for Modbus function code 03: Read Holding Registers. + + :param slave_id: Number of slave. + :return: Byte array with ADU. + """ + function = ReadHoldingRegisters() + function.starting_address = starting_address + function.quantity = quantity + + return _create_request_adu(slave_id, function.request_pdu) + + +def read_input_registers(slave_id, starting_address, quantity): + """ Return ADU for Modbus function code 04: Read Input Registers. + + :param slave_id: Number of slave. + :return: Byte array with ADU. + """ + function = ReadInputRegisters() + function.starting_address = starting_address + function.quantity = quantity + + return _create_request_adu(slave_id, function.request_pdu) + + +def write_single_coil(slave_id, address, value): + """ Return ADU for Modbus function code 05: Write Single Coil. + + :param slave_id: Number of slave. + :return: Byte array with ADU. + """ + function = WriteSingleCoil() + function.address = address + function.value = value + + return _create_request_adu(slave_id, function.request_pdu) + + +def write_single_register(slave_id, address, value): + """ Return ADU for Modbus function code 06: Write Single Register. + + :param slave_id: Number of slave. + :return: Byte array with ADU. + """ + function = WriteSingleRegister() + function.address = address + function.value = value + + return _create_request_adu(slave_id, function.request_pdu) + + +def write_multiple_coils(slave_id, starting_address, values): + """ Return ADU for Modbus function code 15: Write Multiple Coils. + + :param slave_id: Number of slave. + :return: Byte array with ADU. + """ + function = WriteMultipleCoils() + function.starting_address = starting_address + function.values = values + + return _create_request_adu(slave_id, function.request_pdu) + + +def write_multiple_registers(slave_id, starting_address, values): + """ Return ADU for Modbus function code 16: Write Multiple Registers. + + :param slave_id: Number of slave. + :return: Byte array with ADU. + """ + function = WriteMultipleRegisters() + function.starting_address = starting_address + function.values = values + + return _create_request_adu(slave_id, function.request_pdu) + + +def parse_response_adu(resp_adu, req_adu=None): + """ Parse response ADU and return response data. Some functions require + request ADU to fully understand request ADU. + + :param resp_adu: Resonse ADU. + :param req_adu: Request ADU, default None. + :return: Response data. + """ + resp_pdu = resp_adu[7:] + function = create_function_from_response_pdu(resp_pdu, req_adu) + + return function.data + + +def raise_for_exception_adu(resp_adu): + """ Check a response ADU for error + + :param resp_adu: Response ADU. + :raises ModbusError: When a response contains an error code. + """ + resp_pdu = resp_adu[7:] + pdu_to_function_code_or_raise_error(resp_pdu) + + +def send_message(adu, sock): + """ Send ADU over socket to to server and return parsed response. + + :param adu: Request ADU. + :param sock: Socket instance. + :return: Parsed response from server. + """ + sock.sendall(adu) + + # Check exception ADU (which is shorter than all other responses) first. + exception_adu_size = 9 + response_error_adu = recv_exactly(sock.recv, exception_adu_size) + raise_for_exception_adu(response_error_adu) + + expected_response_size = \ + expected_response_pdu_size_from_request_pdu(adu[7:]) + 7 + response_remainder = recv_exactly( + sock.recv, expected_response_size - exception_adu_size) + + return parse_response_adu(response_error_adu + response_remainder, adu) diff --git a/umodbus/client/tcp/asynch.py b/umodbus/client/tcp/asynch.py index 933ae3a..1b04254 100644 --- a/umodbus/client/tcp/asynch.py +++ b/umodbus/client/tcp/asynch.py @@ -3,7 +3,11 @@ transport. """ -from .tcp import raise_for_exception_adu, expected_response_pdu_size_from_request_pdu, parse_response_adu +from . import ( + raise_for_exception_adu, + expected_response_pdu_size_from_request_pdu, + parse_response_adu, +) async def send_message(adu, reader, writer): @@ -21,9 +25,9 @@ async def send_message(adu, reader, writer): response_error_adu = await reader.readexactly(exception_adu_size) raise_for_exception_adu(response_error_adu) - expected_response_size = \ - expected_response_pdu_size_from_request_pdu(adu[7:]) + 7 + expected_response_size = expected_response_pdu_size_from_request_pdu(adu[7:]) + 7 response_remainder = await reader.readexactly( - expected_response_size - exception_adu_size) + expected_response_size - exception_adu_size + ) return parse_response_adu(response_error_adu + response_remainder, adu) diff --git a/umodbus/client/tcp/tcp.py b/umodbus/client/tcp/tcp.py deleted file mode 100644 index 215b325..0000000 --- a/umodbus/client/tcp/tcp.py +++ /dev/null @@ -1,269 +0,0 @@ -""" - -.. note:: This section is based on `MODBUS Messaging on TCP/IP - Implementation Guide V1.0b`_. - -The Application Data Unit (ADU) for Modbus messages carried over a TCP/IP are -build out of two components: a MBAP header and a PDU. The Modbus Application -Header (MBAP) is what makes Modbus TCP/IP requests and responses different from -their counterparts send over a serial line. Below the components of the Modbus -TCP/IP are listed together with their size in bytes: - -+---------------+-----------------+ -| **Component** | **Size** (bytes)| -+---------------+-----------------+ -| MBAP Header | 7 | -+---------------+-----------------+ -| PDU | N | -+---------------+-----------------+ - -Below you see a hexadecimal presentation of request over TCP/IP with Modbus -function code 1. It requests data of slave with 1, starting at coil 100, for -the length of 3 coils: - -.. - Note: the backslash in the bytes below are escaped using an extra back - slash. Without escaping the bytes aren't printed correctly in the HTML - output of this docs. - - To work with the bytes in Python you need to remove the escape sequences. - `b'\\x01\\x00d` -> `b\x01\x00d` - -.. code-block:: python - - >>> # Read coils, starting from coil 100 for the length of 3 coils. - >>> adu = b'\\x00\\x08\\x00\\x00\\x00\\x06\\x01\\x01\\x00d\\x00\\x03' - -The length of the ADU is 12 bytes:: - - >>> len(adu) - 12 - -The MBAP header is 7 bytes long:: - - >>> mbap = adu[:7] - >>> mbap - b'\\x00\\x08\\x00\\x00\\x00\\x06\\x01' - -The MBAP header contains the following fields: - -+------------------------+--------------------+--------------------------------------+ -| **Field** | **Length** (bytes) | **Description** | -+------------------------+--------------------+--------------------------------------+ -| Transaction identifier | 2 | Identification of a | -| | | Modbus request/response transaction. | -+------------------------+--------------------+--------------------------------------+ -| Protocol identifier | 2 | Protocol ID, is 0 for Modbus. | -+------------------------+--------------------+--------------------------------------+ -| Length | 2 | Number of following bytes | -+------------------------+--------------------+--------------------------------------+ -| Unit identifier | 1 | Identification of a | -| | | remote slave | -+------------------------+--------------------+--------------------------------------+ - -When unpacked, these fields have the following values:: - - >>> transaction_id = mbap[:2] - >>> transaction_id - b'\\x00\\x08' - >>> protocol_id = mbap[2:4] - >>> protocol_id - b'\\x00\\x00' - >>> length = mbap[4:6] - >>> length - b'\\x00\\x06' - >>> unit_id = mbap[6:] - >>> unit_id - b'\\0x01' - -The request in words: a request with Transaction ID 8 for slave 1. The -request uses Protocol ID 0, which is the Modbus protocol. The length of the -bytes after the Length field is 6 bytes. These 6 bytes are Unit Identifier (1 -byte) + PDU (5 bytes). - -""" -import struct -from random import randint - -from umodbus.functions import (create_function_from_response_pdu, - expected_response_pdu_size_from_request_pdu, - pdu_to_function_code_or_raise_error, ReadCoils, - ReadDiscreteInputs, ReadHoldingRegisters, - ReadInputRegisters, WriteSingleCoil, - WriteSingleRegister, WriteMultipleCoils, - WriteMultipleRegisters) -from umodbus.utils import recv_exactly - - -def _create_request_adu(slave_id, pdu): - """ Create MBAP header and combine it with PDU to return ADU. - - :param slave_id: Number of slave. - :param pdu: Byte array with PDU. - :return: Byte array with ADU. - """ - return _create_mbap_header(slave_id, pdu) + pdu - - -def _create_mbap_header(slave_id, pdu): - """ Return byte array with MBAP header for PDU. - - :param slave_id: Number of slave. - :param pdu: Byte array with PDU. - :return: Byte array of 7 bytes with MBAP header. - """ - # 65535 = (2**16)-1 aka maximum number that fits in 2 bytes. - transaction_id = randint(0, 65535) - length = len(pdu) + 1 - - return struct.pack('>HHHB', transaction_id, 0, length, slave_id) - - -def read_coils(slave_id, starting_address, quantity): - """ Return ADU for Modbus function code 01: Read Coils. - - :param slave_id: Number of slave. - :return: Byte array with ADU. - """ - function = ReadCoils() - function.starting_address = starting_address - function.quantity = quantity - - return _create_request_adu(slave_id, function.request_pdu) - - -def read_discrete_inputs(slave_id, starting_address, quantity): - """ Return ADU for Modbus function code 02: Read Discrete Inputs. - - :param slave_id: Number of slave. - :return: Byte array with ADU. - """ - function = ReadDiscreteInputs() - function.starting_address = starting_address - function.quantity = quantity - - return _create_request_adu(slave_id, function.request_pdu) - - -def read_holding_registers(slave_id, starting_address, quantity): - """ Return ADU for Modbus function code 03: Read Holding Registers. - - :param slave_id: Number of slave. - :return: Byte array with ADU. - """ - function = ReadHoldingRegisters() - function.starting_address = starting_address - function.quantity = quantity - - return _create_request_adu(slave_id, function.request_pdu) - - -def read_input_registers(slave_id, starting_address, quantity): - """ Return ADU for Modbus function code 04: Read Input Registers. - - :param slave_id: Number of slave. - :return: Byte array with ADU. - """ - function = ReadInputRegisters() - function.starting_address = starting_address - function.quantity = quantity - - return _create_request_adu(slave_id, function.request_pdu) - - -def write_single_coil(slave_id, address, value): - """ Return ADU for Modbus function code 05: Write Single Coil. - - :param slave_id: Number of slave. - :return: Byte array with ADU. - """ - function = WriteSingleCoil() - function.address = address - function.value = value - - return _create_request_adu(slave_id, function.request_pdu) - - -def write_single_register(slave_id, address, value): - """ Return ADU for Modbus function code 06: Write Single Register. - - :param slave_id: Number of slave. - :return: Byte array with ADU. - """ - function = WriteSingleRegister() - function.address = address - function.value = value - - return _create_request_adu(slave_id, function.request_pdu) - - -def write_multiple_coils(slave_id, starting_address, values): - """ Return ADU for Modbus function code 15: Write Multiple Coils. - - :param slave_id: Number of slave. - :return: Byte array with ADU. - """ - function = WriteMultipleCoils() - function.starting_address = starting_address - function.values = values - - return _create_request_adu(slave_id, function.request_pdu) - - -def write_multiple_registers(slave_id, starting_address, values): - """ Return ADU for Modbus function code 16: Write Multiple Registers. - - :param slave_id: Number of slave. - :return: Byte array with ADU. - """ - function = WriteMultipleRegisters() - function.starting_address = starting_address - function.values = values - - return _create_request_adu(slave_id, function.request_pdu) - - -def parse_response_adu(resp_adu, req_adu=None): - """ Parse response ADU and return response data. Some functions require - request ADU to fully understand request ADU. - - :param resp_adu: Resonse ADU. - :param req_adu: Request ADU, default None. - :return: Response data. - """ - resp_pdu = resp_adu[7:] - function = create_function_from_response_pdu(resp_pdu, req_adu) - - return function.data - - -def raise_for_exception_adu(resp_adu): - """ Check a response ADU for error - - :param resp_adu: Response ADU. - :raises ModbusError: When a response contains an error code. - """ - resp_pdu = resp_adu[7:] - pdu_to_function_code_or_raise_error(resp_pdu) - - -def send_message(adu, sock): - """ Send ADU over socket to to server and return parsed response. - - :param adu: Request ADU. - :param sock: Socket instance. - :return: Parsed response from server. - """ - sock.sendall(adu) - - # Check exception ADU (which is shorter than all other responses) first. - exception_adu_size = 9 - response_error_adu = recv_exactly(sock.recv, exception_adu_size) - raise_for_exception_adu(response_error_adu) - - expected_response_size = \ - expected_response_pdu_size_from_request_pdu(adu[7:]) + 7 - response_remainder = recv_exactly( - sock.recv, expected_response_size - exception_adu_size) - - return parse_response_adu(response_error_adu + response_remainder, adu)