""" pyModbusTCP Client """

from .constants import READ_COILS, READ_DISCRETE_INPUTS, READ_HOLDING_REGISTERS, READ_INPUT_REGISTERS, \
    WRITE_MULTIPLE_COILS, WRITE_MULTIPLE_REGISTERS, WRITE_SINGLE_COIL, WRITE_SINGLE_REGISTER, \
    EXP_TXT, EXP_DETAILS, EXP_NONE, \
    MB_ERR_TXT, MB_NO_ERR, MB_SEND_ERR, MB_RECV_ERR, MB_TIMEOUT_ERR, MB_EXCEPT_ERR, MB_CONNECT_ERR, \
    MB_SOCK_CLOSE_ERR, VERSION
from .utils import byte_length, set_bit, valid_host
import socket
from socket import AF_UNSPEC, SOCK_STREAM
import struct
import random


class ModbusClient(object):
    """Modbus TCP client"""

    class _InternalError(Exception):
        pass

    class _NetworkError(_InternalError):
        def __init__(self, code, message):
            self.code = code
            self.message = message

    class _ModbusExcept(_InternalError):
        def __init__(self, code):
            self.code = code

    def __init__(self, host='localhost', port=502, unit_id=1, timeout=30.0,
                 debug=False, auto_open=True, auto_close=False):
        """Constructor.

        :param host: hostname or IPv4/IPv6 address server address
        :type host: str
        :param port: TCP port number
        :type port: int
        :param unit_id: unit ID
        :type unit_id: int
        :param timeout: socket timeout in seconds
        :type timeout: float
        :param debug: debug state
        :type debug: bool
        :param auto_open: auto TCP connect
        :type auto_open: bool
        :param auto_close: auto TCP close)
        :type auto_close: bool
        :return: Object ModbusClient
        :rtype: ModbusClient
        """
        # private
        # internal variables
        self._host = None
        self._port = None
        self._unit_id = None
        self._timeout = None
        self._debug = None
        self._auto_open = None
        self._auto_close = None
        self._sock = None  # socket
        self._transaction_id = 0  # MBAP transaction ID
        self._version = VERSION  # this package version number
        self._last_error = MB_NO_ERR  # last error code
        self._last_except = EXP_NONE  # last except code
        # public
        # constructor arguments: validate them with property setters
        self.host = host
        self.port = port
        self.unit_id = unit_id
        self.timeout = timeout
        self.debug = debug
        self.auto_open = auto_open
        self.auto_close = auto_close

    def __repr__(self):
        r_str = 'ModbusClient(host=\'%s\', port=%d, unit_id=%d, timeout=%.2f, debug=%s, auto_open=%s, auto_close=%s)'
        r_str %= (self.host, self.port, self.unit_id, self.timeout, self.debug, self.auto_open, self.auto_close)
        return r_str

    def __del__(self):
        self.close()

    @property
    def version(self):
        """Return the current package version as a str."""
        return self._version

    @property
    def last_error(self):
        """Last error code."""
        return self._last_error

    @property
    def last_error_as_txt(self):
        """Human-readable text that describe last error."""
        return MB_ERR_TXT.get(self._last_error, 'unknown error')

    @property
    def last_except(self):
        """Return the last modbus exception code."""
        return self._last_except

    @property
    def last_except_as_txt(self):
        """Short human-readable text that describe last modbus exception."""
        default_str = 'unreferenced exception 0x%X' % self._last_except
        return EXP_TXT.get(self._last_except, default_str)

    @property
    def last_except_as_full_txt(self):
        """Verbose human-readable text that describe last modbus exception."""
        default_str = 'unreferenced exception 0x%X' % self._last_except
        return EXP_DETAILS.get(self._last_except, default_str)

    @property
    def host(self):
        """Get or set the server to connect to.

        This can be any string with a valid IPv4 / IPv6 address or hostname.
        Setting host to a new value will close the current socket.
        """
        return self._host

    @host.setter
    def host(self, value):
        # check type
        if type(value) is not str:
            raise TypeError('host must be a str')
        # check value
        if valid_host(value):
            if self._host != value:
                self.close()
                self._host = value
            return
        # can't be set
        raise ValueError('host can\'t be set (not a valid IP address or hostname)')

    @property
    def port(self):
        """Get or set the current TCP port (default is 502).

        Setting port to a new value will close the current socket.
        """
        return self._port

    @port.setter
    def port(self, value):
        # check type
        if type(value) is not int:
            raise TypeError('port must be an int')
        # check validity
        if 0 < value < 65536:
            if self._port != value:
                self.close()
                self._port = value
            return
        # can't be set
        raise ValueError('port can\'t be set (valid if 0 < port < 65536)')

    @property
    def unit_id(self):
        """Get or set the modbus unit identifier (default is 1).

        Any int from 0 to 255 is valid.
        """
        return self._unit_id

    @unit_id.setter
    def unit_id(self, value):
        # check type
        if type(value) is not int:
            raise TypeError('unit_id must be an int')
        # check validity
        if 0 <= value <= 255:
            self._unit_id = value
            return
        # can't be set
        raise ValueError('unit_id can\'t be set (valid from 0 to 255)')

    @property
    def timeout(self):
        """Get or set requests timeout (default is 30 seconds).

        The argument may be a floating point number for sub-second precision.
        Setting timeout to a new value will close the current socket.
        """
        return self._timeout

    @timeout.setter
    def timeout(self, value):
        # enforce type
        value = float(value)
        # check validity
        if 0 < value < 3600:
            if self._timeout != value:
                self.close()
                self._timeout = value
            return
        # can't be set
        raise ValueError('timeout can\'t be set (valid between 0 and 3600)')

    @property
    def debug(self):
        """Get or set the debug flag (True = turn on)."""
        return self._debug

    @debug.setter
    def debug(self, value):
        # enforce type
        self._debug = bool(value)

    @property
    def auto_open(self):
        """Get or set automatic TCP connect mode (True = turn on)."""
        return self._auto_open

    @auto_open.setter
    def auto_open(self, value):
        # enforce type
        self._auto_open = bool(value)

    @property
    def auto_close(self):
        """Get or set automatic TCP close after each request mode (True = turn on)."""
        return self._auto_close

    @auto_close.setter
    def auto_close(self, value):
        # enforce type
        self._auto_close = bool(value)

    @property
    def is_open(self):
        """Get current status of the TCP connection (True = open)."""
        if self._sock:
            return self._sock.fileno() > 0
        else:
            return False

    def open(self):
        """Connect to modbus server (open TCP connection).

        :returns: connect status (True on success)
        :rtype: bool
        """
        try:
            self._open()
            return True
        except ModbusClient._NetworkError as e:
            self._req_except_handler(e)
            return False

    def _open(self):
        """Connect to modbus server (open TCP connection)."""
        # open an already open socket -> reset it
        if self.is_open:
            self.close()
        # init socket and connect
        # list available sockets on the target host/port
        # AF_xxx : AF_INET -> IPv4, AF_INET6 -> IPv6,
        #          AF_UNSPEC -> IPv6 (priority on some system) or 4
        # list available socket on target host
        for res in socket.getaddrinfo(self.host, self.port, AF_UNSPEC, SOCK_STREAM):
            af, sock_type, proto, canon_name, sa = res
            try:
                self._sock = socket.socket(af, sock_type, proto)
            except socket.error:
                continue
            try:
                self._sock.settimeout(self.timeout)
                self._sock.connect(sa)
            except socket.error:
                self._sock.close()
                continue
            break
        # check connect status
        if not self.is_open:
            raise ModbusClient._NetworkError(MB_CONNECT_ERR, 'connection refused')

    def close(self):
        """Close current TCP connection."""
        if self._sock:
            self._sock.close()

    def custom_request(self, pdu):
        """Send a custom modbus request.

        :param pdu: a modbus PDU (protocol data unit)
        :type pdu: bytes
        :returns: modbus frame PDU or None if error
        :rtype: bytes or None
        """
        # make request
        try:
            return self._req_pdu(pdu)
        # handle errors during request
        except ModbusClient._InternalError as e:
            self._req_except_handler(e)
            return None

    def read_coils(self, bit_addr, bit_nb=1):
        """Modbus function READ_COILS (0x01).

        :param bit_addr: bit address (0 to 65535)
        :type bit_addr: int
        :param bit_nb: number of bits to read (1 to 2000)
        :type bit_nb: int
        :returns: bits list or None if error
        :rtype: list of bool or None
        """
        # check params
        if not 0 <= int(bit_addr) <= 0xffff:
            raise ValueError('bit_addr out of range (valid from 0 to 65535)')
        if not 1 <= int(bit_nb) <= 2000:
            raise ValueError('bit_nb out of range (valid from 1 to 2000)')
        if int(bit_addr) + int(bit_nb) > 0x10000:
            raise ValueError('read after end of modbus address space')
        # make request
        try:
            tx_pdu = struct.pack('>BHH', READ_COILS, bit_addr, bit_nb)
            rx_pdu = self._req_pdu(tx_pdu=tx_pdu, rx_min_len=3)
            # field "byte count" from PDU
            byte_count = rx_pdu[1]
            # coils PDU part
            rx_pdu_coils = rx_pdu[2:]
            # check rx_byte_count: match nb of bits request and check buffer size
            if byte_count < byte_length(bit_nb) or byte_count != len(rx_pdu_coils):
                raise ModbusClient._NetworkError(MB_RECV_ERR, 'rx byte count mismatch')
            # allocate coils list to return
            ret_coils = [False] * bit_nb
            # populate it with coils value from the rx PDU
            for i in range(bit_nb):
                ret_coils[i] = bool((rx_pdu_coils[i // 8] >> i % 8) & 0x01)
            # return read coils
            return ret_coils
        # handle error during request
        except ModbusClient._InternalError as e:
            self._req_except_handler(e)
            return None

    def read_discrete_inputs(self, bit_addr, bit_nb=1):
        """Modbus function READ_DISCRETE_INPUTS (0x02).

        :param bit_addr: bit address (0 to 65535)
        :type bit_addr: int
        :param bit_nb: number of bits to read (1 to 2000)
        :type bit_nb: int
        :returns: bits list or None if error
        :rtype: list of bool or None
        """
        # check params
        if not 0 <= int(bit_addr) <= 0xffff:
            raise ValueError('bit_addr out of range (valid from 0 to 65535)')
        if not 1 <= int(bit_nb) <= 2000:
            raise ValueError('bit_nb out of range (valid from 1 to 2000)')
        if int(bit_addr) + int(bit_nb) > 0x10000:
            raise ValueError('read after end of modbus address space')
        # make request
        try:
            tx_pdu = struct.pack('>BHH', READ_DISCRETE_INPUTS, bit_addr, bit_nb)
            rx_pdu = self._req_pdu(tx_pdu=tx_pdu, rx_min_len=3)
            # extract field "byte count"
            byte_count = rx_pdu[1]
            # frame with bits value -> bits[] list
            rx_pdu_d_inputs = rx_pdu[2:]
            # check rx_byte_count: match nb of bits request and check buffer size
            if byte_count < byte_length(bit_nb) or byte_count != len(rx_pdu_d_inputs):
                raise ModbusClient._NetworkError(MB_RECV_ERR, 'rx byte count mismatch')
            # allocate a bit_nb size list
            bits = [False] * bit_nb
            # fill bits list with bit items
            for i in range(bit_nb):
                bits[i] = bool((rx_pdu_d_inputs[i // 8] >> i % 8) & 0x01)
            # return bits list
            return bits
        # handle error during request
        except ModbusClient._InternalError as e:
            self._req_except_handler(e)
            return None

    def read_holding_registers(self, reg_addr, reg_nb=1):
        """Modbus function READ_HOLDING_REGISTERS (0x03).

        :param reg_addr: register address (0 to 65535)
        :type reg_addr: int
        :param reg_nb: number of registers to read (1 to 125)
        :type reg_nb: int
        :returns: registers list or None if fail
        :rtype: list of int or None
        """
        # check params
        if not 0 <= int(reg_addr) <= 0xffff:
            raise ValueError('reg_addr out of range (valid from 0 to 65535)')
        if not 1 <= int(reg_nb) <= 125:
            raise ValueError('reg_nb out of range (valid from 1 to 125)')
        if int(reg_addr) + int(reg_nb) > 0x10000:
            raise ValueError('read after end of modbus address space')
        # make request
        try:
            tx_pdu = struct.pack('>BHH', READ_HOLDING_REGISTERS, reg_addr, reg_nb)
            rx_pdu = self._req_pdu(tx_pdu=tx_pdu, rx_min_len=3)
            # extract field "byte count"
            byte_count = rx_pdu[1]
            # frame with regs value
            f_regs = rx_pdu[2:]
            # check rx_byte_count: buffer size must be consistent and have at least the requested number of registers
            if byte_count < 2 * reg_nb or byte_count != len(f_regs):
                raise ModbusClient._NetworkError(MB_RECV_ERR, 'rx byte count mismatch')
            # allocate a reg_nb size list
            registers = [0] * reg_nb
            # fill registers list with register items
            for i in range(reg_nb):
                registers[i] = struct.unpack('>H', f_regs[i * 2:i * 2 + 2])[0]
            # return registers list
            return registers
        # handle error during request
        except ModbusClient._InternalError as e:
            self._req_except_handler(e)
            return None

    def read_input_registers(self, reg_addr, reg_nb=1):
        """Modbus function READ_INPUT_REGISTERS (0x04).

        :param reg_addr: register address (0 to 65535)
        :type reg_addr: int
        :param reg_nb: number of registers to read (1 to 125)
        :type reg_nb: int
        :returns: registers list or None if fail
        :rtype: list of int or None
        """
        # check params
        if not 0 <= int(reg_addr) <= 0xffff:
            raise ValueError('reg_addr out of range (valid from 0 to 65535)')
        if not 1 <= int(reg_nb) <= 125:
            raise ValueError('reg_nb out of range (valid from 1 to 125)')
        if int(reg_addr) + int(reg_nb) > 0x10000:
            raise ValueError('read after end of modbus address space')
        # make request
        try:
            tx_pdu = struct.pack('>BHH', READ_INPUT_REGISTERS, reg_addr, reg_nb)
            rx_pdu = self._req_pdu(tx_pdu=tx_pdu, rx_min_len=3)
            # extract field "byte count"
            byte_count = rx_pdu[1]
            # frame with regs value
            f_regs = rx_pdu[2:]
            # check rx_byte_count: buffer size must be consistent and have at least the requested number of registers
            if byte_count < 2 * reg_nb or byte_count != len(f_regs):
                raise ModbusClient._NetworkError(MB_RECV_ERR, 'rx byte count mismatch')
            # allocate a reg_nb size list
            registers = [0] * reg_nb
            # fill registers list with register items
            for i in range(reg_nb):
                registers[i] = struct.unpack('>H', f_regs[i * 2:i * 2 + 2])[0]
            # return registers list
            return registers
        # handle error during request
        except ModbusClient._InternalError as e:
            self._req_except_handler(e)
            return None

    def write_single_coil(self, bit_addr, bit_value):
        """Modbus function WRITE_SINGLE_COIL (0x05).

        :param bit_addr: bit address (0 to 65535)
        :type bit_addr: int
        :param bit_value: bit value to write
        :type bit_value: bool
        :returns: True if write ok
        :rtype: bool
        """
        # check params
        if not 0 <= int(bit_addr) <= 0xffff:
            raise ValueError('bit_addr out of range (valid from 0 to 65535)')
        # make request
        try:
            # format "bit value" field for PDU
            bit_value_raw = (0x0000, 0xff00)[bool(bit_value)]
            # make a request
            tx_pdu = struct.pack('>BHH', WRITE_SINGLE_COIL, bit_addr, bit_value_raw)
            rx_pdu = self._req_pdu(tx_pdu=tx_pdu, rx_min_len=5)
            # decode reply
            resp_coil_addr, resp_coil_value = struct.unpack('>HH', rx_pdu[1:5])
            # check server reply
            if (resp_coil_addr != bit_addr) or (resp_coil_value != bit_value_raw):
                raise ModbusClient._NetworkError(MB_RECV_ERR, 'server reply does not match the request')
            return True
        # handle error during request
        except ModbusClient._InternalError as e:
            self._req_except_handler(e)
            return False

    def write_single_register(self, reg_addr, reg_value):
        """Modbus function WRITE_SINGLE_REGISTER (0x06).

        :param reg_addr: register address (0 to 65535)
        :type reg_addr: int
        :param reg_value: register value to write
        :type reg_value: int
        :returns: True if write ok
        :rtype: bool
        """
        # check params
        if not 0 <= int(reg_addr) <= 0xffff:
            raise ValueError('reg_addr out of range (valid from 0 to 65535)')
        if not 0 <= int(reg_value) <= 0xffff:
            raise ValueError('reg_value out of range (valid from 0 to 65535)')
        # make request
        try:
            # make a request
            tx_pdu = struct.pack('>BHH', WRITE_SINGLE_REGISTER, reg_addr, reg_value)
            rx_pdu = self._req_pdu(tx_pdu=tx_pdu, rx_min_len=5)
            # decode reply
            resp_reg_addr, resp_reg_value = struct.unpack('>HH', rx_pdu[1:5])
            # check server reply
            if (resp_reg_addr != reg_addr) or (resp_reg_value != reg_value):
                raise ModbusClient._NetworkError(MB_RECV_ERR, 'server reply does not match the request')
            return True
        # handle error during request
        except ModbusClient._InternalError as e:
            self._req_except_handler(e)
            return False

    def write_multiple_coils(self, bits_addr, bits_value):
        """Modbus function WRITE_MULTIPLE_COILS (0x0F).

        :param bits_addr: bits address (0 to 65535)
        :type bits_addr: int
        :param bits_value: bits values to write
        :type bits_value: list
        :returns: True if write ok
        :rtype: bool
        """
        # check params
        if not 0 <= int(bits_addr) <= 0xffff:
            raise ValueError('bit_addr out of range (valid from 0 to 65535)')
        if not 1 <= len(bits_value) <= 1968:
            raise ValueError('number of coils out of range (valid from 1 to 1968)')
        if int(bits_addr) + len(bits_value) > 0x10000:
            raise ValueError('write after end of modbus address space')
        # make request
        try:
            # build PDU coils part
            # allocate a list of bytes
            byte_l = [0] * byte_length(len(bits_value))
            # populate byte list with coils values
            for i, item in enumerate(bits_value):
                if item:
                    byte_l[i // 8] = set_bit(byte_l[i // 8], i % 8)
            # format PDU coils part with byte list
            pdu_coils_part = struct.pack('%dB' % len(byte_l), *byte_l)
            # concatenate PDU parts
            tx_pdu = struct.pack('>BHHB', WRITE_MULTIPLE_COILS, bits_addr, len(bits_value), len(pdu_coils_part))
            tx_pdu += pdu_coils_part
            # make a request
            rx_pdu = self._req_pdu(tx_pdu=tx_pdu, rx_min_len=5)
            # response decode
            resp_write_addr, resp_write_count = struct.unpack('>HH', rx_pdu[1:5])
            # check response fields
            write_ok = resp_write_addr == bits_addr and resp_write_count == len(bits_value)
            return write_ok
        # handle error during request
        except ModbusClient._InternalError as e:
            self._req_except_handler(e)
            return False

    def write_multiple_registers(self, regs_addr, regs_value):
        """Modbus function WRITE_MULTIPLE_REGISTERS (0x10).

        :param regs_addr: registers address (0 to 65535)
        :type regs_addr: int
        :param regs_value: registers values to write
        :type regs_value: list
        :returns: True if write ok
        :rtype: bool
        """
        # check params
        if not 0 <= int(regs_addr) <= 0xffff:
            raise ValueError('regs_addr out of range (valid from 0 to 65535)')
        if not 1 <= len(regs_value) <= 123:
            raise ValueError('number of registers out of range (valid from 1 to 123)')
        if int(regs_addr) + len(regs_value) > 0x10000:
            raise ValueError('write after end of modbus address space')
        # make request
        try:
            # init PDU registers part
            pdu_regs_part = b''
            # populate it with register values
            for reg in regs_value:
                # check current register value
                if not 0 <= int(reg) <= 0xffff:
                    raise ValueError('regs_value list contains out of range values')
                # pack register for build frame
                pdu_regs_part += struct.pack('>H', reg)
            bytes_nb = len(pdu_regs_part)
            # concatenate PDU parts
            tx_pdu = struct.pack('>BHHB', WRITE_MULTIPLE_REGISTERS, regs_addr, len(regs_value), bytes_nb)
            tx_pdu += pdu_regs_part
            # make a request
            rx_pdu = self._req_pdu(tx_pdu=tx_pdu, rx_min_len=5)
            # response decode
            resp_write_addr, resp_write_count = struct.unpack('>HH', rx_pdu[1:5])
            # check response fields
            write_ok = resp_write_addr == regs_addr and resp_write_count == len(regs_value)
            return write_ok
        # handle error during request
        except ModbusClient._InternalError as e:
            self._req_except_handler(e)
            return False

    def _send(self, frame):
        """Send frame over current socket.

        :param frame: modbus frame to send (MBAP + PDU)
        :type frame: bytes
        """
        # check socket
        if not self.is_open:
            raise ModbusClient._NetworkError(MB_SOCK_CLOSE_ERR, 'try to send on a close socket')
        # send
        try:
            self._sock.send(frame)
        except socket.timeout:
            self._sock.close()
            raise ModbusClient._NetworkError(MB_TIMEOUT_ERR, 'timeout error')
        except socket.error:
            self._sock.close()
            raise ModbusClient._NetworkError(MB_SEND_ERR, 'send error')

    def _send_pdu(self, pdu):
        """Convert modbus PDU to frame and send it.

        :param pdu: modbus frame PDU
        :type pdu: bytes
        """
        # for auto_open mode, check TCP and open on need
        if self.auto_open and not self.is_open:
            self._open()
        # add MBAP header to PDU
        tx_frame = self._add_mbap(pdu)
        # send frame with error check
        self._send(tx_frame)
        # debug
        self._debug_dump('Tx', tx_frame)

    def _recv(self, size):
        """Receive data over current socket.

        :param size: number of bytes to receive
        :type size: int
        :returns: receive data or None if error
        :rtype: bytes
        """
        try:
            r_buffer = self._sock.recv(size)
        except socket.timeout:
            self._sock.close()
            raise ModbusClient._NetworkError(MB_TIMEOUT_ERR, 'timeout error')
        except socket.error:
            r_buffer = b''
        # handle recv error
        if not r_buffer:
            self._sock.close()
            raise ModbusClient._NetworkError(MB_RECV_ERR, 'recv error')
        return r_buffer

    def _recv_all(self, size):
        """Receive data over current socket, loop until all bytes is received (avoid TCP frag).

        :param size: number of bytes to receive
        :type size: int
        :returns: receive data or None if error
        :rtype: bytes
        """
        r_buffer = b''
        while len(r_buffer) < size:
            r_buffer += self._recv(size - len(r_buffer))
        return r_buffer

    def _recv_pdu(self, min_len=2):
        """Receive the modbus PDU (Protocol Data Unit).

        :param min_len: minimal length of the PDU
        :type min_len: int
        :returns: modbus frame PDU or None if error
        :rtype: bytes or None
        """
        # receive 7 bytes header (MBAP)
        rx_mbap = self._recv_all(7)
        # decode MBAP
        (f_transaction_id, f_protocol_id, f_length, f_unit_id) = struct.unpack('>HHHB', rx_mbap)
        # check MBAP fields
        f_transaction_err = f_transaction_id != self._transaction_id
        f_protocol_err = f_protocol_id != 0
        f_length_err = f_length >= 256
        f_unit_id_err = f_unit_id != self.unit_id
        # checking error status of fields
        if f_transaction_err or f_protocol_err or f_length_err or f_unit_id_err:
            self.close()
            self._debug_dump('Rx', rx_mbap)
            raise ModbusClient._NetworkError(MB_RECV_ERR, 'MBAP checking error')
        # recv PDU
        rx_pdu = self._recv_all(f_length - 1)
        # for auto_close mode, close socket after each request
        if self.auto_close:
            self.close()
        # dump frame
        self._debug_dump('Rx', rx_mbap + rx_pdu)
        # body decode
        # check PDU length for global minimal frame (an except frame: func code + exp code)
        if len(rx_pdu) < 2:
            raise ModbusClient._NetworkError(MB_RECV_ERR, 'PDU length is too short')
        # extract function code
        rx_fc = rx_pdu[0]
        # check except status
        if rx_fc >= 0x80:
            exp_code = rx_pdu[1]
            raise ModbusClient._ModbusExcept(exp_code)
        # check PDU length for specific request set in min_len (keep this after except checking)
        if len(rx_pdu) < min_len:
            raise ModbusClient._NetworkError(MB_RECV_ERR, 'PDU length is too short for current request')
        # if no error, return PDU
        return rx_pdu

    def _add_mbap(self, pdu):
        """Return full modbus frame with MBAP (modbus application protocol header) append to PDU.

        :param pdu: modbus PDU (protocol data unit)
        :type pdu: bytes
        :returns: full modbus frame
        :rtype: bytes
        """
        # build MBAP
        self._transaction_id = random.randint(0, 65535)
        protocol_id = 0
        length = len(pdu) + 1
        mbap = struct.pack('>HHHB', self._transaction_id, protocol_id, length, self.unit_id)
        # full modbus/TCP frame = [MBAP]PDU
        return mbap + pdu

    def _req_pdu(self, tx_pdu, rx_min_len=2):
        """Request processing (send and recv PDU).

        :param tx_pdu: modbus PDU (protocol data unit) to send
        :type tx_pdu: bytes
        :param rx_min_len: min length of receive PDU
        :type rx_min_len: int
        :returns: the receive PDU or None if error
        :rtype: bytes
        """
        # init request engine
        self._req_init()
        # send PDU
        self._send_pdu(tx_pdu)
        # return receive PDU
        return self._recv_pdu(min_len=rx_min_len)

    def _req_init(self):
        """Reset request status flags."""
        self._last_error = MB_NO_ERR
        self._last_except = EXP_NONE

    def _req_except_handler(self, _except):
        """Global handler for internal exceptions."""
        # on request network error
        if isinstance(_except, ModbusClient._NetworkError):
            self._last_error = _except.code
            self._debug_msg(_except.message)
        # on request modbus except
        if isinstance(_except, ModbusClient._ModbusExcept):
            self._last_error = MB_EXCEPT_ERR
            self._last_except = _except.code
            self._debug_msg('modbus exception (code %d "%s")' % (self.last_except, self.last_except_as_txt))

    def _debug_msg(self, msg):
        """Print debug message if debug mode is on.

        :param msg: debug message
        :type msg: str
        """
        if self.debug:
            print(msg)

    def _debug_dump(self, label, frame):
        """Print debug dump if debug mode is on.

        :param label: head label
        :type label: str
        :param frame: modbus frame
        :type frame: bytes
        """
        if self.debug:
            self._pretty_dump(label, frame)

    @staticmethod
    def _pretty_dump(label, frame):
        """Dump a modbus frame.

        modbus/TCP format: [MBAP] PDU

        :param label: head label
        :type label: str
        :param frame: modbus frame
        :type frame: bytes
        """
        # split data string items to a list of hex value
        dump = ['%02X' % c for c in frame]
        # format message
        dump_mbap = ' '.join(dump[0:7])
        dump_pdu = ' '.join(dump[7:])
        msg = '[%s] %s' % (dump_mbap, dump_pdu)
        # print result
        print(label)
        print(msg)
