diff --git a/README.rst b/README.rst index 7ffd213..af342e5 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 @@ -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 @@ -126,6 +128,62 @@ 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_. + +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_asyncio_tcp_client.py has been copied to this file. + +.. code:: python + + #!/usr/bin/env python + # scripts/examples/simple_asyncio_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(): + # 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. + 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, reader, writer) + + writer.close() + await writer.wait_closed() + + + asyncio.run(main()) + + Features -------- @@ -156,3 +214,8 @@ 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 +.. _curio: https://curio.rtfd.io/ +.. _trio: https://trio.rtfd.io/ +.. _anyio: https://anyio.rtfd.io/ +.. _gevent: http://www.gevent.org/ 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_asyncio_rtu_client.py b/scripts/examples/simple_asyncio_rtu_client.py new file mode 100755 index 0000000..49787d0 --- /dev/null +++ b/scripts/examples/simple_asyncio_rtu_client.py @@ -0,0 +1,26 @@ +#!/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 +from umodbus.client.serial.asynch import send_message + + +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 = 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 send_message(message, reader, writer) + + writer.close() + await writer.wait_closed() + + +asyncio.run(main()) diff --git a/scripts/examples/simple_asyncio_tcp_client.py b/scripts/examples/simple_asyncio_tcp_client.py new file mode 100755 index 0000000..0db30d5 --- /dev/null +++ b/scripts/examples/simple_asyncio_tcp_client.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python +# scripts/examples/simple_asyncio_tcp_client.py +import asyncio +from argparse import ArgumentParser + +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 + + +async def main(): + # 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. + 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, reader, writer) + + writer.close() + await writer.wait_closed() + + +asyncio.run(main()) diff --git a/scripts/examples/simple_curio_tcp_client.py b/scripts/examples/simple_curio_tcp_client.py new file mode 100755 index 0000000..0b83efe --- /dev/null +++ b/scripts/examples/simple_curio_tcp_client.py @@ -0,0 +1,59 @@ +#!/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 +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(): + # 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 + # 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) 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', 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..7511869 --- /dev/null +++ b/tests/system/responses/test_exception_async_responses.py @@ -0,0 +1,89 @@ +import pytest +import struct +from functools import partial + +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), + (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 exception response with + error code 3. + """ + starting_address = 0 + adu = mbap + struct.pack('>BHH', function_code, starting_address, quantity) + + resp = await req_rep(adu, *async_tcp_streams) + + validate_response_mbap(mbap, resp) + validate_response_error(resp, 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 exception response with + error code 2. + """ + adu = function() + + mbap = adu[:7] + function_code = struct.unpack('>B', adu[7:8])[0] + + resp = await req_rep(adu, *async_tcp_streams) + + validate_response_mbap(mbap, resp) + validate_response_error(resp, 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] + + resp = await req_rep(adu, *async_tcp_streams) + + validate_response_mbap(mbap, resp) + 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 new file mode 100644 index 0000000..db09580 --- /dev/null +++ b/tests/system/responses/test_exception_async_rtu_responses.py @@ -0,0 +1,96 @@ +import pytest +import struct +from functools import partial + +from ..validators import validate_response_error + +from umodbus.client.serial import rtu +from umodbus.client.serial.redundancy_check import (get_crc, validate_crc, + add_crc, CRCError) + + +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), + (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 exception 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 + resp = await req_rep(adu, reader, writer, rtu_server.serial_port) + + validate_crc(resp) + validate_response_error(resp[:-2], 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 exception response with + error code 2. + """ + adu = function() + + function_code = struct.unpack('>B', adu[1:2])[0] + + reader, writer = async_serial_streams + resp = await req_rep(adu, reader, writer, rtu_server.serial_port) + + validate_crc(resp) + validate_response_error(resp[:-2], 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 exception response with + error code 4. + """ + adu = function() + + function_code = struct.unpack('>B', adu[1:2])[0] + + reader, writer = async_serial_streams + resp = await req_rep(adu, reader, writer, rtu_server.serial_port) + + validate_crc(resp) + 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 new file mode 100644 index 0000000..c2f070d --- /dev/null +++ b/tests/system/responses/test_succesful_async_responses.py @@ -0,0 +1,86 @@ +import pytest + +from umodbus import conf +from umodbus.client import tcp +from umodbus.client.tcp.asynch import send_message + +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 = 1 + starting_address = 0 + quantity = 10 + req_adu = function(slave_id, starting_address, quantity) + + assert await 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 = 1 + starting_address = 0 + quantity = 10 + req_adu = function(slave_id, starting_address, quantity) + + assert await 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 = 1 + starting_address = 0 + quantity = 10 + req_adu = function(slave_id, starting_address, value) + + assert await 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 = 1 + starting_address = 0 + quantity = 10 + req_adu = function(slave_id, starting_address, values) + + 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 new file mode 100644 index 0000000..da9c1e0 --- /dev/null +++ b/tests/system/responses/test_succesful_async_rtu_responses.py @@ -0,0 +1,83 @@ +import pytest + +from umodbus import conf +from umodbus.client.serial import rtu +from umodbus.client.serial.asynch import send_message + + +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 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 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 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 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/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] == \ 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/tcp.py b/umodbus/client/tcp/__init__.py similarity index 100% rename from umodbus/client/tcp.py rename to umodbus/client/tcp/__init__.py diff --git a/umodbus/client/tcp/asynch.py b/umodbus/client/tcp/asynch.py new file mode 100644 index 0000000..1b04254 --- /dev/null +++ b/umodbus/client/tcp/asynch.py @@ -0,0 +1,33 @@ +""" +Async I/O. Send ModBus TCP message over any asynchonous communication +transport. +""" + +from . 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)