Skip to content

Commit

Permalink
transport nullmodem (#1591)
Browse files Browse the repository at this point in the history
  • Loading branch information
janiversen authored Jun 12, 2023
1 parent dd6276a commit 8062d3b
Show file tree
Hide file tree
Showing 5 changed files with 286 additions and 27 deletions.
115 changes: 115 additions & 0 deletions pymodbus/transport/nullmodem.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
"""Null modem transport.
This is a special transport, mostly thought of for testing.
NullModem interconnect 2 transport objects and transfers calls:
- server.listen()
- dummy
- client.connect()
- call client.connection_made()
- call server.connection_made()
- client/server.close()
- call client.connection_lost()
- call server.connection_lost()
- server/client.send
- call client/server.data_received()
"""
from __future__ import annotations

import asyncio

from pymodbus.logging import Log
from pymodbus.transport.transport import Transport


class DummyTransport(asyncio.BaseTransport):
"""Use in connection_made calls."""

def close(self):
"""Define dummy."""

def get_protocol(self):
"""Define dummy."""

def is_closing(self):
"""Define dummy."""

def set_protocol(self, _protocol):
"""Define dummy."""

def abort(self):
"""Define dummy."""


class NullModem(Transport):
"""Transport layer.
Contains methods to act as a null modem between 2 objects.
(Allowing tests to be shortcut without actual network calls)
"""

nullmodem_client: NullModem = None
nullmodem_server: NullModem = None

def __init__(self, *arg):
"""Overwrite init."""
self.is_server: bool = False
self.other_end: NullModem = None
super().__init__(*arg)

async def transport_connect(self) -> bool:
"""Handle generic connect and call on to specific transport connect."""
Log.debug("NullModem: Simulate connect on {}", self.comm_params.comm_name)
if not self.loop:
self.loop = asyncio.get_running_loop()
if self.nullmodem_server:
self.__class__.nullmodem_client = self
self.other_end = self.nullmodem_server
self.nullmodem_server.other_end = self
self.cb_connection_made()
self.other_end.cb_connection_made()
return True
return False

async def transport_listen(self):
"""Handle generic listen and call on to specific transport listen."""
Log.debug("NullModem: Simulate listen on {}", self.comm_params.comm_name)
if not self.loop:
self.loop = asyncio.get_running_loop()
self.is_server = True
self.__class__.nullmodem_server = self
return DummyTransport()

# -------------------------------- #
# Helper methods for child classes #
# -------------------------------- #
async def send(self, data: bytes) -> bool:
"""Send request.
:param data: non-empty bytes object with data to send.
"""
Log.debug("NullModem: simulate send {}", data, ":hex")
self.other_end.data_received(data)
return True

def close(self, reconnect: bool = False) -> None:
"""Close connection.
:param reconnect: (default false), try to reconnect
"""
self.recv_buffer = b""
if not reconnect:
if self.nullmodem_client:
self.nullmodem_client.cb_connection_lost(None)
if self.nullmodem_server:
self.nullmodem_server.cb_connection_lost(None)
self.__class__.nullmodem_client = None
self.__class__.nullmodem_server = None

# ----------------- #
# The magic methods #
# ----------------- #
def __str__(self) -> str:
"""Build a string representation of the connection."""
return f"{self.__class__.__name__}({self.comm_params.comm_name})"
14 changes: 6 additions & 8 deletions pymodbus/transport/transport.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Base for all transport types."""
"""Transport layer."""
# mypy: disable-error-code="name-defined"
# needed because asyncio.Server is not defined (to mypy) in v3.8.16
from __future__ import annotations
Expand Down Expand Up @@ -94,7 +94,6 @@ def __init__(

self.reconnect_delay_current: float = 0.0
self.transport: asyncio.BaseTransport | asyncio.Server = None
self.protocol: asyncio.BaseProtocol = None
self.loop: asyncio.AbstractEventLoop = None
self.reconnect_task: asyncio.Task = None
self.recv_buffer: bytes = b""
Expand Down Expand Up @@ -266,9 +265,9 @@ async def transport_connect(self) -> bool:
Log.debug("Connecting {}", self.comm_params.comm_name)
if not self.loop:
self.loop = asyncio.get_running_loop()
self.transport, self.protocol = None, None
self.transport = None
try:
self.transport, self.protocol = await asyncio.wait_for(
self.transport, _protocol = await asyncio.wait_for(
self.call_connect_listen(),
timeout=self.comm_params.timeout_connect,
)
Expand Down Expand Up @@ -346,9 +345,9 @@ def error_received(self, exc):
Log.debug("-> error_received {}", exc)
raise RuntimeError(str(exc))

# -------------------------------- #
# Helper methods for child classes #
# -------------------------------- #
# ----------------------------------- #
# Helper methods for external classes #
# ----------------------------------- #
async def send(self, data: bytes) -> bool:
"""Send request.
Expand All @@ -369,7 +368,6 @@ def close(self, reconnect: bool = False) -> None:
self.transport.abort()
self.transport.close()
self.transport = None
self.protocol = None
if not reconnect and self.reconnect_task:
self.reconnect_task.cancel()
self.reconnect_task = None
Expand Down
54 changes: 39 additions & 15 deletions test/sub_transport/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import pytest
import pytest_asyncio

from pymodbus.transport.nullmodem import NullModem
from pymodbus.transport.transport import Transport


Expand Down Expand Up @@ -38,21 +39,6 @@ def prepare_baseparams(use_port):
return BaseParams


class DummySocket: # pylint: disable=too-few-public-methods
"""Socket simulator for test."""

def __init__(self):
"""Initialize."""
self.close = mock.Mock()
self.abort = mock.Mock()


@pytest.fixture(name="dummy_socket")
def prepare_dummysocket():
"""Prepare dummy_socket class."""
return DummySocket


@pytest.fixture(name="commparams")
def prepare_testparams():
"""Prepare CommParamsClass object."""
Expand Down Expand Up @@ -82,6 +68,44 @@ async def prepare_transport():
return transport


@pytest.fixture(name="nullmodem")
async def prepare_nullmodem():
"""Prepare nullmodem object."""
transport = NullModem(
BaseParams.comm_name,
BaseParams.reconnect_delay,
BaseParams.reconnect_delay_max,
BaseParams.timeout_connect,
mock.Mock(name="cb_connection_made"),
mock.Mock(name="cb_connection_lost"),
mock.Mock(name="cb_handle_data", return_value=0),
)
transport.__class__.nullmodem_client = None
transport.__class__.nullmodem_server = None
with suppress(RuntimeError):
transport.loop = asyncio.get_running_loop()
return transport


@pytest.fixture(name="nullmodem_server")
async def prepare_nullmodem_server():
"""Prepare nullmodem object."""
transport = NullModem(
BaseParams.comm_name,
BaseParams.reconnect_delay,
BaseParams.reconnect_delay_max,
BaseParams.timeout_connect,
mock.Mock(name="cb_connection_made"),
mock.Mock(name="cb_connection_lost"),
mock.Mock(name="cb_handle_data", return_value=0),
)
transport.__class__.nullmodem_client = None
transport.__class__.nullmodem_server = None
with suppress(RuntimeError):
transport.loop = asyncio.get_running_loop()
return transport


@pytest_asyncio.fixture(name="transport_server")
async def prepare_transport_server():
"""Prepare transport object."""
Expand Down
12 changes: 8 additions & 4 deletions test/sub_transport/test_basic.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
import pytest
from serial import SerialException

from pymodbus.transport.nullmodem import DummyTransport


class TestBasicTransport:
"""Test transport module, base part."""
Expand Down Expand Up @@ -45,10 +47,10 @@ async def test_str_magic(self, params, transport):
"""Test magic."""
assert str(transport) == f"Transport({params.comm_name})"

async def test_connection_made(self, dummy_socket, transport, commparams):
async def test_connection_made(self, transport, commparams):
"""Test connection_made()."""
transport.loop = None
transport.connection_made(dummy_socket())
transport.connection_made(DummyTransport())
assert transport.transport
assert not transport.recv_buffer
assert not transport.reconnect_task
Expand Down Expand Up @@ -76,9 +78,11 @@ async def test_connection_lost(self, transport):
transport.close()
assert not transport.reconnect_task

async def test_close(self, dummy_socket, transport):
async def test_close(self, transport):
"""Test close()."""
socket = dummy_socket()
socket = DummyTransport()
socket.abort = mock.Mock()
socket.close = mock.Mock()
transport.connection_made(socket)
transport.cb_connection_made.reset_mock()
transport.cb_connection_lost.reset_mock()
Expand Down
118 changes: 118 additions & 0 deletions test/sub_transport/test_nullmodem.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
"""Test transport."""

from pymodbus.transport.nullmodem import DummyTransport


class TestNullModemTransport:
"""Test null modem module."""

async def test_str_magic(self, nullmodem, params):
"""Test magic."""
str(nullmodem)
assert str(nullmodem) == f"NullModem({params.comm_name})"

def test_DummyTransport(self):
"""Test DummyTransport class."""
socket = DummyTransport()
socket.close()
socket.get_protocol()
socket.is_closing()
socket.set_protocol(None)
socket.abort()

def test_class_variables(self, nullmodem, nullmodem_server):
"""Test connection_made()."""
assert not nullmodem.nullmodem_client
assert not nullmodem.nullmodem_server
assert not nullmodem_server.nullmodem_client
assert not nullmodem_server.nullmodem_server
nullmodem.__class__.nullmodem_client = self
nullmodem.is_server = False
nullmodem_server.__class__.nullmodem_server = self
nullmodem_server.is_server = True

assert nullmodem.nullmodem_client == nullmodem_server.nullmodem_client
assert nullmodem.nullmodem_server == nullmodem_server.nullmodem_server

async def test_transport_connect(self, nullmodem):
"""Test connection_made()."""
nullmodem.loop = None
assert not await nullmodem.transport_connect()
assert not nullmodem.nullmodem_server
assert not nullmodem.nullmodem_client
assert nullmodem.loop
nullmodem.cb_connection_made.assert_not_called()
nullmodem.cb_connection_lost.assert_not_called()
nullmodem.cb_handle_data.assert_not_called()

async def test_transport_listen(self, nullmodem_server):
"""Test connection_made()."""
nullmodem_server.loop = None
assert await nullmodem_server.transport_listen()
assert nullmodem_server.is_server
assert nullmodem_server.nullmodem_server
assert not nullmodem_server.nullmodem_client
assert nullmodem_server.loop
nullmodem_server.cb_connection_made.assert_not_called()
nullmodem_server.cb_connection_lost.assert_not_called()
nullmodem_server.cb_handle_data.assert_not_called()

async def test_connected(self, nullmodem, nullmodem_server):
"""Test connection is correct."""
assert await nullmodem_server.transport_listen()
assert await nullmodem.transport_connect()
assert nullmodem.nullmodem_client
assert nullmodem.nullmodem_server
assert nullmodem.loop
assert not nullmodem.is_server
assert nullmodem_server.is_server
nullmodem.cb_connection_made.assert_called_once()
nullmodem.cb_connection_lost.assert_not_called()
nullmodem.cb_handle_data.assert_not_called()
nullmodem_server.cb_connection_made.assert_called_once()
nullmodem_server.cb_connection_lost.assert_not_called()
nullmodem_server.cb_handle_data.assert_not_called()

async def test_client_close(self, nullmodem, nullmodem_server):
"""Test close()."""
assert await nullmodem_server.transport_listen()
assert await nullmodem.transport_connect()
nullmodem.close()
assert not nullmodem.nullmodem_client
assert not nullmodem.nullmodem_server
nullmodem.cb_connection_made.assert_called_once()
nullmodem.cb_connection_lost.assert_called_once()
nullmodem.cb_handle_data.assert_not_called()
nullmodem_server.cb_connection_made.assert_called_once()
nullmodem_server.cb_connection_lost.assert_called_once()
nullmodem_server.cb_handle_data.assert_not_called()

async def test_server_close(self, nullmodem, nullmodem_server):
"""Test close()."""
assert await nullmodem_server.transport_listen()
assert await nullmodem.transport_connect()
nullmodem_server.close()
assert not nullmodem.nullmodem_client
assert not nullmodem.nullmodem_server
nullmodem.cb_connection_made.assert_called_once()
nullmodem.cb_connection_lost.assert_called_once()
nullmodem.cb_handle_data.assert_not_called()
nullmodem_server.cb_connection_made.assert_called_once()
nullmodem_server.cb_connection_lost.assert_called_once()
nullmodem_server.cb_handle_data.assert_not_called()

async def test_data(self, nullmodem, nullmodem_server):
"""Test data exchange."""
data = b"abcd"
assert await nullmodem_server.transport_listen()
assert await nullmodem.transport_connect()
assert await nullmodem.send(data)
assert nullmodem_server.recv_buffer == data
assert not nullmodem.recv_buffer
nullmodem.cb_handle_data.assert_not_called()
nullmodem_server.cb_handle_data.assert_called_once()
assert await nullmodem_server.send(data)
assert nullmodem_server.recv_buffer == data
assert nullmodem.recv_buffer == data
nullmodem.cb_handle_data.assert_called_once()
nullmodem_server.cb_handle_data.assert_called_once()

0 comments on commit 8062d3b

Please sign in to comment.