From 9aff8879f6e2a645aa08be0f45258a539fb9abb0 Mon Sep 17 00:00:00 2001 From: Ollie Copping Date: Fri, 8 Mar 2024 09:25:00 +0000 Subject: [PATCH 1/4] Add ModbusConnection support --- pyproject.toml | 1 + src/fastcs/connections/__init__.py | 3 +- src/fastcs/connections/modbus_connection.py | 138 ++++++++++++++++++++ 3 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 src/fastcs/connections/modbus_connection.py diff --git a/pyproject.toml b/pyproject.toml index 0a85bfa6..dcc7e944 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,7 @@ dependencies = [ "pydantic", "pvi~=0.7.1", "softioc", + "pymodbus", ] # Add project dependencies here, e.g. ["click", "numpy"] dynamic = ["version"] license.file = "LICENSE" diff --git a/src/fastcs/connections/__init__.py b/src/fastcs/connections/__init__.py index b4880c83..557ada65 100644 --- a/src/fastcs/connections/__init__.py +++ b/src/fastcs/connections/__init__.py @@ -1,3 +1,4 @@ from .ip_connection import IPConnection +from .modbus_connection import ModbusSerialConnection, ModbusTcpConnection, ModbusUdpConnection -__all__ = ["IPConnection"] +__all__ = ["IPConnection", "ModbusSerialConnection", "ModbusTcpConnection", "ModbusUdpConnection"] diff --git a/src/fastcs/connections/modbus_connection.py b/src/fastcs/connections/modbus_connection.py new file mode 100644 index 00000000..9fd1033c --- /dev/null +++ b/src/fastcs/connections/modbus_connection.py @@ -0,0 +1,138 @@ +import asyncio + +from dataclasses import dataclass +from typing import Optional + +from pymodbus.client.base import ModbusBaseClient +from pymodbus.client import ( + AsyncModbusSerialClient, + AsyncModbusTcpClient, + AsyncModbusUdpClient, +) +from pymodbus.exceptions import ModbusException + +from pymodbus.framer import Framer +from pymodbus.pdu import ExceptionResponse + + +# Constants +CR = "\r" +TIMEOUT = 1.0 # Seconds +RECV_BUFFER = 4096 # Bytes + + +@dataclass +class ModbusConnectionSettings: + host: str = "127.0.0.1" + port: int = 7001 + slave: int = 0 + +class ModbusConnection: + + def __init__(self, settings: ModbusConnectionSettings) -> None: + + self.host, self.port, self.slave = settings.host, settings.port, settings.slave + self.running: bool = False + + self._client: ModbusBaseClient + + async def connect(self, framer=Framer.SOCKET): + raise NotImplementedError + + + def disconnect(self): + self._client.close() + + async def _read(self, address: int, count: int = 2) -> Optional[str]: + # address -= 1 # modbus spec starts from 0 not 1 + try: + # address_hex = hex(address) + rr = await self._client.read_holding_registers(address, count=count, slave=self.slave) # type: ignore + print(f"Response: {rr}") + except ModbusException as exc: # pragma no cover + # Received ModbusException from library + self.disconnect() + return + if rr.isError(): # pragma no cover + # Received Modbus library error + self.disconnect() + return + if isinstance(rr, ExceptionResponse): # pragma no cover + # Received Modbus library exception + # THIS IS NOT A PYTHON EXCEPTION, but a valid modbus message + self.disconnect() + + async def send(self, address: int, value: int) -> None: + """Send a request. + + Args: + address: The register address to write to. + value: The value to write. + """ + await self._client.write_registers(address, value, slave=self.slave) + resp = await self._read(address, 2) + +class ModbusSerialConnection(ModbusConnection): + + def __init__(self, settings: ModbusConnectionSettings) -> None: + super().__init__(settings) + + async def connect(self, framer=Framer.SOCKET): + self._client: AsyncModbusSerialClient = AsyncModbusSerialClient( + str(self.port), + framer=framer, + timeout=10, + retries=3, + retry_on_empty=False, + close_comm_on_error=False, + strict=True, + baudrate=9600, + bytesize=8, + parity="N", + stopbits=1, + ) + + await self._client.connect() + assert self._client.connected + +class ModbusTcpConnection(ModbusConnection): + + def __init__(self, settings: ModbusConnectionSettings) -> None: + super().__init__(settings) + + async def connect(self, framer=Framer.SOCKET): + self._client: AsyncModbusTcpClient = AsyncModbusTcpClient( + self.host, + self.port, + framer=framer, + timeout=10, + retries=3, + retry_on_empty=False, + close_comm_on_error=False, + strict=True, + source_address=("localhost", 0), + ) + + await self._client.connect() + assert self._client.connected + +class ModbusUdpConnection(ModbusConnection): + + def __init__(self, settings: ModbusConnectionSettings) -> None: + super().__init__(settings) + + async def connect(self, framer=Framer.SOCKET): + self._client: AsyncModbusUdpClient = AsyncModbusUdpClient( + self.host, + self.port, + framer=framer, + timeout=10, + retries=3, + retry_on_empty=False, + close_comm_on_error=False, + strict=True, + source_address=("localhost", 0), + ) + + await self._client.connect() + assert self._client.connected \ No newline at end of file From 52143a3aba0c0ccfc254c4ebab81468c179c5313 Mon Sep 17 00:00:00 2001 From: Ollie Copping Date: Fri, 8 Mar 2024 09:50:53 +0000 Subject: [PATCH 2/4] Fix type checks/linting --- src/fastcs/connections/modbus_connection.py | 45 ++++++++++----------- 1 file changed, 22 insertions(+), 23 deletions(-) diff --git a/src/fastcs/connections/modbus_connection.py b/src/fastcs/connections/modbus_connection.py index 9fd1033c..63d6cbf1 100644 --- a/src/fastcs/connections/modbus_connection.py +++ b/src/fastcs/connections/modbus_connection.py @@ -1,19 +1,16 @@ -import asyncio - from dataclasses import dataclass from typing import Optional -from pymodbus.client.base import ModbusBaseClient from pymodbus.client import ( AsyncModbusSerialClient, AsyncModbusTcpClient, AsyncModbusUdpClient, + ModbusBaseClient, ) -from pymodbus.exceptions import ModbusException +from pymodbus.exceptions import ModbusException from pymodbus.framer import Framer -from pymodbus.pdu import ExceptionResponse - +from pymodbus.pdu import ExceptionResponse, ModbusResponse # Constants CR = "\r" @@ -27,29 +24,27 @@ class ModbusConnectionSettings: port: int = 7001 slave: int = 0 -class ModbusConnection: +class ModbusConnection: def __init__(self, settings: ModbusConnectionSettings) -> None: - self.host, self.port, self.slave = settings.host, settings.port, settings.slave self.running: bool = False self._client: ModbusBaseClient - async def connect(self, framer=Framer.SOCKET): + async def connect(self) -> None: raise NotImplementedError - def disconnect(self): self._client.close() - async def _read(self, address: int, count: int = 2) -> Optional[str]: + async def _read(self, address: int, count: int = 2) -> Optional[ModbusResponse]: # address -= 1 # modbus spec starts from 0 not 1 try: # address_hex = hex(address) rr = await self._client.read_holding_registers(address, count=count, slave=self.slave) # type: ignore print(f"Response: {rr}") - except ModbusException as exc: # pragma no cover + except ModbusException: # pragma no cover # Received ModbusException from library self.disconnect() return @@ -61,8 +56,11 @@ async def _read(self, address: int, count: int = 2) -> Optional[str]: # Received Modbus library exception # THIS IS NOT A PYTHON EXCEPTION, but a valid modbus message self.disconnect() + else: + return rr + return None - async def send(self, address: int, value: int) -> None: + async def send(self, address: int, value: int) -> ModbusResponse | None: """Send a request. Args: @@ -71,14 +69,15 @@ async def send(self, address: int, value: int) -> None: """ await self._client.write_registers(address, value, slave=self.slave) resp = await self._read(address, 2) + return resp -class ModbusSerialConnection(ModbusConnection): +class ModbusSerialConnection(ModbusConnection): def __init__(self, settings: ModbusConnectionSettings) -> None: super().__init__(settings) - async def connect(self, framer=Framer.SOCKET): - self._client: AsyncModbusSerialClient = AsyncModbusSerialClient( + async def connect(self, framer: Framer=Framer.SOCKET): + self._client = AsyncModbusSerialClient( str(self.port), framer=framer, timeout=10, @@ -95,13 +94,13 @@ async def connect(self, framer=Framer.SOCKET): await self._client.connect() assert self._client.connected -class ModbusTcpConnection(ModbusConnection): +class ModbusTcpConnection(ModbusConnection): def __init__(self, settings: ModbusConnectionSettings) -> None: super().__init__(settings) - async def connect(self, framer=Framer.SOCKET): - self._client: AsyncModbusTcpClient = AsyncModbusTcpClient( + async def connect(self, framer: Framer=Framer.SOCKET): + self._client = AsyncModbusTcpClient( self.host, self.port, framer=framer, @@ -116,13 +115,13 @@ async def connect(self, framer=Framer.SOCKET): await self._client.connect() assert self._client.connected -class ModbusUdpConnection(ModbusConnection): +class ModbusUdpConnection(ModbusConnection): def __init__(self, settings: ModbusConnectionSettings) -> None: super().__init__(settings) - async def connect(self, framer=Framer.SOCKET): - self._client: AsyncModbusUdpClient = AsyncModbusUdpClient( + async def connect(self, framer: Framer=Framer.SOCKET): + self._client = AsyncModbusUdpClient( self.host, self.port, framer=framer, @@ -135,4 +134,4 @@ async def connect(self, framer=Framer.SOCKET): ) await self._client.connect() - assert self._client.connected \ No newline at end of file + assert self._client.connected From cc28527e80f727b63b9319e6a36a981cfbc73c29 Mon Sep 17 00:00:00 2001 From: Ollie Copping Date: Fri, 8 Mar 2024 14:03:44 +0000 Subject: [PATCH 3/4] Reworked _read function --- src/fastcs/connections/modbus_connection.py | 22 +++++++++------------ 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/src/fastcs/connections/modbus_connection.py b/src/fastcs/connections/modbus_connection.py index 63d6cbf1..192328c0 100644 --- a/src/fastcs/connections/modbus_connection.py +++ b/src/fastcs/connections/modbus_connection.py @@ -43,22 +43,18 @@ async def _read(self, address: int, count: int = 2) -> Optional[ModbusResponse]: try: # address_hex = hex(address) rr = await self._client.read_holding_registers(address, count=count, slave=self.slave) # type: ignore - print(f"Response: {rr}") + + if rr.isError() or isinstance(rr, ExceptionResponse): # pragma no cover + # Received Modbus library error or exception + # THIS EXCEPTION IS NOT A PYTHON EXCEPTION, but a valid modbus message + self.disconnect() + return None + return rr + except ModbusException: # pragma no cover # Received ModbusException from library self.disconnect() - return - if rr.isError(): # pragma no cover - # Received Modbus library error - self.disconnect() - return - if isinstance(rr, ExceptionResponse): # pragma no cover - # Received Modbus library exception - # THIS IS NOT A PYTHON EXCEPTION, but a valid modbus message - self.disconnect() - else: - return rr - return None + return None async def send(self, address: int, value: int) -> ModbusResponse | None: """Send a request. From 6b156eebf0c3958b30e758e7086243921441bdfd Mon Sep 17 00:00:00 2001 From: Ollie Copping Date: Mon, 11 Mar 2024 09:04:58 +0000 Subject: [PATCH 4/4] Formatting --- src/fastcs/connections/modbus_connection.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/fastcs/connections/modbus_connection.py b/src/fastcs/connections/modbus_connection.py index 192328c0..ab9ff146 100644 --- a/src/fastcs/connections/modbus_connection.py +++ b/src/fastcs/connections/modbus_connection.py @@ -7,7 +7,6 @@ AsyncModbusUdpClient, ModbusBaseClient, ) - from pymodbus.exceptions import ModbusException from pymodbus.framer import Framer from pymodbus.pdu import ExceptionResponse, ModbusResponse @@ -42,9 +41,11 @@ async def _read(self, address: int, count: int = 2) -> Optional[ModbusResponse]: # address -= 1 # modbus spec starts from 0 not 1 try: # address_hex = hex(address) - rr = await self._client.read_holding_registers(address, count=count, slave=self.slave) # type: ignore + rr = await self._client.read_holding_registers( + address, count=count, slave=self.slave + ) # type: ignore - if rr.isError() or isinstance(rr, ExceptionResponse): # pragma no cover + if rr.isError() or isinstance(rr, ExceptionResponse): # pragma no cover # Received Modbus library error or exception # THIS EXCEPTION IS NOT A PYTHON EXCEPTION, but a valid modbus message self.disconnect() @@ -72,7 +73,7 @@ class ModbusSerialConnection(ModbusConnection): def __init__(self, settings: ModbusConnectionSettings) -> None: super().__init__(settings) - async def connect(self, framer: Framer=Framer.SOCKET): + async def connect(self, framer: Framer = Framer.SOCKET): self._client = AsyncModbusSerialClient( str(self.port), framer=framer, @@ -95,7 +96,7 @@ class ModbusTcpConnection(ModbusConnection): def __init__(self, settings: ModbusConnectionSettings) -> None: super().__init__(settings) - async def connect(self, framer: Framer=Framer.SOCKET): + async def connect(self, framer: Framer = Framer.SOCKET): self._client = AsyncModbusTcpClient( self.host, self.port, @@ -116,7 +117,7 @@ class ModbusUdpConnection(ModbusConnection): def __init__(self, settings: ModbusConnectionSettings) -> None: super().__init__(settings) - async def connect(self, framer: Framer=Framer.SOCKET): + async def connect(self, framer: Framer = Framer.SOCKET): self._client = AsyncModbusUdpClient( self.host, self.port,