"""Collection of transaction based abstractions.""" __all__ = [ "FifoTransactionManager", "DictTransactionManager", "ModbusSocketFramer", "ModbusTlsFramer", "ModbusRtuFramer", "ModbusAsciiFramer", "ModbusBinaryFramer", ] # pylint: disable=missing-type-doc import struct import time from contextlib import suppress from functools import partial from threading import RLock from pymodbus.exceptions import ( InvalidMessageReceivedException, ModbusIOException, NotImplementedException, ) from pymodbus.framer.ascii_framer import ModbusAsciiFramer from pymodbus.framer.binary_framer import ModbusBinaryFramer from pymodbus.framer.rtu_framer import ModbusRtuFramer from pymodbus.framer.socket_framer import ModbusSocketFramer from pymodbus.framer.tls_framer import ModbusTlsFramer from pymodbus.logging import Log from pymodbus.utilities import ModbusTransactionState, hexlify_packets # --------------------------------------------------------------------------- # # The Global Transaction Manager # --------------------------------------------------------------------------- # class ModbusTransactionManager: """Implement a transaction for a manager. The transaction protocol can be represented by the following pseudo code:: count = 0 do result = send(message) if (timeout or result == bad) count++ else break while (count < 3) This module helps to abstract this away from the framer and protocol. """ def __init__(self, client, **kwargs): """Initialize an instance of the ModbusTransactionManager. :param client: The client socket wrapper :param retry_on_empty: Should the client retry on empty :param retries: The number of retries to allow """ self.tid = 0 self.client = client self.backoff = kwargs.get("backoff", 0.3) self.retry_on_empty = kwargs.get("retry_on_empty", False) self.retry_on_invalid = kwargs.get("retry_on_invalid", False) self.retries = kwargs.get("retries", 3) self._transaction_lock = RLock() self._no_response_devices = [] if client: self._set_adu_size() def _set_adu_size(self): """Set adu size.""" # base ADU size of modbus frame in bytes if isinstance(self.client.framer, ModbusSocketFramer): self.base_adu_size = 7 # tid(2), pid(2), length(2), uid(1) elif isinstance(self.client.framer, ModbusRtuFramer): self.base_adu_size = 3 # address(1), CRC(2) elif isinstance(self.client.framer, ModbusAsciiFramer): self.base_adu_size = 7 # start(1)+ Address(2), LRC(2) + end(2) elif isinstance(self.client.framer, ModbusBinaryFramer): self.base_adu_size = 5 # start(1) + Address(1), CRC(2) + end(1) elif isinstance(self.client.framer, ModbusTlsFramer): self.base_adu_size = 0 # no header and footer else: self.base_adu_size = -1 def _calculate_response_length(self, expected_pdu_size): """Calculate response length.""" if self.base_adu_size == -1: return None return self.base_adu_size + expected_pdu_size def _calculate_exception_length(self): """Return the length of the Modbus Exception Response according to the type of Framer.""" if isinstance(self.client.framer, (ModbusSocketFramer, ModbusTlsFramer)): return self.base_adu_size + 2 # Fcode(1), ExceptionCode(1) if isinstance(self.client.framer, ModbusAsciiFramer): return self.base_adu_size + 4 # Fcode(2), ExceptionCode(2) if isinstance(self.client.framer, (ModbusRtuFramer, ModbusBinaryFramer)): return self.base_adu_size + 2 # Fcode(1), ExceptionCode(1) return None def _validate_response(self, request, response, exp_resp_len): """Validate Incoming response against request. :param request: Request sent :param response: Response received :param exp_resp_len: Expected response length :return: New transactions state """ if not response: return False mbap = self.client.framer.decode_data(response) if ( mbap.get("slave") != request.slave_id or mbap.get("fcode") & 0x7F != request.function_code ): return False if "length" in mbap and exp_resp_len: return mbap.get("length") == exp_resp_len return True def execute(self, request): # noqa: C901 """Start the producer to send the next request to consumer.write(Frame(request)).""" with self._transaction_lock: try: Log.debug( "Current transaction state - {}", ModbusTransactionState.to_string(self.client.state), ) retries = self.retries request.transaction_id = self.getNextTID() Log.debug("Running transaction {}", request.transaction_id) if _buffer := hexlify_packets( self.client.framer._buffer # pylint: disable=protected-access ): Log.debug("Clearing current Frame: - {}", _buffer) self.client.framer.resetFrame() if broadcast := ( self.client.params.broadcast_enable and not request.slave_id ): self._transact(request, None, broadcast=True) response = b"Broadcast write sent - no response expected" else: expected_response_length = None if not isinstance(self.client.framer, ModbusSocketFramer): if hasattr(request, "get_response_pdu_size"): response_pdu_size = request.get_response_pdu_size() if isinstance(self.client.framer, ModbusAsciiFramer): response_pdu_size *= 2 if response_pdu_size: expected_response_length = ( self._calculate_response_length(response_pdu_size) ) if ( # pylint: disable=simplifiable-if-statement request.slave_id in self._no_response_devices ): full = True else: full = False c_str = str(self.client) if "modbusudpclient" in c_str.lower().strip(): full = True if not expected_response_length: expected_response_length = 1024 response, last_exception = self._transact( request, expected_response_length, full=full, broadcast=broadcast, ) while retries > 0: valid_response = self._validate_response( request, response, expected_response_length ) if valid_response: if ( request.slave_id in self._no_response_devices and response ): self._no_response_devices.remove(request.slave_id) Log.debug("Got response!!!") break if not response: if request.slave_id not in self._no_response_devices: self._no_response_devices.append(request.slave_id) if self.retry_on_empty: response, last_exception = self._retry_transaction( retries, "empty", request, expected_response_length, full=full, ) retries -= 1 else: # No response received and retries not enabled break elif self.retry_on_invalid: response, last_exception = self._retry_transaction( retries, "invalid", request, expected_response_length, full=full, ) retries -= 1 else: break # full = False addTransaction = partial( # pylint: disable=invalid-name self.addTransaction, tid=request.transaction_id, ) self.client.framer.processIncomingPacket( response, addTransaction, request.slave_id, tid=request.transaction_id, ) if not (response := self.getTransaction(request.transaction_id)): if len(self.transactions): response = self.getTransaction(tid=0) else: last_exception = last_exception or ( "No Response received from the remote slave" "/Unable to decode response" ) response = ModbusIOException( last_exception, request.function_code ) self.client.close() if hasattr(self.client, "state"): Log.debug( "Changing transaction state from " '"PROCESSING REPLY" to ' '"TRANSACTION_COMPLETE"' ) self.client.state = ModbusTransactionState.TRANSACTION_COMPLETE return response except ModbusIOException as exc: # Handle decode errors in processIncomingPacket method Log.error("Modbus IO exception {}", exc) self.client.state = ModbusTransactionState.TRANSACTION_COMPLETE self.client.close() return exc def _retry_transaction(self, retries, reason, packet, response_length, full=False): """Retry transaction.""" Log.debug("Retry on {} response - {}", reason, retries) Log.debug('Changing transaction state from "WAITING_FOR_REPLY" to "RETRYING"') self.client.state = ModbusTransactionState.RETRYING if self.backoff: delay = 2 ** (self.retries - retries) * self.backoff time.sleep(delay) Log.debug("Sleeping {}", delay) self.client.connect() if hasattr(self.client, "_in_waiting"): if ( in_waiting := self.client._in_waiting() # pylint: disable=protected-access ): if response_length == in_waiting: result = self._recv(response_length, full) return result, None return self._transact(packet, response_length, full=full) def _transact(self, packet, response_length, full=False, broadcast=False): """Do a Write and Read transaction. :param packet: packet to be sent :param response_length: Expected response length :param full: the target device was notorious for its no response. Dont waste time this time by partial querying :param broadcast: :return: response """ last_exception = None try: self.client.connect() packet = self.client.framer.buildPacket(packet) Log.debug("SEND: {}", packet, ":hex") size = self._send(packet) if ( isinstance(size, bytes) and self.client.state == ModbusTransactionState.RETRYING ): Log.debug( "Changing transaction state from " '"RETRYING" to "PROCESSING REPLY"' ) self.client.state = ModbusTransactionState.PROCESSING_REPLY return size, None if self.client.comm_params.handle_local_echo is True: if self._recv(size, full) != packet: return b"", "Wrong local echo" if broadcast: if size: Log.debug( 'Changing transaction state from "SENDING" ' 'to "TRANSACTION_COMPLETE"' ) self.client.state = ModbusTransactionState.TRANSACTION_COMPLETE return b"", None if size: Log.debug( 'Changing transaction state from "SENDING" ' 'to "WAITING FOR REPLY"' ) self.client.state = ModbusTransactionState.WAITING_FOR_REPLY result = self._recv(response_length, full) # result2 = self._recv(response_length, full) Log.debug("RECV: {}", result, ":hex") except (OSError, ModbusIOException, InvalidMessageReceivedException) as msg: self.client.close() Log.debug("Transaction failed. ({}) ", msg) last_exception = msg result = b"" return result, last_exception def _send(self, packet, _retrying=False): """Send.""" return self.client.framer.sendPacket(packet) def _recv(self, expected_response_length, full): # noqa: C901 """Receive.""" total = None if not full: exception_length = self._calculate_exception_length() if isinstance(self.client.framer, ModbusSocketFramer): min_size = 8 elif isinstance(self.client.framer, ModbusRtuFramer): min_size = 4 elif isinstance(self.client.framer, ModbusAsciiFramer): min_size = 5 elif isinstance(self.client.framer, ModbusBinaryFramer): min_size = 3 else: min_size = expected_response_length read_min = self.client.framer.recvPacket(min_size) if len(read_min) != min_size: msg_start = "Incomplete message" if read_min else "No response" raise InvalidMessageReceivedException( f"{msg_start} received, expected at least {min_size} bytes " f"({len(read_min)} received)" ) if read_min: if isinstance(self.client.framer, ModbusSocketFramer): func_code = int(read_min[-1]) elif isinstance(self.client.framer, ModbusRtuFramer): func_code = int(read_min[1]) elif isinstance(self.client.framer, ModbusAsciiFramer): func_code = int(read_min[3:5], 16) elif isinstance(self.client.framer, ModbusBinaryFramer): func_code = int(read_min[-1]) else: func_code = -1 if func_code < 0x80: # Not an error if isinstance(self.client.framer, ModbusSocketFramer): # Omit UID, which is included in header size h_size = ( self.client.framer._hsize # pylint: disable=protected-access ) length = struct.unpack(">H", read_min[4:6])[0] - 1 expected_response_length = h_size + length elif expected_response_length is None and isinstance( self.client.framer, ModbusRtuFramer ): with suppress( IndexError # response length indeterminate with available bytes ): expected_response_length = ( self.client.framer.get_expected_response_length( read_min ) ) if expected_response_length is not None: expected_response_length -= min_size total = expected_response_length + min_size else: expected_response_length = exception_length - min_size total = expected_response_length + min_size else: total = expected_response_length else: read_min = b"" total = expected_response_length result = self.client.framer.recvPacket(expected_response_length) result = read_min + result actual = len(result) if total is not None and actual != total: msg_start = "Incomplete message" if actual else "No response" Log.debug( "{} received, Expected {} bytes Received {} bytes !!!!", msg_start, total, actual, ) elif not actual: # If actual == 0 and total is not None then the above # should be triggered, so total must be None here Log.debug("No response received to unbounded read !!!!") if self.client.state != ModbusTransactionState.PROCESSING_REPLY: Log.debug( "Changing transaction state from " '"WAITING FOR REPLY" to "PROCESSING REPLY"' ) self.client.state = ModbusTransactionState.PROCESSING_REPLY return result def addTransaction(self, request, tid=None): """Add a transaction to the handler. This holds the request in case it needs to be resent. After being sent, the request is removed. :param request: The request to hold on to :param tid: The overloaded transaction id to use :raises NotImplementedException: """ raise NotImplementedException("addTransaction") def getTransaction(self, tid): """Return a transaction matching the referenced tid. If the transaction does not exist, None is returned :param tid: The transaction to retrieve :raises NotImplementedException: """ raise NotImplementedException("getTransaction") def delTransaction(self, tid): """Remove a transaction matching the referenced tid. :param tid: The transaction to remove :raises NotImplementedException: """ raise NotImplementedException("delTransaction") def getNextTID(self): """Retrieve the next unique transaction identifier. This handles incrementing the identifier after retrieval :returns: The next unique transaction identifier """ self.tid = (self.tid + 1) & 0xFFFF return self.tid def reset(self): """Reset the transaction identifier.""" self.tid = 0 self.transactions = type( # pylint: disable=attribute-defined-outside-init self.transactions )() class DictTransactionManager(ModbusTransactionManager): """Implements a transaction for a manager. Where the results are keyed based on the supplied transaction id. """ def __init__(self, client, **kwargs): """Initialize an instance of the ModbusTransactionManager. :param client: The client socket wrapper """ self.transactions = {} super().__init__(client, **kwargs) def __iter__(self): """Iterate over the current managed transactions. :returns: An iterator of the managed transactions """ return iter(self.transactions.keys()) def addTransaction(self, request, tid=None): """Add a transaction to the handler. This holds the requests in case it needs to be resent. After being sent, the request is removed. :param request: The request to hold on to :param tid: The overloaded transaction id to use """ tid = tid if tid is not None else request.transaction_id Log.debug("Adding transaction {}", tid) self.transactions[tid] = request def getTransaction(self, tid): """Return a transaction matching the referenced tid. If the transaction does not exist, None is returned :param tid: The transaction to retrieve """ Log.debug("Getting transaction {}", tid) if not tid: if self.transactions: return self.transactions.popitem()[1] return None return self.transactions.pop(tid, None) def delTransaction(self, tid): """Remove a transaction matching the referenced tid. :param tid: The transaction to remove """ Log.debug("deleting transaction {}", tid) self.transactions.pop(tid, None) class FifoTransactionManager(ModbusTransactionManager): """Implements a transaction. For a manager where the results are returned in a FIFO manner. """ def __init__(self, client, **kwargs): """Initialize an instance of the ModbusTransactionManager. :param client: The client socket wrapper """ super().__init__(client, **kwargs) self.transactions = [] def __iter__(self): """Iterate over the current managed transactions. :returns: An iterator of the managed transactions """ return iter(self.transactions) def addTransaction(self, request, tid=None): """Add a transaction to the handler. This holds the requests in case it needs to be resent. After being sent, the request is removed. :param request: The request to hold on to :param tid: The overloaded transaction id to use """ tid = tid if tid is not None else request.transaction_id Log.debug("Adding transaction {}", tid) self.transactions.append(request) def getTransaction(self, tid): """Return a transaction matching the referenced tid. If the transaction does not exist, None is returned :param tid: The transaction to retrieve """ return self.transactions.pop(0) if self.transactions else None def delTransaction(self, tid): """Remove a transaction matching the referenced tid. :param tid: The transaction to remove """ Log.debug("Deleting transaction {}", tid) if self.transactions: self.transactions.pop(0)