From 7409165cd0e1e24dae6f1d179b4f59b47f313b30 Mon Sep 17 00:00:00 2001 From: Jonas Scharpf Date: Mon, 2 Jan 2023 14:53:48 +0100 Subject: [PATCH 01/23] add Queue based on uasyncio, relates to #47 --- fakes/queue.py | 151 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100755 fakes/queue.py diff --git a/fakes/queue.py b/fakes/queue.py new file mode 100755 index 0000000..d0b2156 --- /dev/null +++ b/fakes/queue.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +# -*- coding: UTF-8 -*- + +""" +queue.py: adapted from uasyncio V2 + +Copyright (c) 2018-2020 Peter Hinch +Released under the MIT License (MIT) - see LICENSE file + +Code is based on Paul Sokolovsky's work. +This is a temporary solution until uasyncio V3 gets an efficient official +version +""" + +try: + import uasyncio as asyncio +except ImportError: + import asyncio + + +class QueueEmpty(Exception): + """Exception raised by get_nowait()""" + pass + + +class QueueFull(Exception): + """Exception raised by put_nowait()""" + pass + + +class Queue: + """AsyncIO based Queue""" + def __init__(self, maxsize: int = 0): + self.maxsize = maxsize + self._queue = [] + self._evput = asyncio.Event() # Triggered by put, tested by get + self._evget = asyncio.Event() # Triggered by get, tested by put + + def _get(self): + """ + Remove and return an item from the queue without blocking + + :returns: Return an item if one is immediately available + :rtype: Any + """ + # Schedule all tasks waiting on get + self._evget.set() + self._evget.clear() + return self._queue.pop(0) + + async def get(self): + """ + Remove and return an item from the queue in async blocking mode + + Usage: item = await queue.get() + + :returns: Return an item if one is immediately available + :rtype: Any + """ + # May be multiple tasks waiting on get() + while self.empty(): + # Queue is empty, suspend task until a put occurs + # 1st of N tasks gets, the rest loop again + await self._evput.wait() + return self._get() + + def get_nowait(self): + """ + Remove and return an item from the queue without blocking + + :returns: Return an item if one is immediately available + :rtype: Any + + :raises QueueEmpty: Queue is empty + :raises QueueFull: Queue is full + """ + if self.empty(): + raise QueueEmpty() + return self._get() + + def _put(self, val) -> None: + """ + Put an item into the queue without blocking + + :param val: The value + :type val: Any + """ + # Schedule tasks waiting on put + self._evput.set() + self._evput.clear() + self._queue.append(val) + + async def put(self, val) -> None: + """ + Put an item into the queue in async blocking mode + + Usage: await queue.put(item) + + :param val: The value + :type val: Any + + :raises QueueFull: Queue is full + """ + while self.full(): + # Queue full + await self._evget.wait() + # Task(s) waiting to get from queue, schedule first Task + self._put(val) + + def put_nowait(self, val) -> None: + """ + Put an item into the queue without blocking + + :param val: The value + :type val: Any + + :raises QueueFull: Queue is full + """ + if self.full(): + raise QueueFull() + self._put(val) + + def qsize(self) -> int: + """ + Get number of items in the queue + + :returns: Number of items in the queue + :rtype: int + """ + return len(self._queue) + + def empty(self) -> bool: + """ + Check is queue is empty + + :returns: Return True if the queue is empty, False otherwise + :rtype: bool + """ + return len(self._queue) == 0 + + def full(self) -> bool: + """ + Check if queue is full + + Note: if the Queue was initialized with maxsize=0 (the default) or + any negative number, then full() is never True. + + :returns: Return True if there are maxsize items in the queue. + :rtype: bool + """ + return self.maxsize > 0 and self.qsize() >= self.maxsize From c68f4e648172b1f9de0adfb51e1a490ac4f9652f Mon Sep 17 00:00:00 2001 From: Jonas Scharpf Date: Mon, 2 Jan 2023 14:54:12 +0100 Subject: [PATCH 02/23] implement fake for UART and Pin of machine, contributes to #47 --- fakes/machine.py | 487 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 487 insertions(+) create mode 100755 fakes/machine.py diff --git a/fakes/machine.py b/fakes/machine.py new file mode 100755 index 0000000..1053f66 --- /dev/null +++ b/fakes/machine.py @@ -0,0 +1,487 @@ +#!/usr/bin/env python3 +# -*- coding: UTF-8 -*- + +"""Fakes of several MicroPython classes which are not available in UNIX""" + +import _thread +try: + import ulogging as logging + from umodbus.typing import Optional, Union +except ImportError: + import logging + from typing import Optional, Union +from queue import Queue +import socket +import sys +import time + + +class MachineError(Exception): + """Base class for exceptions in this module.""" + pass + + +class UART(object): + """ + Fake Micropython UART class + + See https://docs.micropython.org/en/latest/library/machine.UART.html + """ + def __init__(self, + uart_id: int, + baudrate: int = 9600, + bits: int = 8, + parity: int = None, + stop: int = 1, + tx: int = 1, + rx: int = 2, + timeout: int = 0) -> None: + self._uart_id = uart_id + if timeout == 0: + timeout = 5.0 + self._timeout = timeout + + self.logger = self._configure_logger() + + # Standard loopback interface address (localhost) + # Port to listen on (non-privileged ports are > 1023) + if sys.implementation.name.lower() == 'micropython': + # on MicroPython connect to the (potentially) running TCP server + # see docker-compose-rtu-test.yaml at micropython-client-rtu + self._host = '172.25.0.2' + else: + # on non MicroPython system connect to localhost + self._host = '127.0.0.1' + self._port = 65433 + self._sock = None + + self._server_lock = _thread.allocate_lock() + self._send_queue = Queue(maxsize=10) + self._receive_queue = Queue(maxsize=10) + + self._is_server = self.no_socket_server_exists( + host=self._host, + port=self._port) + + self.logger.debug('Timeout is: {}'.format(self._timeout)) + + if self._is_server: + self._sock = socket.socket() + + if sys.implementation.name.lower() == 'micropython': + # MicroPython requires a bytearray + # https://docs.micropython.org/en/v1.18/library/socket.html#socket.getaddrinfo + addr_info = socket.getaddrinfo(self._host, self._port)[0][-1] + else: + addr_info = ((self._host, self._port)) + + self._sock.bind(addr_info) + self._sock.listen(10) + # self._sock.settimeout(self._timeout) + self._sock.setblocking(False) + + self.logger.debug('Bound to {} on {}'. + format(self._host, self._port)) + + # start the socket server thread as soon as init is done + self.serving = True + else: + self._sock = socket.socket() + self._sock.settimeout(self._timeout) + # self._sock.setblocking(False) + + if sys.implementation.name.lower() == 'micropython': + # MicroPython requires a bytearray + # https://docs.micropython.org/en/v1.18/library/socket.html#socket.socket.connect + addr_info = socket.getaddrinfo(self._host, self._port)[0][-1] + else: + addr_info = ((self._host, self._port)) + + self._sock.connect(addr_info) + self.logger.debug('Connecting to {} on {}'. + format(self._host, self._port)) + + self.logger.debug('Will act as {}'. + format('server' if self._is_server else 'client')) + + def _configure_logger(self) -> logging.Logger: + """ + Create and configure a logger + + :returns: Configured logger + :rtype: logging.Logger + """ + if sys.implementation.name.lower() == 'micropython': + logging.basicConfig(level=logging.INFO) + else: + custom_format = '[%(asctime)s] [%(levelname)-8s] [%(filename)-15s'\ + ' @ %(funcName)-15s:%(lineno)4s] %(message)s' + logging.basicConfig(level=logging.INFO, + format=custom_format, + stream=sys.stdout) + + logger = logging.getLogger(__name__) + logger.setLevel(logging.DEBUG) + + return logger + + def no_socket_server_exists(self, + host: str, + port: int, + timeout: Optional[float] = 1.0) -> bool: + """ + Determines if no socket server exists. + + :param host: The host IP + :type host: str + :param port: The port + :type port: int + :param timeout: The timeout + :type timeout: float + + :returns: True if no socket server exists, False otherwise. + :rtype: bool + """ + become_server = True + + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(timeout) + + try: + if sys.implementation.name.lower() == 'micropython': + # MicroPython requires a bytearray + # https://docs.micropython.org/en/v1.18/library/socket.html#socket.socket.connect + addr_info = socket.getaddrinfo(host, port)[0][-1] + else: + addr_info = ((host, port)) + sock.connect(addr_info) + except Exception: + self.logger.debug('Exception during potential server connect') + self.logger.debug('Failed to connect to {} on {}'. + format(host, port)) + sock.close() + # assume no server is available + # this device shall thereby become a server itself + return become_server + + self.logger.debug('Connection to server successful') + + # send test message to server + sock.send(b'Ping. Hello UART server?') # not the best message + self.logger.debug('Sent ping to potential server') + + # wait for specified timeout for a response from the server + try: + received_data = sock.recv(127) + if received_data: + self.logger.info('Received as response to ping: {}'. + format(received_data)) + if received_data == b'pong': + # a potential server shall respond with b'pong'. + # The return value shall thereby by "False", as the + # check for no socket server existance failed + become_server = False + except OSError as e: + # 11 = timeout expired (MicroPython) + # 35 = Resource temporarily unavailable (Python OS X) + # "timed out" (Python Windows) + if e.args[0] in [11, 35, 'timed out']: + self.logger.debug('Timeout waiting for server response') + else: + raise e + + sock.close() + + return become_server + + @property + def is_server(self) -> bool: + """ + Determines if this object acts as server aka UART host. + + :returns: True if server, False otherwise. + :rtype: bool + """ + return self._is_server + + @property + def serving(self) -> bool: + """ + Get the server status. + + :returns: Flag whether socket server is running or not. + :rtype: bool + """ + return self._server_lock.locked() + + @serving.setter + def serving(self, value: bool) -> None: + """ + Start or stop running the socket server. + + :param value: The value + :type value: bool + """ + if value and (not self._server_lock.locked()): + # start socket server if not already running + self._server_lock.acquire() + + # parameters of the _serve function + params = ( + self._sock, + self._send_queue, + self._receive_queue, + self._server_lock, + self.logger + ) + _thread.start_new_thread(self._serve, params) + self.logger.info('Socket {} started'. + format('server' if self._is_server else 'client')) + elif (value is False) and self._server_lock.locked(): + # stop server if not already stopped + self._server_lock.release() + self.logger.info('Socket {} lock released'. + format('server' if self._is_server else 'client')) + + def _serve(self, + sock: socket, + send_queue: Queue, + receive_queue: Queue, + lock: int, + logger: logging.Logger) -> None: + """ + Internal socket server function + + :param sock: The socket + :type sock: socket + :param send_queue: The send queue + :type send_queue: Queue + :param receive_queue: The receive queue + :type receive_queue: Queue + :param lock: The lock flag + :type lock: int + :param logger: The logger + :type logger: logging.Logger + """ + if sock is None: + raise Exception('Server not bound') + else: + logger.info('Acting as server') + + _client_sock = None + + while lock.locked(): + new_client_sock = None + + try: + # either a connection is made from a client or + # an OSError is thrown after the configured timeout + new_client_sock, addr = sock.accept() + except OSError as e: + # 11 = timeout expired (MicroPython) + # 35 = Resource temporarily unavailable (Python OS X) + # "timed out" (Python Windows) + if e.args[0] not in [11, 35, 'timed out']: + logger.warning('OSError {}: {}'. + format(e, e.args[0])) + + if new_client_sock is not None: + logger.debug('Connection from {}'.format(addr)) + if _client_sock is not None: + logger.debug('Closed connection to last client') + _client_sock.close() + + _client_sock = new_client_sock + _client_sock.settimeout(0.5) + + if _client_sock is not None: + # get client message + try: + received_data = _client_sock.recv(127) + + # log data and send response + if received_data: + logger.info('Received from client: {}'. + format(received_data)) + + if received_data == b'Ping. Hello UART server?': + _client_sock.send(b'pong') + logger.info('Responded with "pong"') + else: + receive_queue.put_nowait(received_data) + + # grant host enough time to process received + # data, prepare a response for the client and put + # it into the send_queue + # Sleep time shall be less than client timeout + # specified during UART init + # Approx. process time is below 100ms + time.sleep(0.1) + except Exception as e: + logger.debug('Error during connection: {}'.format(e)) + _client_sock.close() + _client_sock = None + + if send_queue.qsize(): + send_data = send_queue.get_nowait() + + logger.info('Responding to client: {}'. + format(send_data)) + _client_sock.send(send_data) + + def deinit(self) -> None: + """Turn off the UART bus""" + raise MachineError('Not yet implemented') + + def any(self) -> int: + """ + Return number of characters available. + + Mock does not return the actual number of characters but the elements + in the receive queue. Which is usually "0" or "1" + + :returns: Number of characters available + :rtype: int + """ + available_data_amount = 0 + + if self._is_server: + available_data_amount = self._receive_queue.qsize() + else: + # patch available data, as reading the socket buffer would drain it + available_data_amount = 1 + + return available_data_amount + + def read(self, nbytes: Optional[int] = None) -> Union[None, bytes]: + """ + Read characters + + :param nbytes: The amount of bytes to read + :type nbytes: int + + :returns: Bytes read, None on timeout + :rtype: Union[None, bytes] + """ + data = None + + if self._is_server: + if self._receive_queue.qsize(): + data = self._receive_queue.get_nowait() + else: + try: + data = self._sock.recv(127) + except OSError as e: + self.logger.warning('OSError {}: {}'.format(e, e.args[0])) + + return data + + def readinto(self, + buf: bytes, + nbytes: Optional[int] = None) -> Union[None, bytes]: + """ + Read bytes into the buffer. + + If nbytes is specified then read at most that many bytes. Otherwise, + read at most len(buf) bytes + + :param buf: The buffer + :type buf: bytes + :param nbytes: The nbytes + :type nbytes: Optional[int] + + :returns: Number of bytes read and stored in buffer, None on timeout + :rtype: Union[None, bytes] + """ + raise MachineError('Not yet implemented') + + def readline(self) -> Union[None, str]: + """ + Read a line, ending in a newline character + + :returns: The line read, None on timeout. + :rtype: Union[None, str] + """ + raise MachineError('Not yet implemented') + + def write(self, buf: bytes) -> Union[None, int]: + """ + Write the buffer of bytes to the bus + + :param buf: The buffer + :type buf: bytes + + :returns: Number of bytes written, None on timeout + :rtype: Union[None, int] + """ + if self._is_server: + self._send_queue.put_nowait(buf) + else: + self._sock.send(buf) + + return len(buf) + + def sendbreak(self) -> None: + """Send a break condition on the bus""" + raise MachineError('Not yet implemented') + + def flush(self) -> None: + """ + Waits until all data has been sent + + In case of a timeout, an exception is raised + + Only available with newer versions than 1.19 + """ + raise MachineError('Not yet implemented') + + def txdone(self) -> bool: + """ + Tells whether all data has been sent or no data transfer is happening + + :returns: If data transmission is ngoing return False, True otherwise + :rtype: bool + """ + raise MachineError('Not yet implemented') + + +class Pin(object): + """ + Fake Micropython Pin class + + See https://docs.micropython.org/en/latest/library/machine.Pin.html + """ + IN = 1 + OUT = 2 + + def __init__(self, pin: int, mode: int): + self._pin = pin + self._mode = mode + self._value = False + + def value(self, val: Optional[Union[int, bool]] = None) -> Optional[bool]: + """ + Set or get the value of the pin + + :param val: The value + :type val: Optional[Union[int, bool]] + + :returns: State of the pin if no value specifed, None otherwise + :rtype: Optional[bool] + """ + if val is not None and self._mode == Pin.OUT: + # set pin state + self._value = bool(val) + else: + # get pin state + return self._value + + def on(self) -> None: + """Set pin to "1" output level""" + if self._mode == Pin.OUT: + self.value(val=True) + + def off(self) -> None: + """Set pin to "0" output level""" + if self._mode == Pin.OUT: + self.value(val=False) From 909bbc7b3d114ca8178d82ae86a0972daeaa2900 Mon Sep 17 00:00:00 2001 From: Jonas Scharpf Date: Mon, 2 Jan 2023 14:59:19 +0100 Subject: [PATCH 03/23] outsource common modbus functions of serial.py and tcp.py to common.py --- umodbus/common.py | 287 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 286 insertions(+), 1 deletion(-) diff --git a/umodbus/common.py b/umodbus/common.py index 59a7b04..a5ebe9f 100644 --- a/umodbus/common.py +++ b/umodbus/common.py @@ -13,9 +13,10 @@ # custom packages from . import const as Const +from . import functions # typing not natively supported on MicroPython -from .typing import Optional +from .typing import List, Optional, Tuple, Union class Request(object): @@ -105,3 +106,287 @@ class ModbusException(Exception): def __init__(self, function_code: int, exception_code: int) -> None: self.function_code = function_code self.exception_code = exception_code + + +class CommonModbusFunctions(object): + """Common Modbus functions""" + def __init__(self): + pass + + def read_coils(self, + slave_addr: int, + starting_addr: int, + coil_qty: int) -> List[bool]: + """ + Read coils (COILS). + + :param slave_addr: The slave address + :type slave_addr: int + :param starting_addr: The coil starting address + :type starting_addr: int + :param coil_qty: The amount of coils to read + :type coil_qty: int + + :returns: State of read coils as list + :rtype: List[bool] + """ + modbus_pdu = functions.read_coils(starting_address=starting_addr, + quantity=coil_qty) + + response = self._send_receive(slave_addr=slave_addr, + modbus_pdu=modbus_pdu, + count=True) + + status_pdu = functions.bytes_to_bool(byte_list=response, + bit_qty=coil_qty) + + return status_pdu + + def read_discrete_inputs(self, + slave_addr: int, + starting_addr: int, + input_qty: int) -> List[bool]: + """ + Read discrete inputs (ISTS). + + :param slave_addr: The slave address + :type slave_addr: int + :param starting_addr: The discrete input starting address + :type starting_addr: int + :param input_qty: The amount of discrete inputs to read + :type input_qty: int + + :returns: State of read discrete inputs as list + :rtype: List[bool] + """ + modbus_pdu = functions.read_discrete_inputs( + starting_address=starting_addr, + quantity=input_qty) + + response = self._send_receive(slave_addr=slave_addr, + modbus_pdu=modbus_pdu, + count=True) + + status_pdu = functions.bytes_to_bool(byte_list=response, + bit_qty=input_qty) + + return status_pdu + + def read_holding_registers(self, + slave_addr: int, + starting_addr: int, + register_qty: int, + signed: bool = True) -> Tuple[int, ...]: + """ + Read holding registers (HREGS). + + :param slave_addr: The slave address + :type slave_addr: int + :param starting_addr: The holding register starting address + :type starting_addr: int + :param register_qty: The amount of holding registers to read + :type register_qty: int + :param signed: Indicates if signed + :type signed: bool + + :returns: State of read holding register as tuple + :rtype: Tuple[int, ...] + """ + modbus_pdu = functions.read_holding_registers( + starting_address=starting_addr, + quantity=register_qty) + + response = self._send_receive(slave_addr=slave_addr, + modbus_pdu=modbus_pdu, + count=True) + + register_value = functions.to_short(byte_array=response, signed=signed) + + return register_value + + def read_input_registers(self, + slave_addr: int, + starting_addr: int, + register_qty: int, + signed: bool = True) -> Tuple[int, ...]: + """ + Read input registers (IREGS). + + :param slave_addr: The slave address + :type slave_addr: int + :param starting_addr: The input register starting address + :type starting_addr: int + :param register_qty: The amount of input registers to read + :type register_qty: int + :param signed: Indicates if signed + :type signed: bool + + :returns: State of read input register as tuple + :rtype: Tuple[int, ...] + """ + modbus_pdu = functions.read_input_registers( + starting_address=starting_addr, + quantity=register_qty) + + response = self._send_receive(slave_addr=slave_addr, + modbus_pdu=modbus_pdu, + count=True) + + register_value = functions.to_short(byte_array=response, signed=signed) + + return register_value + + def write_single_coil(self, + slave_addr: int, + output_address: int, + output_value: Union[int, bool]) -> bool: + """ + Update a single coil. + + :param slave_addr: The slave address + :type slave_addr: int + :param output_address: The output address + :type output_address: int + :param output_value: The output value + :type output_value: Union[int, bool] + + :returns: Result of operation + :rtype: bool + """ + modbus_pdu = functions.write_single_coil(output_address=output_address, + output_value=output_value) + + response = self._send_receive(slave_addr=slave_addr, + modbus_pdu=modbus_pdu, + count=False) + + if response is None: + return False + + operation_status = functions.validate_resp_data( + data=response, + function_code=Const.WRITE_SINGLE_COIL, + address=output_address, + value=output_value, + signed=False) + + return operation_status + + def write_single_register(self, + slave_addr: int, + register_address: int, + register_value: int, + signed: bool = True) -> bool: + """ + Update a single register. + + :param slave_addr: The slave address + :type slave_addr: int + :param register_address: The register address + :type register_address: int + :param register_value: The register value + :type register_value: int + :param signed: Indicates if signed + :type signed: bool + + :returns: Result of operation + :rtype: bool + """ + modbus_pdu = functions.write_single_register( + register_address=register_address, + register_value=register_value, + signed=signed) + + response = self._send_receive(slave_addr=slave_addr, + modbus_pdu=modbus_pdu, + count=False) + + if response is None: + return False + + operation_status = functions.validate_resp_data( + data=response, + function_code=Const.WRITE_SINGLE_REGISTER, + address=register_address, + value=register_value, + signed=signed) + + return operation_status + + def write_multiple_coils(self, + slave_addr: int, + starting_address: int, + output_values: List[Union[int, bool]]) -> bool: + """ + Update multiple coils. + + :param slave_addr: The slave address + :type slave_addr: int + :param starting_address: The address of the first coil + :type starting_address: int + :param output_values: The output values + :type output_values: List[Union[int, bool]] + + :returns: Result of operation + :rtype: bool + """ + modbus_pdu = functions.write_multiple_coils( + starting_address=starting_address, + value_list=output_values) + + response = self._send_receive(slave_addr=slave_addr, + modbus_pdu=modbus_pdu, + count=False) + + if response is None: + return False + + operation_status = functions.validate_resp_data( + data=response, + function_code=Const.WRITE_MULTIPLE_COILS, + address=starting_address, + quantity=len(output_values)) + + return operation_status + + def write_multiple_registers(self, + slave_addr: int, + starting_address: int, + register_values: List[int], + signed: bool = True) -> bool: + """ + Update multiple registers. + + :param slave_addr: The slave address + :type slave_addr: int + :param starting_address: The starting address + :type starting_address: int + :param register_values: The register values + :type register_values: List[int] + :param signed: Indicates if signed + :type signed: bool + + :returns: Result of operation + :rtype: bool + """ + modbus_pdu = functions.write_multiple_registers( + starting_address=starting_address, + register_values=register_values, + signed=signed) + + response = self._send_receive(slave_addr=slave_addr, + modbus_pdu=modbus_pdu, + count=False) + + if response is None: + return False + + operation_status = functions.validate_resp_data( + data=response, + function_code=Const.WRITE_MULTIPLE_REGISTERS, + address=starting_address, + quantity=len(register_values), + signed=signed + ) + + return operation_status From 7c79e1f32f57d9ed6afe27d5182092ce04917a5f Mon Sep 17 00:00:00 2001 From: Jonas Scharpf Date: Mon, 2 Jan 2023 19:13:08 +0100 Subject: [PATCH 04/23] remove outsourced common code from serial, use CommonModbusFunctions functions --- umodbus/serial.py | 265 +--------------------------------------------- 1 file changed, 3 insertions(+), 262 deletions(-) diff --git a/umodbus/serial.py b/umodbus/serial.py index 7d21e3b..d24b981 100644 --- a/umodbus/serial.py +++ b/umodbus/serial.py @@ -18,12 +18,12 @@ # custom packages from . import const as Const from . import functions -from .common import Request +from .common import Request, CommonModbusFunctions from .common import ModbusException from .modbus import Modbus # typing not natively supported on MicroPython -from .typing import List, Optional, Tuple, Union +from .typing import List, Optional, Union class ModbusRTU(Modbus): @@ -69,7 +69,7 @@ def __init__(self, ) -class Serial(object): +class Serial(CommonModbusFunctions): def __init__(self, uart_id: int = 1, baudrate: int = 9600, @@ -333,265 +333,6 @@ def _validate_resp_hdr(self, return response[hdr_length:len(response) - Const.CRC_LENGTH] - def read_coils(self, - slave_addr: int, - starting_addr: int, - coil_qty: int) -> List[bool]: - """ - Read coils (COILS). - - :param slave_addr: The slave address - :type slave_addr: int - :param starting_addr: The coil starting address - :type starting_addr: int - :param coil_qty: The amount of coils to read - :type coil_qty: int - - :returns: State of read coils as list - :rtype: List[bool] - """ - modbus_pdu = functions.read_coils(starting_address=starting_addr, - quantity=coil_qty) - - resp_data = self._send_receive(modbus_pdu=modbus_pdu, - slave_addr=slave_addr, - count=True) - status_pdu = functions.bytes_to_bool(byte_list=resp_data, - bit_qty=coil_qty) - - return status_pdu - - def read_discrete_inputs(self, - slave_addr: int, - starting_addr: int, - input_qty: int) -> List[bool]: - """ - Read discrete inputs (ISTS). - - :param slave_addr: The slave address - :type slave_addr: int - :param starting_addr: The discrete input starting address - :type starting_addr: int - :param input_qty: The amount of discrete inputs to read - :type input_qty: int - - :returns: State of read discrete inputs as list - :rtype: List[bool] - """ - modbus_pdu = functions.read_discrete_inputs( - starting_address=starting_addr, - quantity=input_qty) - - resp_data = self._send_receive(modbus_pdu=modbus_pdu, - slave_addr=slave_addr, - count=True) - status_pdu = functions.bytes_to_bool(byte_list=resp_data, - bit_qty=input_qty) - - return status_pdu - - def read_holding_registers(self, - slave_addr: int, - starting_addr: int, - register_qty: int, - signed: bool = True) -> Tuple[int, ...]: - """ - Read holding registers (HREGS). - - :param slave_addr: The slave address - :type slave_addr: int - :param starting_addr: The holding register starting address - :type starting_addr: int - :param register_qty: The amount of holding registers to read - :type register_qty: int - :param signed: Indicates if signed - :type signed: bool - - :returns: State of read holding register as tuple - :rtype: Tuple[int, ...] - """ - modbus_pdu = functions.read_holding_registers( - starting_address=starting_addr, - quantity=register_qty) - - resp_data = self._send_receive(modbus_pdu=modbus_pdu, - slave_addr=slave_addr, - count=True) - register_value = functions.to_short(byte_array=resp_data, - signed=signed) - - return register_value - - def read_input_registers(self, - slave_addr: int, - starting_addr: int, - register_qty: int, - signed: bool = True) -> Tuple[int, ...]: - """ - Read input registers (IREGS). - - :param slave_addr: The slave address - :type slave_addr: int - :param starting_addr: The input register starting address - :type starting_addr: int - :param register_qty: The amount of input registers to read - :type register_qty: int - :param signed: Indicates if signed - :type signed: bool - - :returns: State of read input register as tuple - :rtype: Tuple[int, ...] - """ - modbus_pdu = functions.read_input_registers( - starting_address=starting_addr, - quantity=register_qty) - - resp_data = self._send_receive(modbus_pdu=modbus_pdu, - slave_addr=slave_addr, - count=True) - register_value = functions.to_short(byte_array=resp_data, - signed=signed) - - return register_value - - def write_single_coil(self, - slave_addr: int, - output_address: int, - output_value: Union[int, bool]) -> bool: - """ - Update a single coil. - - :param slave_addr: The slave address - :type slave_addr: int - :param output_address: The output address - :type output_address: int - :param output_value: The output value - :type output_value: Union[int, bool] - - :returns: Result of operation - :rtype: bool - """ - modbus_pdu = functions.write_single_coil(output_address=output_address, - output_value=output_value) - - resp_data = self._send_receive(modbus_pdu=modbus_pdu, - slave_addr=slave_addr, - count=False) - operation_status = functions.validate_resp_data( - data=resp_data, - function_code=Const.WRITE_SINGLE_COIL, - address=output_address, - value=output_value, - signed=False) - - return operation_status - - def write_single_register(self, - slave_addr: int, - register_address: int, - register_value: int, - signed=True) -> bool: - """ - Update a single register. - - :param slave_addr: The slave address - :type slave_addr: int - :param register_address: The register address - :type register_address: int - :param register_value: The register value - :type register_value: int - :param signed: Indicates if signed - :type signed: bool - - :returns: Result of operation - :rtype: bool - """ - modbus_pdu = functions.write_single_register(register_address, - register_value, - signed) - - resp_data = self._send_receive(modbus_pdu=modbus_pdu, - slave_addr=slave_addr, - count=False) - operation_status = functions.validate_resp_data( - data=resp_data, - function_code=Const.WRITE_SINGLE_REGISTER, - address=register_address, - value=register_value, - signed=signed) - - return operation_status - - def write_multiple_coils(self, - slave_addr: int, - starting_address: int, - output_values: List[Union[int, bool]]) -> bool: - """ - Update multiple coils. - - :param slave_addr: The slave address - :type slave_addr: int - :param starting_address: The address of the first coil - :type starting_address: int - :param output_values: The output values - :type output_values: List[Union[int, bool]] - - :returns: Result of operation - :rtype: bool - """ - modbus_pdu = functions.write_multiple_coils( - starting_address=starting_address, - value_list=output_values) - - resp_data = self._send_receive(modbus_pdu=modbus_pdu, - slave_addr=slave_addr, - count=False) - operation_status = functions.validate_resp_data( - data=resp_data, - function_code=Const.WRITE_MULTIPLE_COILS, - address=starting_address, - quantity=len(output_values)) - - return operation_status - - def write_multiple_registers(self, - slave_addr: int, - starting_address: int, - register_values: List[int], - signed=True) -> bool: - """ - Update multiple registers. - - :param slave_addr: The slave address - :type slave_addr: int - :param starting_address: The starting address - :type starting_address: int - :param register_values: The register values - :type register_values: List[int] - :param signed: Indicates if signed - :type signed: bool - - :returns: Result of operation - :rtype: bool - """ - modbus_pdu = functions.write_multiple_registers( - starting_address=starting_address, - register_values=register_values, - signed=signed) - - resp_data = self._send_receive(modbus_pdu=modbus_pdu, - slave_addr=slave_addr, - count=False) - operation_status = functions.validate_resp_data( - data=resp_data, - function_code=Const.WRITE_MULTIPLE_REGISTERS, - address=starting_address, - quantity=len(register_values), - signed=signed - ) - - return operation_status - def send_response(self, slave_addr: int, function_code: int, From b715bed1fb1213f5944b5b4d85a97ee57dd919be Mon Sep 17 00:00:00 2001 From: Jonas Scharpf Date: Mon, 2 Jan 2023 19:15:19 +0100 Subject: [PATCH 05/23] remove outsourced common code from tcp, use CommonModbusFunctions functions, replace slave_id with slave_addr keywords in all functions of TCPServer --- umodbus/tcp.py | 294 +++---------------------------------------------- 1 file changed, 16 insertions(+), 278 deletions(-) diff --git a/umodbus/tcp.py b/umodbus/tcp.py index 9c1e9e4..00239b3 100644 --- a/umodbus/tcp.py +++ b/umodbus/tcp.py @@ -17,12 +17,12 @@ # custom packages from . import functions from . import const as Const -from .common import Request +from .common import Request, CommonModbusFunctions from .common import ModbusException from .modbus import Modbus # typing not natively supported on MicroPython -from .typing import List, Optional, Tuple, Union +from .typing import Optional, Tuple, Union class ModbusTCP(Modbus): @@ -63,7 +63,7 @@ def get_bound_status(self) -> bool: return False -class TCP(object): +class TCP(CommonModbusFunctions): """ TCP class handling socket connections and parsing the Modbus data @@ -88,13 +88,13 @@ def __init__(self, self._sock.settimeout(timeout) def _create_mbap_hdr(self, - slave_id: int, + slave_addr: int, modbus_pdu: bytes) -> Tuple[bytes, int]: """ Create a Modbus header. - :param slave_id: The slave identifier - :type slave_id: int + :param slave_addr: The slave identifier + :type slave_addr: int :param modbus_pdu: The modbus Protocol Data Unit :type modbus_pdu: bytes @@ -110,14 +110,14 @@ def _create_mbap_hdr(self, self.trans_id_ctr += 1 mbap_hdr = struct.pack( - '>HHHB', trans_id, 0, len(modbus_pdu) + 1, slave_id) + '>HHHB', trans_id, 0, len(modbus_pdu) + 1, slave_addr) return mbap_hdr, trans_id def _validate_resp_hdr(self, response: bytearray, trans_id: int, - slave_id: int, + slave_addr: int, function_code: int, count: bool = False) -> bytes: """ @@ -127,8 +127,8 @@ def _validate_resp_hdr(self, :type response: bytearray :param trans_id: The transaction identifier :type trans_id: int - :param slave_id: The slave identifier - :type slave_id: int + :param slave_addr: The slave identifier + :type slave_addr: int :param function_code: The function code :type function_code: int :param count: The count @@ -146,7 +146,7 @@ def _validate_resp_hdr(self, if (rec_pid != 0): raise ValueError('invalid protocol ID') - if (slave_id != rec_uid): + if (slave_addr != rec_uid): raise ValueError('wrong slave ID') if (rec_fc == (function_code + Const.ERROR_BIAS)): @@ -159,14 +159,14 @@ def _validate_resp_hdr(self, return response[hdr_length:] def _send_receive(self, - slave_id: int, + slave_addr: int, modbus_pdu: bytes, count: bool) -> bytes: """ Send a modbus message and receive the reponse. - :param slave_id: The slave identifier - :type slave_id: int + :param slave_addr: The slave identifier + :type slave_addr: int :param modbus_pdu: The modbus PDU :type modbus_pdu: bytes :param count: The count @@ -175,281 +175,19 @@ def _send_receive(self, :returns: Modbus data :rtype: bytes """ - mbap_hdr, trans_id = self._create_mbap_hdr(slave_id=slave_id, + mbap_hdr, trans_id = self._create_mbap_hdr(slave_addr=slave_addr, modbus_pdu=modbus_pdu) self._sock.send(mbap_hdr + modbus_pdu) response = self._sock.recv(256) modbus_data = self._validate_resp_hdr(response=response, trans_id=trans_id, - slave_id=slave_id, + slave_addr=slave_addr, function_code=modbus_pdu[0], count=count) return modbus_data - def read_coils(self, - slave_addr: int, - starting_addr: int, - coil_qty: int) -> List[bool]: - """ - Read coils (COILS). - - :param slave_addr: The slave address - :type slave_addr: int - :param starting_addr: The coil starting address - :type starting_addr: int - :param coil_qty: The amount of coils to read - :type coil_qty: int - - :returns: State of read coils as list - :rtype: List[bool] - """ - modbus_pdu = functions.read_coils( - starting_address=starting_addr, - quantity=coil_qty) - - response = self._send_receive(slave_id=slave_addr, - modbus_pdu=modbus_pdu, - count=True) - status_pdu = functions.bytes_to_bool(byte_list=response, - bit_qty=coil_qty) - - return status_pdu - - def read_discrete_inputs(self, - slave_addr: int, - starting_addr: int, - input_qty: int) -> List[bool]: - """ - Read discrete inputs (ISTS). - - :param slave_addr: The slave address - :type slave_addr: int - :param starting_addr: The discrete input starting address - :type starting_addr: int - :param input_qty: The amount of discrete inputs to read - :type input_qty: int - - :returns: State of read discrete inputs as list - :rtype: List[bool] - """ - modbus_pdu = functions.read_discrete_inputs( - starting_address=starting_addr, - quantity=input_qty) - - response = self._send_receive(slave_id=slave_addr, - modbus_pdu=modbus_pdu, - count=True) - status_pdu = functions.bytes_to_bool(byte_list=response, - bit_qty=input_qty) - - return status_pdu - - def read_holding_registers(self, - slave_addr: int, - starting_addr: int, - register_qty: int, - signed: bool = True) -> Tuple[int, ...]: - """ - Read holding registers (HREGS). - - :param slave_addr: The slave address - :type slave_addr: int - :param starting_addr: The holding register starting address - :type starting_addr: int - :param register_qty: The amount of holding registers to read - :type register_qty: int - :param signed: Indicates if signed - :type signed: bool - - :returns: State of read holding register as tuple - :rtype: Tuple[int, ...] - """ - modbus_pdu = functions.read_holding_registers( - starting_address=starting_addr, - quantity=register_qty) - - response = self._send_receive(slave_id=slave_addr, - modbus_pdu=modbus_pdu, - count=True) - register_value = functions.to_short(byte_array=response, signed=signed) - - return register_value - - def read_input_registers(self, - slave_addr: int, - starting_addr: int, - register_qty: int, - signed: bool = True) -> Tuple[int, ...]: - """ - Read input registers (IREGS). - - :param slave_addr: The slave address - :type slave_addr: int - :param starting_addr: The input register starting address - :type starting_addr: int - :param register_qty: The amount of input registers to read - :type register_qty: int - :param signed: Indicates if signed - :type signed: bool - - :returns: State of read input register as tuple - :rtype: Tuple[int, ...] - """ - modbus_pdu = functions.read_input_registers( - starting_address=starting_addr, - quantity=register_qty) - - response = self._send_receive(slave_id=slave_addr, - modbus_pdu=modbus_pdu, - count=True) - register_value = functions.to_short(byte_array=response, signed=signed) - - return register_value - - def write_single_coil(self, - slave_addr: int, - output_address: int, - output_value: Union[int, bool]) -> bool: - """ - Update a single coil. - - :param slave_addr: The slave address - :type slave_addr: int - :param output_address: The output address - :type output_address: int - :param output_value: The output value - :type output_value: Union[int, bool] - - :returns: Result of operation - :rtype: bool - """ - modbus_pdu = functions.write_single_coil(output_address=output_address, - output_value=output_value) - - response = self._send_receive(slave_id=slave_addr, - modbus_pdu=modbus_pdu, - count=False) - if response is None: - return False - - operation_status = functions.validate_resp_data( - data=response, - function_code=Const.WRITE_SINGLE_COIL, - address=output_address, - value=output_value, - signed=False) - - return operation_status - - def write_single_register(self, - slave_addr: int, - register_address: int, - register_value: int, - signed: bool = True) -> bool: - """ - Update a single register. - - :param slave_addr: The slave address - :type slave_addr: int - :param register_address: The register address - :type register_address: int - :param register_value: The register value - :type register_value: int - :param signed: Indicates if signed - :type signed: bool - - :returns: Result of operation - :rtype: bool - """ - modbus_pdu = functions.write_single_register( - register_address=register_address, - register_value=register_value, - signed=signed) - - response = self._send_receive(slave_id=slave_addr, - modbus_pdu=modbus_pdu, - count=False) - operation_status = functions.validate_resp_data( - data=response, - function_code=Const.WRITE_SINGLE_REGISTER, - address=register_address, - value=register_value, - signed=signed) - - return operation_status - - def write_multiple_coils(self, - slave_addr: int, - starting_address: int, - output_values: List[Union[int, bool]]) -> bool: - """ - Update multiple coils. - - :param slave_addr: The slave address - :type slave_addr: int - :param starting_address: The address of the first coil - :type starting_address: int - :param output_values: The output values - :type output_values: List[Union[int, bool]] - - :returns: Result of operation - :rtype: bool - """ - modbus_pdu = functions.write_multiple_coils( - starting_address=starting_address, - value_list=output_values) - - response = self._send_receive(slave_id=slave_addr, - modbus_pdu=modbus_pdu, - count=False) - operation_status = functions.validate_resp_data( - data=response, - function_code=Const.WRITE_MULTIPLE_COILS, - address=starting_address, - quantity=len(output_values)) - - return operation_status - - def write_multiple_registers(self, - slave_addr: int, - starting_address: int, - register_values: List[int], - signed=True) -> bool: - """ - Update multiple registers. - - :param slave_addr: The slave address - :type slave_addr: int - :param starting_address: The starting address - :type starting_address: int - :param register_values: The register values - :type register_values: List[int] - :param signed: Indicates if signed - :type signed: bool - - :returns: Result of operation - :rtype: bool - """ - modbus_pdu = functions.write_multiple_registers( - starting_address=starting_address, - register_values=register_values, - signed=signed) - - response = self._send_receive(slave_id=slave_addr, - modbus_pdu=modbus_pdu, - count=False) - operation_status = functions.validate_resp_data( - data=response, - function_code=Const.WRITE_MULTIPLE_REGISTERS, - address=starting_address, - quantity=len(register_values), - signed=signed - ) - - return operation_status - class TCPServer(object): """Modbus TCP host class""" From b54b57178b6f054faf68fc84eb365988c4734743 Mon Sep 17 00:00:00 2001 From: Jonas Scharpf Date: Mon, 2 Jan 2023 19:43:33 +0100 Subject: [PATCH 06/23] rename docker files to be TCP specific, relates to #47 --- Dockerfile.client => Dockerfile.client_tcp | 4 ++-- Dockerfile.host => Dockerfile.host_tcp | 4 ++-- Dockerfile.test_tcp_example => Dockerfile.test_examples | 0 docker-compose-tcp-test.yaml | 4 ++-- docker-compose.yaml | 4 ++-- 5 files changed, 8 insertions(+), 8 deletions(-) rename Dockerfile.client => Dockerfile.client_tcp (68%) rename Dockerfile.host => Dockerfile.host_tcp (69%) rename Dockerfile.test_tcp_example => Dockerfile.test_examples (100%) diff --git a/Dockerfile.client b/Dockerfile.client_tcp similarity index 68% rename from Dockerfile.client rename to Dockerfile.client_tcp index 46e14d8..e1b5f20 100644 --- a/Dockerfile.client +++ b/Dockerfile.client_tcp @@ -1,8 +1,8 @@ # Build image -# $ docker build -t micropython-client -f Dockerfile.client . +# $ docker build -t micropython-client-tcp -f Dockerfile.client_tcp . # # Run image -# $ docker run -it --rm --name micropython-client micropython-client +# $ docker run -it --rm --name micropython-client-tcp micropython-client-tcp FROM micropython/unix:v1.18 diff --git a/Dockerfile.host b/Dockerfile.host_tcp similarity index 69% rename from Dockerfile.host rename to Dockerfile.host_tcp index 7c61c8d..689a2bc 100644 --- a/Dockerfile.host +++ b/Dockerfile.host_tcp @@ -1,8 +1,8 @@ # Build image -# $ docker build -t micropython-host -f Dockerfile.host . +# $ docker build -t micropython-host-tcp -f Dockerfile.host_tcp . # # Run image -# $ docker run -it --rm --name micropython-host micropython-host +# $ docker run -it --rm --name micropython-host-tcp micropython-host-tcp FROM micropython/unix:v1.18 diff --git a/Dockerfile.test_tcp_example b/Dockerfile.test_examples similarity index 100% rename from Dockerfile.test_tcp_example rename to Dockerfile.test_examples diff --git a/docker-compose-tcp-test.yaml b/docker-compose-tcp-test.yaml index 25589dd..1e5ace3 100644 --- a/docker-compose-tcp-test.yaml +++ b/docker-compose-tcp-test.yaml @@ -11,7 +11,7 @@ services: micropython-client: build: context: . - dockerfile: Dockerfile.client + dockerfile: Dockerfile.client_tcp container_name: micropython-client volumes: - ./:/home @@ -30,7 +30,7 @@ services: micropython-host: build: context: . - dockerfile: Dockerfile.test_tcp_example + dockerfile: Dockerfile.test_examples container_name: micropython-host volumes: - ./:/home diff --git a/docker-compose.yaml b/docker-compose.yaml index 549527f..6a224d4 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -11,7 +11,7 @@ services: micropython-client: build: context: . - dockerfile: Dockerfile.client + dockerfile: Dockerfile.client_tcp container_name: micropython-client volumes: - ./:/home @@ -29,7 +29,7 @@ services: micropython-host: build: context: . - dockerfile: Dockerfile.host + dockerfile: Dockerfile.host_tcp container_name: micropython-host volumes: - ./:/home From 4d3eb89fbc245c3bb98737abe329b795d9e2dcfc Mon Sep 17 00:00:00 2001 From: Jonas Scharpf Date: Mon, 2 Jan 2023 23:16:11 +0100 Subject: [PATCH 07/23] update function parameter names in tcp test example --- tests/test_tcp_example.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_tcp_example.py b/tests/test_tcp_example.py index d5a343b..e41720b 100644 --- a/tests/test_tcp_example.py +++ b/tests/test_tcp_example.py @@ -68,7 +68,7 @@ def test__create_mbap_hdr(self) -> None: expectation = (struct.pack('>H', trans_id) + b'\x00\x00\x00\x06\x0A', trans_id) - result = self._host._create_mbap_hdr(slave_id=self._client_addr, + result = self._host._create_mbap_hdr(slave_addr=self._client_addr, modbus_pdu=modbus_pdu) self.assertIsInstance(result, tuple) @@ -127,7 +127,7 @@ def test__validate_resp_hdr(self) -> None: result = self._host._validate_resp_hdr( response=response, trans_id=trans_id, - slave_id=self._client_addr, + slave_addr=self._client_addr, function_code=function_code) self.test_logger.debug('result: {}, expectation: {}'.format( result, expectation)) From 84fe3bf20a303e079d0af7b6e5aedb85a1b1fef2 Mon Sep 17 00:00:00 2001 From: Jonas Scharpf Date: Mon, 2 Jan 2023 23:17:03 +0100 Subject: [PATCH 08/23] update RTU client example for Docker usage as in TCP client example, contributes to #47 --- examples/rtu_client_example.py | 45 ++++++++++++++++++++++++++++------ 1 file changed, 38 insertions(+), 7 deletions(-) diff --git a/examples/rtu_client_example.py b/examples/rtu_client_example.py index acc93b9..d30ced4 100644 --- a/examples/rtu_client_example.py +++ b/examples/rtu_client_example.py @@ -18,6 +18,18 @@ # import modbus client classes from umodbus.serial import ModbusRTU +IS_DOCKER_MICROPYTHON = False +try: + import machine + machine.reset_cause() +except ImportError: + raise Exception('Unable to import machine, are all fakes available?') +except AttributeError: + # machine fake class has no "reset_cause" function + IS_DOCKER_MICROPYTHON = True + import json + + # =============================================== # RTU Slave setup # act as client, provide Modbus data via RTU to a host device @@ -41,6 +53,10 @@ # uart_id=1 # optional, see port specific documentation ) +if IS_DOCKER_MICROPYTHON: + # works only with fake machine UART + assert client._itf._uart._is_server is True + # common slave register setup, to be used with the Master example above register_definitions = { "COILS": { @@ -78,20 +94,35 @@ } } -""" # alternatively the register definitions can also be loaded from a JSON file -import json - -with open('registers/example.json', 'r') as file: - register_definitions = json.load(file) -""" +# this is always done if Docker is used for testing purpose in order to keep +# the client registers in sync with the test registers +if IS_DOCKER_MICROPYTHON: + with open('registers/example.json', 'r') as file: + register_definitions = json.load(file) +print('Setting up registers ...') # use the defined values of each register type provided by register_definitions client.setup_registers(registers=register_definitions) # alternatively use dummy default values (True for bool regs, 999 otherwise) # client.setup_registers(registers=register_definitions, use_default_vals=True) +print('Register setup done') + +reset_data_register = \ + register_definitions['COILS']['RESET_REGISTER_DATA_COIL']['register'] while True: - result = client.process() + try: + result = client.process() + if reset_data_register in client.coils: + if client.get_coil(address=reset_data_register): + print('Resetting register data to default values ...') + client.setup_registers(registers=register_definitions) + print('Default values restored') + except KeyboardInterrupt: + print('KeyboardInterrupt, stopping RTU client...') + break + except Exception as e: + print('Exception during execution: {}'.format(e)) print("Finished providing/accepting data as client") From 511ddfae409911372d339d0f025dfee76ad0fec8 Mon Sep 17 00:00:00 2001 From: Jonas Scharpf Date: Mon, 2 Jan 2023 23:18:35 +0100 Subject: [PATCH 09/23] add RTU host example, relates to #47 --- examples/rtu_host_example.py | 215 +++++++++++++++++++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 examples/rtu_host_example.py diff --git a/examples/rtu_host_example.py b/examples/rtu_host_example.py new file mode 100644 index 0000000..e8d3106 --- /dev/null +++ b/examples/rtu_host_example.py @@ -0,0 +1,215 @@ +#!/usr/bin/env python3 +# -*- coding: UTF-8 -*- + +""" +Main script + +Do your stuff here, this file is similar to the loop() function on Arduino + +Create a Modbus RTU host (master) which requests or sets data on a client +device. + +The RTU communication pins can be choosen freely (check MicroPython device/ +port specific limitations). +The register definitions of the client as well as its connection settings like +bus address and UART communication speed can be defined by the user. +""" + +# system packages +import time + +# import modbus host classes +from umodbus.serial import Serial as ModbusRTUMaster + +IS_DOCKER_MICROPYTHON = False +try: + import machine + machine.reset_cause() +except ImportError: + raise Exception('Unable to import machine, are all fakes available?') +except AttributeError: + # machine fake class has no "reset_cause" function + IS_DOCKER_MICROPYTHON = True + import sys + + +# =============================================== +# RTU Slave setup +slave_addr = 10 # address on bus of the client/slave + +# RTU Master setup +# act as host, collect Modbus data via RTU from a client device +# ModbusRTU can perform serial requests to a client device to get/set data +# check MicroPython UART documentation +# https://docs.micropython.org/en/latest/library/machine.UART.html +# for Device/Port specific setup +# RP2 needs "rtu_pins = (Pin(4), Pin(5))" whereas ESP32 can use any pin +# the following example is for an ESP32 +rtu_pins = (25, 26) # (TX, RX) +baudrate = 9600 +host = ModbusRTUMaster( + baudrate=baudrate, # optional, default 9600 + pins=rtu_pins, # given as tuple (TX, RX) + # data_bits=8, # optional, default 8 + # stop_bits=1, # optional, default 1 + # parity=None, # optional, default None + # ctrl_pin=12, # optional, control DE/RE + # uart_id=1 # optional, see port specific documentation +) + +if IS_DOCKER_MICROPYTHON: + # works only with fake machine UART + assert host._uart._is_server is False + +# commond slave register setup, to be used with the Master example above +register_definitions = { + "COILS": { + "RESET_REGISTER_DATA_COIL": { + "register": 42, + "len": 1, + "val": 0 + }, + "EXAMPLE_COIL": { + "register": 123, + "len": 1, + "val": 1 + } + }, + "HREGS": { + "EXAMPLE_HREG": { + "register": 93, + "len": 1, + "val": 19 + } + }, + "ISTS": { + "EXAMPLE_ISTS": { + "register": 67, + "len": 1, + "val": 0 + } + }, + "IREGS": { + "EXAMPLE_IREG": { + "register": 10, + "len": 2, + "val": 60001 + } + } +} + +""" +# alternatively the register definitions can also be loaded from a JSON file +import json + +with open('registers/example.json', 'r') as file: + register_definitions = json.load(file) +""" + +print('Requesting and updating data on RTU client at {} with {} baud'. + format(slave_addr, baudrate)) +print() + +# READ COILS +coil_address = register_definitions['COILS']['EXAMPLE_COIL']['register'] +coil_qty = register_definitions['COILS']['EXAMPLE_COIL']['len'] +coil_status = host.read_coils( + slave_addr=slave_addr, + starting_addr=coil_address, + coil_qty=coil_qty) +print('Status of coil {}: {}'.format(coil_status, coil_address)) +time.sleep(1) + +# WRITE COILS +new_coil_val = 0 +operation_status = host.write_single_coil( + slave_addr=slave_addr, + output_address=coil_address, + output_value=new_coil_val) +print('Result of setting coil {} to {}'.format(coil_address, operation_status)) +time.sleep(1) + +# READ COILS again +coil_status = host.read_coils( + slave_addr=slave_addr, + starting_addr=coil_address, + coil_qty=coil_qty) +print('Status of coil {}: {}'.format(coil_status, coil_address)) +time.sleep(1) + +print() + +# READ HREGS +hreg_address = register_definitions['HREGS']['EXAMPLE_HREG']['register'] +register_qty = register_definitions['HREGS']['EXAMPLE_HREG']['len'] +register_value = host.read_holding_registers( + slave_addr=slave_addr, + starting_addr=hreg_address, + register_qty=register_qty, + signed=False) +print('Status of hreg {}: {}'.format(hreg_address, register_value)) +time.sleep(1) + +# WRITE HREGS +new_hreg_val = 44 +operation_status = host.write_single_register( + slave_addr=slave_addr, + register_address=hreg_address, + register_value=new_hreg_val, + signed=False) +print('Result of setting hreg {} to {}'.format(hreg_address, operation_status)) +time.sleep(1) + +# READ HREGS again +register_value = host.read_holding_registers( + slave_addr=slave_addr, + starting_addr=hreg_address, + register_qty=register_qty, + signed=False) +print('Status of hreg {}: {}'.format(hreg_address, register_value)) +time.sleep(1) + +print() + +# READ ISTS +ist_address = register_definitions['ISTS']['EXAMPLE_ISTS']['register'] +input_qty = register_definitions['ISTS']['EXAMPLE_ISTS']['len'] +input_status = host.read_discrete_inputs( + slave_addr=slave_addr, + starting_addr=ist_address, + input_qty=input_qty) +print('Status of ist {}: {}'.format(ist_address, input_status)) +time.sleep(1) + +# READ IREGS +ireg_address = register_definitions['IREGS']['EXAMPLE_IREG']['register'] +register_qty = register_definitions['IREGS']['EXAMPLE_IREG']['len'] +register_value = host.read_input_registers( + slave_addr=slave_addr, + starting_addr=ireg_address, + register_qty=register_qty, + signed=False) +print('Status of ireg {}: {}'.format(ireg_address, register_value)) +time.sleep(1) + +print() + +# reset all registers back to their default values on the client +# WRITE COILS +print('Resetting register data to default values...') +coil_address = \ + register_definitions['COILS']['RESET_REGISTER_DATA_COIL']['register'] +new_coil_val = True +operation_status = host.write_single_coil( + slave_addr=slave_addr, + output_address=coil_address, + output_value=new_coil_val) +print('Result of setting COIL {}: {}'.format(coil_address, operation_status)) +time.sleep(1) + +print() + +print("Finished requesting/setting data on client") + +if IS_DOCKER_MICROPYTHON: + sys.exit(0) From 551d6816db178945ab8f1a3292dd2719fa496511 Mon Sep 17 00:00:00 2001 From: Jonas Scharpf Date: Mon, 2 Jan 2023 23:19:58 +0100 Subject: [PATCH 10/23] add Dockerfiles for RTU, contributes to #47 --- Dockerfile.client_rtu | 15 +++++++++++++++ Dockerfile.host_rtu | 15 +++++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 Dockerfile.client_rtu create mode 100644 Dockerfile.host_rtu diff --git a/Dockerfile.client_rtu b/Dockerfile.client_rtu new file mode 100644 index 0000000..0b37d95 --- /dev/null +++ b/Dockerfile.client_rtu @@ -0,0 +1,15 @@ +# Build image +# $ docker build -t micropython-client-rtu -f Dockerfile.client_rtu . +# +# Run image +# $ docker run -it --rm --name micropython-client-rtu micropython-client-rtu + +FROM micropython/unix:v1.18 + +# use "volumes" in docker-compose file to remove need of rebuilding +# COPY ./ /home +# COPY umodbus /root/.micropython/lib/umodbus + +RUN micropython-dev -m upip install micropython-ulogging + +CMD [ "micropython-dev", "-m", "examples/rtu_client_example.py" ] diff --git a/Dockerfile.host_rtu b/Dockerfile.host_rtu new file mode 100644 index 0000000..e1f4dd3 --- /dev/null +++ b/Dockerfile.host_rtu @@ -0,0 +1,15 @@ +# Build image +# $ docker build -t micropython-host-rtu -f Dockerfile.host_rtu . +# +# Run image +# $ docker run -it --rm --name micropython-host-rtu micropython-host-rtu + +FROM micropython/unix:v1.18 + +# use "volumes" in docker-compose file to remove need of rebuilding +# COPY ./ /home +# COPY umodbus /root/.micropython/lib/umodbus + +RUN micropython-dev -m upip install micropython-ulogging + +CMD [ "micropython-dev", "-m", "examples/rtu_host_example.py" ] From f58858ea68bf0d7c413f691d5c7a8a810b6143cb Mon Sep 17 00:00:00 2001 From: Jonas Scharpf Date: Mon, 2 Jan 2023 23:20:28 +0100 Subject: [PATCH 11/23] add docker compose file for RTU example run, contributes to #47 --- docker-compose-rtu.yaml | 57 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 docker-compose-rtu.yaml diff --git a/docker-compose-rtu.yaml b/docker-compose-rtu.yaml new file mode 100644 index 0000000..548f62e --- /dev/null +++ b/docker-compose-rtu.yaml @@ -0,0 +1,57 @@ +# +# build all non-image containers +# $ docker-compose build +# can be combined into one command to also start it afterwards +# $ docker-compose up --build +# + +version: "3.8" + +services: + micropython-client: + build: + context: . + dockerfile: Dockerfile.client_rtu + container_name: micropython-client + volumes: + - ./:/home + - ./umodbus:/root/.micropython/lib/umodbus + - ./fakes:/usr/lib/micropython + expose: + - "65433" + ports: + - "65433:65433" # reach "micropython-client" at 172.25.0.2:65433, see networks + networks: + my_serial_bridge: + # fix IPv4 address to be known and in the MicroPython scripts + # https://docs.docker.com/compose/compose-file/#ipv4_address + ipv4_address: 172.25.0.2 + + micropython-host: + build: + context: . + dockerfile: Dockerfile.host_rtu + container_name: micropython-host + volumes: + - ./:/home + - ./umodbus:/root/.micropython/lib/umodbus + - ./fakes:/usr/lib/micropython + depends_on: + - micropython-client + networks: + my_serial_bridge: + # fix IPv4 address to be known and in the MicroPython scripts + # https://docs.docker.com/compose/compose-file/#ipv4_address + ipv4_address: 172.25.0.3 + +networks: + my_serial_bridge: + # use "external: true" if the network already exists + # check available networks with "docker network ls" + # external: true + driver: bridge + # https://docs.docker.com/compose/compose-file/#ipam + ipam: + config: + - subnet: 172.25.0.0/16 + gateway: 172.25.0.1 From 664ce1bfe477f9db324c7e2bb126d3318961d599 Mon Sep 17 00:00:00 2001 From: Jonas Scharpf Date: Mon, 2 Jan 2023 23:23:26 +0100 Subject: [PATCH 12/23] add unittest for RTU example, contributes to #47 --- tests/test_rtu_example.py | 164 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 tests/test_rtu_example.py diff --git a/tests/test_rtu_example.py b/tests/test_rtu_example.py new file mode 100644 index 0000000..213f438 --- /dev/null +++ b/tests/test_rtu_example.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +# -*- coding: UTF-8 -*- +"""Unittest for testing RTU functions of umodbus""" + +import json +import ulogging as logging +import mpy_unittest as unittest +from umodbus.serial import Serial as ModbusRTUMaster + + +class TestRtuExample(unittest.TestCase): + def setUp(self) -> None: + """Run before every test method""" + # set basic config and level for the logger + logging.basicConfig(level=logging.INFO) + + # create a logger for this TestSuite + self.test_logger = logging.getLogger(__name__) + + # set the test logger level + self.test_logger.setLevel(logging.DEBUG) + + # enable/disable the log output of the device logger for the tests + # if enabled log data inside this test will be printed + self.test_logger.disabled = False + + self._client_addr = 10 # bus address of client + + self._host = ModbusRTUMaster(baudrate=9600, pins=(25, 26)) # (TX, RX) + + test_register_file = 'registers/example.json' + try: + with open(test_register_file, 'r') as file: + self._register_definitions = json.load(file) + except Exception as e: + self.test_logger.error( + 'Is the test register file available at {}?'.format( + test_register_file)) + raise e + + def test_setup(self) -> None: + """Test successful setup of ModbusRTUMaster and the defined register""" + # although it is called "Master" the host is here a client connecting + # to one or more clients/slaves/devices which are providing data + # The reason for calling it "ModbusRTUMaster" is the status of having + # the functions to request or get data from other client/slave/devices + self.assertFalse(self._host._uart._is_server) + self.assertIsInstance(self._register_definitions, dict) + + for reg_type in ['COILS', 'HREGS', 'ISTS', 'IREGS']: + with self.subTest(reg_type=reg_type): + self.assertIn(reg_type, self._register_definitions.keys()) + self.assertIsInstance(self._register_definitions[reg_type], + dict) + self.assertGreaterEqual( + len(self._register_definitions[reg_type]), 1) + + self._read_coils_single() + + def _read_coils_single(self) -> None: + """Test reading sinlge coil of client""" + # read coil with state ON/True + coil_address = \ + self._register_definitions['COILS']['EXAMPLE_COIL']['register'] + coil_qty = self._register_definitions['COILS']['EXAMPLE_COIL']['len'] + expectation_list = [ + bool(self._register_definitions['COILS']['EXAMPLE_COIL']['val']) + ] + + coil_status = self._host.read_coils( + slave_addr=self._client_addr, + starting_addr=coil_address, + coil_qty=coil_qty) + + self.test_logger.debug('Status of COIL {}: {}, expectation: {}'. + format(coil_address, + coil_status, + expectation_list)) + self.assertIsInstance(coil_status, list) + self.assertEqual(len(coil_status), coil_qty) + self.assertTrue(all(isinstance(x, bool) for x in coil_status)) + self.assertEqual(coil_status, expectation_list) + + # read coil with state OFF/False + coil_address = \ + self._register_definitions['COILS']['EXAMPLE_COIL_OFF']['register'] + coil_qty = \ + self._register_definitions['COILS']['EXAMPLE_COIL_OFF']['len'] + expectation_list = [bool( + self._register_definitions['COILS']['EXAMPLE_COIL_OFF']['val'] + )] + + coil_status = self._host.read_coils( + slave_addr=self._client_addr, + starting_addr=coil_address, + coil_qty=coil_qty) + + self.test_logger.debug('Status of COIL {}: {}, expectation: {}'. + format(coil_address, + coil_status, + expectation_list)) + self.assertIsInstance(coil_status, list) + self.assertEqual(len(coil_status), coil_qty) + self.assertTrue(all(isinstance(x, bool) for x in coil_status)) + self.assertEqual(coil_status, expectation_list) + + @unittest.skip('Test not yet implemented') + def test__calculate_crc16(self) -> None: + """Test calculating Modbus CRC16""" + pass + + @unittest.skip('Test not yet implemented') + def test__exit_read(self) -> None: + """Test validating received response""" + pass + + @unittest.skip('Test not yet implemented') + def test__uart_read(self) -> None: + """Test reading data from UART""" + pass + + @unittest.skip('Test not yet implemented') + def test__uart_read_frame(self) -> None: + """Test reading a Modbus frame""" + pass + + @unittest.skip('Test not yet implemented') + def test__send(self) -> None: + """Test sending a Modbus frame""" + pass + + @unittest.skip('Test not yet implemented') + def test__send_receive(self) -> None: + """Test sending a Modbus frame""" + pass + + @unittest.skip('Test not yet implemented') + def test__validate_resp_hdr(self) -> None: + """Test response header validation""" + pass + + @unittest.skip('Test not yet implemented') + def test_send_response(self) -> None: + """Test sending a response to a client""" + pass + + @unittest.skip('Test not yet implemented') + def test_send_exception_response(self) -> None: + """Test sending a exception response to a client""" + pass + + @unittest.skip('Test not yet implemented') + def test_get_request(self) -> None: + """Test checking for a request""" + pass + + def tearDown(self) -> None: + """Run after every test method""" + self._host._uart._sock.close() + self.test_logger.debug('Closed ModbusRTUMaster socket at tearDown') + + +if __name__ == '__main__': + unittest.main() From a9ce1056d551fe74c6984194d60654754b6641e7 Mon Sep 17 00:00:00 2001 From: Jonas Scharpf Date: Mon, 2 Jan 2023 23:23:44 +0100 Subject: [PATCH 13/23] add docker compose file to run RTU unittest, contributes to #47 --- docker-compose-rtu-test.yaml | 64 ++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 docker-compose-rtu-test.yaml diff --git a/docker-compose-rtu-test.yaml b/docker-compose-rtu-test.yaml new file mode 100644 index 0000000..3ae87e7 --- /dev/null +++ b/docker-compose-rtu-test.yaml @@ -0,0 +1,64 @@ +# +# build all non-image containers +# $ docker-compose -f docker-compose-rtu-test.yaml build +# can be combined into one command to also start it afterwards +# $ docker-compose -f docker-compose-rtu-test.yaml up --build +# + +version: "3.8" + +services: + micropython-client: + build: + context: . + dockerfile: Dockerfile.client_rtu + container_name: micropython-client + volumes: + - ./:/home + - ./registers:/home/registers + - ./umodbus:/root/.micropython/lib/umodbus + - ./fakes:/usr/lib/micropython + expose: + - "65433" + ports: + - "65433:65433" # reach "micropython-client" at 172.25.0.2:65433, see networks + networks: + serial_bridge: + # fix IPv4 address to be known and in the MicroPython scripts + # https://docs.docker.com/compose/compose-file/#ipv4_address + ipv4_address: 172.25.0.2 + + micropython-host: + build: + context: . + dockerfile: Dockerfile.test_examples + container_name: micropython-host + volumes: + - ./:/home + - ./umodbus:/root/.micropython/lib/umodbus + - ./fakes:/usr/lib/micropython + - ./mpy_unittest.py:/root/.micropython/lib/mpy_unittest.py + depends_on: + - micropython-client + command: + - /bin/bash + - -c + - | + micropython-dev -c "import mpy_unittest as unittest; unittest.main(name='tests.test_rtu_example', fromlist=['TestRtuExample'])" + networks: + serial_bridge: + # fix IPv4 address to be known and in the MicroPython scripts + # https://docs.docker.com/compose/compose-file/#ipv4_address + ipv4_address: 172.25.0.3 + +networks: + serial_bridge: + # use "external: true" if the network already exists + # check available networks with "docker network ls" + # external: true + driver: bridge + # https://docs.docker.com/compose/compose-file/#ipam + ipam: + config: + - subnet: 172.25.0.0/16 + gateway: 172.25.0.1 From 479e2edf41a799602fec4c97777ba36c8cee27ca Mon Sep 17 00:00:00 2001 From: Jonas Scharpf Date: Mon, 2 Jan 2023 23:24:15 +0100 Subject: [PATCH 14/23] add test_rtu_example to tests init file as comment, contributes to #47 --- tests/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/__init__.py b/tests/__init__.py index efb9b9b..f29af82 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -7,3 +7,6 @@ # TestTcpExample is a non static test and requires a running TCP client # from .test_tcp_example import * + +# TestRtuExample is a non static test and requires a running RTU client +# from .test_rtu_example import * From 85725536c8459562c785a6b5eb0b56d141b48a94 Mon Sep 17 00:00:00 2001 From: Jonas Scharpf Date: Mon, 2 Jan 2023 23:24:52 +0100 Subject: [PATCH 15/23] add RTU example and test to test workflow, relates to #47 --- .github/workflows/test.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e07ac30..aa7f769 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -42,6 +42,12 @@ jobs: - name: Run Client/Host TCP test run: | docker compose -f docker-compose-tcp-test.yaml up --build --exit-code-from micropython-host + - name: Run Client/Host RTU example + run: | + docker compose -f docker-compose-rtu.yaml up --build --exit-code-from micropython-host + - name: Run Client/Host RTU test + run: | + docker compose -f docker-compose-rtu-test.yaml up --build --exit-code-from micropython-host - name: Build package run: | changelog2version \ From cdf86d87d18c0146b3cbe7ef9cb260520aeac067 Mon Sep 17 00:00:00 2001 From: Jonas Scharpf Date: Mon, 2 Jan 2023 23:26:58 +0100 Subject: [PATCH 16/23] update Modbus function documentation from TCP specific to general common module --- docs/USAGE.md | 52 +++++++++++++++++++++++++++------------------------ 1 file changed, 28 insertions(+), 24 deletions(-) diff --git a/docs/USAGE.md b/docs/USAGE.md index 3e6c3c4..792cdfb 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -224,18 +224,18 @@ be `percent`. ### Register usage -This section describes the usage of the following available functions - - - [0x01 `read_coils`](umodbus.tcp.TCP.read_coils) - - [0x02 `read_discrete_inputs`](umodbus.tcp.TCP.read_discrete_inputs) - - [0x03 `read_holding_registers`](umodbus.tcp.TCP.read_holding_registers) - - [0x04 `read_input_registers`](umodbus.tcp.TCP.read_input_registers) - - [0x05 `write_single_coil`](umodbus.tcp.TCP.write_single_coil) - - [0x06 `write_single_register`](umodbus.tcp.TCP.write_single_register) - - [0x0F `write_multiple_coils`](umodbus.tcp.TCP.write_multiple_coils) - - [0x10 `write_multiple_registers`](umodbus.tcp.TCP.write_multiple_registers) - -based on TCP togehter with the latest provided +This section describes the usage of the following implemented functions + + - [0x01 `read_coils`](umodbus.common.CommonModbusFunctions.read_coils) + - [0x02 `read_discrete_inputs`](umodbus.common.CommonModbusFunctions.read_discrete_inputs) + - [0x03 `read_holding_registers`](umodbus.common.CommonModbusFunctions.read_holding_registers) + - [0x04 `read_input_registers`](umodbus.common.CommonModbusFunctions.read_input_registers) + - [0x05 `write_single_coil`](umodbus.common.CommonModbusFunctions.write_single_coil) + - [0x06 `write_single_register`](umodbus.common.CommonModbusFunctions.write_single_register) + - [0x0F `write_multiple_coils`](umodbus.common.CommonModbusFunctions.write_multiple_coils) + - [0x10 `write_multiple_registers`](umodbus.common.CommonModbusFunctions.write_multiple_registers) + +which are available on Modbus RTU and Modbus TCP as shown in the [examples](https://github.com/brainelectronics/micropython-modbus/tree/develop/examples) All described functions require a successful setup of a Host communicating @@ -270,7 +270,6 @@ slave_addr = 10 # bus address of client # the following example is for an ESP32 rtu_pins = (25, 26) # (TX, RX) host = ModbusRTUMaster( - addr=1, # bus address of this Host/Master, usually '1' baudrate=9600, # optional, default 9600 pins=rtu_pins, # given as tuple (TX, RX) # data_bits=8, # optional, default 8 @@ -293,8 +292,9 @@ The function code `0x01` is used to read from 1 to 2000 contiguous status of coils in a remote device. ``` -With the function [`read_coils`](umodbus.tcp.TCP.read_coils) a single coil -status can be read. +With the function +[`read_coils`](umodbus.common.CommonModbusFunctions.read_coils) +a single coil status can be read. ```python coil_address = 125 @@ -331,7 +331,8 @@ The function code `0x05` is used to write a single output to either `ON` or `OFF` in a remote device. ``` -With the function [`write_single_coil`](umodbus.tcp.TCP.write_single_coil) +With the function +[`write_single_coil`](umodbus.common.CommonModbusFunctions.write_single_coil) a single coil status can be set. ```python @@ -363,7 +364,8 @@ The function code `0x0F` is used to force each coil in a sequence of coils to either `ON` or `OFF` in a remote device. ``` -With the function [`write_multiple_coils`](umodbus.tcp.TCP.write_multiple_coils) +With the function +[`write_multiple_coils`](umodbus.common.CommonModbusFunctions.write_multiple_coils) multiple coil states can be set at once. ```python @@ -401,7 +403,8 @@ The function code `0x02` is used to read from 1 to 2000 contiguous status of discrete inputs in a remote device. ``` -With the function [`read_discrete_inputs`](umodbus.tcp.TCP.read_discrete_inputs) +With the function +[`read_discrete_inputs`](umodbus.common.CommonModbusFunctions.read_discrete_inputs) discrete inputs can be read. ```python @@ -429,8 +432,8 @@ of holding registers in a remote device. ``` With the function -[`read_holding_registers`](umodbus.tcp.TCP.read_holding_registers) a single -holding register can be read. +[`read_holding_registers`](umodbus.common.CommonModbusFunctions.read_holding_registers) +a single holding register can be read. ```python hreg_address = 94 @@ -470,8 +473,8 @@ remote device. ``` With the function -[`write_single_register`](umodbus.tcp.TCP.write_single_register) a single -holding register can be set. +[`write_single_register`](umodbus.common.CommonModbusFunctions.write_single_register) +a single holding register can be set. ```python hreg_address = 93 @@ -493,7 +496,7 @@ The function code `0x10` is used to write a block of contiguous registers ``` With the function -[`write_multiple_registers`](umodbus.tcp.TCP.write_multiple_registers) +[`write_multiple_registers`](umodbus.common.CommonModbusFunctions.write_multiple_registers) holding register can be set at once. ```python @@ -536,7 +539,8 @@ The function code `0x04` is used to read from 1 to 125 contiguous input registers in a remote device. ``` -With the function [`read_input_registers`](umodbus.tcp.TCP.read_input_registers) +With the function +[`read_input_registers`](umodbus.common.CommonModbusFunctions.read_input_registers) input registers can be read. ```python From 18023c04c28041c0b7f7c67bee5efc1ad48ab157 Mon Sep 17 00:00:00 2001 From: Jonas Scharpf Date: Mon, 2 Jan 2023 23:28:02 +0100 Subject: [PATCH 17/23] add RTU example section for Client and Host, relates to #47 --- docs/USAGE.md | 73 +++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 68 insertions(+), 5 deletions(-) diff --git a/docs/USAGE.md b/docs/USAGE.md index 792cdfb..3a50ede 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -613,15 +613,15 @@ Connected to WiFi. ('192.168.178.42', '255.255.255.0', '192.168.178.1', '192.168.178.1') Requesting and updating data on TCP client at 192.168.178.69:502 -Status of COIL 123: [True, False, False, False, False, False, False, False] +Status of COIL 123: [True] Result of setting COIL 123: True -Status of COIL 123: [False, False, False, False, False, False, False, False] +Status of COIL 123: [False] Status of HREG 93: (44,) Result of setting HREG 93: True Status of HREG 93: (44,) -Status of IST 67: [False, False, False, False, False, False, False, False] +Status of IST 67: [False] Status of IREG 10: (60001,) Finished requesting/setting data on client @@ -630,12 +630,74 @@ Type "help()" for more information. >>> ``` - + +Adjust the UART pins according to the MicroPython port specific +[documentation][ref-uart-documentation]. RP2 boards e.g. require the UART pins +as tuple of `Pin`, like `rtu_pins = (Pin(4), Pin(5))` and the specific +`uart_id=1` for those, whereas ESP32 boards can use almost alls pins for UART +communication and shall be given as `rtu_pins = (25, 26)`. + +#### Client + +The client, former known as slave, provides some dummy registers which can be +read and updated by another device. + +```bash +cp examples/rtu_client_example.py /pyboard/main.py +cp examples/boot.py /pyboard/boot.py +repl +``` + +Inside the REPL press CTRL+D to perform a soft reboot. The device will serve +several registers now. The log output might look similar to this + +``` +MPY: soft reboot +System booted successfully! +Setting up registers ... +Register setup done +``` + +#### Host + +The host, former known as master, requests and updates some dummy registers of +another device. + +```bash +cp examples/rtu_host_example.py /pyboard/main.py +cp examples/boot.py /pyboard/boot.py +repl +``` + +Inside the REPL press CTRL+D to perform a soft reboot. The device will request +and update registers of the Client after a few seconds. The log output might +look similar to this + +``` +MPY: soft reboot +System booted successfully! +Requesting and updating data on RTU client at 10 with 9600 baud. + +Status of COIL 123: [True] +Result of setting COIL 123: True +Status of COIL 123: [False] + +Status of HREG 93: (44,) +Result of setting HREG 93: True +Status of HREG 93: (44,) + +Status of IST 67: [False] +Status of IREG 10: (60001,) + +Finished requesting/setting data on client +MicroPython v1.18 on 2022-01-17; ESP32 module (spiram) with ESP32 +Type "help()" for more information. +>>> +``` ### TCP-RTU bridge @@ -839,6 +901,7 @@ The created documentation can be found at [`docs/build/html`](docs/build/html). [ref-myevse-be]: https://brainelectronics.de/ [ref-myevse-tindie]: https://www.tindie.com/stores/brainelectronics/ [ref-package-main-file]: https://github.com/brainelectronics/micropython-modbus/blob/c45d6cc334b4adf0e0ffd9152c8f08724e1902d9/main.py +[ref-uart-documentation]: https://docs.micropython.org/en/latest/library/machine.UART.html [ref-github-be-modbus-wrapper]: https://github.com/brainelectronics/be-modbus-wrapper [ref-modules-folder]: https://github.com/brainelectronics/python-modules/tree/43bad716b7db27db07c94c2d279cee57d0c8c753 [ref-rtd-micropython-modbus]: https://micropython-modbus.readthedocs.io/en/latest/ From 4340d352b8e8b6a0178fdaa96852d4c2cc62b56c Mon Sep 17 00:00:00 2001 From: Jonas Scharpf Date: Mon, 2 Jan 2023 23:28:38 +0100 Subject: [PATCH 18/23] update Docker compose usage section for new RTU compose file, relates to #47 --- docs/USAGE.md | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/docs/USAGE.md b/docs/USAGE.md index 3a50ede..69e9ae2 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -843,14 +843,21 @@ failed. #### Docker compose -The following command uses the setup defined in the `docker-compose.yaml` file -to act as two MicroPython devices communicating via TCP. The container -`micropython-host` defined by `Dockerfile.host` acts as host and sets/gets -data at/from the client as defined by `tcp_host_example.py`. On the other hand -the container `micropython-client` defined by `Dockerfile.client` acts as -client and provides data for the host as defined by `tcp_client_example.py`. +The following command uses the setup defined in the individual +`docker-compose-*-test.yaml` file to act as two MicroPython devices +communicating via TCP or RTU. The container `micropython-host-*` defined by +`Dockerfile.host_*` acts as host and sets/gets data at/from the client as +defined by `*_host_example.py`. On the other hand the container +`micropython-client-*` defined by `Dockerfile.client_*` acts as client and +provides data for the host as defined by `*_client_example.py`. + The port defined in `tcp_host_example.py` and `tcp_client_example.py` has to -be open and optionally exposed in the `docker-compose.yaml` file. +be open and optionally exposed in the `docker-compose-tcp-example.yaml` file. + +As the [MicroPython containers](https://hub.docker.com/r/micropython/unix/tags) +does not have a UART interface with is additionally not connectable via two +containers a UART fake has been implemented. It is using a socket connection +to exchange all the data. ```bash docker compose up --build --exit-code-from micropython-host @@ -865,6 +872,12 @@ the containers. All "dynamic" data is shared via `volumes` docker compose -f docker-compose-tcp-test.yaml up --build --exit-code-from micropython-host ``` +##### Test for RTU example + +```bash +docker compose -f docker-compose-rtu-test.yaml up --build --exit-code-from micropython-host-rtu +``` + ## Documentation The documentation is automatically generated on every merge to the develop From c7967e63ff403f344cbf90381f3403b251231976 Mon Sep 17 00:00:00 2001 From: Jonas Scharpf Date: Mon, 2 Jan 2023 23:42:52 +0100 Subject: [PATCH 19/23] update changelog --- changelog.md | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/changelog.md b/changelog.md index 607cc42..414cc91 100644 --- a/changelog.md +++ b/changelog.md @@ -15,6 +15,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Released +## [2.2.0] - 2023-01-03 +### Added +- Fake machine module with UART and Pin to be used on Unix MicroPython container for RTU tests and examples, see #47 +- [RTU host example script](examples/rtu_host_example.py) +- [RTU docker compose file](docker-compose-rtu.yaml) and [RTU docker compose file test](docker-compose-rtu-test.yaml) based in MicroPython 1.18 image +- [RTU client Dockerfile](Dockerfile.client_rtu) and [RTU host Dockerfile](Dockerfile.host_rtu) based on MicroPython 1.18 image +- Initial [RTU examples unittest](tests/test_rtu_example.py) +- RTU example section for Client and Host in USAGE + +### Changed +- Removed the following common functions from [serial.py](umodbus/serial.py) and [tcp.py](umodbus/tcp.py) and added to [common.py](umodbus/common.py): + - `read_coils` + - `read_discrete_inputs` + - `read_holding_registers` + - `read_input_registers` + - `write_single_coil` + - `write_single_register` + - `write_multiple_coils` + - `write_multiple_registers` + +- Extended RTU client example for Docker usage to load all registers from example JSON file +- Update internal functions parameter name `slave_id` to `slave_addr` to use same keywords in TCP and Serial +- Update Modbus function documentation from TCP specific to common module in USAGE file + +### Fixed + ## [2.1.3] - 2022-12-30 ### Fixed - `uart_id` can be specified during init of `ModbusRTU` and `Serial` class and is no longer hardcoded to `1`, but set as `1` by default to ensure backwards compability, see #7 and #43 @@ -195,8 +221,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - PEP8 style issues on all files of [`lib/uModbus`](lib/uModbus) -[Unreleased]: https://github.com/brainelectronics/micropython-modbus/compare/2.1.3...develop +[Unreleased]: https://github.com/brainelectronics/micropython-modbus/compare/2.2.0...develop +[2.2.0]: https://github.com/brainelectronics/micropython-modbus/tree/2.2.0 [2.1.3]: https://github.com/brainelectronics/micropython-modbus/tree/2.1.3 [2.1.2]: https://github.com/brainelectronics/micropython-modbus/tree/2.1.2 [2.1.1]: https://github.com/brainelectronics/micropython-modbus/tree/2.1.1 From 82b14971b550f6b24ae97b3235fa9eae7e716100 Mon Sep 17 00:00:00 2001 From: Jonas Scharpf Date: Tue, 3 Jan 2023 00:04:00 +0100 Subject: [PATCH 20/23] add remove orphans flag to docker compose usage example in USAGE --- docs/USAGE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/USAGE.md b/docs/USAGE.md index 69e9ae2..bbec012 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -869,13 +869,13 @@ the containers. All "dynamic" data is shared via `volumes` ##### Test for TCP example ```bash -docker compose -f docker-compose-tcp-test.yaml up --build --exit-code-from micropython-host +docker compose -f docker-compose-tcp-test.yaml up --build --exit-code-from micropython-host --remove-orphans ``` ##### Test for RTU example ```bash -docker compose -f docker-compose-rtu-test.yaml up --build --exit-code-from micropython-host-rtu +docker compose -f docker-compose-rtu-test.yaml up --build --exit-code-from micropython-host-rtu --remove-orphans ``` ## Documentation From 6fe25feaa8c74087fef0898a77c16939e93943c2 Mon Sep 17 00:00:00 2001 From: Jonas Scharpf Date: Tue, 3 Jan 2023 00:04:11 +0100 Subject: [PATCH 21/23] update changelog --- changelog.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/changelog.md b/changelog.md index 414cc91..fcf8deb 100644 --- a/changelog.md +++ b/changelog.md @@ -17,7 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Released ## [2.2.0] - 2023-01-03 ### Added -- Fake machine module with UART and Pin to be used on Unix MicroPython container for RTU tests and examples, see #47 +- [Fake machine module](fakes/machine.py) with UART and Pin class to be used on Unix MicroPython container for RTU tests and examples, see #47 - [RTU host example script](examples/rtu_host_example.py) - [RTU docker compose file](docker-compose-rtu.yaml) and [RTU docker compose file test](docker-compose-rtu-test.yaml) based in MicroPython 1.18 image - [RTU client Dockerfile](Dockerfile.client_rtu) and [RTU host Dockerfile](Dockerfile.host_rtu) based on MicroPython 1.18 image @@ -25,7 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - RTU example section for Client and Host in USAGE ### Changed -- Removed the following common functions from [serial.py](umodbus/serial.py) and [tcp.py](umodbus/tcp.py) and added to [common.py](umodbus/common.py): +- Outsourced the following common functions of [serial.py](umodbus/serial.py) and [tcp.py](umodbus/tcp.py) into `CommonModbusFunctions` of [common.py](umodbus/common.py): - `read_coils` - `read_discrete_inputs` - `read_holding_registers` @@ -35,11 +35,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `write_multiple_coils` - `write_multiple_registers` +- Inherit from `CommonModbusFunctions` in `Serial` of [serial.py](umodbus/serial.py) and in `TCP` of of [tcp.py](umodbus/tcp.py) - Extended RTU client example for Docker usage to load all registers from example JSON file -- Update internal functions parameter name `slave_id` to `slave_addr` to use same keywords in TCP and Serial +- Update internal functions parameter name from `slave_id` to `slave_addr` of TCP's `_create_mbap_hdr` and `_validate_resp_hdr` function to be the same as in Serial - Update Modbus function documentation from TCP specific to common module in USAGE file - -### Fixed +- Renamed docker files: + - `Dockerfile.client` -> `Dockerfile.client_tcp` + - `Dockerfile.host` -> `Dockerfile.host_tcp` + - `Dockerfile.test_tcp_example` -> `Dockerfile.test_examples` ## [2.1.3] - 2022-12-30 ### Fixed From dfd6be50fdc55e9c59532290062a98a6327aa5ec Mon Sep 17 00:00:00 2001 From: Jonas Scharpf Date: Tue, 3 Jan 2023 00:06:16 +0100 Subject: [PATCH 22/23] update remaining function parameter names in tcp example test --- tests/test_tcp_example.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_tcp_example.py b/tests/test_tcp_example.py index e41720b..73ccefc 100644 --- a/tests/test_tcp_example.py +++ b/tests/test_tcp_example.py @@ -149,7 +149,7 @@ def test__validate_resp_hdr(self) -> None: self._host._validate_resp_hdr( response=response, trans_id=data['tid'] + 1, - slave_id=data['sid'], + slave_addr=data['sid'], function_code=data['fid']) # trigger wrong function ID/throw Modbus exception code assert @@ -157,7 +157,7 @@ def test__validate_resp_hdr(self) -> None: self._host._validate_resp_hdr( response=response, trans_id=data['tid'], - slave_id=data['sid'], + slave_addr=data['sid'], function_code=data['fid'] + 1) # trigger wrong slave ID assert @@ -165,7 +165,7 @@ def test__validate_resp_hdr(self) -> None: self._host._validate_resp_hdr( response=response, trans_id=data['tid'], - slave_id=data['sid'] + 1, + slave_addr=data['sid'] + 1, function_code=data['fid']) @unittest.skip('Test not yet implemented') From 1e5a79642fb323c13203536914c57cd2a23284eb Mon Sep 17 00:00:00 2001 From: Jonas Scharpf Date: Tue, 3 Jan 2023 00:10:55 +0100 Subject: [PATCH 23/23] use same network name for RTU compose files to avoid pool overlap errors --- docker-compose-rtu.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docker-compose-rtu.yaml b/docker-compose-rtu.yaml index 548f62e..b71bb25 100644 --- a/docker-compose-rtu.yaml +++ b/docker-compose-rtu.yaml @@ -22,7 +22,7 @@ services: ports: - "65433:65433" # reach "micropython-client" at 172.25.0.2:65433, see networks networks: - my_serial_bridge: + serial_bridge: # fix IPv4 address to be known and in the MicroPython scripts # https://docs.docker.com/compose/compose-file/#ipv4_address ipv4_address: 172.25.0.2 @@ -39,13 +39,13 @@ services: depends_on: - micropython-client networks: - my_serial_bridge: + serial_bridge: # fix IPv4 address to be known and in the MicroPython scripts # https://docs.docker.com/compose/compose-file/#ipv4_address ipv4_address: 172.25.0.3 networks: - my_serial_bridge: + serial_bridge: # use "external: true" if the network already exists # check available networks with "docker network ls" # external: true