initial
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
"""Pymodbus: Modbus Protocol Implementation.
|
||||
|
||||
Released under the BSD license
|
||||
"""
|
||||
|
||||
__all__ = [
|
||||
"pymodbus_apply_logging_config",
|
||||
"__version__",
|
||||
"__version_full__",
|
||||
]
|
||||
|
||||
from pymodbus.logging import pymodbus_apply_logging_config
|
||||
|
||||
|
||||
__version__ = "3.5.4"
|
||||
__version_full__ = f"[pymodbus, version {__version__}]"
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,270 @@
|
||||
"""Bit Reading Request/Response messages."""
|
||||
|
||||
__all__ = [
|
||||
"ReadBitsResponseBase",
|
||||
"ReadCoilsRequest",
|
||||
"ReadCoilsResponse",
|
||||
"ReadDiscreteInputsRequest",
|
||||
"ReadDiscreteInputsResponse",
|
||||
]
|
||||
|
||||
# pylint: disable=missing-type-doc
|
||||
import struct
|
||||
|
||||
from pymodbus.pdu import ExceptionResponse, ModbusRequest, ModbusResponse
|
||||
from pymodbus.pdu import ModbusExceptions as merror
|
||||
from pymodbus.utilities import pack_bitstring, unpack_bitstring
|
||||
|
||||
|
||||
class ReadBitsRequestBase(ModbusRequest):
|
||||
"""Base class for Messages Requesting bit values."""
|
||||
|
||||
_rtu_frame_size = 8
|
||||
|
||||
def __init__(self, address, count, slave=0, **kwargs):
|
||||
"""Initialize the read request data.
|
||||
|
||||
:param address: The start address to read from
|
||||
:param count: The number of bits after "address" to read
|
||||
:param slave: Modbus slave slave ID
|
||||
"""
|
||||
ModbusRequest.__init__(self, slave, **kwargs)
|
||||
self.address = address
|
||||
self.count = count
|
||||
|
||||
def encode(self):
|
||||
"""Encode a request pdu.
|
||||
|
||||
:returns: The encoded pdu
|
||||
"""
|
||||
return struct.pack(">HH", self.address, self.count)
|
||||
|
||||
def decode(self, data):
|
||||
"""Decode a request pdu.
|
||||
|
||||
:param data: The packet data to decode
|
||||
"""
|
||||
self.address, self.count = struct.unpack(">HH", data)
|
||||
|
||||
def get_response_pdu_size(self):
|
||||
"""Get response pdu size.
|
||||
|
||||
Func_code (1 byte) + Byte Count(1 byte) + Quantity of Coils (n Bytes)/8,
|
||||
if the remainder is different of 0 then N = N+1
|
||||
:return:
|
||||
"""
|
||||
count = self.count // 8
|
||||
if self.count % 8:
|
||||
count += 1
|
||||
|
||||
return 1 + 1 + count
|
||||
|
||||
def __str__(self):
|
||||
"""Return a string representation of the instance.
|
||||
|
||||
:returns: A string representation of the instance
|
||||
"""
|
||||
return f"ReadBitRequest({self.address},{self.count})"
|
||||
|
||||
|
||||
class ReadBitsResponseBase(ModbusResponse):
|
||||
"""Base class for Messages responding to bit-reading values.
|
||||
|
||||
The requested bits can be found in the .bits list.
|
||||
"""
|
||||
|
||||
_rtu_byte_count_pos = 2
|
||||
|
||||
def __init__(self, values, slave=0, **kwargs):
|
||||
"""Initialize a new instance.
|
||||
|
||||
:param values: The requested values to be returned
|
||||
:param slave: Modbus slave slave ID
|
||||
"""
|
||||
ModbusResponse.__init__(self, slave, **kwargs)
|
||||
|
||||
#: A list of booleans representing bit values
|
||||
self.bits = values or []
|
||||
|
||||
def encode(self):
|
||||
"""Encode response pdu.
|
||||
|
||||
:returns: The encoded packet message
|
||||
"""
|
||||
result = pack_bitstring(self.bits)
|
||||
packet = struct.pack(">B", len(result)) + result
|
||||
return packet
|
||||
|
||||
def decode(self, data):
|
||||
"""Decode response pdu.
|
||||
|
||||
:param data: The packet data to decode
|
||||
"""
|
||||
self.byte_count = int(data[0]) # pylint: disable=attribute-defined-outside-init
|
||||
self.bits = unpack_bitstring(data[1:])
|
||||
|
||||
def setBit(self, address, value=1):
|
||||
"""Set the specified bit.
|
||||
|
||||
:param address: The bit to set
|
||||
:param value: The value to set the bit to
|
||||
"""
|
||||
self.bits[address] = bool(value)
|
||||
|
||||
def resetBit(self, address):
|
||||
"""Set the specified bit to 0.
|
||||
|
||||
:param address: The bit to reset
|
||||
"""
|
||||
self.setBit(address, 0)
|
||||
|
||||
def getBit(self, address):
|
||||
"""Get the specified bit's value.
|
||||
|
||||
:param address: The bit to query
|
||||
:returns: The value of the requested bit
|
||||
"""
|
||||
return self.bits[address]
|
||||
|
||||
def __str__(self):
|
||||
"""Return a string representation of the instance.
|
||||
|
||||
:returns: A string representation of the instance
|
||||
"""
|
||||
return f"{self.__class__.__name__}({len(self.bits)})"
|
||||
|
||||
|
||||
class ReadCoilsRequest(ReadBitsRequestBase):
|
||||
"""This function code is used to read from 1 to 2000(0x7d0) contiguous status of coils in a remote device.
|
||||
|
||||
The Request PDU specifies the starting
|
||||
address, ie the address of the first coil specified, and the number of
|
||||
coils. In the PDU Coils are addressed starting at zero. Therefore coils
|
||||
numbered 1-16 are addressed as 0-15.
|
||||
"""
|
||||
|
||||
function_code = 1
|
||||
function_code_name = "read_coils"
|
||||
|
||||
def __init__(self, address=None, count=None, slave=0, **kwargs):
|
||||
"""Initialize a new instance.
|
||||
|
||||
:param address: The address to start reading from
|
||||
:param count: The number of bits to read
|
||||
:param slave: Modbus slave slave ID
|
||||
"""
|
||||
ReadBitsRequestBase.__init__(self, address, count, slave, **kwargs)
|
||||
|
||||
def execute(self, context):
|
||||
"""Run a read coils request against a datastore.
|
||||
|
||||
Before running the request, we make sure that the request is in
|
||||
the max valid range (0x001-0x7d0). Next we make sure that the
|
||||
request is valid against the current datastore.
|
||||
|
||||
:param context: The datastore to request from
|
||||
:returns: An initialized :py:class:`~pymodbus.register_read_message.ReadCoilsResponse`, or an :py:class:`~pymodbus.pdu.ExceptionResponse` if an error occurred
|
||||
"""
|
||||
if not (1 <= self.count <= 0x7D0):
|
||||
return self.doException(merror.IllegalValue)
|
||||
if not context.validate(self.function_code, self.address, self.count):
|
||||
return self.doException(merror.IllegalAddress)
|
||||
values = context.getValues(self.function_code, self.address, self.count)
|
||||
if isinstance(values, ExceptionResponse):
|
||||
return values
|
||||
return ReadCoilsResponse(values)
|
||||
|
||||
|
||||
class ReadCoilsResponse(ReadBitsResponseBase):
|
||||
"""The coils in the response message are packed as one coil per bit of the data field.
|
||||
|
||||
Status is indicated as 1= ON and 0= OFF. The LSB of the
|
||||
first data byte contains the output addressed in the query. The other
|
||||
coils follow toward the high order end of this byte, and from low order
|
||||
to high order in subsequent bytes.
|
||||
|
||||
If the returned output quantity is not a multiple of eight, the
|
||||
remaining bits in the final data byte will be padded with zeros
|
||||
(toward the high order end of the byte). The Byte Count field specifies
|
||||
the quantity of complete bytes of data.
|
||||
|
||||
The requested coils can be found in boolean form in the .bits list.
|
||||
"""
|
||||
|
||||
function_code = 1
|
||||
|
||||
def __init__(self, values=None, slave=0, **kwargs):
|
||||
"""Initialize a new instance.
|
||||
|
||||
:param values: The request values to respond with
|
||||
:param slave: Modbus slave slave ID
|
||||
"""
|
||||
ReadBitsResponseBase.__init__(self, values, slave, **kwargs)
|
||||
|
||||
|
||||
class ReadDiscreteInputsRequest(ReadBitsRequestBase):
|
||||
"""This function code is used to read from 1 to 2000(0x7d0).
|
||||
|
||||
Contiguous status of discrete inputs in a remote device. The Request PDU specifies the
|
||||
starting address, ie the address of the first input specified, and the
|
||||
number of inputs. In the PDU Discrete Inputs are addressed starting at
|
||||
zero. Therefore Discrete inputs numbered 1-16 are addressed as 0-15.
|
||||
"""
|
||||
|
||||
function_code = 2
|
||||
function_code_name = "read_discrete_input"
|
||||
|
||||
def __init__(self, address=None, count=None, slave=0, **kwargs):
|
||||
"""Initialize a new instance.
|
||||
|
||||
:param address: The address to start reading from
|
||||
:param count: The number of bits to read
|
||||
:param slave: Modbus slave slave ID
|
||||
"""
|
||||
ReadBitsRequestBase.__init__(self, address, count, slave, **kwargs)
|
||||
|
||||
def execute(self, context):
|
||||
"""Run a read discrete input request against a datastore.
|
||||
|
||||
Before running the request, we make sure that the request is in
|
||||
the max valid range (0x001-0x7d0). Next we make sure that the
|
||||
request is valid against the current datastore.
|
||||
|
||||
:param context: The datastore to request from
|
||||
:returns: An initialized :py:class:`~pymodbus.register_read_message.ReadDiscreteInputsResponse`, or an :py:class:`~pymodbus.pdu.ExceptionResponse` if an error occurred
|
||||
"""
|
||||
if not (1 <= self.count <= 0x7D0):
|
||||
return self.doException(merror.IllegalValue)
|
||||
if not context.validate(self.function_code, self.address, self.count):
|
||||
return self.doException(merror.IllegalAddress)
|
||||
values = context.getValues(self.function_code, self.address, self.count)
|
||||
if isinstance(values, ExceptionResponse):
|
||||
return values
|
||||
return ReadDiscreteInputsResponse(values)
|
||||
|
||||
|
||||
class ReadDiscreteInputsResponse(ReadBitsResponseBase):
|
||||
"""The discrete inputs in the response message are packed as one input per bit of the data field.
|
||||
|
||||
Status is indicated as 1= ON; 0= OFF. The LSB of
|
||||
the first data byte contains the input addressed in the query. The other
|
||||
inputs follow toward the high order end of this byte, and from low order
|
||||
to high order in subsequent bytes.
|
||||
|
||||
If the returned input quantity is not a multiple of eight, the
|
||||
remaining bits in the final data byte will be padded with zeros
|
||||
(toward the high order end of the byte). The Byte Count field specifies
|
||||
the quantity of complete bytes of data.
|
||||
|
||||
The requested coils can be found in boolean form in the .bits list.
|
||||
"""
|
||||
|
||||
function_code = 2
|
||||
|
||||
def __init__(self, values=None, slave=0, **kwargs):
|
||||
"""Initialize a new instance.
|
||||
|
||||
:param values: The request values to respond with
|
||||
:param slave: Modbus slave slave ID
|
||||
"""
|
||||
ReadBitsResponseBase.__init__(self, values, slave, **kwargs)
|
||||
@@ -0,0 +1,292 @@
|
||||
"""Bit Writing Request/Response.
|
||||
|
||||
TODO write mask request/response
|
||||
"""
|
||||
|
||||
__all__ = [
|
||||
"WriteSingleCoilRequest",
|
||||
"WriteSingleCoilResponse",
|
||||
"WriteMultipleCoilsRequest",
|
||||
"WriteMultipleCoilsResponse",
|
||||
]
|
||||
|
||||
# pylint: disable=missing-type-doc
|
||||
import struct
|
||||
|
||||
from pymodbus.constants import ModbusStatus
|
||||
from pymodbus.pdu import ExceptionResponse, ModbusRequest, ModbusResponse
|
||||
from pymodbus.pdu import ModbusExceptions as merror
|
||||
from pymodbus.utilities import pack_bitstring, unpack_bitstring
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------#
|
||||
# Local Constants
|
||||
# ---------------------------------------------------------------------------#
|
||||
# These are defined in the spec to turn a coil on/off
|
||||
# ---------------------------------------------------------------------------#
|
||||
_turn_coil_on = struct.pack(">H", ModbusStatus.ON)
|
||||
_turn_coil_off = struct.pack(">H", ModbusStatus.OFF)
|
||||
|
||||
|
||||
class WriteSingleCoilRequest(ModbusRequest):
|
||||
"""This function code is used to write a single output to either ON or OFF in a remote device.
|
||||
|
||||
The requested ON/OFF state is specified by a constant in the request
|
||||
data field. A value of FF 00 hex requests the output to be ON. A value
|
||||
of 00 00 requests it to be OFF. All other values are illegal and will
|
||||
not affect the output.
|
||||
|
||||
The Request PDU specifies the address of the coil to be forced. Coils
|
||||
are addressed starting at zero. Therefore coil numbered 1 is addressed
|
||||
as 0. The requested ON/OFF state is specified by a constant in the Coil
|
||||
Value field. A value of 0XFF00 requests the coil to be ON. A value of
|
||||
0X0000 requests the coil to be off. All other values are illegal and
|
||||
will not affect the coil.
|
||||
"""
|
||||
|
||||
function_code = 5
|
||||
function_code_name = "write_coil"
|
||||
|
||||
_rtu_frame_size = 8
|
||||
|
||||
def __init__(self, address=None, value=None, slave=None, **kwargs):
|
||||
"""Initialize a new instance.
|
||||
|
||||
:param address: The variable address to write
|
||||
:param value: The value to write at address
|
||||
"""
|
||||
ModbusRequest.__init__(self, slave=slave, **kwargs)
|
||||
self.address = address
|
||||
self.value = bool(value)
|
||||
|
||||
def encode(self):
|
||||
"""Encode write coil request.
|
||||
|
||||
:returns: The byte encoded message
|
||||
"""
|
||||
result = struct.pack(">H", self.address)
|
||||
if self.value:
|
||||
result += _turn_coil_on
|
||||
else:
|
||||
result += _turn_coil_off
|
||||
return result
|
||||
|
||||
def decode(self, data):
|
||||
"""Decode a write coil request.
|
||||
|
||||
:param data: The packet data to decode
|
||||
"""
|
||||
self.address, value = struct.unpack(">HH", data)
|
||||
self.value = value == ModbusStatus.ON
|
||||
|
||||
def execute(self, context):
|
||||
"""Run a write coil request against a datastore.
|
||||
|
||||
:param context: The datastore to request from
|
||||
:returns: The populated response or exception message
|
||||
"""
|
||||
# if self.value not in [ModbusStatus.Off, ModbusStatus.On]:
|
||||
# return self.doException(merror.IllegalValue)
|
||||
if not context.validate(self.function_code, self.address, 1):
|
||||
return self.doException(merror.IllegalAddress)
|
||||
|
||||
result = context.setValues(self.function_code, self.address, [self.value])
|
||||
if isinstance(result, ExceptionResponse):
|
||||
return result
|
||||
values = context.getValues(self.function_code, self.address, 1)
|
||||
if isinstance(values, ExceptionResponse):
|
||||
return values
|
||||
return WriteSingleCoilResponse(self.address, values[0])
|
||||
|
||||
def get_response_pdu_size(self):
|
||||
"""Get response pdu size.
|
||||
|
||||
Func_code (1 byte) + Output Address (2 byte) + Output Value (2 Bytes)
|
||||
:return:
|
||||
"""
|
||||
return 1 + 2 + 2
|
||||
|
||||
def __str__(self):
|
||||
"""Return a string representation of the instance.
|
||||
|
||||
:return: A string representation of the instance
|
||||
"""
|
||||
return f"WriteCoilRequest({self.address}, {self.value}) => "
|
||||
|
||||
|
||||
class WriteSingleCoilResponse(ModbusResponse):
|
||||
"""The normal response is an echo of the request.
|
||||
|
||||
Returned after the coil state has been written.
|
||||
"""
|
||||
|
||||
function_code = 5
|
||||
_rtu_frame_size = 8
|
||||
|
||||
def __init__(self, address=None, value=None, **kwargs):
|
||||
"""Initialize a new instance.
|
||||
|
||||
:param address: The variable address written to
|
||||
:param value: The value written at address
|
||||
"""
|
||||
ModbusResponse.__init__(self, **kwargs)
|
||||
self.address = address
|
||||
self.value = value
|
||||
|
||||
def encode(self):
|
||||
"""Encode write coil response.
|
||||
|
||||
:return: The byte encoded message
|
||||
"""
|
||||
result = struct.pack(">H", self.address)
|
||||
if self.value:
|
||||
result += _turn_coil_on
|
||||
else:
|
||||
result += _turn_coil_off
|
||||
return result
|
||||
|
||||
def decode(self, data):
|
||||
"""Decode a write coil response.
|
||||
|
||||
:param data: The packet data to decode
|
||||
"""
|
||||
self.address, value = struct.unpack(">HH", data)
|
||||
self.value = value == ModbusStatus.ON
|
||||
|
||||
def __str__(self):
|
||||
"""Return a string representation of the instance.
|
||||
|
||||
:returns: A string representation of the instance
|
||||
"""
|
||||
return f"WriteCoilResponse({self.address}) => {self.value}"
|
||||
|
||||
|
||||
class WriteMultipleCoilsRequest(ModbusRequest):
|
||||
"""This function code is used to forcea sequence of coils.
|
||||
|
||||
To either ON or OFF in a remote device. The Request PDU specifies the coil
|
||||
references to be forced. Coils are addressed starting at zero. Therefore
|
||||
coil numbered 1 is addressed as 0.
|
||||
|
||||
The requested ON/OFF states are specified by contents of the request
|
||||
data field. A logical "1" in a bit position of the field requests the
|
||||
corresponding output to be ON. A logical "0" requests it to be OFF."
|
||||
"""
|
||||
|
||||
function_code = 15
|
||||
function_code_name = "write_coils"
|
||||
_rtu_byte_count_pos = 6
|
||||
|
||||
def __init__(self, address=None, values=None, slave=None, **kwargs):
|
||||
"""Initialize a new instance.
|
||||
|
||||
:param address: The starting request address
|
||||
:param values: The values to write
|
||||
"""
|
||||
ModbusRequest.__init__(self, slave=slave, **kwargs)
|
||||
self.address = address
|
||||
if values is None:
|
||||
values = []
|
||||
elif not hasattr(values, "__iter__"):
|
||||
values = [values]
|
||||
self.values = values
|
||||
self.byte_count = (len(self.values) + 7) // 8
|
||||
|
||||
def encode(self):
|
||||
"""Encode write coils request.
|
||||
|
||||
:returns: The byte encoded message
|
||||
"""
|
||||
count = len(self.values)
|
||||
self.byte_count = (count + 7) // 8
|
||||
packet = struct.pack(">HHB", self.address, count, self.byte_count)
|
||||
packet += pack_bitstring(self.values)
|
||||
return packet
|
||||
|
||||
def decode(self, data):
|
||||
"""Decode a write coils request.
|
||||
|
||||
:param data: The packet data to decode
|
||||
"""
|
||||
self.address, count, self.byte_count = struct.unpack(">HHB", data[0:5])
|
||||
values = unpack_bitstring(data[5:])
|
||||
self.values = values[:count]
|
||||
|
||||
def execute(self, context):
|
||||
"""Run a write coils request against a datastore.
|
||||
|
||||
:param context: The datastore to request from
|
||||
:returns: The populated response or exception message
|
||||
"""
|
||||
count = len(self.values)
|
||||
if not 1 <= count <= 0x07B0:
|
||||
return self.doException(merror.IllegalValue)
|
||||
if self.byte_count != (count + 7) // 8:
|
||||
return self.doException(merror.IllegalValue)
|
||||
if not context.validate(self.function_code, self.address, count):
|
||||
return self.doException(merror.IllegalAddress)
|
||||
|
||||
result = context.setValues(self.function_code, self.address, self.values)
|
||||
if isinstance(result, ExceptionResponse):
|
||||
return result
|
||||
return WriteMultipleCoilsResponse(self.address, count)
|
||||
|
||||
def __str__(self):
|
||||
"""Return a string representation of the instance.
|
||||
|
||||
:returns: A string representation of the instance
|
||||
"""
|
||||
params = (self.address, len(self.values))
|
||||
return (
|
||||
"WriteNCoilRequest (%d) => %d " # pylint: disable=consider-using-f-string
|
||||
% params
|
||||
)
|
||||
|
||||
def get_response_pdu_size(self):
|
||||
"""Get response pdu size.
|
||||
|
||||
Func_code (1 byte) + Output Address (2 byte) + Quantity of Outputs (2 Bytes)
|
||||
:return:
|
||||
"""
|
||||
return 1 + 2 + 2
|
||||
|
||||
|
||||
class WriteMultipleCoilsResponse(ModbusResponse):
|
||||
"""The normal response returns the function code.
|
||||
|
||||
Starting address, and quantity of coils forced.
|
||||
"""
|
||||
|
||||
function_code = 15
|
||||
_rtu_frame_size = 8
|
||||
|
||||
def __init__(self, address=None, count=None, **kwargs):
|
||||
"""Initialize a new instance.
|
||||
|
||||
:param address: The starting variable address written to
|
||||
:param count: The number of values written
|
||||
"""
|
||||
ModbusResponse.__init__(self, **kwargs)
|
||||
self.address = address
|
||||
self.count = count
|
||||
|
||||
def encode(self):
|
||||
"""Encode write coils response.
|
||||
|
||||
:returns: The byte encoded message
|
||||
"""
|
||||
return struct.pack(">HH", self.address, self.count)
|
||||
|
||||
def decode(self, data):
|
||||
"""Decode a write coils response.
|
||||
|
||||
:param data: The packet data to decode
|
||||
"""
|
||||
self.address, self.count = struct.unpack(">HH", data)
|
||||
|
||||
def __str__(self):
|
||||
"""Return a string representation of the instance.
|
||||
|
||||
:returns: A string representation of the instance
|
||||
"""
|
||||
return f"WriteNCoilResponse({self.address}, {self.count})"
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Client"""
|
||||
|
||||
__all__ = [
|
||||
"AsyncModbusSerialClient",
|
||||
"AsyncModbusTcpClient",
|
||||
"AsyncModbusTlsClient",
|
||||
"AsyncModbusUdpClient",
|
||||
"ModbusBaseClient",
|
||||
"ModbusSerialClient",
|
||||
"ModbusTcpClient",
|
||||
"ModbusTlsClient",
|
||||
"ModbusUdpClient",
|
||||
]
|
||||
|
||||
from pymodbus.client.base import ModbusBaseClient
|
||||
from pymodbus.client.serial import AsyncModbusSerialClient, ModbusSerialClient
|
||||
from pymodbus.client.tcp import AsyncModbusTcpClient, ModbusTcpClient
|
||||
from pymodbus.client.tls import AsyncModbusTlsClient, ModbusTlsClient
|
||||
from pymodbus.client.udp import AsyncModbusUdpClient, ModbusUdpClient
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,324 @@
|
||||
"""Base for all clients."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import socket
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable
|
||||
|
||||
from pymodbus.client.mixin import ModbusClientMixin
|
||||
from pymodbus.exceptions import ConnectionException, ModbusIOException
|
||||
from pymodbus.factory import ClientDecoder
|
||||
from pymodbus.framer import ModbusFramer
|
||||
from pymodbus.logging import Log
|
||||
from pymodbus.pdu import ModbusRequest, ModbusResponse
|
||||
from pymodbus.transaction import DictTransactionManager
|
||||
from pymodbus.transport import CommParams, ModbusProtocol
|
||||
from pymodbus.utilities import ModbusTransactionState
|
||||
|
||||
|
||||
class ModbusBaseClient(ModbusClientMixin, ModbusProtocol):
|
||||
"""**ModbusBaseClient**
|
||||
|
||||
**Parameters common to all clients**:
|
||||
|
||||
:param framer: (optional) Modbus Framer class.
|
||||
:param timeout: (optional) Timeout for a request, in seconds.
|
||||
:param retries: (optional) Max number of retries per request.
|
||||
:param retry_on_empty: (optional) Retry on empty response.
|
||||
:param close_comm_on_error: (optional) Close connection on error.
|
||||
:param strict: (optional) Strict timing, 1.5 character between requests.
|
||||
:param broadcast_enable: (optional) True to treat id 0 as broadcast address.
|
||||
:param reconnect_delay: (optional) Minimum delay in milliseconds before reconnecting.
|
||||
:param reconnect_delay_max: (optional) Maximum delay in milliseconds before reconnecting.
|
||||
:param on_reconnect_callback: (optional) Function that will be called just before a reconnection attempt.
|
||||
:param no_resend_on_retry: (optional) Do not resend request when retrying due to missing response.
|
||||
:param kwargs: (optional) Experimental parameters.
|
||||
|
||||
.. tip::
|
||||
Common parameters and all external methods for all clients are documented here,
|
||||
and not repeated with each client.
|
||||
|
||||
.. tip::
|
||||
**reconnect_delay** doubles automatically with each unsuccessful connect, from
|
||||
**reconnect_delay** to **reconnect_delay_max**.
|
||||
Set `reconnect_delay=0` to avoid automatic reconnection.
|
||||
|
||||
:mod:`ModbusBaseClient` is normally not referenced outside :mod:`pymodbus`.
|
||||
|
||||
**Application methods, common to all clients**:
|
||||
"""
|
||||
|
||||
@dataclass
|
||||
class _params:
|
||||
"""Parameter class."""
|
||||
|
||||
retries: int = None
|
||||
retry_on_empty: bool = None
|
||||
close_comm_on_error: bool = None
|
||||
strict: bool = None
|
||||
broadcast_enable: bool = None
|
||||
reconnect_delay: int = None
|
||||
|
||||
source_address: tuple[str, int] = None
|
||||
|
||||
server_hostname: str = None
|
||||
|
||||
def __init__( # pylint: disable=too-many-arguments
|
||||
self,
|
||||
framer: type[ModbusFramer] = None,
|
||||
timeout: float = 3,
|
||||
retries: int = 3,
|
||||
retry_on_empty: bool = False,
|
||||
close_comm_on_error: bool = False,
|
||||
strict: bool = True,
|
||||
broadcast_enable: bool = False,
|
||||
reconnect_delay: float = 0.1,
|
||||
reconnect_delay_max: float = 300,
|
||||
on_reconnect_callback: Callable[[], None] | None = None,
|
||||
no_resend_on_retry: bool = False,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Initialize a client instance."""
|
||||
ModbusClientMixin.__init__(self)
|
||||
self.use_sync = kwargs.get("use_sync", False)
|
||||
setup_params = CommParams(
|
||||
comm_type=kwargs.get("CommType"),
|
||||
comm_name="comm",
|
||||
source_address=kwargs.get("source_address", ("0.0.0.0", 0)),
|
||||
reconnect_delay=reconnect_delay,
|
||||
reconnect_delay_max=reconnect_delay_max,
|
||||
timeout_connect=timeout,
|
||||
host=kwargs.get("host", None),
|
||||
port=kwargs.get("port", 0),
|
||||
sslctx=kwargs.get("sslctx", None),
|
||||
baudrate=kwargs.get("baudrate", None),
|
||||
bytesize=kwargs.get("bytesize", None),
|
||||
parity=kwargs.get("parity", None),
|
||||
stopbits=kwargs.get("stopbits", None),
|
||||
handle_local_echo=kwargs.get("handle_local_echo", False),
|
||||
)
|
||||
if not self.use_sync:
|
||||
ModbusProtocol.__init__(
|
||||
self,
|
||||
setup_params,
|
||||
False,
|
||||
)
|
||||
else:
|
||||
self.comm_params = setup_params
|
||||
self.params = self._params()
|
||||
self.params.retries = int(retries)
|
||||
self.params.retry_on_empty = bool(retry_on_empty)
|
||||
self.params.close_comm_on_error = bool(close_comm_on_error)
|
||||
self.params.strict = bool(strict)
|
||||
self.params.broadcast_enable = bool(broadcast_enable)
|
||||
self.on_reconnect_callback = on_reconnect_callback
|
||||
self.retry_on_empty: int = 0
|
||||
self.no_resend_on_retry = no_resend_on_retry
|
||||
self.slaves: list[int] = []
|
||||
|
||||
# Common variables.
|
||||
self.framer = framer(ClientDecoder(), self)
|
||||
self.transaction = DictTransactionManager(
|
||||
self, retries=retries, retry_on_empty=retry_on_empty, **kwargs
|
||||
)
|
||||
self.reconnect_delay_current = self.params.reconnect_delay
|
||||
self.use_udp = False
|
||||
self.state = ModbusTransactionState.IDLE
|
||||
self.last_frame_end: float = 0
|
||||
self.silent_interval: float = 0
|
||||
|
||||
# ----------------------------------------------------------------------- #
|
||||
# Client external interface
|
||||
# ----------------------------------------------------------------------- #
|
||||
@property
|
||||
def connected(self):
|
||||
"""Connect internal."""
|
||||
return True
|
||||
|
||||
def register(self, custom_response_class: ModbusResponse) -> None:
|
||||
"""Register a custom response class with the decoder (call **sync**).
|
||||
|
||||
:param custom_response_class: (optional) Modbus response class.
|
||||
:raises MessageRegisterException: Check exception text.
|
||||
|
||||
Use register() to add non-standard responses (like e.g. a login prompt) and
|
||||
have them interpreted automatically.
|
||||
"""
|
||||
self.framer.decoder.register(custom_response_class)
|
||||
|
||||
def close(self, reconnect=False) -> None:
|
||||
"""Close connection."""
|
||||
if reconnect:
|
||||
self.connection_lost(asyncio.TimeoutError("Server not responding"))
|
||||
else:
|
||||
self.transport_close()
|
||||
|
||||
def idle_time(self) -> float:
|
||||
"""Time before initiating next transaction (call **sync**).
|
||||
|
||||
Applications can call message functions without checking idle_time(),
|
||||
this is done automatically.
|
||||
"""
|
||||
if self.last_frame_end is None or self.silent_interval is None:
|
||||
return 0
|
||||
return self.last_frame_end + self.silent_interval
|
||||
|
||||
def execute(self, request: ModbusRequest = None) -> ModbusResponse:
|
||||
"""Execute request and get response (call **sync/async**).
|
||||
|
||||
:param request: The request to process
|
||||
:returns: The result of the request execution
|
||||
:raises ConnectionException: Check exception text.
|
||||
"""
|
||||
if self.use_sync:
|
||||
if not self.connect():
|
||||
raise ConnectionException(f"Failed to connect[{self!s}]")
|
||||
return self.transaction.execute(request)
|
||||
if not self.transport:
|
||||
raise ConnectionException(f"Not connected[{self!s}]")
|
||||
return self.async_execute(request)
|
||||
|
||||
# ----------------------------------------------------------------------- #
|
||||
# Merged client methods
|
||||
# ----------------------------------------------------------------------- #
|
||||
async def async_execute(self, request=None):
|
||||
"""Execute requests asynchronously."""
|
||||
request.transaction_id = self.transaction.getNextTID()
|
||||
packet = self.framer.buildPacket(request)
|
||||
|
||||
count = 0
|
||||
while count <= self.params.retries:
|
||||
if not count or not self.no_resend_on_retry:
|
||||
self.transport_send(packet)
|
||||
if self.params.broadcast_enable and not request.slave_id:
|
||||
resp = b"Broadcast write sent - no response expected"
|
||||
break
|
||||
try:
|
||||
req = self._build_response(request.transaction_id)
|
||||
resp = await asyncio.wait_for(
|
||||
req, timeout=self.comm_params.timeout_connect
|
||||
)
|
||||
break
|
||||
except asyncio.exceptions.TimeoutError:
|
||||
count += 1
|
||||
if count > self.params.retries:
|
||||
self.close(reconnect=True)
|
||||
raise ModbusIOException(
|
||||
f"ERROR: No response received after {self.params.retries} retries"
|
||||
)
|
||||
|
||||
return resp
|
||||
|
||||
def callback_data(self, data: bytes, addr: tuple = None) -> int:
|
||||
"""Handle received data
|
||||
|
||||
returns number of bytes consumed
|
||||
"""
|
||||
self.framer.processIncomingPacket(data, self._handle_response, slave=0)
|
||||
return len(data)
|
||||
|
||||
def callback_disconnected(self, _reason: Exception) -> None:
|
||||
"""Handle lost connection"""
|
||||
for tid in list(self.transaction):
|
||||
self.raise_future(
|
||||
self.transaction.getTransaction(tid),
|
||||
ConnectionException("Connection lost during request"),
|
||||
)
|
||||
|
||||
async def connect(self):
|
||||
"""Connect to the modbus remote host."""
|
||||
|
||||
def raise_future(self, my_future, exc):
|
||||
"""Set exception of a future if not done."""
|
||||
if not my_future.done():
|
||||
my_future.set_exception(exc)
|
||||
|
||||
def _handle_response(self, reply, **_kwargs):
|
||||
"""Handle the processed response and link to correct deferred."""
|
||||
if reply is not None:
|
||||
tid = reply.transaction_id
|
||||
if handler := self.transaction.getTransaction(tid):
|
||||
if not handler.done():
|
||||
handler.set_result(reply)
|
||||
else:
|
||||
Log.debug("Unrequested message: {}", reply, ":str")
|
||||
|
||||
def _build_response(self, tid):
|
||||
"""Return a deferred response for the current request."""
|
||||
my_future = asyncio.Future()
|
||||
if not self.transport:
|
||||
self.raise_future(my_future, ConnectionException("Client is not connected"))
|
||||
else:
|
||||
self.transaction.addTransaction(my_future, tid)
|
||||
return my_future
|
||||
|
||||
# ----------------------------------------------------------------------- #
|
||||
# Internal methods
|
||||
# ----------------------------------------------------------------------- #
|
||||
def send(self, request):
|
||||
"""Send request.
|
||||
|
||||
:meta private:
|
||||
"""
|
||||
if self.state != ModbusTransactionState.RETRYING:
|
||||
Log.debug('New Transaction state "SENDING"')
|
||||
self.state = ModbusTransactionState.SENDING
|
||||
return request
|
||||
|
||||
def recv(self, size):
|
||||
"""Receive data.
|
||||
|
||||
:meta private:
|
||||
"""
|
||||
return size
|
||||
|
||||
@classmethod
|
||||
def _get_address_family(cls, address):
|
||||
"""Get the correct address family."""
|
||||
try:
|
||||
_ = socket.inet_pton(socket.AF_INET6, address)
|
||||
except OSError: # not a valid ipv6 address
|
||||
return socket.AF_INET
|
||||
return socket.AF_INET6
|
||||
|
||||
# ----------------------------------------------------------------------- #
|
||||
# The magic methods
|
||||
# ----------------------------------------------------------------------- #
|
||||
def __enter__(self):
|
||||
"""Implement the client with enter block.
|
||||
|
||||
:returns: The current instance of the client
|
||||
:raises ConnectionException:
|
||||
"""
|
||||
|
||||
if not self.connect():
|
||||
raise ConnectionException(f"Failed to connect[{self.__str__()}]")
|
||||
return self
|
||||
|
||||
async def __aenter__(self):
|
||||
"""Implement the client with enter block.
|
||||
|
||||
:returns: The current instance of the client
|
||||
:raises ConnectionException:
|
||||
"""
|
||||
if not await self.connect():
|
||||
raise ConnectionException(f"Failed to connect[{self.__str__()}]")
|
||||
return self
|
||||
|
||||
def __exit__(self, klass, value, traceback):
|
||||
"""Implement the client with exit block."""
|
||||
self.close()
|
||||
|
||||
async def __aexit__(self, klass, value, traceback):
|
||||
"""Implement the client with exit block."""
|
||||
self.close()
|
||||
|
||||
def __str__(self):
|
||||
"""Build a string representation of the connection.
|
||||
|
||||
:returns: The string representation
|
||||
"""
|
||||
return (
|
||||
f"{self.__class__.__name__} {self.comm_params.host}:{self.comm_params.port}"
|
||||
)
|
||||
@@ -0,0 +1,582 @@
|
||||
"""Modbus Client Common."""
|
||||
import struct
|
||||
from enum import Enum
|
||||
from typing import Any, List, Tuple, Union
|
||||
|
||||
import pymodbus.bit_read_message as pdu_bit_read
|
||||
import pymodbus.bit_write_message as pdu_bit_write
|
||||
import pymodbus.diag_message as pdu_diag
|
||||
import pymodbus.file_message as pdu_file_msg
|
||||
import pymodbus.mei_message as pdu_mei
|
||||
import pymodbus.other_message as pdu_other_msg
|
||||
import pymodbus.register_read_message as pdu_reg_read
|
||||
import pymodbus.register_write_message as pdu_req_write
|
||||
from pymodbus.constants import INTERNAL_ERROR
|
||||
from pymodbus.exceptions import ModbusException
|
||||
from pymodbus.pdu import ModbusRequest, ModbusResponse
|
||||
|
||||
|
||||
class ModbusClientMixin: # pylint: disable=too-many-public-methods
|
||||
"""**ModbusClientMixin**.
|
||||
|
||||
This is an interface class to facilitate the sending requests/receiving responses like read_coils.
|
||||
execute() allows to make a call with non-standard or user defined function codes (remember to add a PDU
|
||||
in the transport class to interpret the request/response).
|
||||
|
||||
Simple modbus message call::
|
||||
|
||||
response = client.read_coils(1, 10)
|
||||
# or
|
||||
response = await client.read_coils(1, 10)
|
||||
|
||||
Advanced modbus message call::
|
||||
|
||||
request = ReadCoilsRequest(1,10)
|
||||
response = client.execute(request)
|
||||
# or
|
||||
request = ReadCoilsRequest(1,10)
|
||||
response = await client.execute(request)
|
||||
|
||||
.. tip::
|
||||
All methods can be used directly (synchronous) or
|
||||
with await <method> (asynchronous) depending on the client used.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize."""
|
||||
|
||||
def execute(self, request: ModbusRequest) -> ModbusResponse:
|
||||
"""Execute request (code ???).
|
||||
|
||||
:param request: Request to send
|
||||
:raises ModbusException:
|
||||
|
||||
Call with custom function codes.
|
||||
|
||||
.. tip::
|
||||
Response is not interpreted.
|
||||
"""
|
||||
raise ModbusException(INTERNAL_ERROR)
|
||||
|
||||
def read_coils(
|
||||
self, address: int, count: int = 1, slave: int = 0, **kwargs: Any
|
||||
) -> ModbusResponse:
|
||||
"""Read coils (code 0x01).
|
||||
|
||||
:param address: Start address to read from
|
||||
:param count: (optional) Number of coils to read
|
||||
:param slave: (optional) Modbus slave ID
|
||||
:param kwargs: (optional) Experimental parameters.
|
||||
:raises ModbusException:
|
||||
"""
|
||||
return self.execute(
|
||||
pdu_bit_read.ReadCoilsRequest(address, count, slave, **kwargs)
|
||||
)
|
||||
|
||||
def read_discrete_inputs(
|
||||
self, address: int, count: int = 1, slave: int = 0, **kwargs: Any
|
||||
) -> ModbusResponse:
|
||||
"""Read discrete inputs (code 0x02).
|
||||
|
||||
:param address: Start address to read from
|
||||
:param count: (optional) Number of coils to read
|
||||
:param slave: (optional) Modbus slave ID
|
||||
:param kwargs: (optional) Experimental parameters.
|
||||
:raises ModbusException:
|
||||
"""
|
||||
return self.execute(
|
||||
pdu_bit_read.ReadDiscreteInputsRequest(address, count, slave, **kwargs)
|
||||
)
|
||||
|
||||
def read_holding_registers(
|
||||
self, address: int, count: int = 1, slave: int = 0, **kwargs: Any
|
||||
) -> ModbusResponse:
|
||||
"""Read holding registers (code 0x03).
|
||||
|
||||
:param address: Start address to read from
|
||||
:param count: (optional) Number of coils to read
|
||||
:param slave: (optional) Modbus slave ID
|
||||
:param kwargs: (optional) Experimental parameters.
|
||||
:raises ModbusException:
|
||||
"""
|
||||
return self.execute(
|
||||
pdu_reg_read.ReadHoldingRegistersRequest(address, count, slave, **kwargs)
|
||||
)
|
||||
|
||||
def read_input_registers(
|
||||
self, address: int, count: int = 1, slave: int = 0, **kwargs: Any
|
||||
) -> ModbusResponse:
|
||||
"""Read input registers (code 0x04).
|
||||
|
||||
:param address: Start address to read from
|
||||
:param count: (optional) Number of coils to read
|
||||
:param slave: (optional) Modbus slave ID
|
||||
:param kwargs: (optional) Experimental parameters.
|
||||
:raises ModbusException:
|
||||
"""
|
||||
return self.execute(
|
||||
pdu_reg_read.ReadInputRegistersRequest(address, count, slave, **kwargs)
|
||||
)
|
||||
|
||||
def write_coil(
|
||||
self, address: int, value: bool, slave: int = 0, **kwargs: Any
|
||||
) -> ModbusResponse:
|
||||
"""Write single coil (code 0x05).
|
||||
|
||||
:param address: Address to write to
|
||||
:param value: Boolean to write
|
||||
:param slave: (optional) Modbus slave ID
|
||||
:param kwargs: (optional) Experimental parameters.
|
||||
:raises ModbusException:
|
||||
"""
|
||||
return self.execute(
|
||||
pdu_bit_write.WriteSingleCoilRequest(address, value, slave, **kwargs)
|
||||
)
|
||||
|
||||
def write_register(
|
||||
self, address: int, value: int, slave: int = 0, **kwargs: Any
|
||||
) -> ModbusResponse:
|
||||
"""Write register (code 0x06).
|
||||
|
||||
:param address: Address to write to
|
||||
:param value: Value to write
|
||||
:param slave: (optional) Modbus slave ID
|
||||
:param kwargs: (optional) Experimental parameters.
|
||||
:raises ModbusException:
|
||||
"""
|
||||
return self.execute(
|
||||
pdu_req_write.WriteSingleRegisterRequest(address, value, slave, **kwargs)
|
||||
)
|
||||
|
||||
def read_exception_status(self, slave: int = 0, **kwargs: Any) -> ModbusResponse:
|
||||
"""Read Exception Status (code 0x07).
|
||||
|
||||
:param slave: (optional) Modbus slave ID
|
||||
:param kwargs: (optional) Experimental parameters.
|
||||
:raises ModbusException:
|
||||
"""
|
||||
return self.execute(pdu_other_msg.ReadExceptionStatusRequest(slave, **kwargs))
|
||||
|
||||
def diag_query_data(
|
||||
self, msg: bytearray, slave: int = 0, **kwargs: Any
|
||||
) -> ModbusResponse:
|
||||
"""Diagnose query data (code 0x08 sub 0x00).
|
||||
|
||||
:param msg: Message to be returned
|
||||
:param slave: (optional) Modbus slave ID
|
||||
:param kwargs: (optional) Experimental parameters.
|
||||
:raises ModbusException:
|
||||
"""
|
||||
return self.execute(pdu_diag.ReturnQueryDataRequest(msg, slave=slave, **kwargs))
|
||||
|
||||
def diag_restart_communication(
|
||||
self, toggle: bool, slave: int = 0, **kwargs: Any
|
||||
) -> ModbusResponse:
|
||||
"""Diagnose restart communication (code 0x08 sub 0x01).
|
||||
|
||||
:param toggle: True if toggled.
|
||||
:param slave: (optional) Modbus slave ID
|
||||
:param kwargs: (optional) Experimental parameters.
|
||||
:raises ModbusException:
|
||||
"""
|
||||
return self.execute(
|
||||
pdu_diag.RestartCommunicationsOptionRequest(toggle, slave=slave, **kwargs)
|
||||
)
|
||||
|
||||
def diag_read_diagnostic_register(
|
||||
self, slave: int = 0, **kwargs: Any
|
||||
) -> ModbusResponse:
|
||||
"""Diagnose read diagnostic register (code 0x08 sub 0x02).
|
||||
|
||||
:param slave: (optional) Modbus slave ID
|
||||
:param kwargs: (optional) Experimental parameters.
|
||||
:raises ModbusException:
|
||||
"""
|
||||
return self.execute(
|
||||
pdu_diag.ReturnDiagnosticRegisterRequest(slave=slave, **kwargs)
|
||||
)
|
||||
|
||||
def diag_change_ascii_input_delimeter(
|
||||
self, slave: int = 0, **kwargs: Any
|
||||
) -> ModbusResponse:
|
||||
"""Diagnose change ASCII input delimiter (code 0x08 sub 0x03).
|
||||
|
||||
:param slave: (optional) Modbus slave ID
|
||||
:param kwargs: (optional) Experimental parameters.
|
||||
:raises ModbusException:
|
||||
"""
|
||||
return self.execute(
|
||||
pdu_diag.ChangeAsciiInputDelimiterRequest(slave=slave, **kwargs)
|
||||
)
|
||||
|
||||
def diag_force_listen_only(self, slave: int = 0, **kwargs: Any) -> ModbusResponse:
|
||||
"""Diagnose force listen only (code 0x08 sub 0x04).
|
||||
|
||||
:param slave: (optional) Modbus slave ID
|
||||
:param kwargs: (optional) Experimental parameters.
|
||||
:raises ModbusException:
|
||||
"""
|
||||
return self.execute(pdu_diag.ForceListenOnlyModeRequest(slave=slave, **kwargs))
|
||||
|
||||
def diag_clear_counters(self, slave: int = 0, **kwargs: Any) -> ModbusResponse:
|
||||
"""Diagnose clear counters (code 0x08 sub 0x0A).
|
||||
|
||||
:param slave: (optional) Modbus slave ID
|
||||
:param kwargs: (optional) Experimental parameters.
|
||||
:raises ModbusException:
|
||||
"""
|
||||
return self.execute(pdu_diag.ClearCountersRequest(slave=slave, **kwargs))
|
||||
|
||||
def diag_read_bus_message_count(
|
||||
self, slave: int = 0, **kwargs: Any
|
||||
) -> ModbusResponse:
|
||||
"""Diagnose read bus message count (code 0x08 sub 0x0B).
|
||||
|
||||
:param slave: (optional) Modbus slave ID
|
||||
:param kwargs: (optional) Experimental parameters.
|
||||
:raises ModbusException:
|
||||
"""
|
||||
return self.execute(
|
||||
pdu_diag.ReturnBusMessageCountRequest(slave=slave, **kwargs)
|
||||
)
|
||||
|
||||
def diag_read_bus_comm_error_count(
|
||||
self, slave: int = 0, **kwargs: Any
|
||||
) -> ModbusResponse:
|
||||
"""Diagnose read Bus Communication Error Count (code 0x08 sub 0x0C).
|
||||
|
||||
:param slave: (optional) Modbus slave ID
|
||||
:param kwargs: (optional) Experimental parameters.
|
||||
:raises ModbusException:
|
||||
"""
|
||||
return self.execute(
|
||||
pdu_diag.ReturnBusCommunicationErrorCountRequest(slave=slave, **kwargs)
|
||||
)
|
||||
|
||||
def diag_read_bus_exception_error_count(
|
||||
self, slave: int = 0, **kwargs: Any
|
||||
) -> ModbusResponse:
|
||||
"""Diagnose read Bus Exception Error Count (code 0x08 sub 0x0D).
|
||||
|
||||
:param slave: (optional) Modbus slave ID
|
||||
:param kwargs: (optional) Experimental parameters.
|
||||
:raises ModbusException:
|
||||
"""
|
||||
return self.execute(
|
||||
pdu_diag.ReturnBusExceptionErrorCountRequest(slave=slave, **kwargs)
|
||||
)
|
||||
|
||||
def diag_read_slave_message_count(
|
||||
self, slave: int = 0, **kwargs: Any
|
||||
) -> ModbusResponse:
|
||||
"""Diagnose read Slave Message Count (code 0x08 sub 0x0E).
|
||||
|
||||
:param slave: (optional) Modbus slave ID
|
||||
:param kwargs: (optional) Experimental parameters.
|
||||
:raises ModbusException:
|
||||
"""
|
||||
return self.execute(
|
||||
pdu_diag.ReturnSlaveMessageCountRequest(slave=slave, **kwargs)
|
||||
)
|
||||
|
||||
def diag_read_slave_no_response_count(
|
||||
self, slave: int = 0, **kwargs: Any
|
||||
) -> ModbusResponse:
|
||||
"""Diagnose read Slave No Response Count (code 0x08 sub 0x0F).
|
||||
|
||||
:param slave: (optional) Modbus slave ID
|
||||
:param kwargs: (optional) Experimental parameters.
|
||||
:raises ModbusException:
|
||||
"""
|
||||
return self.execute(
|
||||
pdu_diag.ReturnSlaveNoResponseCountRequest(slave=slave, **kwargs)
|
||||
)
|
||||
|
||||
def diag_read_slave_nak_count(
|
||||
self, slave: int = 0, **kwargs: Any
|
||||
) -> ModbusResponse:
|
||||
"""Diagnose read Slave NAK Count (code 0x08 sub 0x10).
|
||||
|
||||
:param slave: (optional) Modbus slave ID
|
||||
:param kwargs: (optional) Experimental parameters.
|
||||
:raises ModbusException:
|
||||
"""
|
||||
return self.execute(pdu_diag.ReturnSlaveNAKCountRequest(slave=slave, **kwargs))
|
||||
|
||||
def diag_read_slave_busy_count(
|
||||
self, slave: int = 0, **kwargs: Any
|
||||
) -> ModbusResponse:
|
||||
"""Diagnose read Slave Busy Count (code 0x08 sub 0x11).
|
||||
|
||||
:param slave: (optional) Modbus slave ID
|
||||
:param kwargs: (optional) Experimental parameters.
|
||||
:raises ModbusException:
|
||||
"""
|
||||
return self.execute(pdu_diag.ReturnSlaveBusyCountRequest(slave=slave, **kwargs))
|
||||
|
||||
def diag_read_bus_char_overrun_count(
|
||||
self, slave: int = 0, **kwargs: Any
|
||||
) -> ModbusResponse:
|
||||
"""Diagnose read Bus Character Overrun Count (code 0x08 sub 0x12).
|
||||
|
||||
:param slave: (optional) Modbus slave ID
|
||||
:param kwargs: (optional) Experimental parameters.
|
||||
:raises ModbusException:
|
||||
"""
|
||||
return self.execute(
|
||||
pdu_diag.ReturnSlaveBusCharacterOverrunCountRequest(slave=slave, **kwargs)
|
||||
)
|
||||
|
||||
def diag_read_iop_overrun_count(
|
||||
self, slave: int = 0, **kwargs: Any
|
||||
) -> ModbusResponse:
|
||||
"""Diagnose read Iop overrun count (code 0x08 sub 0x13).
|
||||
|
||||
:param slave: (optional) Modbus slave ID
|
||||
:param kwargs: (optional) Experimental parameters.
|
||||
:raises ModbusException:
|
||||
"""
|
||||
return self.execute(
|
||||
pdu_diag.ReturnIopOverrunCountRequest(slave=slave, **kwargs)
|
||||
)
|
||||
|
||||
def diag_clear_overrun_counter(
|
||||
self, slave: int = 0, **kwargs: Any
|
||||
) -> ModbusResponse:
|
||||
"""Diagnose Clear Overrun Counter and Flag (code 0x08 sub 0x14).
|
||||
|
||||
:param slave: (optional) Modbus slave ID
|
||||
:param kwargs: (optional) Experimental parameters.
|
||||
:raises ModbusException:
|
||||
"""
|
||||
return self.execute(pdu_diag.ClearOverrunCountRequest(slave=slave, **kwargs))
|
||||
|
||||
def diag_getclear_modbus_response(
|
||||
self, slave: int = 0, **kwargs: Any
|
||||
) -> ModbusResponse:
|
||||
"""Diagnose Get/Clear modbus plus (code 0x08 sub 0x15).
|
||||
|
||||
:param slave: (optional) Modbus slave ID
|
||||
:param kwargs: (optional) Experimental parameters.
|
||||
:raises ModbusException:
|
||||
"""
|
||||
return self.execute(pdu_diag.GetClearModbusPlusRequest(slave=slave, **kwargs))
|
||||
|
||||
def diag_get_comm_event_counter(self, **kwargs: Any) -> ModbusResponse:
|
||||
"""Diagnose get event counter (code 0x0B).
|
||||
|
||||
:param kwargs: (optional) Experimental parameters.
|
||||
:raises ModbusException:
|
||||
"""
|
||||
return self.execute(pdu_other_msg.GetCommEventCounterRequest(**kwargs))
|
||||
|
||||
def diag_get_comm_event_log(self, **kwargs: Any) -> ModbusResponse:
|
||||
"""Diagnose get event counter (code 0x0C).
|
||||
|
||||
:param kwargs: (optional) Experimental parameters.
|
||||
:raises ModbusException:
|
||||
"""
|
||||
return self.execute(pdu_other_msg.GetCommEventLogRequest(**kwargs))
|
||||
|
||||
def write_coils(
|
||||
self,
|
||||
address: int,
|
||||
values: Union[List[bool], bool],
|
||||
slave: int = 0,
|
||||
**kwargs: Any,
|
||||
) -> ModbusResponse:
|
||||
"""Write coils (code 0x0F).
|
||||
|
||||
:param address: Start address to write to
|
||||
:param values: List of booleans to write, or a single boolean to write
|
||||
:param slave: (optional) Modbus slave ID
|
||||
:param kwargs: (optional) Experimental parameters.
|
||||
:raises ModbusException:
|
||||
"""
|
||||
return self.execute(
|
||||
pdu_bit_write.WriteMultipleCoilsRequest(address, values, slave, **kwargs)
|
||||
)
|
||||
|
||||
def write_registers(
|
||||
self, address: int, values: Union[List[int], int], slave: int = 0, **kwargs: Any
|
||||
) -> ModbusResponse:
|
||||
"""Write registers (code 0x10).
|
||||
|
||||
:param address: Start address to write to
|
||||
:param values: List of values to write, or a single value to write
|
||||
:param slave: (optional) Modbus slave ID
|
||||
:param kwargs: (optional) Experimental parameters.
|
||||
:raises ModbusException:
|
||||
"""
|
||||
return self.execute(
|
||||
pdu_req_write.WriteMultipleRegistersRequest(
|
||||
address, values, slave, **kwargs
|
||||
)
|
||||
)
|
||||
|
||||
def report_slave_id(self, slave: int = 0, **kwargs: Any) -> ModbusResponse:
|
||||
"""Report slave ID (code 0x11).
|
||||
|
||||
:param slave: (optional) Modbus slave ID
|
||||
:param kwargs: (optional) Experimental parameters.
|
||||
:raises ModbusException:
|
||||
"""
|
||||
return self.execute(pdu_other_msg.ReportSlaveIdRequest(slave, **kwargs))
|
||||
|
||||
def read_file_record(self, records: List[Tuple], **kwargs: Any) -> ModbusResponse:
|
||||
"""Read file record (code 0x14).
|
||||
|
||||
:param records: List of (Reference type, File number, Record Number, Record Length)
|
||||
:param kwargs: (optional) Experimental parameters.
|
||||
:raises ModbusException:
|
||||
"""
|
||||
return self.execute(pdu_file_msg.ReadFileRecordRequest(records, **kwargs))
|
||||
|
||||
def write_file_record(self, records: List[Tuple], **kwargs: Any) -> ModbusResponse:
|
||||
"""Write file record (code 0x15).
|
||||
|
||||
:param records: List of (Reference type, File number, Record Number, Record Length)
|
||||
:param kwargs: (optional) Experimental parameters.
|
||||
:raises ModbusException:
|
||||
"""
|
||||
return self.execute(pdu_file_msg.WriteFileRecordRequest(records, **kwargs))
|
||||
|
||||
def mask_write_register(
|
||||
self,
|
||||
address: int = 0x0000,
|
||||
and_mask: int = 0xFFFF,
|
||||
or_mask: int = 0x0000,
|
||||
**kwargs: Any,
|
||||
) -> ModbusResponse:
|
||||
"""Mask write register (code 0x16).
|
||||
|
||||
:param address: The mask pointer address (0x0000 to 0xffff)
|
||||
:param and_mask: The and bitmask to apply to the register address
|
||||
:param or_mask: The or bitmask to apply to the register address
|
||||
:param kwargs: (optional) Experimental parameters.
|
||||
:raises ModbusException:
|
||||
"""
|
||||
return self.execute(
|
||||
pdu_req_write.MaskWriteRegisterRequest(address, and_mask, or_mask, **kwargs)
|
||||
)
|
||||
|
||||
def readwrite_registers(
|
||||
self,
|
||||
read_address: int = 0,
|
||||
read_count: int = 0,
|
||||
write_address: int = 0,
|
||||
values: Union[List[int], int] = 0,
|
||||
slave: int = 0,
|
||||
**kwargs,
|
||||
) -> ModbusResponse:
|
||||
"""Read/Write registers (code 0x17).
|
||||
|
||||
:param read_address: The address to start reading from
|
||||
:param read_count: The number of registers to read from address
|
||||
:param write_address: The address to start writing to
|
||||
:param values: List of values to write, or a single value to write
|
||||
:param slave: (optional) Modbus slave ID
|
||||
:param kwargs:
|
||||
:raises ModbusException:
|
||||
"""
|
||||
return self.execute(
|
||||
pdu_reg_read.ReadWriteMultipleRegistersRequest(
|
||||
read_address=read_address,
|
||||
read_count=read_count,
|
||||
write_address=write_address,
|
||||
write_registers=values,
|
||||
slave=slave,
|
||||
**kwargs,
|
||||
)
|
||||
)
|
||||
|
||||
def read_fifo_queue(self, address: int = 0x0000, **kwargs: Any) -> ModbusResponse:
|
||||
"""Read FIFO queue (code 0x18).
|
||||
|
||||
:param address: The address to start reading from
|
||||
:param kwargs:
|
||||
:raises ModbusException:
|
||||
"""
|
||||
return self.execute(pdu_file_msg.ReadFifoQueueRequest(address, **kwargs))
|
||||
|
||||
# code 0x2B sub 0x0D: CANopen General Reference Request and Response, NOT IMPLEMENTED
|
||||
|
||||
def read_device_information(
|
||||
self, read_code: int = None, object_id: int = 0x00, **kwargs: Any
|
||||
) -> ModbusResponse:
|
||||
"""Read FIFO queue (code 0x2B sub 0x0E).
|
||||
|
||||
:param read_code: The device information read code
|
||||
:param object_id: The object to read from
|
||||
:param kwargs:
|
||||
:raises ModbusException:
|
||||
"""
|
||||
return self.execute(
|
||||
pdu_mei.ReadDeviceInformationRequest(read_code, object_id, **kwargs)
|
||||
)
|
||||
|
||||
# ------------------
|
||||
# Converter methods
|
||||
# ------------------
|
||||
|
||||
class DATATYPE(Enum):
|
||||
"""Datatype enum (name and number of bytes), used for convert_* calls."""
|
||||
|
||||
INT16 = ("h", 1)
|
||||
UINT16 = ("H", 1)
|
||||
INT32 = ("i", 2)
|
||||
UINT32 = ("I", 2)
|
||||
INT64 = ("q", 4)
|
||||
UINT64 = ("Q", 4)
|
||||
FLOAT32 = ("f", 2)
|
||||
FLOAT64 = ("d", 4)
|
||||
STRING = ("s", 0)
|
||||
|
||||
@classmethod
|
||||
def convert_from_registers(
|
||||
cls, registers: List[int], data_type: DATATYPE
|
||||
) -> Union[int, float, str]:
|
||||
"""Convert registers to int/float/str.
|
||||
|
||||
:param registers: list of registers received from e.g. read_holding_registers()
|
||||
:param data_type: data type to convert to
|
||||
:returns: int, float or str depending on "data_type"
|
||||
:raises ModbusException: when size of registers is not 1, 2 or 4
|
||||
"""
|
||||
byte_list = bytearray()
|
||||
for x in registers:
|
||||
byte_list.extend(int.to_bytes(x, 2, "big"))
|
||||
if data_type == cls.DATATYPE.STRING:
|
||||
if byte_list[-1:] == b"\00":
|
||||
byte_list = byte_list[:-1]
|
||||
return byte_list.decode("utf-8")
|
||||
if len(registers) != data_type.value[1]:
|
||||
raise ModbusException(
|
||||
f"Illegal size ({len(registers)}) of register array, cannot convert!"
|
||||
)
|
||||
return struct.unpack(f">{data_type.value[0]}", byte_list)[0]
|
||||
|
||||
@classmethod
|
||||
def convert_to_registers(
|
||||
cls, value: Union[int, float, str], data_type: DATATYPE
|
||||
) -> List[int]:
|
||||
"""Convert int/float/str to registers (16/32/64 bit).
|
||||
|
||||
:param value: value to be converted
|
||||
:param data_type: data type to be encoded as registers
|
||||
:returns: List of registers, can be used directly in e.g. write_registers()
|
||||
:raises TypeError: when there is a mismatch between data_type and value
|
||||
"""
|
||||
if data_type == cls.DATATYPE.STRING:
|
||||
if not isinstance(value, str):
|
||||
raise TypeError(f"Value should be string but is {type(value)}.")
|
||||
byte_list = value.encode()
|
||||
if len(byte_list) % 2:
|
||||
byte_list += b"\x00"
|
||||
else:
|
||||
byte_list = struct.pack(f">{data_type.value[0]}", value)
|
||||
regs = [
|
||||
int.from_bytes(byte_list[x : x + 2], "big")
|
||||
for x in range(0, len(byte_list), 2)
|
||||
]
|
||||
return regs
|
||||
@@ -0,0 +1,291 @@
|
||||
"""Modbus client async serial communication."""
|
||||
import asyncio
|
||||
import time
|
||||
from contextlib import suppress
|
||||
from functools import partial
|
||||
from typing import Any, Type
|
||||
|
||||
from pymodbus.client.base import ModbusBaseClient
|
||||
from pymodbus.exceptions import ConnectionException
|
||||
from pymodbus.framer import ModbusFramer
|
||||
from pymodbus.framer.rtu_framer import ModbusRtuFramer
|
||||
from pymodbus.logging import Log
|
||||
from pymodbus.transport import CommType
|
||||
from pymodbus.utilities import ModbusTransactionState
|
||||
|
||||
|
||||
with suppress(ImportError):
|
||||
import serial
|
||||
|
||||
|
||||
class AsyncModbusSerialClient(ModbusBaseClient, asyncio.Protocol):
|
||||
"""**AsyncModbusSerialClient**.
|
||||
|
||||
:param port: Serial port used for communication.
|
||||
:param framer: (optional) Framer class.
|
||||
:param baudrate: (optional) Bits per second.
|
||||
:param bytesize: (optional) Number of bits per byte 7-8.
|
||||
:param parity: (optional) 'E'ven, 'O'dd or 'N'one
|
||||
:param stopbits: (optional) Number of stop bits 0-2¡.
|
||||
:param handle_local_echo: (optional) Discard local echo from dongle.
|
||||
:param kwargs: (optional) Experimental parameters
|
||||
|
||||
The serial communication is RS-485 based, and usually used with a usb RS485 dongle.
|
||||
|
||||
Example::
|
||||
|
||||
from pymodbus.client import AsyncModbusSerialClient
|
||||
|
||||
async def run():
|
||||
client = AsyncModbusSerialClient("dev/serial0")
|
||||
|
||||
await client.connect()
|
||||
...
|
||||
client.close()
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
port: str,
|
||||
framer: Type[ModbusFramer] = ModbusRtuFramer,
|
||||
baudrate: int = 19200,
|
||||
bytesize: int = 8,
|
||||
parity: str = "N",
|
||||
stopbits: int = 1,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Initialize Asyncio Modbus Serial Client."""
|
||||
asyncio.Protocol.__init__(self)
|
||||
ModbusBaseClient.__init__(
|
||||
self,
|
||||
framer=framer,
|
||||
CommType=CommType.SERIAL,
|
||||
host=port,
|
||||
baudrate=baudrate,
|
||||
bytesize=bytesize,
|
||||
parity=parity,
|
||||
stopbits=stopbits,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@property
|
||||
def connected(self):
|
||||
"""Connect internal."""
|
||||
return self.is_active()
|
||||
|
||||
async def connect(self) -> bool:
|
||||
"""Connect Async client."""
|
||||
self.reset_delay()
|
||||
Log.debug("Connecting to {}.", self.comm_params.host)
|
||||
return await self.transport_connect()
|
||||
|
||||
|
||||
class ModbusSerialClient(ModbusBaseClient):
|
||||
"""**ModbusSerialClient**.
|
||||
|
||||
:param port: Serial port used for communication.
|
||||
:param framer: (optional) Framer class.
|
||||
:param baudrate: (optional) Bits per second.
|
||||
:param bytesize: (optional) Number of bits per byte 7-8.
|
||||
:param parity: (optional) 'E'ven, 'O'dd or 'N'one
|
||||
:param stopbits: (optional) Number of stop bits 0-2¡.
|
||||
:param handle_local_echo: (optional) Discard local echo from dongle.
|
||||
:param kwargs: (optional) Experimental parameters
|
||||
|
||||
The serial communication is RS-485 based, and usually used with a usb RS485 dongle.
|
||||
|
||||
Example::
|
||||
|
||||
from pymodbus.client import ModbusSerialClient
|
||||
|
||||
def run():
|
||||
client = ModbusSerialClient("dev/serial0")
|
||||
|
||||
client.connect()
|
||||
...
|
||||
client.close()
|
||||
|
||||
|
||||
Remark: There are no automatic reconnect as with AsyncModbusSerialClient
|
||||
"""
|
||||
|
||||
state = ModbusTransactionState.IDLE
|
||||
inter_char_timeout: float = 0
|
||||
silent_interval: float = 0
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
port: str,
|
||||
framer: Type[ModbusFramer] = ModbusRtuFramer,
|
||||
baudrate: int = 19200,
|
||||
bytesize: int = 8,
|
||||
parity: str = "N",
|
||||
stopbits: int = 1,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Initialize Modbus Serial Client."""
|
||||
self.transport = None
|
||||
kwargs["use_sync"] = True
|
||||
ModbusBaseClient.__init__(
|
||||
self,
|
||||
framer=framer,
|
||||
CommType=CommType.SERIAL,
|
||||
host=port,
|
||||
baudrate=baudrate,
|
||||
bytesize=bytesize,
|
||||
parity=parity,
|
||||
stopbits=stopbits,
|
||||
**kwargs,
|
||||
)
|
||||
self.socket = None
|
||||
|
||||
self.last_frame_end = None
|
||||
|
||||
self._t0 = float(1 + 8 + 2) / self.comm_params.baudrate
|
||||
|
||||
"""
|
||||
The minimum delay is 0.01s and the maximum can be set to 0.05s.
|
||||
Setting too large a setting affects efficiency.
|
||||
"""
|
||||
self._recv_interval = (
|
||||
(round((100 * self._t0), 2) + 0.01)
|
||||
if (round((100 * self._t0), 2) + 0.01) < 0.05
|
||||
else 0.05
|
||||
)
|
||||
|
||||
if self.comm_params.baudrate > 19200:
|
||||
self.silent_interval = 1.75 / 1000 # ms
|
||||
else:
|
||||
self.inter_char_timeout = 1.5 * self._t0
|
||||
self.silent_interval = 3.5 * self._t0
|
||||
self.silent_interval = round(self.silent_interval, 6)
|
||||
|
||||
@property
|
||||
def connected(self):
|
||||
"""Connect internal."""
|
||||
return self.connect()
|
||||
|
||||
def connect(self): # pylint: disable=invalid-overridden-method
|
||||
"""Connect to the modbus serial server."""
|
||||
if self.socket:
|
||||
return True
|
||||
try:
|
||||
self.socket = serial.serial_for_url(
|
||||
self.comm_params.host,
|
||||
timeout=self.comm_params.timeout_connect,
|
||||
bytesize=self.comm_params.bytesize,
|
||||
stopbits=self.comm_params.stopbits,
|
||||
baudrate=self.comm_params.baudrate,
|
||||
parity=self.comm_params.parity,
|
||||
)
|
||||
if isinstance(self.framer, ModbusRtuFramer):
|
||||
if self.params.strict:
|
||||
self.socket.interCharTimeout = self.inter_char_timeout
|
||||
self.last_frame_end = None
|
||||
except serial.SerialException as msg:
|
||||
Log.error("{}", msg)
|
||||
self.close()
|
||||
return self.socket is not None
|
||||
|
||||
def close(self): # pylint: disable=arguments-differ
|
||||
"""Close the underlying socket connection."""
|
||||
if self.socket:
|
||||
self.socket.close()
|
||||
self.socket = None
|
||||
|
||||
def _in_waiting(self):
|
||||
"""Return _in_waiting."""
|
||||
in_waiting = "in_waiting" if hasattr(self.socket, "in_waiting") else "inWaiting"
|
||||
|
||||
if in_waiting == "in_waiting":
|
||||
waitingbytes = getattr(self.socket, in_waiting)
|
||||
else:
|
||||
waitingbytes = getattr(self.socket, in_waiting)()
|
||||
return waitingbytes
|
||||
|
||||
def send(self, request):
|
||||
"""Send data on the underlying socket.
|
||||
|
||||
If receive buffer still holds some data then flush it.
|
||||
|
||||
Sleep if last send finished less than 3.5 character times ago.
|
||||
"""
|
||||
super().send(request)
|
||||
if not self.socket:
|
||||
raise ConnectionException(str(self))
|
||||
if request:
|
||||
try:
|
||||
if waitingbytes := self._in_waiting():
|
||||
result = self.socket.read(waitingbytes)
|
||||
if self.state == ModbusTransactionState.RETRYING:
|
||||
Log.debug(
|
||||
"Sending available data in recv buffer {}", result, ":hex"
|
||||
)
|
||||
return result
|
||||
Log.warning("Cleanup recv buffer before send: {}", result, ":hex")
|
||||
except NotImplementedError:
|
||||
pass
|
||||
if self.state != ModbusTransactionState.SENDING:
|
||||
Log.debug('New Transaction state "SENDING"')
|
||||
self.state = ModbusTransactionState.SENDING
|
||||
size = self.socket.write(request)
|
||||
return size
|
||||
return 0
|
||||
|
||||
def _wait_for_data(self):
|
||||
"""Wait for data."""
|
||||
size = 0
|
||||
more_data = False
|
||||
if (
|
||||
self.comm_params.timeout_connect is not None
|
||||
and self.comm_params.timeout_connect
|
||||
):
|
||||
condition = partial(
|
||||
lambda start, timeout: (time.time() - start) <= timeout,
|
||||
timeout=self.comm_params.timeout_connect,
|
||||
)
|
||||
else:
|
||||
condition = partial(lambda dummy1, dummy2: True, dummy2=None)
|
||||
start = time.time()
|
||||
while condition(start):
|
||||
available = self._in_waiting()
|
||||
if (more_data and not available) or (more_data and available == size):
|
||||
break
|
||||
if available and available != size:
|
||||
more_data = True
|
||||
size = available
|
||||
time.sleep(self._recv_interval)
|
||||
return size
|
||||
|
||||
def recv(self, size):
|
||||
"""Read data from the underlying descriptor."""
|
||||
super().recv(size)
|
||||
if not self.socket:
|
||||
raise ConnectionException(
|
||||
self.__str__() # pylint: disable=unnecessary-dunder-call
|
||||
)
|
||||
if size is None:
|
||||
size = self._wait_for_data()
|
||||
if size > self._in_waiting():
|
||||
self._wait_for_data()
|
||||
result = self.socket.read(size)
|
||||
return result
|
||||
|
||||
def is_socket_open(self):
|
||||
"""Check if socket is open."""
|
||||
if self.socket:
|
||||
if hasattr(self.socket, "is_open"):
|
||||
return self.socket.is_open
|
||||
return self.socket.isOpen()
|
||||
return False
|
||||
|
||||
def __str__(self):
|
||||
"""Build a string representation of the connection."""
|
||||
return f"ModbusSerialClient({self.framer} baud[{self.comm_params.baudrate}])"
|
||||
|
||||
def __repr__(self):
|
||||
"""Return string representation."""
|
||||
return (
|
||||
f"<{self.__class__.__name__} at {hex(id(self))} socket={self.socket}, "
|
||||
f"framer={self.framer}, timeout={self.comm_params.timeout_connect}>"
|
||||
)
|
||||
@@ -0,0 +1,275 @@
|
||||
"""Modbus client async TCP communication."""
|
||||
import asyncio
|
||||
import select
|
||||
import socket
|
||||
import time
|
||||
from typing import Any, Tuple, Type
|
||||
|
||||
from pymodbus.client.base import ModbusBaseClient
|
||||
from pymodbus.exceptions import ConnectionException
|
||||
from pymodbus.framer import ModbusFramer
|
||||
from pymodbus.framer.socket_framer import ModbusSocketFramer
|
||||
from pymodbus.logging import Log
|
||||
from pymodbus.transport import CommType
|
||||
from pymodbus.utilities import ModbusTransactionState
|
||||
|
||||
|
||||
class AsyncModbusTcpClient(ModbusBaseClient, asyncio.Protocol):
|
||||
"""**AsyncModbusTcpClient**.
|
||||
|
||||
:param host: Host IP address or host name
|
||||
:param port: (optional) Port used for communication
|
||||
:param framer: (optional) Framer class
|
||||
:param source_address: (optional) source address of client
|
||||
:param kwargs: (optional) Experimental parameters
|
||||
|
||||
Example::
|
||||
|
||||
from pymodbus.client import AsyncModbusTcpClient
|
||||
|
||||
async def run():
|
||||
client = AsyncModbusTcpClient("localhost")
|
||||
|
||||
await client.connect()
|
||||
...
|
||||
client.close()
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str,
|
||||
port: int = 502,
|
||||
framer: Type[ModbusFramer] = ModbusSocketFramer,
|
||||
source_address: Tuple[str, int] = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Initialize Asyncio Modbus TCP Client."""
|
||||
asyncio.Protocol.__init__(self)
|
||||
if "CommType" not in kwargs:
|
||||
kwargs["CommType"] = CommType.TCP
|
||||
if source_address:
|
||||
kwargs["source_address"] = source_address
|
||||
ModbusBaseClient.__init__(
|
||||
self,
|
||||
framer=framer,
|
||||
host=host,
|
||||
port=port,
|
||||
**kwargs,
|
||||
)
|
||||
self.params.source_address = source_address
|
||||
|
||||
async def connect(self) -> bool:
|
||||
"""Initiate connection to start client."""
|
||||
self.reset_delay()
|
||||
Log.debug(
|
||||
"Connecting to {}:{}.",
|
||||
self.comm_params.host,
|
||||
self.comm_params.port,
|
||||
)
|
||||
return await self.transport_connect()
|
||||
|
||||
@property
|
||||
def connected(self):
|
||||
"""Return true if connected."""
|
||||
return self.is_active()
|
||||
|
||||
|
||||
class ModbusTcpClient(ModbusBaseClient):
|
||||
"""**ModbusTcpClient**.
|
||||
|
||||
:param host: Host IP address or host name
|
||||
:param port: (optional) Port used for communication
|
||||
:param framer: (optional) Framer class
|
||||
:param source_address: (optional) source address of client
|
||||
:param kwargs: (optional) Experimental parameters
|
||||
|
||||
Example::
|
||||
|
||||
from pymodbus.client import ModbusTcpClient
|
||||
|
||||
async def run():
|
||||
client = ModbusTcpClient("localhost")
|
||||
|
||||
client.connect()
|
||||
...
|
||||
client.close()
|
||||
|
||||
Remark: There are no automatic reconnect as with AsyncModbusTcpClient
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str,
|
||||
port: int = 502,
|
||||
framer: Type[ModbusFramer] = ModbusSocketFramer,
|
||||
source_address: Tuple[str, int] = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Initialize Modbus TCP Client."""
|
||||
if "CommType" not in kwargs:
|
||||
kwargs["CommType"] = CommType.TCP
|
||||
kwargs["use_sync"] = True
|
||||
self.transport = None
|
||||
super().__init__(framer=framer, host=host, port=port, **kwargs)
|
||||
self.params.source_address = source_address
|
||||
self.socket = None
|
||||
|
||||
@property
|
||||
def connected(self):
|
||||
"""Connect internal."""
|
||||
return self.socket is not None
|
||||
|
||||
def connect(self): # pylint: disable=invalid-overridden-method
|
||||
"""Connect to the modbus tcp server."""
|
||||
if self.socket:
|
||||
return True
|
||||
try:
|
||||
self.socket = socket.create_connection(
|
||||
(self.comm_params.host, self.comm_params.port),
|
||||
timeout=self.comm_params.timeout_connect,
|
||||
source_address=self.params.source_address,
|
||||
)
|
||||
Log.debug(
|
||||
"Connection to Modbus server established. Socket {}",
|
||||
self.socket.getsockname(),
|
||||
)
|
||||
except OSError as msg:
|
||||
Log.error(
|
||||
"Connection to ({}, {}) failed: {}",
|
||||
self.comm_params.host,
|
||||
self.comm_params.port,
|
||||
msg,
|
||||
)
|
||||
self.close()
|
||||
return self.socket is not None
|
||||
|
||||
def close(self): # pylint: disable=arguments-differ
|
||||
"""Close the underlying socket connection."""
|
||||
if self.socket:
|
||||
self.socket.close()
|
||||
self.socket = None
|
||||
|
||||
def _check_read_buffer(self):
|
||||
"""Check read buffer."""
|
||||
time_ = time.time()
|
||||
end = time_ + self.comm_params.timeout_connect
|
||||
data = None
|
||||
ready = select.select([self.socket], [], [], end - time_)
|
||||
if ready[0]:
|
||||
data = self.socket.recv(1024)
|
||||
return data
|
||||
|
||||
def send(self, request):
|
||||
"""Send data on the underlying socket."""
|
||||
super().send(request)
|
||||
if not self.socket:
|
||||
raise ConnectionException(str(self))
|
||||
if self.state == ModbusTransactionState.RETRYING:
|
||||
if data := self._check_read_buffer():
|
||||
return data
|
||||
|
||||
if request:
|
||||
return self.socket.send(request)
|
||||
return 0
|
||||
|
||||
def recv(self, size):
|
||||
"""Read data from the underlying descriptor."""
|
||||
super().recv(size)
|
||||
if not self.socket:
|
||||
raise ConnectionException(str(self))
|
||||
|
||||
# socket.recv(size) waits until it gets some data from the host but
|
||||
# not necessarily the entire response that can be fragmented in
|
||||
# many packets.
|
||||
# To avoid split responses to be recognized as invalid
|
||||
# messages and to be discarded, loops socket.recv until full data
|
||||
# is received or timeout is expired.
|
||||
# If timeout expires returns the read data, also if its length is
|
||||
# less than the expected size.
|
||||
self.socket.setblocking(0)
|
||||
|
||||
timeout = self.comm_params.timeout_connect
|
||||
|
||||
# If size isn't specified read up to 4096 bytes at a time.
|
||||
if size is None:
|
||||
recv_size = 4096
|
||||
else:
|
||||
recv_size = size
|
||||
|
||||
data = []
|
||||
data_length = 0
|
||||
time_ = time.time()
|
||||
end = time_ + timeout
|
||||
while recv_size > 0:
|
||||
try:
|
||||
ready = select.select([self.socket], [], [], end - time_)
|
||||
except ValueError:
|
||||
return self._handle_abrupt_socket_close(size, data, time.time() - time_)
|
||||
if ready[0]:
|
||||
if (recv_data := self.socket.recv(recv_size)) == b"":
|
||||
return self._handle_abrupt_socket_close(
|
||||
size, data, time.time() - time_
|
||||
)
|
||||
data.append(recv_data)
|
||||
data_length += len(recv_data)
|
||||
time_ = time.time()
|
||||
|
||||
# If size isn't specified continue to read until timeout expires.
|
||||
if size:
|
||||
recv_size = size - data_length
|
||||
|
||||
# Timeout is reduced also if some data has been received in order
|
||||
# to avoid infinite loops when there isn't an expected response
|
||||
# size and the slave sends noisy data continuously.
|
||||
if time_ > end:
|
||||
break
|
||||
|
||||
return b"".join(data)
|
||||
|
||||
def _handle_abrupt_socket_close(self, size, data, duration):
|
||||
"""Handle unexpected socket close by remote end.
|
||||
|
||||
Intended to be invoked after determining that the remote end
|
||||
has unexpectedly closed the connection, to clean up and handle
|
||||
the situation appropriately.
|
||||
|
||||
:param size: The number of bytes that was attempted to read
|
||||
:param data: The actual data returned
|
||||
:param duration: Duration from the read was first attempted
|
||||
until it was determined that the remote closed the
|
||||
socket
|
||||
:return: The more than zero bytes read from the remote end
|
||||
:raises ConnectionException: If the remote end didn't send any
|
||||
data at all before closing the connection.
|
||||
"""
|
||||
self.close()
|
||||
size_txt = size if size else "unbounded read"
|
||||
readsize = f"read of {size_txt} bytes"
|
||||
msg = (
|
||||
f"{self}: Connection unexpectedly closed "
|
||||
f"{duration} seconds into {readsize}"
|
||||
)
|
||||
if data:
|
||||
result = b"".join(data)
|
||||
Log.warning(" after returning {} bytes: {} ", len(result), result)
|
||||
return result
|
||||
msg += " without response from slave before it closed connection"
|
||||
raise ConnectionException(msg)
|
||||
|
||||
def is_socket_open(self):
|
||||
"""Check if socket is open."""
|
||||
return self.socket is not None
|
||||
|
||||
def __str__(self):
|
||||
"""Build a string representation of the connection.
|
||||
|
||||
:returns: The string representation
|
||||
"""
|
||||
return f"ModbusTcpClient({self.comm_params.host}:{self.comm_params.port})"
|
||||
|
||||
def __repr__(self):
|
||||
"""Return string representation."""
|
||||
return (
|
||||
f"<{self.__class__.__name__} at {hex(id(self))} socket={self.socket}, "
|
||||
f"ipaddr={self.comm_params.host}, port={self.comm_params.port}, timeout={self.comm_params.timeout_connect}>"
|
||||
)
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Modbus client async TLS communication."""
|
||||
import socket
|
||||
import ssl
|
||||
from typing import Any, Type
|
||||
|
||||
from pymodbus.client.tcp import AsyncModbusTcpClient, ModbusTcpClient
|
||||
from pymodbus.framer import ModbusFramer
|
||||
from pymodbus.framer.tls_framer import ModbusTlsFramer
|
||||
from pymodbus.logging import Log
|
||||
from pymodbus.transport import CommParams, CommType
|
||||
|
||||
|
||||
class AsyncModbusTlsClient(AsyncModbusTcpClient):
|
||||
"""**AsyncModbusTlsClient**.
|
||||
|
||||
:param host: Host IP address or host name
|
||||
:param port: (optional) Port used for communication
|
||||
:param framer: (optional) Framer class
|
||||
:param source_address: (optional) Source address of client
|
||||
:param sslctx: (optional) SSLContext to use for TLS
|
||||
:param certfile: (optional) Cert file path for TLS server request
|
||||
:param keyfile: (optional) Key file path for TLS server request
|
||||
:param password: (optional) Password for for decrypting private key file
|
||||
:param server_hostname: (optional) Bind certificate to host
|
||||
:param kwargs: (optional) Experimental parameters
|
||||
|
||||
..tip::
|
||||
See ModbusBaseClient for common parameters.
|
||||
|
||||
Example::
|
||||
|
||||
from pymodbus.client import AsyncModbusTlsClient
|
||||
|
||||
async def run():
|
||||
client = AsyncModbusTlsClient("localhost")
|
||||
|
||||
await client.connect()
|
||||
...
|
||||
client.close()
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str,
|
||||
port: int = 802,
|
||||
framer: Type[ModbusFramer] = ModbusTlsFramer,
|
||||
sslctx: ssl.SSLContext = None,
|
||||
certfile: str = None,
|
||||
keyfile: str = None,
|
||||
password: str = None,
|
||||
server_hostname: str = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
"""Initialize Asyncio Modbus TLS Client."""
|
||||
AsyncModbusTcpClient.__init__(
|
||||
self,
|
||||
host,
|
||||
port=port,
|
||||
framer=framer,
|
||||
CommType=CommType.TLS,
|
||||
sslctx=CommParams.generate_ssl(
|
||||
False, certfile, keyfile, password, sslctx=sslctx
|
||||
),
|
||||
**kwargs,
|
||||
)
|
||||
self.params.server_hostname = server_hostname
|
||||
|
||||
async def connect(self) -> bool:
|
||||
"""Initiate connection to start client."""
|
||||
self.reset_delay()
|
||||
Log.debug(
|
||||
"Connecting to {}:{}.",
|
||||
self.comm_params.host,
|
||||
self.comm_params.port,
|
||||
)
|
||||
return await self.transport_connect()
|
||||
|
||||
|
||||
class ModbusTlsClient(ModbusTcpClient):
|
||||
"""**ModbusTlsClient**.
|
||||
|
||||
:param host: Host IP address or host name
|
||||
:param port: (optional) Port used for communication
|
||||
:param framer: (optional) Framer class
|
||||
:param source_address: (optional) Source address of client
|
||||
:param sslctx: (optional) SSLContext to use for TLS
|
||||
:param certfile: (optional) Cert file path for TLS server request
|
||||
:param keyfile: (optional) Key file path for TLS server request
|
||||
:param password: (optional) Password for decrypting private key file
|
||||
:param server_hostname: (optional) Bind certificate to host
|
||||
:param kwargs: (optional) Experimental parameters
|
||||
|
||||
..tip::
|
||||
See ModbusBaseClient for common parameters.
|
||||
|
||||
Example::
|
||||
|
||||
from pymodbus.client import ModbusTlsClient
|
||||
|
||||
async def run():
|
||||
client = ModbusTlsClient("localhost")
|
||||
|
||||
client.connect()
|
||||
...
|
||||
client.close()
|
||||
|
||||
|
||||
Remark: There are no automatic reconnect as with AsyncModbusTlsClient
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str,
|
||||
port: int = 802,
|
||||
framer: Type[ModbusFramer] = ModbusTlsFramer,
|
||||
sslctx: ssl.SSLContext = None,
|
||||
certfile: str = None,
|
||||
keyfile: str = None,
|
||||
password: str = None,
|
||||
server_hostname: str = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
"""Initialize Modbus TLS Client."""
|
||||
self.transport = None
|
||||
super().__init__(
|
||||
host, CommType=CommType.TLS, port=port, framer=framer, **kwargs
|
||||
)
|
||||
self.sslctx = CommParams.generate_ssl(
|
||||
False, certfile, keyfile, password, sslctx=sslctx
|
||||
)
|
||||
self.params.server_hostname = server_hostname
|
||||
|
||||
@property
|
||||
def connected(self):
|
||||
"""Connect internal."""
|
||||
return self.transport is not None
|
||||
|
||||
def connect(self):
|
||||
"""Connect to the modbus tls server."""
|
||||
if self.socket:
|
||||
return True
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
if self.params.source_address:
|
||||
sock.bind(self.params.source_address)
|
||||
self.socket = self.sslctx.wrap_socket(
|
||||
sock, server_side=False, server_hostname=self.comm_params.host
|
||||
)
|
||||
self.socket.settimeout(self.comm_params.timeout_connect)
|
||||
self.socket.connect((self.comm_params.host, self.comm_params.port))
|
||||
except OSError as msg:
|
||||
Log.error(
|
||||
"Connection to ({}, {}) failed: {}",
|
||||
self.comm_params.host,
|
||||
self.comm_params.port,
|
||||
msg,
|
||||
)
|
||||
self.close()
|
||||
return self.socket is not None
|
||||
|
||||
def __str__(self):
|
||||
"""Build a string representation of the connection."""
|
||||
return f"ModbusTlsClient({self.comm_params.host}:{self.comm_params.port})"
|
||||
|
||||
def __repr__(self):
|
||||
"""Return string representation."""
|
||||
return (
|
||||
f"<{self.__class__.__name__} at {hex(id(self))} socket={self.socket}, "
|
||||
f"ipaddr={self.comm_params.host}, port={self.comm_params.port}, sslctx={self.sslctx}, "
|
||||
f"timeout={self.comm_params.timeout_connect}>"
|
||||
)
|
||||
@@ -0,0 +1,190 @@
|
||||
"""Modbus client async UDP communication."""
|
||||
import asyncio
|
||||
import socket
|
||||
from typing import Any, Tuple, Type
|
||||
|
||||
from pymodbus.client.base import ModbusBaseClient
|
||||
from pymodbus.exceptions import ConnectionException
|
||||
from pymodbus.framer import ModbusFramer
|
||||
from pymodbus.framer.socket_framer import ModbusSocketFramer
|
||||
from pymodbus.logging import Log
|
||||
from pymodbus.transport import CommType
|
||||
|
||||
|
||||
DGRAM_TYPE = socket.SOCK_DGRAM
|
||||
|
||||
|
||||
class AsyncModbusUdpClient(
|
||||
ModbusBaseClient, asyncio.Protocol, asyncio.DatagramProtocol
|
||||
):
|
||||
"""**AsyncModbusUdpClient**.
|
||||
|
||||
:param host: Host IP address or host name
|
||||
:param port: (optional) Port used for communication.
|
||||
:param framer: (optional) Framer class.
|
||||
:param source_address: (optional) source address of client,
|
||||
:param kwargs: (optional) Experimental parameters
|
||||
|
||||
..tip::
|
||||
See ModbusBaseClient for common parameters.
|
||||
|
||||
Example::
|
||||
|
||||
from pymodbus.client import AsyncModbusUdpClient
|
||||
|
||||
async def run():
|
||||
client = AsyncModbusUdpClient("localhost")
|
||||
|
||||
await client.connect()
|
||||
...
|
||||
client.close()
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str,
|
||||
port: int = 502,
|
||||
framer: Type[ModbusFramer] = ModbusSocketFramer,
|
||||
source_address: Tuple[str, int] = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Initialize Asyncio Modbus UDP Client."""
|
||||
asyncio.DatagramProtocol.__init__(self)
|
||||
asyncio.Protocol.__init__(self)
|
||||
ModbusBaseClient.__init__(
|
||||
self, framer=framer, CommType=CommType.UDP, host=host, port=port, **kwargs
|
||||
)
|
||||
self.params.source_address = source_address
|
||||
|
||||
@property
|
||||
def connected(self):
|
||||
"""Return true if connected."""
|
||||
return self.is_active()
|
||||
|
||||
async def connect(self) -> bool:
|
||||
"""Start reconnecting asynchronous udp client.
|
||||
|
||||
:meta private:
|
||||
"""
|
||||
self.reset_delay()
|
||||
Log.debug(
|
||||
"Connecting to {}:{}.",
|
||||
self.comm_params.host,
|
||||
self.comm_params.port,
|
||||
)
|
||||
return await self.transport_connect()
|
||||
|
||||
|
||||
class ModbusUdpClient(ModbusBaseClient):
|
||||
"""**ModbusUdpClient**.
|
||||
|
||||
:param host: Host IP address or host name
|
||||
:param port: (optional) Port used for communication.
|
||||
:param framer: (optional) Framer class.
|
||||
:param source_address: (optional) source address of client,
|
||||
:param kwargs: (optional) Experimental parameters
|
||||
|
||||
..tip::
|
||||
See ModbusBaseClient for common parameters.
|
||||
|
||||
Example::
|
||||
|
||||
from pymodbus.client import ModbusUdpClient
|
||||
|
||||
async def run():
|
||||
client = ModbusUdpClient("localhost")
|
||||
|
||||
client.connect()
|
||||
...
|
||||
client.close()
|
||||
|
||||
Remark: There are no automatic reconnect as with AsyncModbusUdpClient
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str,
|
||||
port: int = 502,
|
||||
framer: Type[ModbusFramer] = ModbusSocketFramer,
|
||||
source_address: Tuple[str, int] = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Initialize Modbus UDP Client."""
|
||||
kwargs["use_sync"] = True
|
||||
self.transport = None
|
||||
super().__init__(
|
||||
framer=framer, port=port, host=host, CommType=CommType.UDP, **kwargs
|
||||
)
|
||||
self.params.source_address = source_address
|
||||
|
||||
self.socket = None
|
||||
|
||||
@property
|
||||
def connected(self):
|
||||
"""Connect internal."""
|
||||
return self.socket is not None
|
||||
|
||||
def connect(self): # pylint: disable=invalid-overridden-method
|
||||
"""Connect to the modbus tcp server.
|
||||
|
||||
:meta private:
|
||||
"""
|
||||
if self.socket:
|
||||
return True
|
||||
try:
|
||||
family = ModbusUdpClient._get_address_family(self.comm_params.host)
|
||||
self.socket = socket.socket(family, socket.SOCK_DGRAM)
|
||||
self.socket.settimeout(self.comm_params.timeout_connect)
|
||||
except OSError as exc:
|
||||
Log.error("Unable to create udp socket {}", exc)
|
||||
self.close()
|
||||
return self.socket is not None
|
||||
|
||||
def close(self): # pylint: disable=arguments-differ
|
||||
"""Close the underlying socket connection.
|
||||
|
||||
:meta private:
|
||||
"""
|
||||
self.socket = None
|
||||
|
||||
def send(self, request):
|
||||
"""Send data on the underlying socket.
|
||||
|
||||
:meta private:
|
||||
"""
|
||||
super().send(request)
|
||||
if not self.socket:
|
||||
raise ConnectionException(str(self))
|
||||
if request:
|
||||
return self.socket.sendto(
|
||||
request, (self.comm_params.host, self.comm_params.port)
|
||||
)
|
||||
return 0
|
||||
|
||||
def recv(self, size):
|
||||
"""Read data from the underlying descriptor.
|
||||
|
||||
:meta private:
|
||||
"""
|
||||
super().recv(size)
|
||||
if not self.socket:
|
||||
raise ConnectionException(str(self))
|
||||
return self.socket.recvfrom(size)[0]
|
||||
|
||||
def is_socket_open(self):
|
||||
"""Check if socket is open.
|
||||
|
||||
:meta private:
|
||||
"""
|
||||
return True
|
||||
|
||||
def __str__(self):
|
||||
"""Build a string representation of the connection."""
|
||||
return f"ModbusUdpClient({self.comm_params.host}:{self.comm_params.port})"
|
||||
|
||||
def __repr__(self):
|
||||
"""Return string representation."""
|
||||
return (
|
||||
f"<{self.__class__.__name__} at {hex(id(self))} socket={self.socket}, "
|
||||
f"ipaddr={self.comm_params.host}, port={self.comm_params.port}, timeout={self.comm_params.timeout_connect}>"
|
||||
)
|
||||
@@ -0,0 +1,159 @@
|
||||
"""Constants For Modbus Server/Client.
|
||||
|
||||
This is the single location for storing default
|
||||
values for the servers and clients.
|
||||
"""
|
||||
import enum
|
||||
|
||||
|
||||
INTERNAL_ERROR = "Pymodbus internal error"
|
||||
|
||||
|
||||
class ModbusStatus(int, enum.Enum):
|
||||
"""These represent various status codes in the modbus protocol.
|
||||
|
||||
.. attribute:: WAITING
|
||||
|
||||
This indicates that a modbus device is currently
|
||||
waiting for a given request to finish some running task.
|
||||
|
||||
.. attribute:: READY
|
||||
|
||||
This indicates that a modbus device is currently
|
||||
free to perform the next request task.
|
||||
|
||||
.. attribute:: ON
|
||||
|
||||
This indicates that the given modbus entity is on
|
||||
|
||||
.. attribute:: OFF
|
||||
|
||||
This indicates that the given modbus entity is off
|
||||
|
||||
.. attribute:: SLAVE_ON
|
||||
|
||||
This indicates that the given modbus slave is running
|
||||
|
||||
.. attribute:: SLAVE_OFF
|
||||
|
||||
This indicates that the given modbus slave is not running
|
||||
"""
|
||||
|
||||
WAITING = 0xFFFF
|
||||
READY = 0x0000
|
||||
ON = 0xFF00
|
||||
OFF = 0x0000
|
||||
SLAVE_ON = 0xFF
|
||||
SLAVE_OFF = 0x00
|
||||
|
||||
def __str__(self):
|
||||
"""Override to force int representation for enum members"""
|
||||
return str(int(self))
|
||||
|
||||
|
||||
class Endian(str, enum.Enum):
|
||||
"""An enumeration representing the various byte endianness.
|
||||
|
||||
.. attribute:: AUTO
|
||||
|
||||
This indicates that the byte order is chosen by the
|
||||
current native environment.
|
||||
|
||||
.. attribute:: BIG
|
||||
|
||||
This indicates that the bytes are in big endian format
|
||||
|
||||
.. attribute:: LITTLE
|
||||
|
||||
This indicates that the bytes are in little endian format
|
||||
|
||||
.. note:: I am simply borrowing the format strings from the
|
||||
python struct module for my convenience.
|
||||
"""
|
||||
|
||||
AUTO = "@"
|
||||
BIG = ">"
|
||||
LITTLE = "<"
|
||||
|
||||
def __str__(self):
|
||||
"""Override to force str representation for enum members"""
|
||||
return str.__str__(self)
|
||||
|
||||
|
||||
class ModbusPlusOperation(int, enum.Enum):
|
||||
"""Represents the type of modbus plus request.
|
||||
|
||||
.. attribute:: GET_STATISTICS
|
||||
|
||||
Operation requesting that the current modbus plus statistics
|
||||
be returned in the response.
|
||||
|
||||
.. attribute:: CLEAR_STATISTICS
|
||||
|
||||
Operation requesting that the current modbus plus statistics
|
||||
be cleared and not returned in the response.
|
||||
"""
|
||||
|
||||
GET_STATISTICS = 0x0003
|
||||
CLEAR_STATISTICS = 0x0004
|
||||
|
||||
def __str__(self):
|
||||
"""Override to force int representation for enum members"""
|
||||
return str(int(self))
|
||||
|
||||
|
||||
class DeviceInformation(int, enum.Enum):
|
||||
"""Represents what type of device information to read.
|
||||
|
||||
.. attribute:: BASIC
|
||||
|
||||
This is the basic (required) device information to be returned.
|
||||
This includes VendorName, ProductCode, and MajorMinorRevision
|
||||
code.
|
||||
|
||||
.. attribute:: REGULAR
|
||||
|
||||
In addition to basic data objects, the device provides additional
|
||||
and optional identification and description data objects. All of
|
||||
the objects of this category are defined in the standard but their
|
||||
implementation is optional.
|
||||
|
||||
.. attribute:: EXTENDED
|
||||
|
||||
In addition to regular data objects, the device provides additional
|
||||
and optional identification and description private data about the
|
||||
physical device itself. All of these data are device dependent.
|
||||
|
||||
.. attribute:: SPECIFIC
|
||||
|
||||
Request to return a single data object.
|
||||
"""
|
||||
|
||||
BASIC = 0x01
|
||||
REGULAR = 0x02
|
||||
EXTENDED = 0x03
|
||||
SPECIFIC = 0x04
|
||||
|
||||
def __str__(self):
|
||||
"""Override to force int representation for enum members"""
|
||||
return str(int(self))
|
||||
|
||||
|
||||
class MoreData(int, enum.Enum):
|
||||
"""Represents the more follows condition.
|
||||
|
||||
.. attribute:: NOTHING
|
||||
|
||||
This indicates that no more objects are going to be returned.
|
||||
|
||||
.. attribute:: KEEP_READING
|
||||
|
||||
This indicates that there are more objects to be returned.
|
||||
"""
|
||||
|
||||
NOTHING = 0x00
|
||||
KEEP_READING = 0xFF
|
||||
|
||||
def __str__(self):
|
||||
"""Override to force int representation for enum members"""
|
||||
return str(int(self))
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Datastore."""
|
||||
|
||||
__all__ = [
|
||||
"ModbusBaseSlaveContext",
|
||||
"ModbusSequentialDataBlock",
|
||||
"ModbusSparseDataBlock",
|
||||
"ModbusSlaveContext",
|
||||
"ModbusServerContext",
|
||||
"ModbusSimulatorContext",
|
||||
]
|
||||
|
||||
from pymodbus.datastore.context import (
|
||||
ModbusBaseSlaveContext,
|
||||
ModbusServerContext,
|
||||
ModbusSlaveContext,
|
||||
)
|
||||
from pymodbus.datastore.simulator import ModbusSimulatorContext
|
||||
from pymodbus.datastore.store import (
|
||||
ModbusSequentialDataBlock,
|
||||
ModbusSparseDataBlock,
|
||||
)
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,195 @@
|
||||
"""Context for datastore."""
|
||||
# pylint: disable=missing-type-doc
|
||||
from pymodbus.datastore.store import ModbusSequentialDataBlock
|
||||
from pymodbus.exceptions import NoSuchSlaveException
|
||||
from pymodbus.logging import Log
|
||||
|
||||
|
||||
class ModbusBaseSlaveContext: # pylint: disable=too-few-public-methods
|
||||
"""Interface for a modbus slave data context.
|
||||
|
||||
Derived classes must implemented the following methods:
|
||||
reset(self)
|
||||
validate(self, fx, address, count=1)
|
||||
getValues(self, fx, address, count=1)
|
||||
setValues(self, fx, address, values)
|
||||
"""
|
||||
|
||||
_fx_mapper = {2: "d", 4: "i"}
|
||||
_fx_mapper.update([(i, "h") for i in (3, 6, 16, 22, 23)])
|
||||
_fx_mapper.update([(i, "c") for i in (1, 5, 15)])
|
||||
|
||||
def decode(self, fx):
|
||||
"""Convert the function code to the datastore to.
|
||||
|
||||
:param fx: The function we are working with
|
||||
:returns: one of [d(iscretes),i(nputs),h(olding),c(oils)
|
||||
"""
|
||||
return self._fx_mapper[fx]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------#
|
||||
# Slave Contexts
|
||||
# ---------------------------------------------------------------------------#
|
||||
class ModbusSlaveContext(ModbusBaseSlaveContext):
|
||||
"""This creates a modbus data model with each data access stored in a block."""
|
||||
|
||||
def __init__(self, *_args, **kwargs):
|
||||
"""Initialize the datastores.
|
||||
|
||||
:param kwargs: Each element is a ModbusDataBlock
|
||||
|
||||
"di" - Discrete Inputs initializer
|
||||
"co" - Coils initializer
|
||||
"hr" - Holding Register initializer
|
||||
"ir" - Input Registers iniatializer
|
||||
"""
|
||||
self.store = {}
|
||||
self.store["d"] = kwargs.get("di", ModbusSequentialDataBlock.create())
|
||||
self.store["c"] = kwargs.get("co", ModbusSequentialDataBlock.create())
|
||||
self.store["i"] = kwargs.get("ir", ModbusSequentialDataBlock.create())
|
||||
self.store["h"] = kwargs.get("hr", ModbusSequentialDataBlock.create())
|
||||
self.zero_mode = kwargs.get("zero_mode", False)
|
||||
|
||||
def __str__(self):
|
||||
"""Return a string representation of the context.
|
||||
|
||||
:returns: A string representation of the context
|
||||
"""
|
||||
return "Modbus Slave Context"
|
||||
|
||||
def reset(self):
|
||||
"""Reset all the datastores to their default values."""
|
||||
for datastore in iter(self.store.values()):
|
||||
datastore.reset()
|
||||
|
||||
def validate(self, fc_as_hex, address, count=1):
|
||||
"""Validate the request to make sure it is in range.
|
||||
|
||||
:param fc_as_hex: The function we are working with
|
||||
:param address: The starting address
|
||||
:param count: The number of values to test
|
||||
:returns: True if the request in within range, False otherwise
|
||||
"""
|
||||
if not self.zero_mode:
|
||||
address += 1
|
||||
Log.debug("validate: fc-[{}] address-{}: count-{}", fc_as_hex, address, count)
|
||||
return self.store[self.decode(fc_as_hex)].validate(address, count)
|
||||
|
||||
def getValues(self, fc_as_hex, address, count=1):
|
||||
"""Get `count` values from datastore.
|
||||
|
||||
:param fc_as_hex: The function we are working with
|
||||
:param address: The starting address
|
||||
:param count: The number of values to retrieve
|
||||
:returns: The requested values from a:a+c
|
||||
"""
|
||||
if not self.zero_mode:
|
||||
address += 1
|
||||
Log.debug("getValues: fc-[{}] address-{}: count-{}", fc_as_hex, address, count)
|
||||
return self.store[self.decode(fc_as_hex)].getValues(address, count)
|
||||
|
||||
def setValues(self, fc_as_hex, address, values):
|
||||
"""Set the datastore with the supplied values.
|
||||
|
||||
:param fc_as_hex: The function we are working with
|
||||
:param address: The starting address
|
||||
:param values: The new values to be set
|
||||
"""
|
||||
if not self.zero_mode:
|
||||
address += 1
|
||||
Log.debug("setValues[{}] address-{}: count-{}", fc_as_hex, address, len(values))
|
||||
self.store[self.decode(fc_as_hex)].setValues(address, values)
|
||||
|
||||
def register(self, function_code, fc_as_hex, datablock=None):
|
||||
"""Register a datablock with the slave context.
|
||||
|
||||
:param function_code: function code (int)
|
||||
:param fc_as_hex: string representation of function code (e.g "cf" )
|
||||
:param datablock: datablock to associate with this function code
|
||||
"""
|
||||
self.store[fc_as_hex] = datablock or ModbusSequentialDataBlock.create()
|
||||
self._fx_mapper[function_code] = fc_as_hex
|
||||
|
||||
|
||||
class ModbusServerContext:
|
||||
"""This represents a master collection of slave contexts.
|
||||
|
||||
If single is set to true, it will be treated as a single
|
||||
context so every slave_id returns the same context. If single
|
||||
is set to false, it will be interpreted as a collection of
|
||||
slave contexts.
|
||||
"""
|
||||
|
||||
def __init__(self, slaves=None, single=True):
|
||||
"""Initialize a new instance of a modbus server context.
|
||||
|
||||
:param slaves: A dictionary of client contexts
|
||||
:param single: Set to true to treat this as a single context
|
||||
"""
|
||||
self.single = single
|
||||
self._slaves = slaves or {}
|
||||
if self.single:
|
||||
self._slaves = {0: self._slaves}
|
||||
|
||||
def __iter__(self):
|
||||
"""Iterate over the current collection of slave contexts.
|
||||
|
||||
:returns: An iterator over the slave contexts
|
||||
"""
|
||||
return iter(self._slaves.items())
|
||||
|
||||
def __contains__(self, slave):
|
||||
"""Check if the given slave is in this list.
|
||||
|
||||
:param slave: slave The slave to check for existence
|
||||
:returns: True if the slave exists, False otherwise
|
||||
"""
|
||||
if self.single and self._slaves:
|
||||
return True
|
||||
return slave in self._slaves
|
||||
|
||||
def __setitem__(self, slave, context):
|
||||
"""Use to set a new slave context.
|
||||
|
||||
:param slave: The slave context to set
|
||||
:param context: The new context to set for this slave
|
||||
:raises NoSuchSlaveException:
|
||||
"""
|
||||
if self.single:
|
||||
slave = 0
|
||||
if 0xF7 >= slave >= 0x00:
|
||||
self._slaves[slave] = context
|
||||
else:
|
||||
raise NoSuchSlaveException(f"slave index :{slave} out of range")
|
||||
|
||||
def __delitem__(self, slave):
|
||||
"""Use to access the slave context.
|
||||
|
||||
:param slave: The slave context to remove
|
||||
:raises NoSuchSlaveException:
|
||||
"""
|
||||
if not self.single and (0xF7 >= slave >= 0x00):
|
||||
del self._slaves[slave]
|
||||
else:
|
||||
raise NoSuchSlaveException(f"slave index: {slave} out of range")
|
||||
|
||||
def __getitem__(self, slave):
|
||||
"""Use to get access to a slave context.
|
||||
|
||||
:param slave: The slave context to get
|
||||
:returns: The requested slave context
|
||||
:raises NoSuchSlaveException:
|
||||
"""
|
||||
if self.single:
|
||||
slave = 0
|
||||
if slave in self._slaves:
|
||||
return self._slaves.get(slave)
|
||||
raise NoSuchSlaveException(
|
||||
f"slave - {slave} does not exist, or is out of range"
|
||||
)
|
||||
|
||||
def slaves(self):
|
||||
"""Define slaves."""
|
||||
# Python3 now returns keys() as iterable
|
||||
return list(self._slaves.keys())
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Remote datastore."""
|
||||
from pymodbus.datastore import ModbusBaseSlaveContext
|
||||
from pymodbus.exceptions import NotImplementedException
|
||||
from pymodbus.logging import Log
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------#
|
||||
# Context
|
||||
# ---------------------------------------------------------------------------#
|
||||
class RemoteSlaveContext(ModbusBaseSlaveContext):
|
||||
"""TODO.
|
||||
|
||||
This creates a modbus data model that connects to
|
||||
a remote device (depending on the client used)
|
||||
"""
|
||||
|
||||
def __init__(self, client, slave=None):
|
||||
"""Initialize the datastores.
|
||||
|
||||
:param client: The client to retrieve values with
|
||||
:param slave: Unit ID of the remote slave
|
||||
"""
|
||||
self._client = client
|
||||
self.slave = slave
|
||||
self.result = None
|
||||
self.__build_mapping()
|
||||
if not self.__set_callbacks:
|
||||
Log.error("Init went wrong.")
|
||||
|
||||
def reset(self):
|
||||
"""Reset all the datastores to their default values."""
|
||||
raise NotImplementedException()
|
||||
|
||||
def validate(self, _fc_as_hex, _address, _count):
|
||||
"""Validate the request to make sure it is in range.
|
||||
|
||||
:returns: True
|
||||
"""
|
||||
return True
|
||||
|
||||
def getValues(self, fc_as_hex, _address, _count=1):
|
||||
"""Get values from real call in validate"""
|
||||
if fc_as_hex in self._write_fc:
|
||||
return [0]
|
||||
group_fx = self.decode(fc_as_hex)
|
||||
func_fc = self.__get_callbacks[group_fx]
|
||||
self.result = func_fc(_address, _count)
|
||||
return self.__extract_result(self.decode(fc_as_hex), self.result)
|
||||
|
||||
def setValues(self, fc_as_hex, address, values):
|
||||
"""Set the datastore with the supplied values."""
|
||||
group_fx = self.decode(fc_as_hex)
|
||||
if fc_as_hex in self._write_fc:
|
||||
func_fc = self.__set_callbacks[f"{group_fx}{fc_as_hex}"]
|
||||
if fc_as_hex in {0x0F, 0x10}:
|
||||
self.result = func_fc(address, values)
|
||||
else:
|
||||
self.result = func_fc(address, values[0])
|
||||
if self.result.isError():
|
||||
return self.result
|
||||
return None
|
||||
|
||||
def __str__(self):
|
||||
"""Return a string representation of the context.
|
||||
|
||||
:returns: A string representation of the context
|
||||
"""
|
||||
return f"Remote Slave Context({self._client})"
|
||||
|
||||
def __build_mapping(self):
|
||||
"""Build the function code mapper."""
|
||||
kwargs = {}
|
||||
if self.slave:
|
||||
kwargs["slave"] = self.slave
|
||||
self.__get_callbacks = {
|
||||
"d": lambda a, c: self._client.read_discrete_inputs( # pylint: disable=unnecessary-lambda
|
||||
a, c, **kwargs
|
||||
),
|
||||
"c": lambda a, c: self._client.read_coils( # pylint: disable=unnecessary-lambda
|
||||
a, c, **kwargs
|
||||
),
|
||||
"h": lambda a, c: self._client.read_holding_registers( # pylint: disable=unnecessary-lambda
|
||||
a, c, **kwargs
|
||||
),
|
||||
"i": lambda a, c: self._client.read_input_registers( # pylint: disable=unnecessary-lambda
|
||||
a, c, **kwargs
|
||||
),
|
||||
}
|
||||
self.__set_callbacks = {
|
||||
"d5": lambda a, v: self._client.write_coil( # pylint: disable=unnecessary-lambda
|
||||
a, v, **kwargs
|
||||
),
|
||||
"d15": lambda a, v: self._client.write_coils( # pylint: disable=unnecessary-lambda
|
||||
a, v, **kwargs
|
||||
),
|
||||
"c5": lambda a, v: self._client.write_coil( # pylint: disable=unnecessary-lambda
|
||||
a, v, **kwargs
|
||||
),
|
||||
"c15": lambda a, v: self._client.write_coils( # pylint: disable=unnecessary-lambda
|
||||
a, v, **kwargs
|
||||
),
|
||||
"h6": lambda a, v: self._client.write_register( # pylint: disable=unnecessary-lambda
|
||||
a, v, **kwargs
|
||||
),
|
||||
"h16": lambda a, v: self._client.write_registers( # pylint: disable=unnecessary-lambda
|
||||
a, v, **kwargs
|
||||
),
|
||||
"i6": lambda a, v: self._client.write_register( # pylint: disable=unnecessary-lambda
|
||||
a, v, **kwargs
|
||||
),
|
||||
"i16": lambda a, v: self._client.write_registers( # pylint: disable=unnecessary-lambda
|
||||
a, v, **kwargs
|
||||
),
|
||||
}
|
||||
self._write_fc = (0x05, 0x06, 0x0F, 0x10)
|
||||
|
||||
def __extract_result(self, fc_as_hex, result):
|
||||
"""Extract the values out of a response.
|
||||
|
||||
TODO make this consistent (values?)
|
||||
"""
|
||||
if not result.isError():
|
||||
if fc_as_hex in {"d", "c"}:
|
||||
return result.bits
|
||||
if fc_as_hex in {"h", "i"}:
|
||||
return result.registers
|
||||
else:
|
||||
return result
|
||||
return None
|
||||
@@ -0,0 +1,785 @@
|
||||
"""Pymodbus ModbusSimulatorContext."""
|
||||
import dataclasses
|
||||
import random
|
||||
import struct
|
||||
from datetime import datetime
|
||||
from typing import Any, Callable, Dict, List
|
||||
|
||||
|
||||
WORD_SIZE = 16
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class CellType:
|
||||
"""Define single cell types"""
|
||||
|
||||
INVALID: int = 0
|
||||
BITS: int = 1
|
||||
UINT16: int = 2
|
||||
UINT32: int = 3
|
||||
FLOAT32: int = 4
|
||||
STRING: int = 5
|
||||
NEXT: int = 6
|
||||
|
||||
|
||||
@dataclasses.dataclass(repr=False, eq=False)
|
||||
class Cell:
|
||||
"""Handle a single cell."""
|
||||
|
||||
type: int = CellType.INVALID
|
||||
access: bool = False
|
||||
value: int = 0
|
||||
action: int = 0
|
||||
action_kwargs: Dict[str, Any] = None
|
||||
count_read: int = 0
|
||||
count_write: int = 0
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class Label: # pylint: disable=too-many-instance-attributes
|
||||
"""Defines all dict values.
|
||||
|
||||
:meta private:
|
||||
"""
|
||||
|
||||
action: str = "action"
|
||||
addr: str = "addr"
|
||||
any: str = "any"
|
||||
co_size: str = "co size"
|
||||
defaults: str = "defaults"
|
||||
di_size: str = "di size"
|
||||
hr_size: str = "hr size"
|
||||
increment: str = "increment"
|
||||
invalid: str = "invalid"
|
||||
ir_size: str = "ir size"
|
||||
kwargs: str = "kwargs"
|
||||
method: str = "method"
|
||||
next: str = "next"
|
||||
none: str = "none"
|
||||
random: str = "random"
|
||||
repeat: str = "repeat"
|
||||
reset: str = "reset"
|
||||
setup: str = "setup"
|
||||
shared_blocks: str = "shared blocks"
|
||||
timestamp: str = "timestamp"
|
||||
repeat_to: str = "to"
|
||||
type: str = "type"
|
||||
type_bits = "bits"
|
||||
type_exception: str = "type exception"
|
||||
type_uint16: str = "uint16"
|
||||
type_uint32: str = "uint32"
|
||||
type_float32: str = "float32"
|
||||
type_string: str = "string"
|
||||
uptime: str = "uptime"
|
||||
value: str = "value"
|
||||
write: str = "write"
|
||||
|
||||
@classmethod
|
||||
def try_get(cls, key, config_part):
|
||||
"""Check if entry is present in config."""
|
||||
if key not in config_part:
|
||||
txt = f"ERROR Configuration invalid, missing {key} in {config_part}"
|
||||
raise RuntimeError(txt)
|
||||
return config_part[key]
|
||||
|
||||
|
||||
class Setup:
|
||||
"""Setup simulator.
|
||||
|
||||
:meta private:
|
||||
"""
|
||||
|
||||
def __init__(self, runtime):
|
||||
"""Initialize."""
|
||||
self.runtime = runtime
|
||||
self.config = None
|
||||
self.config_types = {
|
||||
Label.type_bits: {
|
||||
Label.type: CellType.BITS,
|
||||
Label.next: None,
|
||||
Label.value: 0,
|
||||
Label.action: None,
|
||||
Label.method: self.handle_type_bits,
|
||||
},
|
||||
Label.type_uint16: {
|
||||
Label.type: CellType.UINT16,
|
||||
Label.next: None,
|
||||
Label.value: 0,
|
||||
Label.action: None,
|
||||
Label.method: self.handle_type_uint16,
|
||||
},
|
||||
Label.type_uint32: {
|
||||
Label.type: CellType.UINT32,
|
||||
Label.next: CellType.NEXT,
|
||||
Label.value: 0,
|
||||
Label.action: None,
|
||||
Label.method: self.handle_type_uint32,
|
||||
},
|
||||
Label.type_float32: {
|
||||
Label.type: CellType.FLOAT32,
|
||||
Label.next: CellType.NEXT,
|
||||
Label.value: 0,
|
||||
Label.action: None,
|
||||
Label.method: self.handle_type_float32,
|
||||
},
|
||||
Label.type_string: {
|
||||
Label.type: CellType.STRING,
|
||||
Label.next: CellType.NEXT,
|
||||
Label.value: 0,
|
||||
Label.action: None,
|
||||
Label.method: self.handle_type_string,
|
||||
},
|
||||
}
|
||||
|
||||
def handle_type_bits(self, start, stop, value, action, action_kwargs):
|
||||
"""Handle type bits."""
|
||||
for reg in self.runtime.registers[start:stop]:
|
||||
if reg.type != CellType.INVALID:
|
||||
raise RuntimeError(f'ERROR "{Label.type_bits}" {reg} used')
|
||||
reg.value = value
|
||||
reg.type = CellType.BITS
|
||||
reg.action = action
|
||||
reg.action_kwargs = action_kwargs
|
||||
|
||||
def handle_type_uint16(self, start, stop, value, action, action_kwargs):
|
||||
"""Handle type uint16."""
|
||||
for reg in self.runtime.registers[start:stop]:
|
||||
if reg.type != CellType.INVALID:
|
||||
raise RuntimeError(f'ERROR "{Label.type_uint16}" {reg} used')
|
||||
reg.value = value
|
||||
reg.type = CellType.UINT16
|
||||
reg.action = action
|
||||
reg.action_kwargs = action_kwargs
|
||||
|
||||
def handle_type_uint32(self, start, stop, value, action, action_kwargs):
|
||||
"""Handle type uint32."""
|
||||
regs_value = ModbusSimulatorContext.build_registers_from_value(value, True)
|
||||
for i in range(start, stop, 2):
|
||||
regs = self.runtime.registers[i : i + 2]
|
||||
if regs[0].type != CellType.INVALID or regs[1].type != CellType.INVALID:
|
||||
raise RuntimeError(f'ERROR "{Label.type_uint32}" {i},{i + 1} used')
|
||||
regs[0].value = regs_value[0]
|
||||
regs[0].type = CellType.UINT32
|
||||
regs[0].action = action
|
||||
regs[0].action_kwargs = action_kwargs
|
||||
regs[1].value = regs_value[1]
|
||||
regs[1].type = CellType.NEXT
|
||||
|
||||
def handle_type_float32(self, start, stop, value, action, action_kwargs):
|
||||
"""Handle type uint32."""
|
||||
regs_value = ModbusSimulatorContext.build_registers_from_value(value, False)
|
||||
for i in range(start, stop, 2):
|
||||
regs = self.runtime.registers[i : i + 2]
|
||||
if regs[0].type != CellType.INVALID or regs[1].type != CellType.INVALID:
|
||||
raise RuntimeError(f'ERROR "{Label.type_float32}" {i},{i + 1} used')
|
||||
regs[0].value = regs_value[0]
|
||||
regs[0].type = CellType.FLOAT32
|
||||
regs[0].action = action
|
||||
regs[0].action_kwargs = action_kwargs
|
||||
regs[1].value = regs_value[1]
|
||||
regs[1].type = CellType.NEXT
|
||||
|
||||
def handle_type_string(self, start, stop, value, action, action_kwargs):
|
||||
"""Handle type string."""
|
||||
regs = stop - start
|
||||
reg_len = regs * 2
|
||||
if len(value) > reg_len:
|
||||
raise RuntimeError(
|
||||
f'ERROR "{Label.type_string}" {start} too long "{value}"'
|
||||
)
|
||||
value = value.ljust(reg_len)
|
||||
for i in range(stop - start):
|
||||
reg = self.runtime.registers[start + i]
|
||||
if reg.type != CellType.INVALID:
|
||||
raise RuntimeError(f'ERROR "{Label.type_string}" {start + i} used')
|
||||
j = i * 2
|
||||
reg.value = int.from_bytes(bytes(value[j : j + 2], "UTF-8"), "big")
|
||||
reg.type = CellType.NEXT
|
||||
self.runtime.registers[start].type = CellType.STRING
|
||||
self.runtime.registers[start].action = action
|
||||
self.runtime.registers[start].action_kwargs = action_kwargs
|
||||
|
||||
def handle_setup_section(self):
|
||||
"""Load setup section"""
|
||||
layout = Label.try_get(Label.setup, self.config)
|
||||
self.runtime.fc_offset = {key: 0 for key in range(25)}
|
||||
size_co = Label.try_get(Label.co_size, layout)
|
||||
size_di = Label.try_get(Label.di_size, layout)
|
||||
size_hr = Label.try_get(Label.hr_size, layout)
|
||||
size_ir = Label.try_get(Label.ir_size, layout)
|
||||
if Label.try_get(Label.shared_blocks, layout):
|
||||
total_size = max(size_co, size_di, size_hr, size_ir)
|
||||
else:
|
||||
# set offset (block) for each function code
|
||||
# starting with fc = 1, 5, 15
|
||||
self.runtime.fc_offset[2] = size_co
|
||||
total_size = size_co + size_di
|
||||
self.runtime.fc_offset[4] = total_size
|
||||
total_size += size_ir
|
||||
for i in (3, 6, 16, 22, 23):
|
||||
self.runtime.fc_offset[i] = total_size
|
||||
total_size += size_hr
|
||||
first_cell = Cell()
|
||||
self.runtime.registers = [
|
||||
dataclasses.replace(first_cell) for i in range(total_size)
|
||||
]
|
||||
self.runtime.register_count = total_size
|
||||
self.runtime.type_exception = bool(Label.try_get(Label.type_exception, layout))
|
||||
defaults = Label.try_get(Label.defaults, layout)
|
||||
defaults_value = Label.try_get(Label.value, defaults)
|
||||
defaults_action = Label.try_get(Label.action, defaults)
|
||||
for key, entry in self.config_types.items():
|
||||
entry[Label.value] = Label.try_get(key, defaults_value)
|
||||
if (
|
||||
action := Label.try_get(key, defaults_action)
|
||||
) not in self.runtime.action_name_to_id:
|
||||
raise RuntimeError(f"ERROR illegal action {key} in {defaults_action}")
|
||||
entry[Label.action] = action
|
||||
del self.config[Label.setup]
|
||||
|
||||
def handle_invalid_address(self):
|
||||
"""Handle invalid address"""
|
||||
for entry in Label.try_get(Label.invalid, self.config):
|
||||
if isinstance(entry, int):
|
||||
entry = [entry, entry]
|
||||
for i in range(entry[0], entry[1] + 1):
|
||||
if i >= self.runtime.register_count:
|
||||
raise RuntimeError(
|
||||
f'Error section "{Label.invalid}" addr {entry} out of range'
|
||||
)
|
||||
reg = self.runtime.registers[i]
|
||||
reg.type = CellType.INVALID
|
||||
del self.config[Label.invalid]
|
||||
|
||||
def handle_write_allowed(self):
|
||||
"""Handle write allowed"""
|
||||
for entry in Label.try_get(Label.write, self.config):
|
||||
if isinstance(entry, int):
|
||||
entry = [entry, entry]
|
||||
for i in range(entry[0], entry[1] + 1):
|
||||
if i >= self.runtime.register_count:
|
||||
raise RuntimeError(
|
||||
f'Error section "{Label.write}" addr {entry} out of range'
|
||||
)
|
||||
reg = self.runtime.registers[i]
|
||||
if reg.type == CellType.INVALID:
|
||||
txt = f'ERROR Configuration invalid in section "write" register {i} not defined'
|
||||
raise RuntimeError(txt)
|
||||
reg.access = True
|
||||
del self.config[Label.write]
|
||||
|
||||
def handle_types(self):
|
||||
"""Handle the different types"""
|
||||
for section, type_entry in self.config_types.items():
|
||||
layout = Label.try_get(section, self.config)
|
||||
for entry in layout:
|
||||
if not isinstance(entry, dict):
|
||||
entry = {Label.addr: entry}
|
||||
regs = Label.try_get(Label.addr, entry)
|
||||
if not isinstance(regs, list):
|
||||
regs = [regs, regs]
|
||||
start = regs[0]
|
||||
if (stop := regs[1]) >= self.runtime.register_count:
|
||||
raise RuntimeError(f'Error "{section}" {start}, {stop} illegal')
|
||||
type_entry[Label.method](
|
||||
start,
|
||||
stop + 1,
|
||||
entry.get(Label.value, type_entry[Label.value]),
|
||||
self.runtime.action_name_to_id[
|
||||
entry.get(Label.action, type_entry[Label.action])
|
||||
],
|
||||
entry.get(Label.kwargs, None),
|
||||
)
|
||||
del self.config[section]
|
||||
|
||||
def handle_repeat(self):
|
||||
"""Handle repeat."""
|
||||
for entry in Label.try_get(Label.repeat, self.config):
|
||||
addr = Label.try_get(Label.addr, entry)
|
||||
copy_start = addr[0]
|
||||
copy_end = addr[1]
|
||||
copy_inx = copy_start - 1
|
||||
addr_to = Label.try_get(Label.repeat_to, entry)
|
||||
for inx in range(addr_to[0], addr_to[1] + 1):
|
||||
copy_inx = copy_start if copy_inx >= copy_end else copy_inx + 1
|
||||
if inx >= self.runtime.register_count:
|
||||
raise RuntimeError(
|
||||
f'Error section "{Label.repeat}" entry {entry} out of range'
|
||||
)
|
||||
self.runtime.registers[inx] = dataclasses.replace(
|
||||
self.runtime.registers[copy_inx]
|
||||
)
|
||||
del self.config[Label.repeat]
|
||||
|
||||
def setup(self, config, custom_actions) -> None:
|
||||
"""Load layout from dict with json structure."""
|
||||
actions = {
|
||||
Label.increment: self.runtime.action_increment,
|
||||
Label.random: self.runtime.action_random,
|
||||
Label.reset: self.runtime.action_reset,
|
||||
Label.timestamp: self.runtime.action_timestamp,
|
||||
Label.uptime: self.runtime.action_uptime,
|
||||
}
|
||||
if custom_actions:
|
||||
actions.update(custom_actions)
|
||||
self.runtime.action_name_to_id = {None: 0}
|
||||
self.runtime.action_id_to_name = [Label.none]
|
||||
self.runtime.action_methods = [None]
|
||||
i = 1
|
||||
for key, method in actions.items():
|
||||
self.runtime.action_name_to_id[key] = i
|
||||
self.runtime.action_id_to_name.append(key)
|
||||
self.runtime.action_methods.append(method)
|
||||
i += 1
|
||||
self.runtime.registerType_name_to_id = {
|
||||
Label.type_bits: CellType.BITS,
|
||||
Label.type_uint16: CellType.UINT16,
|
||||
Label.type_uint32: CellType.UINT32,
|
||||
Label.type_float32: CellType.FLOAT32,
|
||||
Label.type_string: CellType.STRING,
|
||||
Label.next: CellType.NEXT,
|
||||
Label.invalid: CellType.INVALID,
|
||||
}
|
||||
self.runtime.registerType_id_to_name = [None] * len(
|
||||
self.runtime.registerType_name_to_id
|
||||
)
|
||||
for name, cell_type in self.runtime.registerType_name_to_id.items():
|
||||
self.runtime.registerType_id_to_name[cell_type] = name
|
||||
|
||||
self.config = config
|
||||
self.handle_setup_section()
|
||||
self.handle_invalid_address()
|
||||
self.handle_types()
|
||||
self.handle_write_allowed()
|
||||
self.handle_repeat()
|
||||
if self.config:
|
||||
raise RuntimeError(f"INVALID key in setup: {self.config}")
|
||||
|
||||
|
||||
class ModbusSimulatorContext:
|
||||
"""Modbus simulator
|
||||
|
||||
:param config: A dict with structure as shown below.
|
||||
:param actions: A dict with "<name>": <function> structure.
|
||||
:raises RuntimeError: if json contains errors (msg explains what)
|
||||
|
||||
It builds and maintains a virtual copy of a device, with simulation of
|
||||
device specific functions.
|
||||
|
||||
The device is described in a dict, user supplied actions will
|
||||
be added to the builtin actions.
|
||||
|
||||
It is used in conjunction with a pymodbus server.
|
||||
|
||||
Example::
|
||||
|
||||
store = ModbusSimulatorContext(<config dict>, <actions dict>)
|
||||
StartAsyncTcpServer(<host>, context=store)
|
||||
|
||||
Now the server will simulate the defined device with features like:
|
||||
|
||||
- invalid addresses
|
||||
- write protected addresses
|
||||
- optional control of access for string, uint32, bit/bits
|
||||
- builtin actions for e.g. reset/datetime, value increment by read
|
||||
- custom actions
|
||||
|
||||
Description of the json file or dict to be supplied::
|
||||
|
||||
{
|
||||
"setup": {
|
||||
"di size": 0, --> Size of discrete input block (8 bit)
|
||||
"co size": 0, --> Size of coils block (8 bit)
|
||||
"ir size": 0, --> Size of input registers block (16 bit)
|
||||
"hr size": 0, --> Size of holding registers block (16 bit)
|
||||
"shared blocks": True, --> share memory for all blocks (largest size wins)
|
||||
"defaults": {
|
||||
"value": { --> Initial values (can be overwritten)
|
||||
"bits": 0x01,
|
||||
"uint16": 122,
|
||||
"uint32": 67000,
|
||||
"float32": 127.4,
|
||||
"string": " ",
|
||||
},
|
||||
"action": { --> default action (can be overwritten)
|
||||
"bits": None,
|
||||
"uint16": None,
|
||||
"uint32": None,
|
||||
"float32": None,
|
||||
"string": None,
|
||||
},
|
||||
},
|
||||
"type exception": False, --> return IO exception if read/write on non boundary
|
||||
},
|
||||
"invalid": [ --> List of invalid addresses, IO exception returned
|
||||
51, --> single register
|
||||
[78, 99], --> start, end registers, repeated as needed
|
||||
],
|
||||
"write": [ --> allow write, efault is ReadOnly
|
||||
[5, 5] --> start, end bytes, repeated as needed
|
||||
],
|
||||
"bits": [ --> Define bits (1 register == 1 byte)
|
||||
[30, 31], --> start, end registers, repeated as needed
|
||||
{"addr": [32, 34], "value": 0xF1}, --> with value
|
||||
{"addr": [35, 36], "action": "increment"}, --> with action
|
||||
{"addr": [37, 38], "action": "increment", "value": 0xF1} --> with action and value
|
||||
{"addr": [37, 38], "action": "increment", "kwargs": {"min": 0, "max": 100}} --> with action with arguments
|
||||
],
|
||||
"uint16": [ --> Define uint16 (1 register == 2 bytes)
|
||||
--> same as type_bits
|
||||
],
|
||||
"uint32": [ --> Define 32 bit integers (2 registers == 4 bytes)
|
||||
--> same as type_bits
|
||||
],
|
||||
"float32": [ --> Define 32 bit floats (2 registers == 4 bytes)
|
||||
--> same as type_bits
|
||||
],
|
||||
"string": [ --> Define strings (variable number of registers (each 2 bytes))
|
||||
[21, 22], --> start, end registers, define 1 string
|
||||
{"addr": 23, 25], "value": "ups"}, --> with value
|
||||
{"addr": 26, 27], "action": "user"}, --> with action
|
||||
{"addr": 28, 29], "action": "", "value": "user"} --> with action and value
|
||||
],
|
||||
"repeat": [ --> allows to repeat section e.g. for n devices
|
||||
{"addr": [100, 200], "to": [50, 275]} --> Repeat registers 100-200 to 50+ until 275
|
||||
]
|
||||
}
|
||||
"""
|
||||
|
||||
# --------------------------------------------
|
||||
# External interfaces
|
||||
# --------------------------------------------
|
||||
start_time = int(datetime.now().timestamp())
|
||||
|
||||
def __init__(
|
||||
self, config: Dict[str, Any], custom_actions: Dict[str, Callable]
|
||||
) -> None:
|
||||
"""Initialize."""
|
||||
self.registers: List[int] = []
|
||||
self.fc_offset: Dict[int, int] = {}
|
||||
self.register_count = 0
|
||||
self.type_exception = False
|
||||
self.action_name_to_id: Dict[str, int] = {}
|
||||
self.action_id_to_name: List[str] = []
|
||||
self.action_methods: List[Callable] = []
|
||||
self.registerType_name_to_id: Dict[str, int] = {}
|
||||
self.registerType_id_to_name: List[str] = []
|
||||
Setup(self).setup(config, custom_actions)
|
||||
|
||||
# --------------------------------------------
|
||||
# Simulator server interface
|
||||
# --------------------------------------------
|
||||
def get_text_register(self, register):
|
||||
"""Get raw register."""
|
||||
reg = self.registers[register]
|
||||
text_cell = Cell()
|
||||
text_cell.type = self.registerType_id_to_name[reg.type]
|
||||
text_cell.access = str(reg.access)
|
||||
text_cell.count_read = str(reg.count_read)
|
||||
text_cell.count_write = str(reg.count_write)
|
||||
text_cell.action = self.action_id_to_name[reg.action]
|
||||
if reg.action_kwargs:
|
||||
text_cell.action = f"{text_cell.action}({reg.action_kwargs})"
|
||||
if reg.type in (CellType.INVALID, CellType.UINT16, CellType.NEXT):
|
||||
text_cell.value = str(reg.value)
|
||||
build_len = 0
|
||||
elif reg.type == CellType.BITS:
|
||||
text_cell.value = hex(reg.value)
|
||||
build_len = 0
|
||||
elif reg.type == CellType.UINT32:
|
||||
tmp_regs = [reg.value, self.registers[register + 1].value]
|
||||
text_cell.value = str(self.build_value_from_registers(tmp_regs, True))
|
||||
build_len = 1
|
||||
elif reg.type == CellType.FLOAT32:
|
||||
tmp_regs = [reg.value, self.registers[register + 1].value]
|
||||
text_cell.value = str(self.build_value_from_registers(tmp_regs, False))
|
||||
build_len = 1
|
||||
else: # reg.type == CellType.STRING:
|
||||
j = register
|
||||
text_cell.value = ""
|
||||
while True:
|
||||
text_cell.value += str(
|
||||
self.registers[j].value.to_bytes(2, "big"),
|
||||
encoding="utf-8",
|
||||
errors="ignore",
|
||||
)
|
||||
j += 1
|
||||
if self.registers[j].type != CellType.NEXT:
|
||||
break
|
||||
build_len = j - register - 1
|
||||
reg_txt = f"{register}-{register + build_len}" if build_len else f"{register}"
|
||||
return reg_txt, text_cell
|
||||
|
||||
# --------------------------------------------
|
||||
# Modbus server interface
|
||||
# --------------------------------------------
|
||||
|
||||
_write_func_code = (5, 6, 15, 16, 22, 23)
|
||||
_bits_func_code = (1, 2, 5, 15)
|
||||
|
||||
def loop_validate(self, address, end_address, fx_write):
|
||||
"""Validate entry in loop.
|
||||
|
||||
:meta private:
|
||||
"""
|
||||
i = address
|
||||
while i < end_address:
|
||||
reg = self.registers[i]
|
||||
if fx_write and not reg.access or reg.type == CellType.INVALID:
|
||||
return False
|
||||
if not self.type_exception:
|
||||
i += 1
|
||||
continue
|
||||
if reg.type == CellType.NEXT:
|
||||
return False
|
||||
if reg.type in (CellType.BITS, CellType.UINT16):
|
||||
i += 1
|
||||
elif reg.type in (CellType.UINT32, CellType.FLOAT32):
|
||||
if i + 1 >= end_address:
|
||||
return False
|
||||
i += 2
|
||||
else:
|
||||
i += 1
|
||||
while i < end_address:
|
||||
if self.registers[i].type == CellType.NEXT:
|
||||
i += 1
|
||||
return True
|
||||
|
||||
def validate(self, func_code, address, count=1):
|
||||
"""Check to see if the request is in range.
|
||||
|
||||
:meta private:
|
||||
"""
|
||||
if func_code in self._bits_func_code:
|
||||
# Bit count, correct to register count
|
||||
count = int((count + WORD_SIZE - 1) / WORD_SIZE)
|
||||
address = int(address / 16)
|
||||
|
||||
real_address = self.fc_offset[func_code] + address
|
||||
if real_address < 0 or real_address > self.register_count:
|
||||
return False
|
||||
|
||||
fx_write = func_code in self._write_func_code
|
||||
return self.loop_validate(real_address, real_address + count, fx_write)
|
||||
|
||||
def getValues(self, func_code, address, count=1):
|
||||
"""Return the requested values of the datastore.
|
||||
|
||||
:meta private:
|
||||
"""
|
||||
result = []
|
||||
if func_code not in self._bits_func_code:
|
||||
real_address = self.fc_offset[func_code] + address
|
||||
for i in range(real_address, real_address + count):
|
||||
reg = self.registers[i]
|
||||
kwargs = reg.action_kwargs if reg.action_kwargs else {}
|
||||
if reg.action:
|
||||
self.action_methods[reg.action](self.registers, i, reg, **kwargs)
|
||||
self.registers[i].count_read += 1
|
||||
result.append(reg.value)
|
||||
else:
|
||||
# bit access
|
||||
real_address = self.fc_offset[func_code] + int(address / 16)
|
||||
bit_index = address % 16
|
||||
reg_count = int((count + bit_index + 15) / 16)
|
||||
for i in range(real_address, real_address + reg_count):
|
||||
reg = self.registers[i]
|
||||
if reg.action:
|
||||
self.action_methods[reg.action](
|
||||
self.registers, i, reg, reg.action_kwargs
|
||||
)
|
||||
self.registers[i].count_read += 1
|
||||
while count and bit_index < 16:
|
||||
result.append(bool(reg.value & (2**bit_index)))
|
||||
count -= 1
|
||||
bit_index += 1
|
||||
bit_index = 0
|
||||
return result
|
||||
|
||||
def setValues(self, func_code, address, values):
|
||||
"""Set the requested values of the datastore.
|
||||
|
||||
:meta private:
|
||||
"""
|
||||
if func_code not in self._bits_func_code:
|
||||
real_address = self.fc_offset[func_code] + address
|
||||
for value in values:
|
||||
self.registers[real_address].value = value
|
||||
self.registers[real_address].count_write += 1
|
||||
real_address += 1
|
||||
return
|
||||
|
||||
# bit access
|
||||
real_address = self.fc_offset[func_code] + int(address / 16)
|
||||
bit_index = address % 16
|
||||
for value in values:
|
||||
bit_mask = 2**bit_index
|
||||
if bool(value):
|
||||
self.registers[real_address].value |= bit_mask
|
||||
else:
|
||||
self.registers[real_address].value &= ~bit_mask
|
||||
self.registers[real_address].count_write += 1
|
||||
bit_index += 1
|
||||
if bit_index == 16:
|
||||
bit_index = 0
|
||||
real_address += 1
|
||||
return
|
||||
|
||||
# --------------------------------------------
|
||||
# Internal action methods
|
||||
# --------------------------------------------
|
||||
|
||||
@classmethod
|
||||
def action_random(cls, registers, inx, cell, minval=1, maxval=65536):
|
||||
"""Update with random value.
|
||||
|
||||
:meta private:
|
||||
"""
|
||||
if cell.type in (CellType.BITS, CellType.UINT16):
|
||||
registers[inx].value = random.randint(int(minval), int(maxval))
|
||||
elif cell.type == CellType.FLOAT32:
|
||||
regs = cls.build_registers_from_value(
|
||||
random.uniform(float(minval), float(maxval)), False
|
||||
)
|
||||
registers[inx].value = regs[0]
|
||||
registers[inx + 1].value = regs[1]
|
||||
elif cell.type == CellType.UINT32:
|
||||
regs = cls.build_registers_from_value(
|
||||
random.randint(int(minval), int(maxval)), True
|
||||
)
|
||||
registers[inx].value = regs[0]
|
||||
registers[inx + 1].value = regs[1]
|
||||
|
||||
@classmethod
|
||||
def action_increment(cls, registers, inx, cell, minval=None, maxval=None):
|
||||
"""Increment value reset with overflow.
|
||||
|
||||
:meta private:
|
||||
"""
|
||||
reg = registers[inx]
|
||||
reg2 = registers[inx + 1]
|
||||
if cell.type in (CellType.BITS, CellType.UINT16):
|
||||
value = reg.value + 1
|
||||
if maxval and value > maxval:
|
||||
value = minval
|
||||
if minval and value < minval:
|
||||
value = minval
|
||||
reg.value = value
|
||||
elif cell.type == CellType.FLOAT32:
|
||||
tmp_reg = [reg.value, reg2.value]
|
||||
value = cls.build_value_from_registers(tmp_reg, False)
|
||||
value += 1.0
|
||||
if maxval and value > maxval:
|
||||
value = minval
|
||||
if minval and value < minval:
|
||||
value = minval
|
||||
new_regs = cls.build_registers_from_value(value, False)
|
||||
reg.value = new_regs[0]
|
||||
reg2.value = new_regs[1]
|
||||
elif cell.type == CellType.UINT32:
|
||||
tmp_reg = [reg.value, reg2.value]
|
||||
value = cls.build_value_from_registers(tmp_reg, True)
|
||||
value += 1
|
||||
if maxval and value > maxval:
|
||||
value = minval
|
||||
if minval and value < minval:
|
||||
value = minval
|
||||
new_regs = cls.build_registers_from_value(value, True)
|
||||
reg.value = new_regs[0]
|
||||
reg2.value = new_regs[1]
|
||||
|
||||
@classmethod
|
||||
def action_timestamp(cls, registers, inx, _cell, **_kwargs):
|
||||
"""Set current time.
|
||||
|
||||
:meta private:
|
||||
"""
|
||||
system_time = datetime.now()
|
||||
registers[inx].value = system_time.year
|
||||
registers[inx + 1].value = system_time.month - 1
|
||||
registers[inx + 2].value = system_time.day
|
||||
registers[inx + 3].value = system_time.weekday() + 1
|
||||
registers[inx + 4].value = system_time.hour
|
||||
registers[inx + 5].value = system_time.minute
|
||||
registers[inx + 6].value = system_time.second
|
||||
|
||||
@classmethod
|
||||
def action_reset(cls, _registers, _inx, _cell, **_kwargs):
|
||||
"""Reboot server.
|
||||
|
||||
:meta private:
|
||||
"""
|
||||
raise RuntimeError("RESET server")
|
||||
|
||||
@classmethod
|
||||
def action_uptime(cls, registers, inx, cell, **_kwargs):
|
||||
"""Return uptime in seconds.
|
||||
|
||||
:meta private:
|
||||
"""
|
||||
value = int(datetime.now().timestamp()) - cls.start_time + 1
|
||||
|
||||
if cell.type in (CellType.BITS, CellType.UINT16):
|
||||
registers[inx].value = value
|
||||
elif cell.type == CellType.FLOAT32:
|
||||
regs = cls.build_registers_from_value(value, False)
|
||||
registers[inx].value = regs[0]
|
||||
registers[inx + 1].value = regs[1]
|
||||
elif cell.type == CellType.UINT32:
|
||||
regs = cls.build_registers_from_value(value, True)
|
||||
registers[inx].value = regs[0]
|
||||
registers[inx + 1].value = regs[1]
|
||||
|
||||
# --------------------------------------------
|
||||
# Internal helper methods
|
||||
# --------------------------------------------
|
||||
|
||||
def validate_type(self, func_code, real_address, count):
|
||||
"""Check if request is done against correct type
|
||||
|
||||
:meta private:
|
||||
"""
|
||||
|
||||
if func_code in self._bits_func_code:
|
||||
# Bit access
|
||||
check = (CellType.BITS, -1)
|
||||
reg_step = 1
|
||||
elif count % 2:
|
||||
# 16 bit access
|
||||
check = (CellType.UINT16, CellType.STRING)
|
||||
reg_step = 1
|
||||
else:
|
||||
check = (CellType.UINT32, CellType.FLOAT32, CellType.STRING)
|
||||
reg_step = 2
|
||||
|
||||
for i in range(real_address, real_address + count, reg_step):
|
||||
if self.registers[i].type in check:
|
||||
continue
|
||||
if self.registers[i].type is CellType.NEXT:
|
||||
continue
|
||||
return False
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def build_registers_from_value(cls, value, is_int):
|
||||
"""Build registers from int32 or float32"""
|
||||
regs = [0, 0]
|
||||
if is_int:
|
||||
value_bytes = int.to_bytes(value, 4, "big")
|
||||
else:
|
||||
value_bytes = struct.pack(">f", value)
|
||||
regs[0] = int.from_bytes(value_bytes[:2], "big")
|
||||
regs[1] = int.from_bytes(value_bytes[-2:], "big")
|
||||
return regs
|
||||
|
||||
@classmethod
|
||||
def build_value_from_registers(cls, registers, is_int):
|
||||
"""Build int32 or float32 value from registers"""
|
||||
value_bytes = int.to_bytes(registers[0], 2, "big") + int.to_bytes(
|
||||
registers[1], 2, "big"
|
||||
)
|
||||
if is_int:
|
||||
value = int.from_bytes(value_bytes, "big")
|
||||
else:
|
||||
value = struct.unpack(">f", value_bytes)[0]
|
||||
return value
|
||||
@@ -0,0 +1,314 @@
|
||||
"""Modbus Server Datastore.
|
||||
|
||||
For each server, you will create a ModbusServerContext and pass
|
||||
in the default address space for each data access. The class
|
||||
will create and manage the data.
|
||||
|
||||
Further modification of said data accesses should be performed
|
||||
with [get,set][access]Values(address, count)
|
||||
|
||||
Datastore Implementation
|
||||
-------------------------
|
||||
|
||||
There are two ways that the server datastore can be implemented.
|
||||
The first is a complete range from "address" start to "count"
|
||||
number of indices. This can be thought of as a straight array::
|
||||
|
||||
data = range(1, 1 + count)
|
||||
[1,2,3,...,count]
|
||||
|
||||
The other way that the datastore can be implemented (and how
|
||||
many devices implement it) is a associate-array::
|
||||
|
||||
data = {1:"1", 3:"3", ..., count:"count"}
|
||||
[1,3,...,count]
|
||||
|
||||
The difference between the two is that the latter will allow
|
||||
arbitrary gaps in its datastore while the former will not.
|
||||
This is seen quite commonly in some modbus implementations.
|
||||
What follows is a clear example from the field:
|
||||
|
||||
Say a company makes two devices to monitor power usage on a rack.
|
||||
One works with three-phase and the other with a single phase. The
|
||||
company will dictate a modbus data mapping such that registers::
|
||||
|
||||
n: phase 1 power
|
||||
n+1: phase 2 power
|
||||
n+2: phase 3 power
|
||||
|
||||
Using this, layout, the first device will implement n, n+1, and n+2,
|
||||
however, the second device may set the latter two values to 0 or
|
||||
will simply not implemented the registers thus causing a single read
|
||||
or a range read to fail.
|
||||
|
||||
I have both methods implemented, and leave it up to the user to change
|
||||
based on their preference.
|
||||
"""
|
||||
# pylint: disable=missing-type-doc
|
||||
from pymodbus.exceptions import NotImplementedException, ParameterException
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------#
|
||||
# Datablock Storage
|
||||
# ---------------------------------------------------------------------------#
|
||||
class BaseModbusDataBlock:
|
||||
"""Base class for a modbus datastore
|
||||
|
||||
Derived classes must create the following fields:
|
||||
@address The starting address point
|
||||
@defult_value The default value of the datastore
|
||||
@values The actual datastore values
|
||||
|
||||
Derived classes must implemented the following methods:
|
||||
validate(self, address, count=1)
|
||||
getValues(self, address, count=1)
|
||||
setValues(self, address, values)
|
||||
"""
|
||||
|
||||
def default(self, count, value=False):
|
||||
"""Use to initialize a store to one value.
|
||||
|
||||
:param count: The number of fields to set
|
||||
:param value: The default value to set to the fields
|
||||
"""
|
||||
self.default_value = value # pylint: disable=attribute-defined-outside-init
|
||||
self.values = [ # pylint: disable=attribute-defined-outside-init
|
||||
self.default_value
|
||||
] * count
|
||||
self.address = 0x00 # pylint: disable=attribute-defined-outside-init
|
||||
|
||||
def reset(self):
|
||||
"""Reset the datastore to the initialized default value."""
|
||||
self.values = [ # pylint: disable=attribute-defined-outside-init
|
||||
self.default_value
|
||||
] * len(self.values)
|
||||
|
||||
def validate(self, address, count=1):
|
||||
"""Check to see if the request is in range.
|
||||
|
||||
:param address: The starting address
|
||||
:param count: The number of values to test for
|
||||
:raises NotImplementedException:
|
||||
"""
|
||||
raise NotImplementedException("Datastore Address Check")
|
||||
|
||||
def getValues(self, address, count=1):
|
||||
"""Return the requested values from the datastore.
|
||||
|
||||
:param address: The starting address
|
||||
:param count: The number of values to retrieve
|
||||
:raises NotImplementedException:
|
||||
"""
|
||||
raise NotImplementedException("Datastore Value Retrieve")
|
||||
|
||||
def setValues(self, address, values):
|
||||
"""Return the requested values from the datastore.
|
||||
|
||||
:param address: The starting address
|
||||
:param values: The values to store
|
||||
:raises NotImplementedException:
|
||||
"""
|
||||
raise NotImplementedException("Datastore Value Retrieve")
|
||||
|
||||
def __str__(self):
|
||||
"""Build a representation of the datastore.
|
||||
|
||||
:returns: A string representation of the datastore
|
||||
"""
|
||||
return f"DataStore({len(self.values)}, {self.default_value})"
|
||||
|
||||
def __iter__(self):
|
||||
"""Iterate over the data block data.
|
||||
|
||||
:returns: An iterator of the data block data
|
||||
"""
|
||||
if isinstance(self.values, dict):
|
||||
return iter(self.values.items())
|
||||
return enumerate(self.values, self.address)
|
||||
|
||||
|
||||
class ModbusSequentialDataBlock(BaseModbusDataBlock):
|
||||
"""Creates a sequential modbus datastore."""
|
||||
|
||||
def __init__(self, address, values):
|
||||
"""Initialize the datastore.
|
||||
|
||||
:param address: The starting address of the datastore
|
||||
:param values: Either a list or a dictionary of values
|
||||
"""
|
||||
self.address = address
|
||||
if hasattr(values, "__iter__"):
|
||||
self.values = list(values)
|
||||
else:
|
||||
self.values = [values]
|
||||
self.default_value = self.values[0].__class__()
|
||||
|
||||
@classmethod
|
||||
def create(cls):
|
||||
"""Create a datastore.
|
||||
|
||||
With the full address space initialized to 0x00
|
||||
|
||||
:returns: An initialized datastore
|
||||
"""
|
||||
return cls(0x00, [0x00] * 65536)
|
||||
|
||||
def validate(self, address, count=1):
|
||||
"""Check to see if the request is in range.
|
||||
|
||||
:param address: The starting address
|
||||
:param count: The number of values to test for
|
||||
:returns: True if the request in within range, False otherwise
|
||||
"""
|
||||
result = self.address <= address
|
||||
result &= (self.address + len(self.values)) >= (address + count)
|
||||
return result
|
||||
|
||||
def getValues(self, address, count=1):
|
||||
"""Return the requested values of the datastore.
|
||||
|
||||
:param address: The starting address
|
||||
:param count: The number of values to retrieve
|
||||
:returns: The requested values from a:a+c
|
||||
"""
|
||||
start = address - self.address
|
||||
return self.values[start : start + count]
|
||||
|
||||
def setValues(self, address, values):
|
||||
"""Set the requested values of the datastore.
|
||||
|
||||
:param address: The starting address
|
||||
:param values: The new values to be set
|
||||
"""
|
||||
if not isinstance(values, list):
|
||||
values = [values]
|
||||
start = address - self.address
|
||||
self.values[start : start + len(values)] = values
|
||||
|
||||
|
||||
class ModbusSparseDataBlock(BaseModbusDataBlock):
|
||||
"""Create a sparse modbus datastore.
|
||||
|
||||
E.g Usage.
|
||||
sparse = ModbusSparseDataBlock({10: [3, 5, 6, 8], 30: 1, 40: [0]*20})
|
||||
|
||||
This would create a datablock with 3 blocks starting at
|
||||
offset 10 with length 4 , 30 with length 1 and 40 with length 20
|
||||
|
||||
sparse = ModbusSparseDataBlock([10]*100)
|
||||
Creates a sparse datablock of length 100 starting at offset 0 and default value of 10
|
||||
|
||||
sparse = ModbusSparseDataBlock() --> Create Empty datablock
|
||||
sparse.setValues(0, [10]*10) --> Add block 1 at offset 0 with length 10 (default value 10)
|
||||
sparse.setValues(30, [20]*5) --> Add block 2 at offset 30 with length 5 (default value 20)
|
||||
|
||||
if mutable is set to True during initialization, the datablock can not be altered with
|
||||
setValues (new datablocks can not be added)
|
||||
"""
|
||||
|
||||
def __init__(self, values=None, mutable=True):
|
||||
"""Initialize a sparse datastore.
|
||||
|
||||
Will only answer to addresses
|
||||
registered, either initially here, or later via setValues()
|
||||
|
||||
:param values: Either a list or a dictionary of values
|
||||
:param mutable: The data-block can be altered later with setValues(i.e add more blocks)
|
||||
|
||||
If values are list , This is as good as sequential datablock.
|
||||
Values as dictionary should be in {offset: <values>} format, if values
|
||||
is a list, a sparse datablock is created starting at offset with the length of values.
|
||||
If values is a integer, then the value is set for the corresponding offset.
|
||||
|
||||
"""
|
||||
self.values = {}
|
||||
self._process_values(values)
|
||||
self.mutable = mutable
|
||||
self.default_value = self.values.copy()
|
||||
self.address = next(iter(self.values.keys()), None)
|
||||
|
||||
@classmethod
|
||||
def create(cls, values=None):
|
||||
"""Create sparse datastore.
|
||||
|
||||
Use setValues to initialize registers.
|
||||
|
||||
:param values: Either a list or a dictionary of values
|
||||
:returns: An initialized datastore
|
||||
"""
|
||||
return cls(values)
|
||||
|
||||
def reset(self):
|
||||
"""Reset the store to the initially provided defaults."""
|
||||
self.values = self.default_value.copy()
|
||||
|
||||
def validate(self, address, count=1):
|
||||
"""Check to see if the request is in range.
|
||||
|
||||
:param address: The starting address
|
||||
:param count: The number of values to test for
|
||||
:returns: True if the request in within range, False otherwise
|
||||
"""
|
||||
if not count:
|
||||
return False
|
||||
handle = set(range(address, address + count))
|
||||
return handle.issubset(set(iter(self.values.keys())))
|
||||
|
||||
def getValues(self, address, count=1):
|
||||
"""Return the requested values of the datastore.
|
||||
|
||||
:param address: The starting address
|
||||
:param count: The number of values to retrieve
|
||||
:returns: The requested values from a:a+c
|
||||
"""
|
||||
return [self.values[i] for i in range(address, address + count)]
|
||||
|
||||
def _process_values(self, values):
|
||||
"""Process values."""
|
||||
|
||||
def _process_as_dict(values):
|
||||
for idx, val in iter(values.items()):
|
||||
if isinstance(val, (list, tuple)):
|
||||
for i, v_item in enumerate(val):
|
||||
self.values[idx + i] = v_item
|
||||
else:
|
||||
self.values[idx] = int(val)
|
||||
|
||||
if isinstance(values, dict):
|
||||
_process_as_dict(values)
|
||||
return
|
||||
if hasattr(values, "__iter__"):
|
||||
values = dict(enumerate(values))
|
||||
elif values is None:
|
||||
values = {} # Must make a new dict here per instance
|
||||
else:
|
||||
raise ParameterException(
|
||||
"Values for datastore must be a list or dictionary"
|
||||
)
|
||||
_process_as_dict(values)
|
||||
|
||||
def setValues(self, address, values, use_as_default=False):
|
||||
"""Set the requested values of the datastore.
|
||||
|
||||
:param address: The starting address
|
||||
:param values: The new values to be set
|
||||
:param use_as_default: Use the values as default
|
||||
:raises ParameterException:
|
||||
"""
|
||||
if isinstance(values, dict):
|
||||
new_offsets = list(set(values.keys()) - set(self.values.keys()))
|
||||
if new_offsets and not self.mutable:
|
||||
raise ParameterException(f"Offsets {new_offsets} not in range")
|
||||
self._process_values(values)
|
||||
else:
|
||||
if not isinstance(values, list):
|
||||
values = [values]
|
||||
for idx, val in enumerate(values):
|
||||
if address + idx not in self.values and not self.mutable:
|
||||
raise ParameterException("Offset {address+idx} not in range")
|
||||
self.values[address + idx] = val
|
||||
if not self.address:
|
||||
self.address = next(iter(self.values.keys()), None)
|
||||
if use_as_default:
|
||||
for idx, val in iter(self.values.items()):
|
||||
self.default_value[idx] = val
|
||||
+608
@@ -0,0 +1,608 @@
|
||||
"""Modbus Device Controller.
|
||||
|
||||
These are the device management handlers. They should be
|
||||
maintained in the server context and the various methods
|
||||
should be inserted in the correct locations.
|
||||
"""
|
||||
|
||||
__all__ = [
|
||||
"ModbusPlusStatistics",
|
||||
"ModbusDeviceIdentification",
|
||||
"DeviceInformationFactory",
|
||||
]
|
||||
|
||||
import struct
|
||||
|
||||
# pylint: disable=missing-type-doc
|
||||
from collections import OrderedDict
|
||||
from typing import List
|
||||
|
||||
from pymodbus.constants import INTERNAL_ERROR, DeviceInformation
|
||||
from pymodbus.events import ModbusEvent
|
||||
from pymodbus.utilities import dict_property
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------#
|
||||
# Modbus Plus Statistics
|
||||
# ---------------------------------------------------------------------------#
|
||||
class ModbusPlusStatistics:
|
||||
"""This is used to maintain the current modbus plus statistics count.
|
||||
|
||||
As of right now this is simply a stub to complete the modbus implementation.
|
||||
For more information, see the modbus implementation guide page 87.
|
||||
"""
|
||||
|
||||
__data = OrderedDict(
|
||||
{
|
||||
"node_type_id": [0x00] * 2, # 00
|
||||
"software_version_number": [0x00] * 2, # 01
|
||||
"network_address": [0x00] * 2, # 02
|
||||
"mac_state_variable": [0x00] * 2, # 03
|
||||
"peer_status_code": [0x00] * 2, # 04
|
||||
"token_pass_counter": [0x00] * 2, # 05
|
||||
"token_rotation_time": [0x00] * 2, # 06
|
||||
"program_master_token_failed": [0x00], # 07 hi
|
||||
"data_master_token_failed": [0x00], # 07 lo
|
||||
"program_master_token_owner": [0x00], # 08 hi
|
||||
"data_master_token_owner": [0x00], # 08 lo
|
||||
"program_slave_token_owner": [0x00], # 09 hi
|
||||
"data_slave_token_owner": [0x00], # 09 lo
|
||||
"data_slave_command_transfer": [0x00], # 10 hi
|
||||
"__unused_10_lowbit": [0x00], # 10 lo
|
||||
"program_slave_command_transfer": [0x00], # 11 hi
|
||||
"program_master_rsp_transfer": [0x00], # 11 lo
|
||||
"program_slave_auto_logout": [0x00], # 12 hi
|
||||
"program_master_connect_status": [0x00], # 12 lo
|
||||
"receive_buffer_dma_overrun": [0x00], # 13 hi
|
||||
"pretransmit_deferral_error": [0x00], # 13 lo
|
||||
"frame_size_error": [0x00], # 14 hi
|
||||
"repeated_command_received": [0x00], # 14 lo
|
||||
"receiver_alignment_error": [0x00], # 15 hi
|
||||
"receiver_collision_abort_error": [0x00], # 15 lo
|
||||
"bad_packet_length_error": [0x00], # 16 hi
|
||||
"receiver_crc_error": [0x00], # 16 lo
|
||||
"transmit_buffer_dma_underrun": [0x00], # 17 hi
|
||||
"bad_link_address_error": [0x00], # 17 lo
|
||||
"bad_mac_function_code_error": [0x00], # 18 hi
|
||||
"internal_packet_length_error": [0x00], # 18 lo
|
||||
"communication_failed_error": [0x00], # 19 hi
|
||||
"communication_retries": [0x00], # 19 lo
|
||||
"no_response_error": [0x00], # 20 hi
|
||||
"good_receive_packet": [0x00], # 20 lo
|
||||
"unexpected_path_error": [0x00], # 21 hi
|
||||
"exception_response_error": [0x00], # 21 lo
|
||||
"forgotten_transaction_error": [0x00], # 22 hi
|
||||
"unexpected_response_error": [0x00], # 22 lo
|
||||
"active_station_bit_map": [0x00] * 8, # 23-26
|
||||
"token_station_bit_map": [0x00] * 8, # 27-30
|
||||
"global_data_bit_map": [0x00] * 8, # 31-34
|
||||
"receive_buffer_use_bit_map": [0x00] * 8, # 35-37
|
||||
"data_master_output_path": [0x00] * 8, # 38-41
|
||||
"data_slave_input_path": [0x00] * 8, # 42-45
|
||||
"program_master_outptu_path": [0x00] * 8, # 46-49
|
||||
"program_slave_input_path": [0x00] * 8, # 50-53
|
||||
}
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the modbus plus statistics with the default information."""
|
||||
self.reset()
|
||||
|
||||
def __iter__(self):
|
||||
"""Iterate over the statistics.
|
||||
|
||||
:returns: An iterator of the modbus plus statistics
|
||||
"""
|
||||
return iter(self.__data.items())
|
||||
|
||||
def reset(self):
|
||||
"""Clear all of the modbus plus statistics."""
|
||||
for key in self.__data:
|
||||
self.__data[key] = [0x00] * len(self.__data[key])
|
||||
|
||||
def summary(self):
|
||||
"""Return a summary of the modbus plus statistics.
|
||||
|
||||
:returns: 54 16-bit words representing the status
|
||||
"""
|
||||
return iter(self.__data.values())
|
||||
|
||||
def encode(self):
|
||||
"""Return a summary of the modbus plus statistics.
|
||||
|
||||
:returns: 54 16-bit words representing the status
|
||||
"""
|
||||
total, values = [], sum(self.__data.values(), [])
|
||||
for i in range(0, len(values), 2):
|
||||
total.append((values[i] << 8) | values[i + 1])
|
||||
return total
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------#
|
||||
# Device Information Control
|
||||
# ---------------------------------------------------------------------------#
|
||||
class ModbusDeviceIdentification:
|
||||
"""This is used to supply the device identification.
|
||||
|
||||
For the readDeviceIdentification function
|
||||
|
||||
For more information read section 6.21 of the modbus
|
||||
application protocol.
|
||||
"""
|
||||
|
||||
__data = {
|
||||
0x00: "", # VendorName
|
||||
0x01: "", # ProductCode
|
||||
0x02: "", # MajorMinorRevision
|
||||
0x03: "", # VendorUrl
|
||||
0x04: "", # ProductName
|
||||
0x05: "", # ModelName
|
||||
0x06: "", # UserApplicationName
|
||||
0x07: "", # reserved
|
||||
0x08: "", # reserved
|
||||
# 0x80 -> 0xFF are privatek
|
||||
}
|
||||
|
||||
__names = [
|
||||
"VendorName",
|
||||
"ProductCode",
|
||||
"MajorMinorRevision",
|
||||
"VendorUrl",
|
||||
"ProductName",
|
||||
"ModelName",
|
||||
"UserApplicationName",
|
||||
]
|
||||
|
||||
def __init__(self, info=None, info_name=None):
|
||||
"""Initialize the datastore with the elements you need.
|
||||
|
||||
(note acceptable range is [0x00-0x06,0x80-0xFF] inclusive)
|
||||
|
||||
:param info: A dictionary of {int:string} of values
|
||||
:param set: A dictionary of {name:string} of values
|
||||
"""
|
||||
if isinstance(info_name, dict):
|
||||
for key in info_name:
|
||||
inx = self.__names.index(key)
|
||||
self.__data[inx] = info_name[key]
|
||||
|
||||
if isinstance(info, dict):
|
||||
for key in info:
|
||||
if (0x06 >= key >= 0x00) or (0xFF >= key >= 0x80):
|
||||
self.__data[key] = info[key]
|
||||
|
||||
def __iter__(self):
|
||||
"""Iterate over the device information.
|
||||
|
||||
:returns: An iterator of the device information
|
||||
"""
|
||||
return iter(self.__data.items())
|
||||
|
||||
def summary(self):
|
||||
"""Return a summary of the main items.
|
||||
|
||||
:returns: An dictionary of the main items
|
||||
"""
|
||||
return dict(zip(self.__names, iter(self.__data.values())))
|
||||
|
||||
def update(self, value):
|
||||
"""Update the values of this identity.
|
||||
|
||||
using another identify as the value
|
||||
|
||||
:param value: The value to copy values from
|
||||
"""
|
||||
self.__data.update(value)
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
"""Access the device information.
|
||||
|
||||
:param key: The register to set
|
||||
:param value: The new value for referenced register
|
||||
"""
|
||||
if key not in [0x07, 0x08]:
|
||||
self.__data[key] = value
|
||||
|
||||
def __getitem__(self, key):
|
||||
"""Access the device information.
|
||||
|
||||
:param key: The register to read
|
||||
"""
|
||||
return self.__data.setdefault(key, "")
|
||||
|
||||
def __str__(self):
|
||||
"""Build a representation of the device.
|
||||
|
||||
:returns: A string representation of the device
|
||||
"""
|
||||
return "DeviceIdentity"
|
||||
|
||||
# -------------------------------------------------------------------------#
|
||||
# Properties
|
||||
# -------------------------------------------------------------------------#
|
||||
VendorName = dict_property(
|
||||
lambda s: s.__data, 0 # pylint: disable=protected-access
|
||||
)
|
||||
ProductCode = dict_property(
|
||||
lambda s: s.__data, 1 # pylint: disable=protected-access
|
||||
)
|
||||
MajorMinorRevision = dict_property(
|
||||
lambda s: s.__data, 2 # pylint: disable=protected-access
|
||||
)
|
||||
VendorUrl = dict_property(lambda s: s.__data, 3) # pylint: disable=protected-access
|
||||
ProductName = dict_property(
|
||||
lambda s: s.__data, 4 # pylint: disable=protected-access
|
||||
)
|
||||
ModelName = dict_property(lambda s: s.__data, 5) # pylint: disable=protected-access
|
||||
UserApplicationName = dict_property(
|
||||
lambda s: s.__data, 6 # pylint: disable=protected-access
|
||||
)
|
||||
|
||||
|
||||
class DeviceInformationFactory: # pylint: disable=too-few-public-methods
|
||||
"""This is a helper factory.
|
||||
|
||||
That really just hides
|
||||
some of the complexity of processing the device information
|
||||
requests (function code 0x2b 0x0e).
|
||||
"""
|
||||
|
||||
__lookup = {
|
||||
DeviceInformation.BASIC: lambda c, r, i: c.__gets( # pylint: disable=protected-access
|
||||
r, list(range(i, 0x03))
|
||||
),
|
||||
DeviceInformation.REGULAR: lambda c, r, i: c.__gets( # pylint: disable=protected-access
|
||||
r,
|
||||
list(range(i, 0x07))
|
||||
if c.__get(r, i)[i] # pylint: disable=protected-access
|
||||
else list(range(0, 0x07)),
|
||||
),
|
||||
DeviceInformation.EXTENDED: lambda c, r, i: c.__gets( # pylint: disable=protected-access
|
||||
r,
|
||||
[x for x in range(i, 0x100) if x not in range(0x07, 0x80)]
|
||||
if c.__get(r, i)[i] # pylint: disable=protected-access
|
||||
else [x for x in range(0, 0x100) if x not in range(0x07, 0x80)],
|
||||
),
|
||||
DeviceInformation.SPECIFIC: lambda c, r, i: c.__get( # pylint: disable=protected-access
|
||||
r, i
|
||||
),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get(cls, control, read_code=DeviceInformation.BASIC, object_id=0x00):
|
||||
"""Get the requested device data from the system.
|
||||
|
||||
:param control: The control block to pull data from
|
||||
:param read_code: The read code to process
|
||||
:param object_id: The specific object_id to read
|
||||
:returns: The requested data (id, length, value)
|
||||
"""
|
||||
identity = control.Identity
|
||||
return cls.__lookup[read_code](cls, identity, object_id)
|
||||
|
||||
@classmethod
|
||||
def __get(cls, identity, object_id): # pylint: disable=unused-private-member
|
||||
"""Read a single object_id from the device information.
|
||||
|
||||
:param identity: The identity block to pull data from
|
||||
:param object_id: The specific object id to read
|
||||
:returns: The requested data (id, length, value)
|
||||
"""
|
||||
return {object_id: identity[object_id]}
|
||||
|
||||
@classmethod
|
||||
def __gets(cls, identity, object_ids): # pylint: disable=unused-private-member
|
||||
"""Read multiple object_ids from the device information.
|
||||
|
||||
:param identity: The identity block to pull data from
|
||||
:param object_ids: The specific object ids to read
|
||||
:returns: The requested data (id, length, value)
|
||||
"""
|
||||
return {oid: identity[oid] for oid in object_ids if identity[oid]}
|
||||
|
||||
def __init__(self):
|
||||
"""Prohibit objects."""
|
||||
raise RuntimeError(INTERNAL_ERROR)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------#
|
||||
# Counters Handler
|
||||
# ---------------------------------------------------------------------------#
|
||||
class ModbusCountersHandler:
|
||||
"""This is a helper class to simplify the properties for the counters.
|
||||
|
||||
0x0B 1 Return Bus Message Count
|
||||
|
||||
Quantity of messages that the remote
|
||||
device has detected on the communications system since its
|
||||
last restart, clear counters operation, or power-up. Messages
|
||||
with bad CRC are not taken into account.
|
||||
|
||||
0x0C 2 Return Bus Communication Error Count
|
||||
|
||||
Quantity of CRC errors encountered by the remote device since its
|
||||
last restart, clear counters operation, or power-up. In case of
|
||||
an error detected on the character level, (overrun, parity error),
|
||||
or in case of a message length < 3 bytes, the receiving device is
|
||||
not able to calculate the CRC. In such cases, this counter is
|
||||
also incremented.
|
||||
|
||||
0x0D 3 Return Slave Exception Error Count
|
||||
|
||||
Quantity of MODBUS exception error detected by the remote device
|
||||
since its last restart, clear counters operation, or power-up. It
|
||||
comprises also the error detected in broadcast messages even if an
|
||||
exception message is not returned in this case.
|
||||
Exception errors are described and listed in "MODBUS Application
|
||||
Protocol Specification" document.
|
||||
|
||||
0xOE 4 Return Slave Message Count
|
||||
|
||||
Quantity of messages addressed to the remote device, including
|
||||
broadcast messages, that the remote device has processed since its
|
||||
last restart, clear counters operation, or power-up.
|
||||
|
||||
0x0F 5 Return Slave No Response Count
|
||||
|
||||
Quantity of messages received by the remote device for which it
|
||||
returned no response (neither a normal response nor an exception
|
||||
response), since its last restart, clear counters operation, or
|
||||
power-up. Then, this counter counts the number of broadcast
|
||||
messages it has received.
|
||||
|
||||
0x10 6 Return Slave NAK Count
|
||||
|
||||
Quantity of messages addressed to the remote device for which it
|
||||
returned a Negative Acknowledge (NAK) exception response, since
|
||||
its last restart, clear counters operation, or power-up. Exception
|
||||
responses are described and listed in "MODBUS Application Protocol
|
||||
Specification" document.
|
||||
|
||||
0x11 7 Return Slave Busy Count
|
||||
|
||||
Quantity of messages addressed to the remote device for which it
|
||||
returned a Slave Device Busy exception response, since its last
|
||||
restart, clear counters operation, or power-up. Exception
|
||||
responses are described and listed in "MODBUS Application
|
||||
Protocol Specification" document.
|
||||
|
||||
0x12 8 Return Bus Character Overrun Count
|
||||
|
||||
Quantity of messages addressed to the remote device that it could
|
||||
not handle due to a character overrun condition, since its last
|
||||
restart, clear counters operation, or power-up. A character
|
||||
overrun is caused by data characters arriving at the port faster
|
||||
than they can.
|
||||
|
||||
.. note:: I threw the event counter in here for convenience
|
||||
"""
|
||||
|
||||
__data = {i: 0x0000 for i in range(9)}
|
||||
__names = [
|
||||
"BusMessage",
|
||||
"BusCommunicationError",
|
||||
"SlaveExceptionError",
|
||||
"SlaveMessage",
|
||||
"SlaveNoResponse",
|
||||
"SlaveNAK",
|
||||
"SlaveBusy",
|
||||
"BusCharacterOverrun",
|
||||
]
|
||||
|
||||
def __iter__(self):
|
||||
"""Iterate over the device counters.
|
||||
|
||||
:returns: An iterator of the device counters
|
||||
"""
|
||||
return zip(self.__names, iter(self.__data.values()))
|
||||
|
||||
def update(self, values):
|
||||
"""Update the values of this identity.
|
||||
|
||||
using another identify as the value
|
||||
|
||||
:param values: The value to copy values from
|
||||
"""
|
||||
for k, v_item in iter(values.items()):
|
||||
v_item += self.__getattribute__( # pylint: disable=unnecessary-dunder-call
|
||||
k
|
||||
)
|
||||
self.__setattr__(k, v_item) # pylint: disable=unnecessary-dunder-call
|
||||
|
||||
def reset(self):
|
||||
"""Clear all of the system counters."""
|
||||
self.__data = {i: 0x0000 for i in range(9)}
|
||||
|
||||
def summary(self):
|
||||
"""Return a summary of the counters current status.
|
||||
|
||||
:returns: A byte with each bit representing each counter
|
||||
"""
|
||||
count, result = 0x01, 0x00
|
||||
for i in iter(self.__data.values()):
|
||||
if i != 0x00: # pylint: disable=compare-to-zero
|
||||
result |= count
|
||||
count <<= 1
|
||||
return result
|
||||
|
||||
# -------------------------------------------------------------------------#
|
||||
# Properties
|
||||
# -------------------------------------------------------------------------#
|
||||
BusMessage = dict_property(
|
||||
lambda s: s.__data, 0 # pylint: disable=protected-access
|
||||
)
|
||||
BusCommunicationError = dict_property(
|
||||
lambda s: s.__data, 1 # pylint: disable=protected-access
|
||||
)
|
||||
BusExceptionError = dict_property(
|
||||
lambda s: s.__data, 2 # pylint: disable=protected-access
|
||||
)
|
||||
SlaveMessage = dict_property(
|
||||
lambda s: s.__data, 3 # pylint: disable=protected-access
|
||||
)
|
||||
SlaveNoResponse = dict_property(
|
||||
lambda s: s.__data, 4 # pylint: disable=protected-access
|
||||
)
|
||||
SlaveNAK = dict_property(lambda s: s.__data, 5) # pylint: disable=protected-access
|
||||
SlaveBusy = dict_property(lambda s: s.__data, 6) # pylint: disable=protected-access
|
||||
BusCharacterOverrun = dict_property(
|
||||
lambda s: s.__data, 7 # pylint: disable=protected-access
|
||||
)
|
||||
Event = dict_property(lambda s: s.__data, 8) # pylint: disable=protected-access
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------#
|
||||
# Main server control block
|
||||
# ---------------------------------------------------------------------------#
|
||||
class ModbusControlBlock:
|
||||
"""This is a global singleton that controls all system information.
|
||||
|
||||
All activity should be logged here and all diagnostic requests
|
||||
should come from here.
|
||||
"""
|
||||
|
||||
__mode = "ASCII"
|
||||
__diagnostic = [False] * 16
|
||||
__listen_only = False
|
||||
__delimiter = b"\r"
|
||||
__counters = ModbusCountersHandler()
|
||||
__identity = ModbusDeviceIdentification()
|
||||
__plus = ModbusPlusStatistics()
|
||||
__events: List[ModbusEvent] = []
|
||||
|
||||
# -------------------------------------------------------------------------#
|
||||
# Magic
|
||||
# -------------------------------------------------------------------------#
|
||||
def __str__(self):
|
||||
"""Build a representation of the control block.
|
||||
|
||||
:returns: A string representation of the control block
|
||||
"""
|
||||
return "ModbusControl"
|
||||
|
||||
def __iter__(self):
|
||||
"""Iterate over the device counters.
|
||||
|
||||
:returns: An iterator of the device counters
|
||||
"""
|
||||
return self.__counters.__iter__()
|
||||
|
||||
def __new__(cls, *_args, **_kwargs):
|
||||
"""Create a new instance."""
|
||||
if "_inst" not in vars(cls):
|
||||
cls._inst = object.__new__(cls)
|
||||
return cls._inst
|
||||
|
||||
# -------------------------------------------------------------------------#
|
||||
# Events
|
||||
# -------------------------------------------------------------------------#
|
||||
def addEvent(self, event: ModbusEvent):
|
||||
"""Add a new event to the event log.
|
||||
|
||||
:param event: A new event to add to the log
|
||||
"""
|
||||
self.__events.insert(0, event)
|
||||
self.__events = self.__events[0:64] # chomp to 64 entries
|
||||
self.Counter.Event += 1
|
||||
|
||||
def getEvents(self):
|
||||
"""Return an encoded collection of the event log.
|
||||
|
||||
:returns: The encoded events packet
|
||||
"""
|
||||
events = [event.encode() for event in self.__events]
|
||||
return b"".join(events)
|
||||
|
||||
def clearEvents(self):
|
||||
"""Clear the current list of events."""
|
||||
self.__events = []
|
||||
|
||||
# -------------------------------------------------------------------------#
|
||||
# Other Properties
|
||||
# -------------------------------------------------------------------------#
|
||||
Identity = property(lambda s: s.__identity)
|
||||
Counter = property(lambda s: s.__counters)
|
||||
Events = property(lambda s: s.__events)
|
||||
Plus = property(lambda s: s.__plus)
|
||||
|
||||
def reset(self):
|
||||
"""Clear all of the system counters and the diagnostic register."""
|
||||
self.__events = []
|
||||
self.__counters.reset()
|
||||
self.__diagnostic = [False] * 16
|
||||
|
||||
# -------------------------------------------------------------------------#
|
||||
# Listen Properties
|
||||
# -------------------------------------------------------------------------#
|
||||
def _setListenOnly(self, value):
|
||||
"""Toggle the listen only status.
|
||||
|
||||
:param value: The value to set the listen status to
|
||||
"""
|
||||
self.__listen_only = bool(value) # pylint: disable=unused-private-member
|
||||
|
||||
ListenOnly = property(lambda s: s.__listen_only, _setListenOnly)
|
||||
|
||||
# -------------------------------------------------------------------------#
|
||||
# Mode Properties
|
||||
# -------------------------------------------------------------------------#
|
||||
def _setMode(self, mode):
|
||||
"""Toggle the current serial mode.
|
||||
|
||||
:param mode: The data transfer method in (RTU, ASCII)
|
||||
"""
|
||||
if mode in {"ASCII", "RTU"}:
|
||||
self.__mode = mode # pylint: disable=unused-private-member
|
||||
|
||||
Mode = property(lambda s: s.__mode, _setMode)
|
||||
|
||||
# -------------------------------------------------------------------------#
|
||||
# Delimiter Properties
|
||||
# -------------------------------------------------------------------------#
|
||||
def _setDelimiter(self, char):
|
||||
"""Change the serial delimiter character.
|
||||
|
||||
:param char: The new serial delimiter character
|
||||
"""
|
||||
if isinstance(char, str):
|
||||
self.__delimiter = char.encode() # pylint: disable=unused-private-member
|
||||
if isinstance(char, bytes):
|
||||
self.__delimiter = char # pylint: disable=unused-private-member
|
||||
elif isinstance(char, int):
|
||||
self.__delimiter = struct.pack( # pylint: disable=unused-private-member
|
||||
">B", char
|
||||
)
|
||||
|
||||
Delimiter = property(lambda s: s.__delimiter, _setDelimiter)
|
||||
|
||||
# -------------------------------------------------------------------------#
|
||||
# Diagnostic Properties
|
||||
# -------------------------------------------------------------------------#
|
||||
def setDiagnostic(self, mapping):
|
||||
"""Set the value in the diagnostic register.
|
||||
|
||||
:param mapping: Dictionary of key:value pairs to set
|
||||
"""
|
||||
for entry in iter(mapping.items()):
|
||||
if entry[0] >= 0 and entry[0] < len(self.__diagnostic):
|
||||
self.__diagnostic[entry[0]] = bool(entry[1])
|
||||
|
||||
def getDiagnostic(self, bit):
|
||||
"""Get the value in the diagnostic register.
|
||||
|
||||
:param bit: The bit to get
|
||||
:returns: The current value of the requested bit
|
||||
"""
|
||||
try:
|
||||
if bit and 0 <= bit < len(self.__diagnostic):
|
||||
return self.__diagnostic[bit]
|
||||
except Exception: # pylint: disable=broad-except
|
||||
return None
|
||||
return None
|
||||
|
||||
def getDiagnosticRegister(self):
|
||||
"""Get the entire diagnostic register.
|
||||
|
||||
:returns: The diagnostic register collection
|
||||
"""
|
||||
return self.__diagnostic
|
||||
@@ -0,0 +1,870 @@
|
||||
"""Diagnostic Record Read/Write.
|
||||
|
||||
These need to be tied into a the current server context
|
||||
or linked to the appropriate data
|
||||
"""
|
||||
|
||||
__all__ = [
|
||||
"DiagnosticStatusRequest",
|
||||
"DiagnosticStatusResponse",
|
||||
"ReturnQueryDataRequest",
|
||||
"ReturnQueryDataResponse",
|
||||
"RestartCommunicationsOptionRequest",
|
||||
"RestartCommunicationsOptionResponse",
|
||||
"ReturnDiagnosticRegisterRequest",
|
||||
"ReturnDiagnosticRegisterResponse",
|
||||
"ChangeAsciiInputDelimiterRequest",
|
||||
"ChangeAsciiInputDelimiterResponse",
|
||||
"ForceListenOnlyModeRequest",
|
||||
"ForceListenOnlyModeResponse",
|
||||
"ClearCountersRequest",
|
||||
"ClearCountersResponse",
|
||||
"ReturnBusMessageCountRequest",
|
||||
"ReturnBusMessageCountResponse",
|
||||
"ReturnBusCommunicationErrorCountRequest",
|
||||
"ReturnBusCommunicationErrorCountResponse",
|
||||
"ReturnBusExceptionErrorCountRequest",
|
||||
"ReturnBusExceptionErrorCountResponse",
|
||||
"ReturnSlaveMessageCountRequest",
|
||||
"ReturnSlaveMessageCountResponse",
|
||||
"ReturnSlaveNoResponseCountRequest",
|
||||
"ReturnSlaveNoResponseCountResponse",
|
||||
"ReturnSlaveNAKCountRequest",
|
||||
"ReturnSlaveNAKCountResponse",
|
||||
"ReturnSlaveBusyCountRequest",
|
||||
"ReturnSlaveBusyCountResponse",
|
||||
"ReturnSlaveBusCharacterOverrunCountRequest",
|
||||
"ReturnSlaveBusCharacterOverrunCountResponse",
|
||||
"ReturnIopOverrunCountRequest",
|
||||
"ReturnIopOverrunCountResponse",
|
||||
"ClearOverrunCountRequest",
|
||||
"ClearOverrunCountResponse",
|
||||
"GetClearModbusPlusRequest",
|
||||
"GetClearModbusPlusResponse",
|
||||
]
|
||||
|
||||
# pylint: disable=missing-type-doc
|
||||
import struct
|
||||
|
||||
from pymodbus.constants import ModbusPlusOperation, ModbusStatus
|
||||
from pymodbus.device import ModbusControlBlock
|
||||
from pymodbus.exceptions import ModbusException, NotImplementedException
|
||||
from pymodbus.pdu import ModbusRequest, ModbusResponse
|
||||
from pymodbus.utilities import pack_bitstring
|
||||
|
||||
|
||||
_MCB = ModbusControlBlock()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------#
|
||||
# Diagnostic Function Codes Base Classes
|
||||
# diagnostic 08, 00-18,20
|
||||
# ---------------------------------------------------------------------------#
|
||||
# TODO Make sure all the data is decoded from the response # pylint: disable=fixme
|
||||
# ---------------------------------------------------------------------------#
|
||||
class DiagnosticStatusRequest(ModbusRequest):
|
||||
"""This is a base class for all of the diagnostic request functions."""
|
||||
|
||||
function_code = 0x08
|
||||
function_code_name = "diagnostic_status"
|
||||
_rtu_frame_size = 8
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
"""Initialize a diagnostic request."""
|
||||
ModbusRequest.__init__(self, **kwargs)
|
||||
self.message = None
|
||||
|
||||
def encode(self):
|
||||
"""Encode a diagnostic response.
|
||||
|
||||
we encode the data set in self.message
|
||||
|
||||
:returns: The encoded packet
|
||||
"""
|
||||
packet = struct.pack(">H", self.sub_function_code)
|
||||
if self.message is not None:
|
||||
if isinstance(self.message, str):
|
||||
packet += self.message.encode()
|
||||
elif isinstance(self.message, bytes):
|
||||
packet += self.message
|
||||
elif isinstance(self.message, (list, tuple)):
|
||||
for piece in self.message:
|
||||
packet += struct.pack(">H", piece)
|
||||
elif isinstance(self.message, int):
|
||||
packet += struct.pack(">H", self.message)
|
||||
return packet
|
||||
|
||||
def decode(self, data):
|
||||
"""Decode a diagnostic request.
|
||||
|
||||
:param data: The data to decode into the function code
|
||||
"""
|
||||
(
|
||||
self.sub_function_code, # pylint: disable=attribute-defined-outside-init
|
||||
) = struct.unpack(">H", data[:2])
|
||||
if self.sub_function_code == ReturnQueryDataRequest.sub_function_code:
|
||||
self.message = data[2:]
|
||||
else:
|
||||
(self.message,) = struct.unpack(">H", data[2:])
|
||||
|
||||
def get_response_pdu_size(self):
|
||||
"""Get response pdu size.
|
||||
|
||||
Func_code (1 byte) + Sub function code (2 byte) + Data (2 * N bytes)
|
||||
:return:
|
||||
"""
|
||||
if not isinstance(self.message, list):
|
||||
self.message = [self.message]
|
||||
return 1 + 2 + 2 * len(self.message)
|
||||
|
||||
|
||||
class DiagnosticStatusResponse(ModbusResponse):
|
||||
"""Diagnostic status.
|
||||
|
||||
This is a base class for all of the diagnostic response functions
|
||||
|
||||
It works by performing all of the encoding and decoding of variable
|
||||
data and lets the higher classes define what extra data to append
|
||||
and how to execute a request
|
||||
"""
|
||||
|
||||
function_code = 0x08
|
||||
_rtu_frame_size = 8
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
"""Initialize a diagnostic response."""
|
||||
ModbusResponse.__init__(self, **kwargs)
|
||||
self.message = None
|
||||
|
||||
def encode(self):
|
||||
"""Encode diagnostic response.
|
||||
|
||||
we encode the data set in self.message
|
||||
|
||||
:returns: The encoded packet
|
||||
"""
|
||||
packet = struct.pack(">H", self.sub_function_code)
|
||||
if self.message is not None:
|
||||
if isinstance(self.message, str):
|
||||
packet += self.message.encode()
|
||||
elif isinstance(self.message, bytes):
|
||||
packet += self.message
|
||||
elif isinstance(self.message, (list, tuple)):
|
||||
for piece in self.message:
|
||||
packet += struct.pack(">H", piece)
|
||||
elif isinstance(self.message, int):
|
||||
packet += struct.pack(">H", self.message)
|
||||
return packet
|
||||
|
||||
def decode(self, data):
|
||||
"""Decode diagnostic response.
|
||||
|
||||
:param data: The data to decode into the function code
|
||||
"""
|
||||
(
|
||||
self.sub_function_code, # pylint: disable=attribute-defined-outside-init
|
||||
) = struct.unpack(">H", data[:2])
|
||||
data = data[2:]
|
||||
if self.sub_function_code == ReturnQueryDataRequest.sub_function_code:
|
||||
self.message = data
|
||||
else:
|
||||
word_len = len(data) // 2
|
||||
if len(data) % 2:
|
||||
word_len += 1
|
||||
data += b"0"
|
||||
data = struct.unpack(">" + "H" * word_len, data)
|
||||
self.message = data
|
||||
|
||||
|
||||
class DiagnosticStatusSimpleRequest(DiagnosticStatusRequest):
|
||||
"""Return diagnostic status.
|
||||
|
||||
A large majority of the diagnostic functions are simple
|
||||
status request functions. They work by sending 0x0000
|
||||
as data and their function code and they are returned
|
||||
2 bytes of data.
|
||||
|
||||
If a function inherits this, they only need to implement
|
||||
the execute method
|
||||
"""
|
||||
|
||||
def __init__(self, data=0x0000, **kwargs):
|
||||
"""Initialize a simple diagnostic request
|
||||
|
||||
The data defaults to 0x0000 if not provided as over half
|
||||
of the functions require it.
|
||||
|
||||
:param data: The data to send along with the request
|
||||
"""
|
||||
DiagnosticStatusRequest.__init__(self, **kwargs)
|
||||
self.message = data
|
||||
|
||||
def execute(self, *args):
|
||||
"""Raise if not implemented."""
|
||||
raise NotImplementedException("Diagnostic Message Has No Execute Method")
|
||||
|
||||
|
||||
class DiagnosticStatusSimpleResponse(DiagnosticStatusResponse):
|
||||
"""Diagnostic status.
|
||||
|
||||
A large majority of the diagnostic functions are simple
|
||||
status request functions. They work by sending 0x0000
|
||||
as data and their function code and they are returned
|
||||
2 bytes of data.
|
||||
"""
|
||||
|
||||
def __init__(self, data=0x0000, **kwargs):
|
||||
"""Return a simple diagnostic response.
|
||||
|
||||
:param data: The resulting data to return to the client
|
||||
"""
|
||||
DiagnosticStatusResponse.__init__(self, **kwargs)
|
||||
self.message = data
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------#
|
||||
# Diagnostic Sub Code 00
|
||||
# ---------------------------------------------------------------------------#
|
||||
class ReturnQueryDataRequest(DiagnosticStatusRequest):
|
||||
"""Return query data.
|
||||
|
||||
The data passed in the request data field is to be returned (looped back)
|
||||
in the response. The entire response message should be identical to the
|
||||
request.
|
||||
"""
|
||||
|
||||
sub_function_code = 0x0000
|
||||
|
||||
def __init__(self, message=b"\x00\x00", slave=None, **kwargs):
|
||||
"""Initialize a new instance of the request.
|
||||
|
||||
:param message: The message to send to loopback
|
||||
"""
|
||||
DiagnosticStatusRequest.__init__(self, slave=slave, **kwargs)
|
||||
if not isinstance(message, bytes):
|
||||
raise ModbusException(f"message({type(message)}) must be bytes")
|
||||
self.message = message
|
||||
|
||||
def execute(self, *_args):
|
||||
"""Execute the loopback request (builds the response).
|
||||
|
||||
:returns: The populated loopback response message
|
||||
"""
|
||||
return ReturnQueryDataResponse(self.message)
|
||||
|
||||
|
||||
class ReturnQueryDataResponse(DiagnosticStatusResponse):
|
||||
"""Return query data.
|
||||
|
||||
The data passed in the request data field is to be returned (looped back)
|
||||
in the response. The entire response message should be identical to the
|
||||
request.
|
||||
"""
|
||||
|
||||
sub_function_code = 0x0000
|
||||
|
||||
def __init__(self, message=b"\x00\x00", **kwargs):
|
||||
"""Initialize a new instance of the response.
|
||||
|
||||
:param message: The message to loopback
|
||||
"""
|
||||
DiagnosticStatusResponse.__init__(self, **kwargs)
|
||||
if not isinstance(message, bytes):
|
||||
raise ModbusException(f"message({type(message)}) must be bytes")
|
||||
self.message = message
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------#
|
||||
# Diagnostic Sub Code 01
|
||||
# ---------------------------------------------------------------------------#
|
||||
class RestartCommunicationsOptionRequest(DiagnosticStatusRequest):
|
||||
"""Restart communication.
|
||||
|
||||
The remote device serial line port must be initialized and restarted, and
|
||||
all of its communications event counters are cleared. If the port is
|
||||
currently in Listen Only Mode, no response is returned. This function is
|
||||
the only one that brings the port out of Listen Only Mode. If the port is
|
||||
not currently in Listen Only Mode, a normal response is returned. This
|
||||
occurs before the restart is executed.
|
||||
"""
|
||||
|
||||
sub_function_code = 0x0001
|
||||
|
||||
def __init__(self, toggle=False, slave=None, **kwargs):
|
||||
"""Initialize a new request.
|
||||
|
||||
:param toggle: Set to True to toggle, False otherwise
|
||||
"""
|
||||
DiagnosticStatusRequest.__init__(self, slave=slave, **kwargs)
|
||||
if toggle:
|
||||
self.message = [ModbusStatus.ON]
|
||||
else:
|
||||
self.message = [ModbusStatus.OFF]
|
||||
|
||||
def execute(self, *_args):
|
||||
"""Clear event log and restart.
|
||||
|
||||
:returns: The initialized response message
|
||||
"""
|
||||
# if _MCB.ListenOnly:
|
||||
return RestartCommunicationsOptionResponse(self.message)
|
||||
|
||||
|
||||
class RestartCommunicationsOptionResponse(DiagnosticStatusResponse):
|
||||
"""Restart Communication.
|
||||
|
||||
The remote device serial line port must be initialized and restarted, and
|
||||
all of its communications event counters are cleared. If the port is
|
||||
currently in Listen Only Mode, no response is returned. This function is
|
||||
the only one that brings the port out of Listen Only Mode. If the port is
|
||||
not currently in Listen Only Mode, a normal response is returned. This
|
||||
occurs before the restart is executed.
|
||||
"""
|
||||
|
||||
sub_function_code = 0x0001
|
||||
|
||||
def __init__(self, toggle=False, **kwargs):
|
||||
"""Initialize a new response.
|
||||
|
||||
:param toggle: Set to True if we toggled, False otherwise
|
||||
"""
|
||||
DiagnosticStatusResponse.__init__(self, **kwargs)
|
||||
if toggle:
|
||||
self.message = [ModbusStatus.ON]
|
||||
else:
|
||||
self.message = [ModbusStatus.OFF]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------#
|
||||
# Diagnostic Sub Code 02
|
||||
# ---------------------------------------------------------------------------#
|
||||
class ReturnDiagnosticRegisterRequest(DiagnosticStatusSimpleRequest):
|
||||
"""The contents of the remote device's 16-bit diagnostic register are returned in the response."""
|
||||
|
||||
sub_function_code = 0x0002
|
||||
|
||||
def execute(self, *args):
|
||||
"""Execute the diagnostic request on the given device.
|
||||
|
||||
:returns: The initialized response message
|
||||
"""
|
||||
# if _MCB.isListenOnly():
|
||||
register = pack_bitstring(_MCB.getDiagnosticRegister())
|
||||
return ReturnDiagnosticRegisterResponse(register)
|
||||
|
||||
|
||||
class ReturnDiagnosticRegisterResponse(DiagnosticStatusSimpleResponse):
|
||||
"""Return diagnostic register.
|
||||
|
||||
The contents of the remote device's 16-bit diagnostic register are
|
||||
returned in the response
|
||||
"""
|
||||
|
||||
sub_function_code = 0x0002
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------#
|
||||
# Diagnostic Sub Code 03
|
||||
# ---------------------------------------------------------------------------#
|
||||
class ChangeAsciiInputDelimiterRequest(DiagnosticStatusSimpleRequest):
|
||||
"""Change ascii input delimiter.
|
||||
|
||||
The character "CHAR" passed in the request data field becomes the end of
|
||||
message delimiter for future messages (replacing the default LF
|
||||
character). This function is useful in cases of a Line Feed is not
|
||||
required at the end of ASCII messages.
|
||||
"""
|
||||
|
||||
sub_function_code = 0x0003
|
||||
|
||||
def execute(self, *args):
|
||||
"""Execute the diagnostic request on the given device.
|
||||
|
||||
:returns: The initialized response message
|
||||
"""
|
||||
char = (self.message & 0xFF00) >> 8
|
||||
_MCB._setDelimiter(char) # pylint: disable=protected-access
|
||||
return ChangeAsciiInputDelimiterResponse(self.message)
|
||||
|
||||
|
||||
class ChangeAsciiInputDelimiterResponse(DiagnosticStatusSimpleResponse):
|
||||
"""Change ascii input delimiter.
|
||||
|
||||
The character "CHAR" passed in the request data field becomes the end of
|
||||
message delimiter for future messages (replacing the default LF
|
||||
character). This function is useful in cases of a Line Feed is not
|
||||
required at the end of ASCII messages.
|
||||
"""
|
||||
|
||||
sub_function_code = 0x0003
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------#
|
||||
# Diagnostic Sub Code 04
|
||||
# ---------------------------------------------------------------------------#
|
||||
class ForceListenOnlyModeRequest(DiagnosticStatusSimpleRequest):
|
||||
"""Forces the addressed remote device to its Listen Only Mode for MODBUS communications.
|
||||
|
||||
This isolates it from the other devices on the network,
|
||||
allowing them to continue communicating without interruption from the
|
||||
addressed remote device. No response is returned.
|
||||
"""
|
||||
|
||||
sub_function_code = 0x0004
|
||||
|
||||
def execute(self, *args):
|
||||
"""Execute the diagnostic request on the given device.
|
||||
|
||||
:returns: The initialized response message
|
||||
"""
|
||||
_MCB._setListenOnly(True) # pylint: disable=protected-access
|
||||
return ForceListenOnlyModeResponse()
|
||||
|
||||
|
||||
class ForceListenOnlyModeResponse(DiagnosticStatusResponse):
|
||||
"""Forces the addressed remote device to its Listen Only Mode for MODBUS communications.
|
||||
|
||||
This isolates it from the other devices on the network,
|
||||
allowing them to continue communicating without interruption from the
|
||||
addressed remote device. No response is returned.
|
||||
|
||||
This does not send a response
|
||||
"""
|
||||
|
||||
sub_function_code = 0x0004
|
||||
should_respond = False
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
"""Initialize to block a return response."""
|
||||
DiagnosticStatusResponse.__init__(self, **kwargs)
|
||||
self.message = []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------#
|
||||
# Diagnostic Sub Code 10
|
||||
# ---------------------------------------------------------------------------#
|
||||
class ClearCountersRequest(DiagnosticStatusSimpleRequest):
|
||||
"""Clear ll counters and the diagnostic register.
|
||||
|
||||
Also, counters are cleared upon power-up
|
||||
"""
|
||||
|
||||
sub_function_code = 0x000A
|
||||
|
||||
def execute(self, *args):
|
||||
"""Execute the diagnostic request on the given device.
|
||||
|
||||
:returns: The initialized response message
|
||||
"""
|
||||
_MCB.reset()
|
||||
return ClearCountersResponse(self.message)
|
||||
|
||||
|
||||
class ClearCountersResponse(DiagnosticStatusSimpleResponse):
|
||||
"""Clear ll counters and the diagnostic register.
|
||||
|
||||
Also, counters are cleared upon power-up
|
||||
"""
|
||||
|
||||
sub_function_code = 0x000A
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------#
|
||||
# Diagnostic Sub Code 11
|
||||
# ---------------------------------------------------------------------------#
|
||||
class ReturnBusMessageCountRequest(DiagnosticStatusSimpleRequest):
|
||||
"""Return bus message count.
|
||||
|
||||
The response data field returns the quantity of messages that the
|
||||
remote device has detected on the communications systems since its last
|
||||
restart, clear counters operation, or power-up
|
||||
"""
|
||||
|
||||
sub_function_code = 0x000B
|
||||
|
||||
def execute(self, *args):
|
||||
"""Execute the diagnostic request on the given device.
|
||||
|
||||
:returns: The initialized response message
|
||||
"""
|
||||
count = _MCB.Counter.BusMessage
|
||||
return ReturnBusMessageCountResponse(count)
|
||||
|
||||
|
||||
class ReturnBusMessageCountResponse(DiagnosticStatusSimpleResponse):
|
||||
"""Return bus message count.
|
||||
|
||||
The response data field returns the quantity of messages that the
|
||||
remote device has detected on the communications systems since its last
|
||||
restart, clear counters operation, or power-up
|
||||
"""
|
||||
|
||||
sub_function_code = 0x000B
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------#
|
||||
# Diagnostic Sub Code 12
|
||||
# ---------------------------------------------------------------------------#
|
||||
class ReturnBusCommunicationErrorCountRequest(DiagnosticStatusSimpleRequest):
|
||||
"""Return bus comm. count.
|
||||
|
||||
The response data field returns the quantity of CRC errors encountered
|
||||
by the remote device since its last restart, clear counter operation, or
|
||||
power-up
|
||||
"""
|
||||
|
||||
sub_function_code = 0x000C
|
||||
|
||||
def execute(self, *args):
|
||||
"""Execute the diagnostic request on the given device.
|
||||
|
||||
:returns: The initialized response message
|
||||
"""
|
||||
count = _MCB.Counter.BusCommunicationError
|
||||
return ReturnBusCommunicationErrorCountResponse(count)
|
||||
|
||||
|
||||
class ReturnBusCommunicationErrorCountResponse(DiagnosticStatusSimpleResponse):
|
||||
"""Return bus comm. error.
|
||||
|
||||
The response data field returns the quantity of CRC errors encountered
|
||||
by the remote device since its last restart, clear counter operation, or
|
||||
power-up
|
||||
"""
|
||||
|
||||
sub_function_code = 0x000C
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------#
|
||||
# Diagnostic Sub Code 13
|
||||
# ---------------------------------------------------------------------------#
|
||||
class ReturnBusExceptionErrorCountRequest(DiagnosticStatusSimpleRequest):
|
||||
"""Return bus exception.
|
||||
|
||||
The response data field returns the quantity of modbus exception
|
||||
responses returned by the remote device since its last restart,
|
||||
clear counters operation, or power-up
|
||||
"""
|
||||
|
||||
sub_function_code = 0x000D
|
||||
|
||||
def execute(self, *args):
|
||||
"""Execute the diagnostic request on the given device.
|
||||
|
||||
:returns: The initialized response message
|
||||
"""
|
||||
count = _MCB.Counter.BusExceptionError
|
||||
return ReturnBusExceptionErrorCountResponse(count)
|
||||
|
||||
|
||||
class ReturnBusExceptionErrorCountResponse(DiagnosticStatusSimpleResponse):
|
||||
"""Return bus exception.
|
||||
|
||||
The response data field returns the quantity of modbus exception
|
||||
responses returned by the remote device since its last restart,
|
||||
clear counters operation, or power-up
|
||||
"""
|
||||
|
||||
sub_function_code = 0x000D
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------#
|
||||
# Diagnostic Sub Code 14
|
||||
# ---------------------------------------------------------------------------#
|
||||
class ReturnSlaveMessageCountRequest(DiagnosticStatusSimpleRequest):
|
||||
"""Return slave message count.
|
||||
|
||||
The response data field returns the quantity of messages addressed to the
|
||||
remote device, or broadcast, that the remote device has processed since
|
||||
its last restart, clear counters operation, or power-up
|
||||
"""
|
||||
|
||||
sub_function_code = 0x000E
|
||||
|
||||
def execute(self, *args):
|
||||
"""Execute the diagnostic request on the given device.
|
||||
|
||||
:returns: The initialized response message
|
||||
"""
|
||||
count = _MCB.Counter.SlaveMessage
|
||||
return ReturnSlaveMessageCountResponse(count)
|
||||
|
||||
|
||||
class ReturnSlaveMessageCountResponse(DiagnosticStatusSimpleResponse):
|
||||
"""Return slave message count.
|
||||
|
||||
The response data field returns the quantity of messages addressed to the
|
||||
remote device, or broadcast, that the remote device has processed since
|
||||
its last restart, clear counters operation, or power-up
|
||||
"""
|
||||
|
||||
sub_function_code = 0x000E
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------#
|
||||
# Diagnostic Sub Code 15
|
||||
# ---------------------------------------------------------------------------#
|
||||
class ReturnSlaveNoResponseCountRequest(DiagnosticStatusSimpleRequest):
|
||||
"""Return slave no response.
|
||||
|
||||
The response data field returns the quantity of messages addressed to the
|
||||
remote device, or broadcast, that the remote device has processed since
|
||||
its last restart, clear counters operation, or power-up
|
||||
"""
|
||||
|
||||
sub_function_code = 0x000F
|
||||
|
||||
def execute(self, *args):
|
||||
"""Execute the diagnostic request on the given device.
|
||||
|
||||
:returns: The initialized response message
|
||||
"""
|
||||
count = _MCB.Counter.SlaveNoResponse
|
||||
return ReturnSlaveNoResponseCountResponse(count)
|
||||
|
||||
|
||||
class ReturnSlaveNoResponseCountResponse(DiagnosticStatusSimpleResponse):
|
||||
"""Return slave no response.
|
||||
|
||||
The response data field returns the quantity of messages addressed to the
|
||||
remote device, or broadcast, that the remote device has processed since
|
||||
its last restart, clear counters operation, or power-up
|
||||
"""
|
||||
|
||||
sub_function_code = 0x000F
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------#
|
||||
# Diagnostic Sub Code 16
|
||||
# ---------------------------------------------------------------------------#
|
||||
class ReturnSlaveNAKCountRequest(DiagnosticStatusSimpleRequest):
|
||||
"""Return slave NAK count.
|
||||
|
||||
The response data field returns the quantity of messages addressed to the
|
||||
remote device for which it returned a Negative Acknowledge (NAK) exception
|
||||
response, since its last restart, clear counters operation, or power-up.
|
||||
Exception responses are described and listed in section 7 .
|
||||
"""
|
||||
|
||||
sub_function_code = 0x0010
|
||||
|
||||
def execute(self, *args):
|
||||
"""Execute the diagnostic request on the given device.
|
||||
|
||||
:returns: The initialized response message
|
||||
"""
|
||||
count = _MCB.Counter.SlaveNAK
|
||||
return ReturnSlaveNAKCountResponse(count)
|
||||
|
||||
|
||||
class ReturnSlaveNAKCountResponse(DiagnosticStatusSimpleResponse):
|
||||
"""Return slave NAK.
|
||||
|
||||
The response data field returns the quantity of messages addressed to the
|
||||
remote device for which it returned a Negative Acknowledge (NAK) exception
|
||||
response, since its last restart, clear counters operation, or power-up.
|
||||
Exception responses are described and listed in section 7.
|
||||
"""
|
||||
|
||||
sub_function_code = 0x0010
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------#
|
||||
# Diagnostic Sub Code 17
|
||||
# ---------------------------------------------------------------------------#
|
||||
class ReturnSlaveBusyCountRequest(DiagnosticStatusSimpleRequest):
|
||||
"""Return slave busy count.
|
||||
|
||||
The response data field returns the quantity of messages addressed to the
|
||||
remote device for which it returned a Slave Device Busy exception response,
|
||||
since its last restart, clear counters operation, or power-up.
|
||||
"""
|
||||
|
||||
sub_function_code = 0x0011
|
||||
|
||||
def execute(self, *args):
|
||||
"""Execute the diagnostic request on the given device.
|
||||
|
||||
:returns: The initialized response message
|
||||
"""
|
||||
count = _MCB.Counter.SlaveBusy
|
||||
return ReturnSlaveBusyCountResponse(count)
|
||||
|
||||
|
||||
class ReturnSlaveBusyCountResponse(DiagnosticStatusSimpleResponse):
|
||||
"""Return slave busy count.
|
||||
|
||||
The response data field returns the quantity of messages addressed to the
|
||||
remote device for which it returned a Slave Device Busy exception response,
|
||||
since its last restart, clear counters operation, or power-up.
|
||||
"""
|
||||
|
||||
sub_function_code = 0x0011
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------#
|
||||
# Diagnostic Sub Code 18
|
||||
# ---------------------------------------------------------------------------#
|
||||
class ReturnSlaveBusCharacterOverrunCountRequest(DiagnosticStatusSimpleRequest):
|
||||
"""Return slave character overrun.
|
||||
|
||||
The response data field returns the quantity of messages addressed to the
|
||||
remote device that it could not handle due to a character overrun condition,
|
||||
since its last restart, clear counters operation, or power-up. A character
|
||||
overrun is caused by data characters arriving at the port faster than they
|
||||
can be stored, or by the loss of a character due to a hardware malfunction.
|
||||
"""
|
||||
|
||||
sub_function_code = 0x0012
|
||||
|
||||
def execute(self, *args):
|
||||
"""Execute the diagnostic request on the given device.
|
||||
|
||||
:returns: The initialized response message
|
||||
"""
|
||||
count = _MCB.Counter.BusCharacterOverrun
|
||||
return ReturnSlaveBusCharacterOverrunCountResponse(count)
|
||||
|
||||
|
||||
class ReturnSlaveBusCharacterOverrunCountResponse(DiagnosticStatusSimpleResponse):
|
||||
"""Return the quantity of messages addressed to the remote device unhandled due to a character overrun.
|
||||
|
||||
Since its last restart, clear counters operation, or power-up. A character
|
||||
overrun is caused by data characters arriving at the port faster than they
|
||||
can be stored, or by the loss of a character due to a hardware malfunction.
|
||||
"""
|
||||
|
||||
sub_function_code = 0x0012
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------#
|
||||
# Diagnostic Sub Code 19
|
||||
# ---------------------------------------------------------------------------#
|
||||
class ReturnIopOverrunCountRequest(DiagnosticStatusSimpleRequest):
|
||||
"""Return IopOverrun.
|
||||
|
||||
An IOP overrun is caused by data characters arriving at the port
|
||||
faster than they can be stored, or by the loss of a character due
|
||||
to a hardware malfunction. This function is specific to the 884.
|
||||
"""
|
||||
|
||||
sub_function_code = 0x0013
|
||||
|
||||
def execute(self, *args):
|
||||
"""Execute the diagnostic request on the given device.
|
||||
|
||||
:returns: The initialized response message
|
||||
"""
|
||||
count = _MCB.Counter.BusCharacterOverrun
|
||||
return ReturnIopOverrunCountResponse(count)
|
||||
|
||||
|
||||
class ReturnIopOverrunCountResponse(DiagnosticStatusSimpleResponse):
|
||||
"""Return Iop overrun count.
|
||||
|
||||
The response data field returns the quantity of messages
|
||||
addressed to the slave that it could not handle due to an 884
|
||||
IOP overrun condition, since its last restart, clear counters
|
||||
operation, or power-up.
|
||||
"""
|
||||
|
||||
sub_function_code = 0x0013
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------#
|
||||
# Diagnostic Sub Code 20
|
||||
# ---------------------------------------------------------------------------#
|
||||
class ClearOverrunCountRequest(DiagnosticStatusSimpleRequest):
|
||||
"""Clear the overrun error counter and reset the error flag.
|
||||
|
||||
An error flag should be cleared, but nothing else in the
|
||||
specification mentions is, so it is ignored.
|
||||
"""
|
||||
|
||||
sub_function_code = 0x0014
|
||||
|
||||
def execute(self, *args):
|
||||
"""Execute the diagnostic request on the given device.
|
||||
|
||||
:returns: The initialized response message
|
||||
"""
|
||||
_MCB.Counter.BusCharacterOverrun = 0x0000
|
||||
return ClearOverrunCountResponse(self.message)
|
||||
|
||||
|
||||
class ClearOverrunCountResponse(DiagnosticStatusSimpleResponse):
|
||||
"""Clear the overrun error counter and reset the error flag."""
|
||||
|
||||
sub_function_code = 0x0014
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------#
|
||||
# Diagnostic Sub Code 21
|
||||
# ---------------------------------------------------------------------------#
|
||||
class GetClearModbusPlusRequest(DiagnosticStatusSimpleRequest):
|
||||
"""Get/Clear modbus plus request.
|
||||
|
||||
In addition to the Function code (08) and Subfunction code
|
||||
(00 15 hex) in the query, a two-byte Operation field is used
|
||||
to specify either a "Get Statistics" or a "Clear Statistics"
|
||||
operation. The two operations are exclusive - the "Get"
|
||||
operation cannot clear the statistics, and the "Clear"
|
||||
operation does not return statistics prior to clearing
|
||||
them. Statistics are also cleared on power-up of the slave
|
||||
device.
|
||||
"""
|
||||
|
||||
sub_function_code = 0x0015
|
||||
|
||||
def __init__(self, slave=None, **kwargs):
|
||||
"""Initialize."""
|
||||
super().__init__(slave=slave, **kwargs)
|
||||
|
||||
def get_response_pdu_size(self):
|
||||
"""Return a series of 54 16-bit words (108 bytes) in the data field of the response.
|
||||
|
||||
This function differs from the usual two-byte length of the data field.
|
||||
The data contains the statistics for the Modbus Plus peer processor in the slave device.
|
||||
Func_code (1 byte) + Sub function code (2 byte) + Operation (2 byte) + Data (108 bytes)
|
||||
:return:
|
||||
"""
|
||||
if self.message == ModbusPlusOperation.GET_STATISTICS:
|
||||
data = 2 + 108 # byte count(2) + data (54*2)
|
||||
else:
|
||||
data = 0
|
||||
return 1 + 2 + 2 + 2 + data
|
||||
|
||||
def execute(self, *args):
|
||||
"""Execute the diagnostic request on the given device.
|
||||
|
||||
:returns: The initialized response message
|
||||
"""
|
||||
message = None # the clear operation does not return info
|
||||
if self.message == ModbusPlusOperation.CLEAR_STATISTICS:
|
||||
_MCB.Plus.reset()
|
||||
message = self.message
|
||||
else:
|
||||
message = [self.message]
|
||||
message += _MCB.Plus.encode()
|
||||
return GetClearModbusPlusResponse(message)
|
||||
|
||||
def encode(self):
|
||||
"""Encode a diagnostic response.
|
||||
|
||||
we encode the data set in self.message
|
||||
|
||||
:returns: The encoded packet
|
||||
"""
|
||||
packet = struct.pack(">H", self.sub_function_code)
|
||||
packet += struct.pack(">H", self.message)
|
||||
return packet
|
||||
|
||||
|
||||
class GetClearModbusPlusResponse(DiagnosticStatusSimpleResponse):
|
||||
"""Return a series of 54 16-bit words (108 bytes) in the data field of the response.
|
||||
|
||||
This function differs from the usual two-byte length of the data field.
|
||||
The data contains the statistics for the Modbus Plus peer processor in the slave device.
|
||||
"""
|
||||
|
||||
sub_function_code = 0x0015
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
"""Modbus Remote Events.
|
||||
|
||||
An event byte returned by the Get Communications Event Log function
|
||||
can be any one of four types. The type is defined by bit 7
|
||||
(the high-order bit) in each byte. It may be further defined by bit 6.
|
||||
"""
|
||||
# pylint: disable=missing-type-doc
|
||||
from pymodbus.exceptions import NotImplementedException, ParameterException
|
||||
from pymodbus.utilities import pack_bitstring, unpack_bitstring
|
||||
|
||||
|
||||
class ModbusEvent:
|
||||
"""Define modbus events."""
|
||||
|
||||
def encode(self):
|
||||
"""Encode the status bits to an event message.
|
||||
|
||||
:raises NotImplementedException:
|
||||
"""
|
||||
raise NotImplementedException
|
||||
|
||||
def decode(self, event):
|
||||
"""Decode the event message to its status bits.
|
||||
|
||||
:param event: The event to decode
|
||||
:raises NotImplementedException:
|
||||
"""
|
||||
raise NotImplementedException
|
||||
|
||||
|
||||
class RemoteReceiveEvent(ModbusEvent):
|
||||
"""Remote device MODBUS Receive Event.
|
||||
|
||||
The remote device stores this type of event byte when a query message
|
||||
is received. It is stored before the remote device processes the message.
|
||||
This event is defined by bit 7 set to logic "1". The other bits will be
|
||||
set to a logic "1" if the corresponding condition is TRUE. The bit layout
|
||||
is::
|
||||
|
||||
Bit Contents
|
||||
----------------------------------
|
||||
0 Not Used
|
||||
2 Not Used
|
||||
3 Not Used
|
||||
4 Character Overrun
|
||||
5 Currently in Listen Only Mode
|
||||
6 Broadcast Receive
|
||||
7 1
|
||||
"""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
"""Initialize a new event instance."""
|
||||
self.overrun = kwargs.get("overrun", False)
|
||||
self.listen = kwargs.get("listen", False)
|
||||
self.broadcast = kwargs.get("broadcast", False)
|
||||
|
||||
def encode(self) -> bytes:
|
||||
"""Encode the status bits to an event message.
|
||||
|
||||
:returns: The encoded event message
|
||||
"""
|
||||
bits = [False] * 3
|
||||
bits += [self.overrun, self.listen, self.broadcast, True]
|
||||
packet = pack_bitstring(bits)
|
||||
return packet
|
||||
|
||||
def decode(self, event: bytes) -> None:
|
||||
"""Decode the event message to its status bits.
|
||||
|
||||
:param event: The event to decode
|
||||
"""
|
||||
bits = unpack_bitstring(event)
|
||||
self.overrun = bits[4]
|
||||
self.listen = bits[5]
|
||||
self.broadcast = bits[6]
|
||||
|
||||
|
||||
class RemoteSendEvent(ModbusEvent):
|
||||
"""Remote device MODBUS Send Event.
|
||||
|
||||
The remote device stores this type of event byte when it finishes
|
||||
processing a request message. It is stored if the remote device
|
||||
returned a normal or exception response, or no response.
|
||||
|
||||
This event is defined by bit 7 set to a logic "0", with bit 6 set to a "1".
|
||||
The other bits will be set to a logic "1" if the corresponding
|
||||
condition is TRUE. The bit layout is::
|
||||
|
||||
Bit Contents
|
||||
-----------------------------------------------------------
|
||||
0 Read Exception Sent (Exception Codes 1-3)
|
||||
1 Slave Abort Exception Sent (Exception Code 4)
|
||||
2 Slave Busy Exception Sent (Exception Codes 5-6)
|
||||
3 Slave Program NAK Exception Sent (Exception Code 7)
|
||||
4 Write Timeout Error Occurred
|
||||
5 Currently in Listen Only Mode
|
||||
6 1
|
||||
7 0
|
||||
"""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
"""Initialize a new event instance."""
|
||||
self.read = kwargs.get("read", False)
|
||||
self.slave_abort = kwargs.get("slave_abort", False)
|
||||
self.slave_busy = kwargs.get("slave_busy", False)
|
||||
self.slave_nak = kwargs.get("slave_nak", False)
|
||||
self.write_timeout = kwargs.get("write_timeout", False)
|
||||
self.listen = kwargs.get("listen", False)
|
||||
|
||||
def encode(self):
|
||||
"""Encode the status bits to an event message.
|
||||
|
||||
:returns: The encoded event message
|
||||
"""
|
||||
bits = [
|
||||
self.read,
|
||||
self.slave_abort,
|
||||
self.slave_busy,
|
||||
self.slave_nak,
|
||||
self.write_timeout,
|
||||
self.listen,
|
||||
]
|
||||
bits += [True, False]
|
||||
packet = pack_bitstring(bits)
|
||||
return packet
|
||||
|
||||
def decode(self, event):
|
||||
"""Decode the event message to its status bits.
|
||||
|
||||
:param event: The event to decode
|
||||
"""
|
||||
# todo fix the start byte count # pylint: disable=fixme
|
||||
bits = unpack_bitstring(event)
|
||||
self.read = bits[0]
|
||||
self.slave_abort = bits[1]
|
||||
self.slave_busy = bits[2]
|
||||
self.slave_nak = bits[3]
|
||||
self.write_timeout = bits[4]
|
||||
self.listen = bits[5]
|
||||
|
||||
|
||||
class EnteredListenModeEvent(ModbusEvent):
|
||||
"""Enter Remote device Listen Only Mode
|
||||
|
||||
The remote device stores this type of event byte when it enters
|
||||
the Listen Only Mode. The event is defined by a content of 04 hex.
|
||||
"""
|
||||
|
||||
value = 0x04
|
||||
__encoded = b"\x04"
|
||||
|
||||
def encode(self):
|
||||
"""Encode the status bits to an event message.
|
||||
|
||||
:returns: The encoded event message
|
||||
"""
|
||||
return self.__encoded
|
||||
|
||||
def decode(self, event):
|
||||
"""Decode the event message to its status bits.
|
||||
|
||||
:param event: The event to decode
|
||||
:raises ParameterException:
|
||||
"""
|
||||
if event != self.__encoded:
|
||||
raise ParameterException("Invalid decoded value")
|
||||
|
||||
|
||||
class CommunicationRestartEvent(ModbusEvent):
|
||||
"""Restart remote device Initiated Communication.
|
||||
|
||||
The remote device stores this type of event byte when its communications
|
||||
port is restarted. The remote device can be restarted by the Diagnostics
|
||||
function (code 08), with sub-function Restart Communications Option
|
||||
(code 00 01).
|
||||
|
||||
That function also places the remote device into a "Continue on Error"
|
||||
or "Stop on Error" mode. If the remote device is placed into "Continue on
|
||||
Error" mode, the event byte is added to the existing event log. If the
|
||||
remote device is placed into "Stop on Error" mode, the byte is added to
|
||||
the log and the rest of the log is cleared to zeros.
|
||||
|
||||
The event is defined by a content of zero.
|
||||
"""
|
||||
|
||||
value = 0x00
|
||||
__encoded = b"\x00"
|
||||
|
||||
def encode(self):
|
||||
"""Encode the status bits to an event message.
|
||||
|
||||
:returns: The encoded event message
|
||||
"""
|
||||
return self.__encoded
|
||||
|
||||
def decode(self, event):
|
||||
"""Decode the event message to its status bits.
|
||||
|
||||
:param event: The event to decode
|
||||
:raises ParameterException:
|
||||
"""
|
||||
if event != self.__encoded:
|
||||
raise ParameterException("Invalid decoded value")
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Pymodbus Exceptions.
|
||||
|
||||
Custom exceptions to be used in the Modbus code.
|
||||
"""
|
||||
|
||||
__all__ = [
|
||||
"ModbusException",
|
||||
"ModbusIOException",
|
||||
"ParameterException",
|
||||
"NotImplementedException",
|
||||
"ConnectionException",
|
||||
"NoSuchSlaveException",
|
||||
"InvalidMessageReceivedException",
|
||||
"MessageRegisterException",
|
||||
]
|
||||
|
||||
|
||||
class ModbusException(Exception):
|
||||
"""Base modbus exception."""
|
||||
|
||||
def __init__(self, string):
|
||||
"""Initialize the exception.
|
||||
|
||||
:param string: The message to append to the error
|
||||
"""
|
||||
self.string = string
|
||||
super().__init__(string)
|
||||
|
||||
def __str__(self):
|
||||
"""Return string representation."""
|
||||
return f"Modbus Error: {self.string}"
|
||||
|
||||
def isError(self):
|
||||
"""Error"""
|
||||
return True
|
||||
|
||||
|
||||
class ModbusIOException(ModbusException):
|
||||
"""Error resulting from data i/o."""
|
||||
|
||||
def __init__(self, string="", function_code=None):
|
||||
"""Initialize the exception.
|
||||
|
||||
:param string: The message to append to the error
|
||||
"""
|
||||
self.fcode = function_code
|
||||
self.message = f"[Input/Output] {string}"
|
||||
ModbusException.__init__(self, self.message)
|
||||
|
||||
|
||||
class ParameterException(ModbusException):
|
||||
"""Error resulting from invalid parameter."""
|
||||
|
||||
def __init__(self, string=""):
|
||||
"""Initialize the exception.
|
||||
|
||||
:param string: The message to append to the error
|
||||
"""
|
||||
message = f"[Invalid Parameter] {string}"
|
||||
ModbusException.__init__(self, message)
|
||||
|
||||
|
||||
class NoSuchSlaveException(ModbusException):
|
||||
"""Error resulting from making a request to a slave that does not exist."""
|
||||
|
||||
def __init__(self, string=""):
|
||||
"""Initialize the exception.
|
||||
|
||||
:param string: The message to append to the error
|
||||
"""
|
||||
message = f"[No Such Slave] {string}"
|
||||
ModbusException.__init__(self, message)
|
||||
|
||||
|
||||
class NotImplementedException(ModbusException):
|
||||
"""Error resulting from not implemented function."""
|
||||
|
||||
def __init__(self, string=""):
|
||||
"""Initialize the exception.
|
||||
|
||||
:param string: The message to append to the error
|
||||
"""
|
||||
message = f"[Not Implemented] {string}"
|
||||
ModbusException.__init__(self, message)
|
||||
|
||||
|
||||
class ConnectionException(ModbusException):
|
||||
"""Error resulting from a bad connection."""
|
||||
|
||||
def __init__(self, string=""):
|
||||
"""Initialize the exception.
|
||||
|
||||
:param string: The message to append to the error
|
||||
"""
|
||||
message = f"[Connection] {string}"
|
||||
ModbusException.__init__(self, message)
|
||||
|
||||
|
||||
class InvalidMessageReceivedException(ModbusException):
|
||||
"""Error resulting from invalid response received or decoded."""
|
||||
|
||||
def __init__(self, string=""):
|
||||
"""Initialize the exception.
|
||||
|
||||
:param string: The message to append to the error
|
||||
"""
|
||||
message = f"[Invalid Message] {string}"
|
||||
ModbusException.__init__(self, message)
|
||||
|
||||
|
||||
class MessageRegisterException(ModbusException):
|
||||
"""Error resulting from failing to register a custom message request/response."""
|
||||
|
||||
def __init__(self, string=""):
|
||||
"""Initialize."""
|
||||
message = f"[Error registering message] {string}"
|
||||
ModbusException.__init__(self, message)
|
||||
@@ -0,0 +1,374 @@
|
||||
"""Modbus Request/Response Decoder Factories.
|
||||
|
||||
The following factories make it easy to decode request/response messages.
|
||||
To add a new request/response pair to be decodeable by the library, simply
|
||||
add them to the respective function lookup table (order doesn't matter, but
|
||||
it does help keep things organized).
|
||||
|
||||
Regardless of how many functions are added to the lookup, O(1) behavior is
|
||||
kept as a result of a pre-computed lookup dictionary.
|
||||
"""
|
||||
|
||||
# pylint: disable=missing-type-doc
|
||||
from pymodbus.bit_read_message import (
|
||||
ReadCoilsRequest,
|
||||
ReadCoilsResponse,
|
||||
ReadDiscreteInputsRequest,
|
||||
ReadDiscreteInputsResponse,
|
||||
)
|
||||
from pymodbus.bit_write_message import (
|
||||
WriteMultipleCoilsRequest,
|
||||
WriteMultipleCoilsResponse,
|
||||
WriteSingleCoilRequest,
|
||||
WriteSingleCoilResponse,
|
||||
)
|
||||
from pymodbus.diag_message import (
|
||||
ChangeAsciiInputDelimiterRequest,
|
||||
ChangeAsciiInputDelimiterResponse,
|
||||
ClearCountersRequest,
|
||||
ClearCountersResponse,
|
||||
ClearOverrunCountRequest,
|
||||
ClearOverrunCountResponse,
|
||||
DiagnosticStatusRequest,
|
||||
DiagnosticStatusResponse,
|
||||
ForceListenOnlyModeRequest,
|
||||
ForceListenOnlyModeResponse,
|
||||
GetClearModbusPlusRequest,
|
||||
GetClearModbusPlusResponse,
|
||||
RestartCommunicationsOptionRequest,
|
||||
RestartCommunicationsOptionResponse,
|
||||
ReturnBusCommunicationErrorCountRequest,
|
||||
ReturnBusCommunicationErrorCountResponse,
|
||||
ReturnBusExceptionErrorCountRequest,
|
||||
ReturnBusExceptionErrorCountResponse,
|
||||
ReturnBusMessageCountRequest,
|
||||
ReturnBusMessageCountResponse,
|
||||
ReturnDiagnosticRegisterRequest,
|
||||
ReturnDiagnosticRegisterResponse,
|
||||
ReturnIopOverrunCountRequest,
|
||||
ReturnIopOverrunCountResponse,
|
||||
ReturnQueryDataRequest,
|
||||
ReturnQueryDataResponse,
|
||||
ReturnSlaveBusCharacterOverrunCountRequest,
|
||||
ReturnSlaveBusCharacterOverrunCountResponse,
|
||||
ReturnSlaveBusyCountRequest,
|
||||
ReturnSlaveBusyCountResponse,
|
||||
ReturnSlaveMessageCountRequest,
|
||||
ReturnSlaveMessageCountResponse,
|
||||
ReturnSlaveNAKCountRequest,
|
||||
ReturnSlaveNAKCountResponse,
|
||||
ReturnSlaveNoResponseCountRequest,
|
||||
ReturnSlaveNoResponseCountResponse,
|
||||
)
|
||||
from pymodbus.exceptions import MessageRegisterException, ModbusException
|
||||
from pymodbus.file_message import (
|
||||
ReadFifoQueueRequest,
|
||||
ReadFifoQueueResponse,
|
||||
ReadFileRecordRequest,
|
||||
ReadFileRecordResponse,
|
||||
WriteFileRecordRequest,
|
||||
WriteFileRecordResponse,
|
||||
)
|
||||
from pymodbus.logging import Log
|
||||
from pymodbus.mei_message import (
|
||||
ReadDeviceInformationRequest,
|
||||
ReadDeviceInformationResponse,
|
||||
)
|
||||
from pymodbus.other_message import (
|
||||
GetCommEventCounterRequest,
|
||||
GetCommEventCounterResponse,
|
||||
GetCommEventLogRequest,
|
||||
GetCommEventLogResponse,
|
||||
ReadExceptionStatusRequest,
|
||||
ReadExceptionStatusResponse,
|
||||
ReportSlaveIdRequest,
|
||||
ReportSlaveIdResponse,
|
||||
)
|
||||
from pymodbus.pdu import (
|
||||
ExceptionResponse,
|
||||
IllegalFunctionRequest,
|
||||
ModbusRequest,
|
||||
ModbusResponse,
|
||||
)
|
||||
from pymodbus.pdu import ModbusExceptions as ecode
|
||||
from pymodbus.register_read_message import (
|
||||
ReadHoldingRegistersRequest,
|
||||
ReadHoldingRegistersResponse,
|
||||
ReadInputRegistersRequest,
|
||||
ReadInputRegistersResponse,
|
||||
ReadWriteMultipleRegistersRequest,
|
||||
ReadWriteMultipleRegistersResponse,
|
||||
)
|
||||
from pymodbus.register_write_message import (
|
||||
MaskWriteRegisterRequest,
|
||||
MaskWriteRegisterResponse,
|
||||
WriteMultipleRegistersRequest,
|
||||
WriteMultipleRegistersResponse,
|
||||
WriteSingleRegisterRequest,
|
||||
WriteSingleRegisterResponse,
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Server Decoder
|
||||
# --------------------------------------------------------------------------- #
|
||||
class ServerDecoder:
|
||||
"""Request Message Factory (Server).
|
||||
|
||||
To add more implemented functions, simply add them to the list
|
||||
"""
|
||||
|
||||
__function_table = [
|
||||
ReadHoldingRegistersRequest,
|
||||
ReadDiscreteInputsRequest,
|
||||
ReadInputRegistersRequest,
|
||||
ReadCoilsRequest,
|
||||
WriteMultipleCoilsRequest,
|
||||
WriteMultipleRegistersRequest,
|
||||
WriteSingleRegisterRequest,
|
||||
WriteSingleCoilRequest,
|
||||
ReadWriteMultipleRegistersRequest,
|
||||
DiagnosticStatusRequest,
|
||||
ReadExceptionStatusRequest,
|
||||
GetCommEventCounterRequest,
|
||||
GetCommEventLogRequest,
|
||||
ReportSlaveIdRequest,
|
||||
ReadFileRecordRequest,
|
||||
WriteFileRecordRequest,
|
||||
MaskWriteRegisterRequest,
|
||||
ReadFifoQueueRequest,
|
||||
ReadDeviceInformationRequest,
|
||||
]
|
||||
__sub_function_table = [
|
||||
ReturnQueryDataRequest,
|
||||
RestartCommunicationsOptionRequest,
|
||||
ReturnDiagnosticRegisterRequest,
|
||||
ChangeAsciiInputDelimiterRequest,
|
||||
ForceListenOnlyModeRequest,
|
||||
ClearCountersRequest,
|
||||
ReturnBusMessageCountRequest,
|
||||
ReturnBusCommunicationErrorCountRequest,
|
||||
ReturnBusExceptionErrorCountRequest,
|
||||
ReturnSlaveMessageCountRequest,
|
||||
ReturnSlaveNoResponseCountRequest,
|
||||
ReturnSlaveNAKCountRequest,
|
||||
ReturnSlaveBusyCountRequest,
|
||||
ReturnSlaveBusCharacterOverrunCountRequest,
|
||||
ReturnIopOverrunCountRequest,
|
||||
ClearOverrunCountRequest,
|
||||
GetClearModbusPlusRequest,
|
||||
ReadDeviceInformationRequest,
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def getFCdict(cls):
|
||||
"""Build function code - class list."""
|
||||
return {f.function_code: f for f in cls.__function_table}
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the client lookup tables."""
|
||||
functions = {f.function_code for f in self.__function_table}
|
||||
self.lookup = self.getFCdict()
|
||||
self.__sub_lookup = {f: {} for f in functions}
|
||||
for f in self.__sub_function_table:
|
||||
self.__sub_lookup[f.function_code][f.sub_function_code] = f
|
||||
|
||||
def decode(self, message):
|
||||
"""Decode a request packet
|
||||
|
||||
:param message: The raw modbus request packet
|
||||
:return: The decoded modbus message or None if error
|
||||
"""
|
||||
try:
|
||||
return self._helper(message)
|
||||
except ModbusException as exc:
|
||||
Log.warning("Unable to decode request {}", exc)
|
||||
return None
|
||||
|
||||
def lookupPduClass(self, function_code):
|
||||
"""Use `function_code` to determine the class of the PDU.
|
||||
|
||||
:param function_code: The function code specified in a frame.
|
||||
:returns: The class of the PDU that has a matching `function_code`.
|
||||
"""
|
||||
return self.lookup.get(function_code, ExceptionResponse)
|
||||
|
||||
def _helper(self, data):
|
||||
"""Generate the correct request object from a valid request packet.
|
||||
|
||||
This decodes from a list of the currently implemented request types.
|
||||
|
||||
:param data: The request packet to decode
|
||||
:returns: The decoded request or illegal function request object
|
||||
"""
|
||||
function_code = int(data[0])
|
||||
if not (request := self.lookup.get(function_code, lambda: None)()):
|
||||
Log.debug("Factory Request[{}]", function_code)
|
||||
request = IllegalFunctionRequest(function_code)
|
||||
else:
|
||||
fc_string = "{}: {}".format( # pylint: disable=consider-using-f-string
|
||||
str(self.lookup[function_code]) # pylint: disable=use-maxsplit-arg
|
||||
.split(".")[-1]
|
||||
.rstrip('">"'),
|
||||
function_code,
|
||||
)
|
||||
Log.debug("Factory Request[{}]", fc_string)
|
||||
request.decode(data[1:])
|
||||
|
||||
if hasattr(request, "sub_function_code"):
|
||||
lookup = self.__sub_lookup.get(request.function_code, {})
|
||||
if subtype := lookup.get(request.sub_function_code, None):
|
||||
request.__class__ = subtype
|
||||
|
||||
return request
|
||||
|
||||
def register(self, function=None):
|
||||
"""Register a function and sub function class with the decoder.
|
||||
|
||||
:param function: Custom function class to register
|
||||
:raises MessageRegisterException:
|
||||
"""
|
||||
if function and not issubclass(function, ModbusRequest):
|
||||
raise MessageRegisterException(
|
||||
f'"{function.__class__.__name__}" is Not a valid Modbus Message'
|
||||
". Class needs to be derived from "
|
||||
"`pymodbus.pdu.ModbusRequest` "
|
||||
)
|
||||
self.lookup[function.function_code] = function
|
||||
if hasattr(function, "sub_function_code"):
|
||||
if function.function_code not in self.__sub_lookup:
|
||||
self.__sub_lookup[function.function_code] = {}
|
||||
self.__sub_lookup[function.function_code][
|
||||
function.sub_function_code
|
||||
] = function
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Client Decoder
|
||||
# --------------------------------------------------------------------------- #
|
||||
class ClientDecoder:
|
||||
"""Response Message Factory (Client).
|
||||
|
||||
To add more implemented functions, simply add them to the list
|
||||
"""
|
||||
|
||||
function_table = [
|
||||
ReadHoldingRegistersResponse,
|
||||
ReadDiscreteInputsResponse,
|
||||
ReadInputRegistersResponse,
|
||||
ReadCoilsResponse,
|
||||
WriteMultipleCoilsResponse,
|
||||
WriteMultipleRegistersResponse,
|
||||
WriteSingleRegisterResponse,
|
||||
WriteSingleCoilResponse,
|
||||
ReadWriteMultipleRegistersResponse,
|
||||
DiagnosticStatusResponse,
|
||||
ReadExceptionStatusResponse,
|
||||
GetCommEventCounterResponse,
|
||||
GetCommEventLogResponse,
|
||||
ReportSlaveIdResponse,
|
||||
ReadFileRecordResponse,
|
||||
WriteFileRecordResponse,
|
||||
MaskWriteRegisterResponse,
|
||||
ReadFifoQueueResponse,
|
||||
ReadDeviceInformationResponse,
|
||||
]
|
||||
__sub_function_table = [
|
||||
ReturnQueryDataResponse,
|
||||
RestartCommunicationsOptionResponse,
|
||||
ReturnDiagnosticRegisterResponse,
|
||||
ChangeAsciiInputDelimiterResponse,
|
||||
ForceListenOnlyModeResponse,
|
||||
ClearCountersResponse,
|
||||
ReturnBusMessageCountResponse,
|
||||
ReturnBusCommunicationErrorCountResponse,
|
||||
ReturnBusExceptionErrorCountResponse,
|
||||
ReturnSlaveMessageCountResponse,
|
||||
ReturnSlaveNoResponseCountResponse,
|
||||
ReturnSlaveNAKCountResponse,
|
||||
ReturnSlaveBusyCountResponse,
|
||||
ReturnSlaveBusCharacterOverrunCountResponse,
|
||||
ReturnIopOverrunCountResponse,
|
||||
ClearOverrunCountResponse,
|
||||
GetClearModbusPlusResponse,
|
||||
ReadDeviceInformationResponse,
|
||||
]
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the client lookup tables."""
|
||||
functions = {f.function_code for f in self.function_table}
|
||||
self.lookup = {f.function_code: f for f in self.function_table}
|
||||
self.__sub_lookup = {f: {} for f in functions}
|
||||
for f in self.__sub_function_table:
|
||||
self.__sub_lookup[f.function_code][f.sub_function_code] = f
|
||||
|
||||
def lookupPduClass(self, function_code):
|
||||
"""Use `function_code` to determine the class of the PDU.
|
||||
|
||||
:param function_code: The function code specified in a frame.
|
||||
:returns: The class of the PDU that has a matching `function_code`.
|
||||
"""
|
||||
return self.lookup.get(function_code, ExceptionResponse)
|
||||
|
||||
def decode(self, message):
|
||||
"""Decode a response packet.
|
||||
|
||||
:param message: The raw packet to decode
|
||||
:return: The decoded modbus message or None if error
|
||||
"""
|
||||
try:
|
||||
return self._helper(message)
|
||||
except ModbusException as exc:
|
||||
Log.error("Unable to decode response {}", exc)
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
Log.error("General exception: {}", exc)
|
||||
return None
|
||||
|
||||
def _helper(self, data):
|
||||
"""Generate the correct response object from a valid response packet.
|
||||
|
||||
This decodes from a list of the currently implemented request types.
|
||||
|
||||
:param data: The response packet to decode
|
||||
:returns: The decoded request or an exception response object
|
||||
:raises ModbusException:
|
||||
"""
|
||||
fc_string = function_code = int(data[0])
|
||||
if function_code in self.lookup:
|
||||
fc_string = "{}: {}".format( # pylint: disable=consider-using-f-string
|
||||
str(self.lookup[function_code]) # pylint: disable=use-maxsplit-arg
|
||||
.split(".")[-1]
|
||||
.rstrip('">"'),
|
||||
function_code,
|
||||
)
|
||||
Log.debug("Factory Response[{}]", fc_string)
|
||||
response = self.lookup.get(function_code, lambda: None)()
|
||||
if function_code > 0x80:
|
||||
code = function_code & 0x7F # strip error portion
|
||||
response = ExceptionResponse(code, ecode.IllegalFunction)
|
||||
if not response:
|
||||
raise ModbusException(f"Unknown response {function_code}")
|
||||
response.decode(data[1:])
|
||||
|
||||
if hasattr(response, "sub_function_code"):
|
||||
lookup = self.__sub_lookup.get(response.function_code, {})
|
||||
if subtype := lookup.get(response.sub_function_code, None):
|
||||
response.__class__ = subtype
|
||||
|
||||
return response
|
||||
|
||||
def register(self, function):
|
||||
"""Register a function and sub function class with the decoder."""
|
||||
if function and not issubclass(function, ModbusResponse):
|
||||
raise MessageRegisterException(
|
||||
f'"{function.__class__.__name__}" is Not a valid Modbus Message'
|
||||
". Class needs to be derived from "
|
||||
"`pymodbus.pdu.ModbusResponse` "
|
||||
)
|
||||
self.lookup[function.function_code] = function
|
||||
if hasattr(function, "sub_function_code"):
|
||||
if function.function_code not in self.__sub_lookup:
|
||||
self.__sub_lookup[function.function_code] = {}
|
||||
self.__sub_lookup[function.function_code][
|
||||
function.sub_function_code
|
||||
] = function
|
||||
@@ -0,0 +1,437 @@
|
||||
"""File Record Read/Write Messages.
|
||||
|
||||
Currently none of these messages are implemented
|
||||
"""
|
||||
|
||||
__all__ = [
|
||||
"FileRecord",
|
||||
"ReadFileRecordRequest",
|
||||
"ReadFileRecordResponse",
|
||||
"WriteFileRecordRequest",
|
||||
"WriteFileRecordResponse",
|
||||
"ReadFifoQueueRequest",
|
||||
"ReadFifoQueueResponse",
|
||||
]
|
||||
|
||||
# pylint: disable=missing-type-doc
|
||||
import struct
|
||||
|
||||
from pymodbus.pdu import ModbusExceptions as merror
|
||||
from pymodbus.pdu import ModbusRequest, ModbusResponse
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------#
|
||||
# File Record Types
|
||||
# ---------------------------------------------------------------------------#
|
||||
class FileRecord: # pylint: disable=eq-without-hash
|
||||
"""Represents a file record and its relevant data."""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
"""Initialize a new instance.
|
||||
|
||||
:params reference_type: must be 0x06
|
||||
:params file_number: Indicates which file number we are reading
|
||||
:params record_number: Indicates which record in the file
|
||||
:params record_data: The actual data of the record
|
||||
:params record_length: The length in registers of the record
|
||||
:params response_length: The length in bytes of the record
|
||||
"""
|
||||
self.reference_type = kwargs.get("reference_type", 0x06)
|
||||
self.file_number = kwargs.get("file_number", 0x00)
|
||||
self.record_number = kwargs.get("record_number", 0x00)
|
||||
self.record_data = kwargs.get("record_data", "")
|
||||
|
||||
self.record_length = kwargs.get("record_length", len(self.record_data) // 2)
|
||||
self.response_length = kwargs.get("response_length", len(self.record_data) + 1)
|
||||
|
||||
def __eq__(self, relf):
|
||||
"""Compare the left object to the right."""
|
||||
return (
|
||||
self.reference_type == relf.reference_type
|
||||
and self.file_number == relf.file_number
|
||||
and self.record_number == relf.record_number
|
||||
and self.record_length == relf.record_length
|
||||
and self.record_data == relf.record_data
|
||||
)
|
||||
|
||||
def __ne__(self, relf):
|
||||
"""Compare the left object to the right."""
|
||||
return not self.__eq__(relf)
|
||||
|
||||
def __repr__(self):
|
||||
"""Give a representation of the file record."""
|
||||
params = (self.file_number, self.record_number, self.record_length)
|
||||
return (
|
||||
"FileRecord(file=%d, record=%d, length=%d)" # pylint: disable=consider-using-f-string
|
||||
% params
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------#
|
||||
# File Requests/Responses
|
||||
# ---------------------------------------------------------------------------#
|
||||
class ReadFileRecordRequest(ModbusRequest):
|
||||
"""Read file record request.
|
||||
|
||||
This function code is used to perform a file record read. All request
|
||||
data lengths are provided in terms of number of bytes and all record
|
||||
lengths are provided in terms of registers.
|
||||
|
||||
A file is an organization of records. Each file contains 10000 records,
|
||||
addressed 0000 to 9999 decimal or 0x0000 to 0x270f. For example, record
|
||||
12 is addressed as 12. The function can read multiple groups of
|
||||
references. The groups can be separating (non-contiguous), but the
|
||||
references within each group must be sequential. Each group is defined
|
||||
in a separate "sub-request" field that contains seven bytes::
|
||||
|
||||
The reference type: 1 byte (must be 0x06)
|
||||
The file number: 2 bytes
|
||||
The starting record number within the file: 2 bytes
|
||||
The length of the record to be read: 2 bytes
|
||||
|
||||
The quantity of registers to be read, combined with all other fields
|
||||
in the expected response, must not exceed the allowable length of the
|
||||
MODBUS PDU: 235 bytes.
|
||||
"""
|
||||
|
||||
function_code = 0x14
|
||||
function_code_name = "read_file_record"
|
||||
_rtu_byte_count_pos = 2
|
||||
|
||||
def __init__(self, records=None, **kwargs):
|
||||
"""Initialize a new instance.
|
||||
|
||||
:param records: The file record requests to be read
|
||||
"""
|
||||
ModbusRequest.__init__(self, **kwargs)
|
||||
self.records = records or []
|
||||
|
||||
def encode(self):
|
||||
"""Encode the request packet.
|
||||
|
||||
:returns: The byte encoded packet
|
||||
"""
|
||||
packet = struct.pack("B", len(self.records) * 7)
|
||||
for record in self.records:
|
||||
packet += struct.pack(
|
||||
">BHHH",
|
||||
0x06,
|
||||
record.file_number,
|
||||
record.record_number,
|
||||
record.record_length,
|
||||
)
|
||||
return packet
|
||||
|
||||
def decode(self, data):
|
||||
"""Decode the incoming request.
|
||||
|
||||
:param data: The data to decode into the address
|
||||
"""
|
||||
self.records = []
|
||||
byte_count = int(data[0])
|
||||
for count in range(1, byte_count, 7):
|
||||
decoded = struct.unpack(">BHHH", data[count : count + 7])
|
||||
record = FileRecord(
|
||||
file_number=decoded[1],
|
||||
record_number=decoded[2],
|
||||
record_length=decoded[3],
|
||||
)
|
||||
if decoded[0] == 0x06:
|
||||
self.records.append(record)
|
||||
|
||||
def execute(self, _context):
|
||||
"""Run a read exception status request against the store.
|
||||
|
||||
:returns: The populated response
|
||||
"""
|
||||
# TODO do some new context operation here # pylint: disable=fixme
|
||||
# if file number, record number, or address + length
|
||||
# is too big, return an error.
|
||||
files = []
|
||||
return ReadFileRecordResponse(files)
|
||||
|
||||
|
||||
class ReadFileRecordResponse(ModbusResponse):
|
||||
"""Read file record response.
|
||||
|
||||
The normal response is a series of "sub-responses," one for each
|
||||
"sub-request." The byte count field is the total combined count of
|
||||
bytes in all "sub-responses." In addition, each "sub-response"
|
||||
contains a field that shows its own byte count.
|
||||
"""
|
||||
|
||||
function_code = 0x14
|
||||
_rtu_byte_count_pos = 2
|
||||
|
||||
def __init__(self, records=None, **kwargs):
|
||||
"""Initialize a new instance.
|
||||
|
||||
:param records: The requested file records
|
||||
"""
|
||||
ModbusResponse.__init__(self, **kwargs)
|
||||
self.records = records or []
|
||||
|
||||
def encode(self):
|
||||
"""Encode the response.
|
||||
|
||||
:returns: The byte encoded message
|
||||
"""
|
||||
total = sum(record.response_length + 1 for record in self.records)
|
||||
packet = struct.pack("B", total)
|
||||
for record in self.records:
|
||||
packet += struct.pack(">BB", 0x06, record.record_length)
|
||||
packet += record.record_data
|
||||
return packet
|
||||
|
||||
def decode(self, data):
|
||||
"""Decode the response.
|
||||
|
||||
:param data: The packet data to decode
|
||||
"""
|
||||
count, self.records = 1, []
|
||||
byte_count = int(data[0])
|
||||
while count < byte_count:
|
||||
response_length, reference_type = struct.unpack(
|
||||
">BB", data[count : count + 2]
|
||||
)
|
||||
count += response_length + 1 # the count is not included
|
||||
record = FileRecord(
|
||||
response_length=response_length,
|
||||
record_data=data[count - response_length + 1 : count],
|
||||
)
|
||||
if reference_type == 0x06:
|
||||
self.records.append(record)
|
||||
|
||||
|
||||
class WriteFileRecordRequest(ModbusRequest):
|
||||
"""Write file record request.
|
||||
|
||||
This function code is used to perform a file record write. All
|
||||
request data lengths are provided in terms of number of bytes
|
||||
and all record lengths are provided in terms of the number of 16
|
||||
bit words.
|
||||
"""
|
||||
|
||||
function_code = 0x15
|
||||
function_code_name = "write_file_record"
|
||||
_rtu_byte_count_pos = 2
|
||||
|
||||
def __init__(self, records=None, **kwargs):
|
||||
"""Initialize a new instance.
|
||||
|
||||
:param records: The file record requests to be read
|
||||
"""
|
||||
ModbusRequest.__init__(self, **kwargs)
|
||||
self.records = records or []
|
||||
|
||||
def encode(self):
|
||||
"""Encode the request packet.
|
||||
|
||||
:returns: The byte encoded packet
|
||||
"""
|
||||
total_length = sum((record.record_length * 2) + 7 for record in self.records)
|
||||
packet = struct.pack("B", total_length)
|
||||
|
||||
for record in self.records:
|
||||
packet += struct.pack(
|
||||
">BHHH",
|
||||
0x06,
|
||||
record.file_number,
|
||||
record.record_number,
|
||||
record.record_length,
|
||||
)
|
||||
packet += record.record_data
|
||||
return packet
|
||||
|
||||
def decode(self, data):
|
||||
"""Decode the incoming request.
|
||||
|
||||
:param data: The data to decode into the address
|
||||
"""
|
||||
byte_count = int(data[0])
|
||||
count, self.records = 1, []
|
||||
while count < byte_count:
|
||||
decoded = struct.unpack(">BHHH", data[count : count + 7])
|
||||
response_length = decoded[3] * 2
|
||||
count += response_length + 7
|
||||
record = FileRecord(
|
||||
record_length=decoded[3],
|
||||
file_number=decoded[1],
|
||||
record_number=decoded[2],
|
||||
record_data=data[count - response_length : count],
|
||||
)
|
||||
if decoded[0] == 0x06:
|
||||
self.records.append(record)
|
||||
|
||||
def execute(self, _context):
|
||||
"""Run the write file record request against the context.
|
||||
|
||||
:returns: The populated response
|
||||
"""
|
||||
# TODO do some new context operation here # pylint: disable=fixme
|
||||
# if file number, record number, or address + length
|
||||
# is too big, return an error.
|
||||
return WriteFileRecordResponse(self.records)
|
||||
|
||||
|
||||
class WriteFileRecordResponse(ModbusResponse):
|
||||
"""The normal response is an echo of the request."""
|
||||
|
||||
function_code = 0x15
|
||||
_rtu_byte_count_pos = 2
|
||||
|
||||
def __init__(self, records=None, **kwargs):
|
||||
"""Initialize a new instance.
|
||||
|
||||
:param records: The file record requests to be read
|
||||
"""
|
||||
ModbusResponse.__init__(self, **kwargs)
|
||||
self.records = records or []
|
||||
|
||||
def encode(self):
|
||||
"""Encode the response.
|
||||
|
||||
:returns: The byte encoded message
|
||||
"""
|
||||
total_length = sum((record.record_length * 2) + 7 for record in self.records)
|
||||
packet = struct.pack("B", total_length)
|
||||
for record in self.records:
|
||||
packet += struct.pack(
|
||||
">BHHH",
|
||||
0x06,
|
||||
record.file_number,
|
||||
record.record_number,
|
||||
record.record_length,
|
||||
)
|
||||
packet += record.record_data
|
||||
return packet
|
||||
|
||||
def decode(self, data):
|
||||
"""Decode the incoming request.
|
||||
|
||||
:param data: The data to decode into the address
|
||||
"""
|
||||
count, self.records = 1, []
|
||||
byte_count = int(data[0])
|
||||
while count < byte_count:
|
||||
decoded = struct.unpack(">BHHH", data[count : count + 7])
|
||||
response_length = decoded[3] * 2
|
||||
count += response_length + 7
|
||||
record = FileRecord(
|
||||
record_length=decoded[3],
|
||||
file_number=decoded[1],
|
||||
record_number=decoded[2],
|
||||
record_data=data[count - response_length : count],
|
||||
)
|
||||
if decoded[0] == 0x06:
|
||||
self.records.append(record)
|
||||
|
||||
|
||||
class ReadFifoQueueRequest(ModbusRequest):
|
||||
"""Read fifo queue request.
|
||||
|
||||
This function code allows to read the contents of a First-In-First-Out
|
||||
(FIFO) queue of register in a remote device. The function returns a
|
||||
count of the registers in the queue, followed by the queued data.
|
||||
Up to 32 registers can be read: the count, plus up to 31 queued data
|
||||
registers.
|
||||
|
||||
The queue count register is returned first, followed by the queued data
|
||||
registers. The function reads the queue contents, but does not clear
|
||||
them.
|
||||
"""
|
||||
|
||||
function_code = 0x18
|
||||
function_code_name = "read_fifo_queue"
|
||||
_rtu_frame_size = 6
|
||||
|
||||
def __init__(self, address=0x0000, **kwargs):
|
||||
"""Initialize a new instance.
|
||||
|
||||
:param address: The fifo pointer address (0x0000 to 0xffff)
|
||||
"""
|
||||
ModbusRequest.__init__(self, **kwargs)
|
||||
self.address = address
|
||||
self.values = [] # this should be added to the context
|
||||
|
||||
def encode(self):
|
||||
"""Encode the request packet.
|
||||
|
||||
:returns: The byte encoded packet
|
||||
"""
|
||||
return struct.pack(">H", self.address)
|
||||
|
||||
def decode(self, data):
|
||||
"""Decode the incoming request.
|
||||
|
||||
:param data: The data to decode into the address
|
||||
"""
|
||||
self.address = struct.unpack(">H", data)[0]
|
||||
|
||||
def execute(self, _context):
|
||||
"""Run a read exception status request against the store.
|
||||
|
||||
:returns: The populated response
|
||||
"""
|
||||
if not 0x0000 <= self.address <= 0xFFFF:
|
||||
return self.doException(merror.IllegalValue)
|
||||
if len(self.values) > 31:
|
||||
return self.doException(merror.IllegalValue)
|
||||
# TODO pull the values from some context # pylint: disable=fixme
|
||||
return ReadFifoQueueResponse(self.values)
|
||||
|
||||
|
||||
class ReadFifoQueueResponse(ModbusResponse):
|
||||
"""Read Fifo queue response.
|
||||
|
||||
In a normal response, the byte count shows the quantity of bytes to
|
||||
follow, including the queue count bytes and value register bytes
|
||||
(but not including the error check field). The queue count is the
|
||||
quantity of data registers in the queue (not including the count register).
|
||||
|
||||
If the queue count exceeds 31, an exception response is returned with an
|
||||
error code of 03 (Illegal Data Value).
|
||||
"""
|
||||
|
||||
function_code = 0x18
|
||||
|
||||
@classmethod
|
||||
def calculateRtuFrameSize(cls, buffer):
|
||||
"""Calculate the size of the message.
|
||||
|
||||
:param buffer: A buffer containing the data that have been received.
|
||||
:returns: The number of bytes in the response.
|
||||
"""
|
||||
hi_byte = int(buffer[2])
|
||||
lo_byte = int(buffer[3])
|
||||
return (hi_byte << 16) + lo_byte + 6
|
||||
|
||||
def __init__(self, values=None, **kwargs):
|
||||
"""Initialize a new instance.
|
||||
|
||||
:param values: The list of values of the fifo to return
|
||||
"""
|
||||
ModbusResponse.__init__(self, **kwargs)
|
||||
self.values = values or []
|
||||
|
||||
def encode(self):
|
||||
"""Encode the response.
|
||||
|
||||
:returns: The byte encoded message
|
||||
"""
|
||||
length = len(self.values) * 2
|
||||
packet = struct.pack(">HH", 2 + length, length)
|
||||
for value in self.values:
|
||||
packet += struct.pack(">H", value)
|
||||
return packet
|
||||
|
||||
def decode(self, data):
|
||||
"""Decode a the response.
|
||||
|
||||
:param data: The packet data to decode
|
||||
"""
|
||||
self.values = []
|
||||
_, count = struct.unpack(">HH", data[0:4])
|
||||
for index in range(0, count - 4):
|
||||
idx = 4 + index * 2
|
||||
self.values.append(struct.unpack(">H", data[idx : idx + 2])[0])
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Framer"""
|
||||
|
||||
__all__ = [
|
||||
"ModbusFramer",
|
||||
"ModbusAsciiFramer",
|
||||
"ModbusBinaryFramer",
|
||||
"ModbusRtuFramer",
|
||||
"ModbusSocketFramer",
|
||||
"ModbusTlsFramer",
|
||||
]
|
||||
|
||||
from pymodbus.framer.ascii_framer import ModbusAsciiFramer
|
||||
from pymodbus.framer.base import ModbusFramer
|
||||
from pymodbus.framer.binary_framer import ModbusBinaryFramer
|
||||
from pymodbus.framer.rtu_framer import ModbusRtuFramer
|
||||
from pymodbus.framer.socket_framer import ModbusSocketFramer
|
||||
from pymodbus.framer.tls_framer import ModbusTlsFramer
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,152 @@
|
||||
"""Ascii_framer."""
|
||||
# pylint: disable=missing-type-doc
|
||||
import struct
|
||||
from binascii import a2b_hex, b2a_hex
|
||||
|
||||
from pymodbus.exceptions import ModbusIOException
|
||||
from pymodbus.framer.base import BYTE_ORDER, FRAME_HEADER, ModbusFramer
|
||||
from pymodbus.logging import Log
|
||||
from pymodbus.utilities import checkLRC, computeLRC
|
||||
|
||||
|
||||
ASCII_FRAME_HEADER = BYTE_ORDER + FRAME_HEADER
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Modbus ASCII Message
|
||||
# --------------------------------------------------------------------------- #
|
||||
class ModbusAsciiFramer(ModbusFramer):
|
||||
r"""Modbus ASCII Frame Controller.
|
||||
|
||||
[ Start ][Address ][ Function ][ Data ][ LRC ][ End ]
|
||||
1c 2c 2c Nc 2c 2c
|
||||
|
||||
* data can be 0 - 2x252 chars
|
||||
* end is "\\r\\n" (Carriage return line feed), however the line feed
|
||||
character can be changed via a special command
|
||||
* start is ":"
|
||||
|
||||
This framer is used for serial transmission. Unlike the RTU protocol,
|
||||
the data in this framer is transferred in plain text ascii.
|
||||
"""
|
||||
|
||||
method = "ascii"
|
||||
|
||||
def __init__(self, decoder, client=None):
|
||||
"""Initialize a new instance of the framer.
|
||||
|
||||
:param decoder: The decoder implementation to use
|
||||
"""
|
||||
super().__init__(decoder, client)
|
||||
self._hsize = 0x02
|
||||
self._start = b":"
|
||||
self._end = b"\r\n"
|
||||
|
||||
# ----------------------------------------------------------------------- #
|
||||
# Private Helper Functions
|
||||
# ----------------------------------------------------------------------- #
|
||||
def decode_data(self, data):
|
||||
"""Decode data."""
|
||||
if len(data) > 1:
|
||||
uid = int(data[1:3], 16)
|
||||
fcode = int(data[3:5], 16)
|
||||
return {"slave": uid, "fcode": fcode}
|
||||
return {}
|
||||
|
||||
def checkFrame(self):
|
||||
"""Check and decode the next frame.
|
||||
|
||||
:returns: True if we successful, False otherwise
|
||||
"""
|
||||
start = self._buffer.find(self._start)
|
||||
if start == -1:
|
||||
return False
|
||||
if start > 0: # go ahead and skip old bad data
|
||||
self._buffer = self._buffer[start:]
|
||||
start = 0
|
||||
|
||||
if (end := self._buffer.find(self._end)) != -1:
|
||||
self._header["len"] = end
|
||||
self._header["uid"] = int(self._buffer[1:3], 16)
|
||||
self._header["lrc"] = int(self._buffer[end - 2 : end], 16)
|
||||
data = a2b_hex(self._buffer[start + 1 : end - 2])
|
||||
return checkLRC(data, self._header["lrc"])
|
||||
return False
|
||||
|
||||
def advanceFrame(self):
|
||||
"""Skip over the current framed message.
|
||||
|
||||
This allows us to skip over the current message after we have processed
|
||||
it or determined that it contains an error. It also has to reset the
|
||||
current frame header handle
|
||||
"""
|
||||
self._buffer = self._buffer[self._header["len"] + 2 :]
|
||||
self._header = {"lrc": "0000", "len": 0, "uid": 0x00}
|
||||
|
||||
def isFrameReady(self):
|
||||
"""Check if we should continue decode logic.
|
||||
|
||||
This is meant to be used in a while loop in the decoding phase to let
|
||||
the decoder know that there is still data in the buffer.
|
||||
|
||||
:returns: True if ready, False otherwise
|
||||
"""
|
||||
return len(self._buffer) > 1
|
||||
|
||||
def getFrame(self):
|
||||
"""Get the next frame from the buffer.
|
||||
|
||||
:returns: The frame data or ""
|
||||
"""
|
||||
start = self._hsize + 1
|
||||
end = self._header["len"] - 2
|
||||
buffer = self._buffer[start:end]
|
||||
if end > 0:
|
||||
return a2b_hex(buffer)
|
||||
return b""
|
||||
|
||||
# ----------------------------------------------------------------------- #
|
||||
# Public Member Functions
|
||||
# ----------------------------------------------------------------------- #
|
||||
def frameProcessIncomingPacket(self, single, callback, slave, _tid=None, **kwargs):
|
||||
"""Process new packet pattern."""
|
||||
while self.isFrameReady():
|
||||
if not self.checkFrame():
|
||||
break
|
||||
if not self._validate_slave_id(slave, single):
|
||||
header_txt = self._header["uid"]
|
||||
Log.error("Not a valid slave id - {}, ignoring!!", header_txt)
|
||||
self.resetFrame()
|
||||
continue
|
||||
|
||||
frame = self.getFrame()
|
||||
if (result := self.decoder.decode(frame)) is None:
|
||||
raise ModbusIOException("Unable to decode response")
|
||||
self.populateResult(result)
|
||||
self.advanceFrame()
|
||||
callback(result) # defer this
|
||||
|
||||
def buildPacket(self, message):
|
||||
"""Create a ready to send modbus packet.
|
||||
|
||||
Built off of a modbus request/response
|
||||
|
||||
:param message: The request/response to send
|
||||
:return: The encoded packet
|
||||
"""
|
||||
encoded = message.encode()
|
||||
buffer = struct.pack(
|
||||
ASCII_FRAME_HEADER, message.slave_id, message.function_code
|
||||
)
|
||||
checksum = computeLRC(encoded + buffer)
|
||||
|
||||
packet = bytearray()
|
||||
packet.extend(self._start)
|
||||
packet.extend(f"{message.slave_id:02x}{message.function_code:02x}".encode())
|
||||
packet.extend(b2a_hex(encoded))
|
||||
packet.extend(f"{checksum:02x}".encode())
|
||||
packet.extend(self._end)
|
||||
return bytes(packet).upper()
|
||||
|
||||
|
||||
# __END__
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Framer start."""
|
||||
# pylint: disable=missing-type-doc
|
||||
from typing import Any, Dict, Union
|
||||
|
||||
from pymodbus.factory import ClientDecoder, ServerDecoder
|
||||
from pymodbus.logging import Log
|
||||
|
||||
|
||||
# Unit ID, Function Code
|
||||
BYTE_ORDER = ">"
|
||||
FRAME_HEADER = "BB"
|
||||
|
||||
# Transaction Id, Protocol ID, Length, Unit ID, Function Code
|
||||
SOCKET_FRAME_HEADER = BYTE_ORDER + "HHH" + FRAME_HEADER
|
||||
|
||||
# Function Code
|
||||
TLS_FRAME_HEADER = BYTE_ORDER + "B"
|
||||
|
||||
|
||||
class ModbusFramer:
|
||||
"""Base Framer class."""
|
||||
|
||||
name = ""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
decoder: Union[ClientDecoder, ServerDecoder],
|
||||
client=None,
|
||||
) -> None:
|
||||
"""Initialize a new instance of the framer.
|
||||
|
||||
:param decoder: The decoder implementation to use
|
||||
"""
|
||||
self.decoder = decoder
|
||||
self.client = client
|
||||
self._header: Dict[str, Any] = {
|
||||
"lrc": "0000",
|
||||
"len": 0,
|
||||
"uid": 0x00,
|
||||
"tid": 0,
|
||||
"pid": 0,
|
||||
"crc": b"\x00\x00",
|
||||
}
|
||||
self._buffer = b""
|
||||
|
||||
def _validate_slave_id(self, slaves: list, single: bool) -> bool:
|
||||
"""Validate if the received data is valid for the client.
|
||||
|
||||
:param slaves: list of slave id for which the transaction is valid
|
||||
:param single: Set to true to treat this as a single context
|
||||
:return:
|
||||
"""
|
||||
if single:
|
||||
return True
|
||||
if 0 in slaves or 0xFF in slaves:
|
||||
# Handle Modbus TCP slave identifier (0x00 0r 0xFF)
|
||||
# in asynchronous requests
|
||||
return True
|
||||
return self._header["uid"] in slaves
|
||||
|
||||
def sendPacket(self, message):
|
||||
"""Send packets on the bus.
|
||||
|
||||
With 3.5char delay between frames
|
||||
:param message: Message to be sent over the bus
|
||||
:return:
|
||||
"""
|
||||
return self.client.send(message)
|
||||
|
||||
def recvPacket(self, size):
|
||||
"""Receive packet from the bus.
|
||||
|
||||
With specified len
|
||||
:param size: Number of bytes to read
|
||||
:return:
|
||||
"""
|
||||
return self.client.recv(size)
|
||||
|
||||
def resetFrame(self):
|
||||
"""Reset the entire message frame.
|
||||
|
||||
This allows us to skip ovver errors that may be in the stream.
|
||||
It is hard to know if we are simply out of sync or if there is
|
||||
an error in the stream as we have no way to check the start or
|
||||
end of the message (python just doesn't have the resolution to
|
||||
check for millisecond delays).
|
||||
"""
|
||||
Log.debug(
|
||||
"Resetting frame - Current Frame in buffer - {}", self._buffer, ":hex"
|
||||
)
|
||||
self._buffer = b""
|
||||
self._header = {
|
||||
"lrc": "0000",
|
||||
"crc": b"\x00\x00",
|
||||
"len": 0,
|
||||
"uid": 0x00,
|
||||
"pid": 0,
|
||||
"tid": 0,
|
||||
}
|
||||
|
||||
def populateResult(self, result):
|
||||
"""Populate the modbus result header.
|
||||
|
||||
The serial packets do not have any header information
|
||||
that is copied.
|
||||
|
||||
:param result: The response packet
|
||||
"""
|
||||
result.slave_id = self._header.get("uid", 0)
|
||||
result.transaction_id = self._header.get("tid", 0)
|
||||
result.protocol_id = self._header.get("pid", 0)
|
||||
|
||||
def processIncomingPacket(self, data, callback, slave, **kwargs):
|
||||
"""Process new packet pattern.
|
||||
|
||||
This takes in a new request packet, adds it to the current
|
||||
packet stream, and performs framing on it. That is, checks
|
||||
for complete messages, and once found, will process all that
|
||||
exist. This handles the case when we read N + 1 or 1 // N
|
||||
messages at a time instead of 1.
|
||||
|
||||
The processed and decoded messages are pushed to the callback
|
||||
function to process and send.
|
||||
|
||||
:param data: The new packet data
|
||||
:param callback: The function to send results to
|
||||
:param slave: Process if slave id matches, ignore otherwise (could be a
|
||||
list of slave ids (server) or single slave id(client/server))
|
||||
:param kwargs:
|
||||
:raises ModbusIOException:
|
||||
"""
|
||||
Log.debug("Processing: {}", data, ":hex")
|
||||
self._buffer += data
|
||||
if not isinstance(slave, (list, tuple)):
|
||||
slave = [slave]
|
||||
single = kwargs.pop("single", False)
|
||||
self.frameProcessIncomingPacket(single, callback, slave, **kwargs)
|
||||
|
||||
def frameProcessIncomingPacket(
|
||||
self, _single, _callback, _slave, _tid=None, **kwargs
|
||||
):
|
||||
"""Process new packet pattern."""
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Binary framer."""
|
||||
# pylint: disable=missing-type-doc
|
||||
import struct
|
||||
|
||||
from pymodbus.exceptions import ModbusIOException
|
||||
from pymodbus.framer.base import BYTE_ORDER, FRAME_HEADER, ModbusFramer
|
||||
from pymodbus.logging import Log
|
||||
from pymodbus.utilities import checkCRC, computeCRC
|
||||
|
||||
|
||||
BINARY_FRAME_HEADER = BYTE_ORDER + FRAME_HEADER
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Modbus Binary Message
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class ModbusBinaryFramer(ModbusFramer):
|
||||
"""Modbus Binary Frame Controller.
|
||||
|
||||
[ Start ][Address ][ Function ][ Data ][ CRC ][ End ]
|
||||
1b 1b 1b Nb 2b 1b
|
||||
|
||||
* data can be 0 - 2x252 chars
|
||||
* end is "}"
|
||||
* start is "{"
|
||||
|
||||
The idea here is that we implement the RTU protocol, however,
|
||||
instead of using timing for message delimiting, we use start
|
||||
and end of message characters (in this case { and }). Basically,
|
||||
this is a binary framer.
|
||||
|
||||
The only case we have to watch out for is when a message contains
|
||||
the { or } characters. If we encounter these characters, we
|
||||
simply duplicate them. Hopefully we will not encounter those
|
||||
characters that often and will save a little bit of bandwitch
|
||||
without a real-time system.
|
||||
|
||||
Protocol defined by jamod.sourceforge.net.
|
||||
"""
|
||||
|
||||
method = "binary"
|
||||
|
||||
def __init__(self, decoder, client=None):
|
||||
"""Initialize a new instance of the framer.
|
||||
|
||||
:param decoder: The decoder implementation to use
|
||||
"""
|
||||
super().__init__(decoder, client)
|
||||
# self._header.update({"crc": 0x0000})
|
||||
self._hsize = 0x01
|
||||
self._start = b"\x7b" # {
|
||||
self._end = b"\x7d" # }
|
||||
self._repeat = [b"}"[0], b"{"[0]] # python3 hack
|
||||
|
||||
# ----------------------------------------------------------------------- #
|
||||
# Private Helper Functions
|
||||
# ----------------------------------------------------------------------- #
|
||||
def decode_data(self, data):
|
||||
"""Decode data."""
|
||||
if len(data) > self._hsize:
|
||||
uid = struct.unpack(">B", data[1:2])[0]
|
||||
fcode = struct.unpack(">B", data[2:3])[0]
|
||||
return {"slave": uid, "fcode": fcode}
|
||||
return {}
|
||||
|
||||
def checkFrame(self) -> bool:
|
||||
"""Check and decode the next frame.
|
||||
|
||||
:returns: True if we are successful, False otherwise
|
||||
"""
|
||||
start = self._buffer.find(self._start)
|
||||
if start == -1:
|
||||
return False
|
||||
if start > 0: # go ahead and skip old bad data
|
||||
self._buffer = self._buffer[start:]
|
||||
|
||||
if (end := self._buffer.find(self._end)) != -1:
|
||||
self._header["len"] = end
|
||||
self._header["uid"] = struct.unpack(">B", self._buffer[1:2])[0]
|
||||
self._header["crc"] = struct.unpack(">H", self._buffer[end - 2 : end])[0]
|
||||
data = self._buffer[start + 1 : end - 2]
|
||||
return checkCRC(data, self._header["crc"])
|
||||
return False
|
||||
|
||||
def advanceFrame(self) -> None:
|
||||
"""Skip over the current framed message.
|
||||
|
||||
This allows us to skip over the current message after we have processed
|
||||
it or determined that it contains an error. It also has to reset the
|
||||
current frame header handle
|
||||
"""
|
||||
self._buffer = self._buffer[self._header["len"] + 2 :]
|
||||
self._header = {"crc": 0x0000, "len": 0, "uid": 0x00}
|
||||
|
||||
def isFrameReady(self) -> bool:
|
||||
"""Check if we should continue decode logic.
|
||||
|
||||
This is meant to be used in a while loop in the decoding phase to let
|
||||
the decoder know that there is still data in the buffer.
|
||||
|
||||
:returns: True if ready, False otherwise
|
||||
"""
|
||||
return len(self._buffer) > 1
|
||||
|
||||
def getFrame(self):
|
||||
"""Get the next frame from the buffer.
|
||||
|
||||
:returns: The frame data or ""
|
||||
"""
|
||||
start = self._hsize + 1
|
||||
end = self._header["len"] - 2
|
||||
buffer = self._buffer[start:end]
|
||||
if end > 0:
|
||||
return buffer
|
||||
return b""
|
||||
|
||||
# ----------------------------------------------------------------------- #
|
||||
# Public Member Functions
|
||||
# ----------------------------------------------------------------------- #
|
||||
def frameProcessIncomingPacket(self, single, callback, slave, _tid=None, **kwargs):
|
||||
"""Process new packet pattern."""
|
||||
while self.isFrameReady():
|
||||
if not self.checkFrame():
|
||||
Log.debug("Frame check failed, ignoring!!")
|
||||
self.resetFrame()
|
||||
break
|
||||
if not self._validate_slave_id(slave, single):
|
||||
header_txt = self._header["uid"]
|
||||
Log.debug("Not a valid slave id - {}, ignoring!!", header_txt)
|
||||
self.resetFrame()
|
||||
break
|
||||
if (result := self.decoder.decode(self.getFrame())) is None:
|
||||
raise ModbusIOException("Unable to decode response")
|
||||
self.populateResult(result)
|
||||
self.advanceFrame()
|
||||
callback(result) # defer or push to a thread?
|
||||
|
||||
def buildPacket(self, message):
|
||||
"""Create a ready to send modbus packet.
|
||||
|
||||
:param message: The request/response to send
|
||||
:returns: The encoded packet
|
||||
"""
|
||||
data = self._preflight(message.encode())
|
||||
packet = (
|
||||
struct.pack(BINARY_FRAME_HEADER, message.slave_id, message.function_code)
|
||||
+ data
|
||||
)
|
||||
packet += struct.pack(">H", computeCRC(packet))
|
||||
packet = self._start + packet + self._end
|
||||
return packet
|
||||
|
||||
def _preflight(self, data):
|
||||
"""Do preflight buffer test.
|
||||
|
||||
This basically scans the buffer for start and end
|
||||
tags and if found, escapes them.
|
||||
|
||||
:param data: The message to escape
|
||||
:returns: the escaped packet
|
||||
"""
|
||||
array = bytearray()
|
||||
for item in data:
|
||||
if item in self._repeat:
|
||||
array.append(item)
|
||||
array.append(item)
|
||||
return bytes(array)
|
||||
|
||||
|
||||
# __END__
|
||||
@@ -0,0 +1,323 @@
|
||||
"""RTU framer."""
|
||||
# pylint: disable=missing-type-doc
|
||||
import struct
|
||||
import time
|
||||
|
||||
from pymodbus.exceptions import (
|
||||
InvalidMessageReceivedException,
|
||||
ModbusIOException,
|
||||
)
|
||||
from pymodbus.framer.base import BYTE_ORDER, FRAME_HEADER, ModbusFramer
|
||||
from pymodbus.logging import Log
|
||||
from pymodbus.utilities import ModbusTransactionState, checkCRC, computeCRC
|
||||
|
||||
|
||||
RTU_FRAME_HEADER = BYTE_ORDER + FRAME_HEADER
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Modbus RTU Message
|
||||
# --------------------------------------------------------------------------- #
|
||||
class ModbusRtuFramer(ModbusFramer):
|
||||
"""Modbus RTU Frame controller.
|
||||
|
||||
[ Start Wait ] [Address ][ Function Code] [ Data ][ CRC ][ End Wait ]
|
||||
3.5 chars 1b 1b Nb 2b 3.5 chars
|
||||
|
||||
Wait refers to the amount of time required to transmit at least x many
|
||||
characters. In this case it is 3.5 characters. Also, if we receive a
|
||||
wait of 1.5 characters at any point, we must trigger an error message.
|
||||
Also, it appears as though this message is little endian. The logic is
|
||||
simplified as the following::
|
||||
|
||||
block-on-read:
|
||||
read until 3.5 delay
|
||||
check for errors
|
||||
decode
|
||||
|
||||
The following table is a listing of the baud wait times for the specified
|
||||
baud rates::
|
||||
|
||||
------------------------------------------------------------------
|
||||
Baud 1.5c (18 bits) 3.5c (38 bits)
|
||||
------------------------------------------------------------------
|
||||
1200 13333.3 us 31666.7 us
|
||||
4800 3333.3 us 7916.7 us
|
||||
9600 1666.7 us 3958.3 us
|
||||
19200 833.3 us 1979.2 us
|
||||
38400 416.7 us 989.6 us
|
||||
------------------------------------------------------------------
|
||||
1 Byte = start + 8 bits + parity + stop = 11 bits
|
||||
(1/Baud)(bits) = delay seconds
|
||||
"""
|
||||
|
||||
method = "rtu"
|
||||
|
||||
def __init__(self, decoder, client=None):
|
||||
"""Initialize a new instance of the framer.
|
||||
|
||||
:param decoder: The decoder factory implementation to use
|
||||
"""
|
||||
super().__init__(decoder, client)
|
||||
self._hsize = 0x01
|
||||
self._end = b"\x0d\x0a"
|
||||
self._min_frame_size = 4
|
||||
self.function_codes = decoder.lookup.keys() if decoder else {}
|
||||
|
||||
# ----------------------------------------------------------------------- #
|
||||
# Private Helper Functions
|
||||
# ----------------------------------------------------------------------- #
|
||||
def decode_data(self, data):
|
||||
"""Decode data."""
|
||||
if len(data) > self._hsize:
|
||||
uid = int(data[0])
|
||||
fcode = int(data[1])
|
||||
return {"slave": uid, "fcode": fcode}
|
||||
return {}
|
||||
|
||||
def checkFrame(self):
|
||||
"""Check if the next frame is available.
|
||||
|
||||
Return True if we were successful.
|
||||
|
||||
1. Populate header
|
||||
2. Discard frame if UID does not match
|
||||
"""
|
||||
try:
|
||||
self.populateHeader()
|
||||
frame_size = self._header["len"]
|
||||
data = self._buffer[: frame_size - 2]
|
||||
crc = self._header["crc"]
|
||||
crc_val = (int(crc[0]) << 8) + int(crc[1])
|
||||
return checkCRC(data, crc_val)
|
||||
except (IndexError, KeyError, struct.error):
|
||||
return False
|
||||
|
||||
def advanceFrame(self):
|
||||
"""Skip over the current framed message.
|
||||
|
||||
This allows us to skip over the current message after we have processed
|
||||
it or determined that it contains an error. It also has to reset the
|
||||
current frame header handle
|
||||
"""
|
||||
self._buffer = self._buffer[self._header["len"] :]
|
||||
Log.debug("Frame advanced, resetting header!!")
|
||||
self._header = {"uid": 0x00, "len": 0, "crc": b"\x00\x00"}
|
||||
|
||||
def resetFrame(self):
|
||||
"""Reset the entire message frame.
|
||||
|
||||
This allows us to skip over errors that may be in the stream.
|
||||
It is hard to know if we are simply out of sync or if there is
|
||||
an error in the stream as we have no way to check the start or
|
||||
end of the message (python just doesn't have the resolution to
|
||||
check for millisecond delays).
|
||||
"""
|
||||
x = self._buffer
|
||||
super().resetFrame()
|
||||
self._buffer = x
|
||||
|
||||
def isFrameReady(self):
|
||||
"""Check if we should continue decode logic.
|
||||
|
||||
This is meant to be used in a while loop in the decoding phase to let
|
||||
the decoder know that there is still data in the buffer.
|
||||
|
||||
:returns: True if ready, False otherwise
|
||||
"""
|
||||
size = self._header.get("len", 0)
|
||||
if not size and len(self._buffer) > self._hsize:
|
||||
try:
|
||||
# Frame is ready only if populateHeader() successfully
|
||||
# populates crc field which finishes RTU frame otherwise,
|
||||
# if buffer is not yet long enough, populateHeader() raises IndexError
|
||||
size = self.populateHeader()
|
||||
except IndexError:
|
||||
return False
|
||||
|
||||
return len(self._buffer) >= size if size > 0 else False
|
||||
|
||||
def populateHeader(self, data=None):
|
||||
"""Try to set the headers `uid`, `len` and `crc`.
|
||||
|
||||
This method examines `self._buffer` and writes meta
|
||||
information into `self._header`.
|
||||
|
||||
Beware that this method will raise an IndexError if
|
||||
`self._buffer` is not yet long enough.
|
||||
"""
|
||||
data = data if data is not None else self._buffer
|
||||
self._header["uid"] = int(data[0])
|
||||
self._header["tid"] = int(data[0])
|
||||
size = self.get_expected_response_length(data)
|
||||
self._header["len"] = size
|
||||
|
||||
if len(data) < size:
|
||||
# crc yet not available
|
||||
raise IndexError
|
||||
self._header["crc"] = data[size - 2 : size]
|
||||
return size
|
||||
|
||||
def getFrame(self):
|
||||
"""Get the next frame from the buffer.
|
||||
|
||||
:returns: The frame data or ""
|
||||
"""
|
||||
start = self._hsize
|
||||
end = self._header["len"] - 2
|
||||
buffer = self._buffer[start:end]
|
||||
if end > 0:
|
||||
Log.debug("Getting Frame - {}", buffer, ":hex")
|
||||
return buffer
|
||||
return b""
|
||||
|
||||
def populateResult(self, result):
|
||||
"""Populate the modbus result header.
|
||||
|
||||
The serial packets do not have any header information
|
||||
that is copied.
|
||||
|
||||
:param result: The response packet
|
||||
"""
|
||||
result.slave_id = self._header["uid"]
|
||||
result.transaction_id = self._header["tid"]
|
||||
|
||||
def getFrameStart(self, slaves, broadcast, skip_cur_frame):
|
||||
"""Scan buffer for a relevant frame start."""
|
||||
start = 1 if skip_cur_frame else 0
|
||||
if (buf_len := len(self._buffer)) < 4:
|
||||
return False
|
||||
for i in range(start, buf_len - 3): # <slave id><function code><crc 2 bytes>
|
||||
if not broadcast and self._buffer[i] not in slaves:
|
||||
continue
|
||||
if (
|
||||
self._buffer[i + 1] not in self.function_codes
|
||||
and (self._buffer[i + 1] - 0x80) not in self.function_codes
|
||||
):
|
||||
continue
|
||||
if i:
|
||||
self._buffer = self._buffer[i:] # remove preceding trash.
|
||||
return True
|
||||
if buf_len > 3:
|
||||
self._buffer = self._buffer[-3:]
|
||||
return False
|
||||
|
||||
# ----------------------------------------------------------------------- #
|
||||
# Public Member Functions
|
||||
# ----------------------------------------------------------------------- #
|
||||
def frameProcessIncomingPacket(self, single, callback, slave, _tid=None, **kwargs):
|
||||
"""Process new packet pattern."""
|
||||
broadcast = not slave[0]
|
||||
skip_cur_frame = False
|
||||
while self.getFrameStart(slave, broadcast, skip_cur_frame):
|
||||
if not self.isFrameReady():
|
||||
Log.debug("Frame - not ready")
|
||||
break
|
||||
if not self.checkFrame():
|
||||
Log.debug("Frame check failed, ignoring!!")
|
||||
self.resetFrame()
|
||||
skip_cur_frame = True
|
||||
continue
|
||||
if not self._validate_slave_id(slave, single):
|
||||
header_txt = self._header["uid"]
|
||||
Log.debug("Not a valid slave id - {}, ignoring!!", header_txt)
|
||||
self.resetFrame()
|
||||
skip_cur_frame = True
|
||||
continue
|
||||
self._process(callback)
|
||||
|
||||
def buildPacket(self, message):
|
||||
"""Create a ready to send modbus packet.
|
||||
|
||||
:param message: The populated request/response to send
|
||||
"""
|
||||
data = message.encode()
|
||||
packet = (
|
||||
struct.pack(RTU_FRAME_HEADER, message.slave_id, message.function_code)
|
||||
+ data
|
||||
)
|
||||
packet += struct.pack(">H", computeCRC(packet))
|
||||
# Ensure that transaction is actually the slave id for serial comms
|
||||
message.transaction_id = message.slave_id
|
||||
return packet
|
||||
|
||||
def sendPacket(self, message):
|
||||
"""Send packets on the bus with 3.5char delay between frames.
|
||||
|
||||
:param message: Message to be sent over the bus
|
||||
:return:
|
||||
"""
|
||||
start = time.time()
|
||||
timeout = start + self.client.comm_params.timeout_connect
|
||||
while self.client.state != ModbusTransactionState.IDLE:
|
||||
if self.client.state == ModbusTransactionState.TRANSACTION_COMPLETE:
|
||||
timestamp = round(time.time(), 6)
|
||||
Log.debug(
|
||||
"Changing state to IDLE - Last Frame End - {} Current Time stamp - {}",
|
||||
self.client.last_frame_end,
|
||||
timestamp,
|
||||
)
|
||||
if self.client.last_frame_end:
|
||||
idle_time = self.client.idle_time()
|
||||
if round(timestamp - idle_time, 6) <= self.client.silent_interval:
|
||||
Log.debug(
|
||||
"Waiting for 3.5 char before next send - {} ms",
|
||||
self.client.silent_interval * 1000,
|
||||
)
|
||||
time.sleep(self.client.silent_interval)
|
||||
else:
|
||||
# Recovering from last error ??
|
||||
time.sleep(self.client.silent_interval)
|
||||
self.client.state = ModbusTransactionState.IDLE
|
||||
elif self.client.state == ModbusTransactionState.RETRYING:
|
||||
# Simple lets settle down!!!
|
||||
# To check for higher baudrates
|
||||
time.sleep(self.client.comm_params.timeout_connect)
|
||||
break
|
||||
elif time.time() > timeout:
|
||||
Log.debug(
|
||||
"Spent more time than the read time out, "
|
||||
"resetting the transaction to IDLE"
|
||||
)
|
||||
self.client.state = ModbusTransactionState.IDLE
|
||||
else:
|
||||
Log.debug("Sleeping")
|
||||
time.sleep(self.client.silent_interval)
|
||||
size = self.client.send(message)
|
||||
self.client.last_frame_end = round(time.time(), 6)
|
||||
return size
|
||||
|
||||
def recvPacket(self, size):
|
||||
"""Receive packet from the bus with specified len.
|
||||
|
||||
:param size: Number of bytes to read
|
||||
:return:
|
||||
"""
|
||||
result = self.client.recv(size)
|
||||
self.client.last_frame_end = round(time.time(), 6)
|
||||
return result
|
||||
|
||||
def _process(self, callback, error=False):
|
||||
"""Process incoming packets irrespective error condition."""
|
||||
data = self._buffer if error else self.getFrame()
|
||||
if (result := self.decoder.decode(data)) is None:
|
||||
raise ModbusIOException("Unable to decode request")
|
||||
if error and result.function_code < 0x80:
|
||||
raise InvalidMessageReceivedException(result)
|
||||
self.populateResult(result)
|
||||
self.advanceFrame()
|
||||
callback(result) # defer or push to a thread?
|
||||
|
||||
def get_expected_response_length(self, data):
|
||||
"""Get the expected response length.
|
||||
|
||||
:param data: Message data read so far
|
||||
:raises IndexError: If not enough data to read byte count
|
||||
:return: Total frame size
|
||||
"""
|
||||
func_code = int(data[1])
|
||||
pdu_class = self.decoder.lookupPduClass(func_code)
|
||||
return pdu_class.calculateRtuFrameSize(data)
|
||||
|
||||
|
||||
# __END__
|
||||
@@ -0,0 +1,182 @@
|
||||
"""Socket framer."""
|
||||
# pylint: disable=missing-type-doc
|
||||
import struct
|
||||
|
||||
from pymodbus.exceptions import (
|
||||
InvalidMessageReceivedException,
|
||||
ModbusIOException,
|
||||
)
|
||||
from pymodbus.framer.base import SOCKET_FRAME_HEADER, ModbusFramer
|
||||
from pymodbus.logging import Log
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Modbus TCP Message
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class ModbusSocketFramer(ModbusFramer):
|
||||
"""Modbus Socket Frame controller.
|
||||
|
||||
Before each modbus TCP message is an MBAP header which is used as a
|
||||
message frame. It allows us to easily separate messages as follows::
|
||||
|
||||
[ MBAP Header ] [ Function Code] [ Data ] \
|
||||
[ tid ][ pid ][ length ][ uid ]
|
||||
2b 2b 2b 1b 1b Nb
|
||||
|
||||
while len(message) > 0:
|
||||
tid, pid, length`, uid = struct.unpack(">HHHB", message)
|
||||
request = message[0:7 + length - 1`]
|
||||
message = [7 + length - 1:]
|
||||
|
||||
* length = uid + function code + data
|
||||
* The -1 is to account for the uid byte
|
||||
"""
|
||||
|
||||
method = "socket"
|
||||
|
||||
def __init__(self, decoder, client=None):
|
||||
"""Initialize a new instance of the framer.
|
||||
|
||||
:param decoder: The decoder factory implementation to use
|
||||
"""
|
||||
super().__init__(decoder, client)
|
||||
self._hsize = 0x07
|
||||
|
||||
# ----------------------------------------------------------------------- #
|
||||
# Private Helper Functions
|
||||
# ----------------------------------------------------------------------- #
|
||||
def checkFrame(self):
|
||||
"""Check and decode the next frame.
|
||||
|
||||
Return true if we were successful.
|
||||
"""
|
||||
if self.isFrameReady():
|
||||
(
|
||||
self._header["tid"],
|
||||
self._header["pid"],
|
||||
self._header["len"],
|
||||
self._header["uid"],
|
||||
) = struct.unpack(">HHHB", self._buffer[0 : self._hsize])
|
||||
|
||||
# someone sent us an error? ignore it
|
||||
if self._header["len"] < 2:
|
||||
self.advanceFrame()
|
||||
# we have at least a complete message, continue
|
||||
elif len(self._buffer) - self._hsize + 1 >= self._header["len"]:
|
||||
return True
|
||||
# we don't have enough of a message yet, wait
|
||||
return False
|
||||
|
||||
def advanceFrame(self):
|
||||
"""Skip over the current framed message.
|
||||
|
||||
This allows us to skip over the current message after we have processed
|
||||
it or determined that it contains an error. It also has to reset the
|
||||
current frame header handle
|
||||
"""
|
||||
length = self._hsize + self._header["len"] - 1
|
||||
self._buffer = self._buffer[length:]
|
||||
self._header = {"tid": 0, "pid": 0, "len": 0, "uid": 0}
|
||||
|
||||
def isFrameReady(self):
|
||||
"""Check if we should continue decode logic.
|
||||
|
||||
This is meant to be used in a while loop in the decoding phase to let
|
||||
the decoder factory know that there is still data in the buffer.
|
||||
|
||||
:returns: True if ready, False otherwise
|
||||
"""
|
||||
return len(self._buffer) > self._hsize
|
||||
|
||||
def getFrame(self):
|
||||
"""Return the next frame from the buffered data.
|
||||
|
||||
:returns: The next full frame buffer
|
||||
"""
|
||||
length = self._hsize + self._header["len"] - 1
|
||||
return self._buffer[self._hsize : length]
|
||||
|
||||
# ----------------------------------------------------------------------- #
|
||||
# Public Member Functions
|
||||
# ----------------------------------------------------------------------- #
|
||||
def decode_data(self, data):
|
||||
"""Decode data."""
|
||||
if len(data) > self._hsize:
|
||||
tid, pid, length, uid, fcode = struct.unpack(
|
||||
SOCKET_FRAME_HEADER, data[0 : self._hsize + 1]
|
||||
)
|
||||
return {
|
||||
"tid": tid,
|
||||
"pid": pid,
|
||||
"length": length,
|
||||
"slave": uid,
|
||||
"fcode": fcode,
|
||||
}
|
||||
return {}
|
||||
|
||||
def frameProcessIncomingPacket(self, single, callback, slave, tid=None, **kwargs):
|
||||
"""Process new packet pattern.
|
||||
|
||||
This takes in a new request packet, adds it to the current
|
||||
packet stream, and performs framing on it. That is, checks
|
||||
for complete messages, and once found, will process all that
|
||||
exist. This handles the case when we read N + 1 or 1 // N
|
||||
messages at a time instead of 1.
|
||||
|
||||
The processed and decoded messages are pushed to the callback
|
||||
function to process and send.
|
||||
"""
|
||||
while True:
|
||||
if not self.isFrameReady():
|
||||
if len(self._buffer):
|
||||
# Possible error ???
|
||||
if self._header["len"] < 2:
|
||||
self._process(callback, tid, error=True)
|
||||
break
|
||||
if not self.checkFrame():
|
||||
Log.debug("Frame check failed, ignoring!!")
|
||||
self.resetFrame()
|
||||
continue
|
||||
if not self._validate_slave_id(slave, single):
|
||||
header_txt = self._header["uid"]
|
||||
Log.debug("Not a valid slave id - {}, ignoring!!", header_txt)
|
||||
self.resetFrame()
|
||||
continue
|
||||
self._process(callback, tid)
|
||||
|
||||
def _process(self, callback, tid, error=False):
|
||||
"""Process incoming packets irrespective error condition."""
|
||||
data = self._buffer if error else self.getFrame()
|
||||
if (result := self.decoder.decode(data)) is None:
|
||||
self.resetFrame()
|
||||
raise ModbusIOException("Unable to decode request")
|
||||
if error and result.function_code < 0x80:
|
||||
raise InvalidMessageReceivedException(result)
|
||||
self.populateResult(result)
|
||||
self.advanceFrame()
|
||||
if tid and tid != result.transaction_id:
|
||||
self.resetFrame()
|
||||
else:
|
||||
callback(result) # defer or push to a thread?
|
||||
|
||||
def buildPacket(self, message):
|
||||
"""Create a ready to send modbus packet.
|
||||
|
||||
:param message: The populated request/response to send
|
||||
"""
|
||||
data = message.encode()
|
||||
packet = struct.pack(
|
||||
SOCKET_FRAME_HEADER,
|
||||
message.transaction_id,
|
||||
message.protocol_id,
|
||||
len(data) + 2,
|
||||
message.slave_id,
|
||||
message.function_code,
|
||||
)
|
||||
packet += data
|
||||
return packet
|
||||
|
||||
|
||||
# __END__
|
||||
@@ -0,0 +1,127 @@
|
||||
"""TLS framer."""
|
||||
# pylint: disable=missing-type-doc
|
||||
import struct
|
||||
|
||||
from pymodbus.exceptions import (
|
||||
InvalidMessageReceivedException,
|
||||
ModbusIOException,
|
||||
)
|
||||
from pymodbus.framer.base import TLS_FRAME_HEADER, ModbusFramer
|
||||
from pymodbus.logging import Log
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Modbus TLS Message
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class ModbusTlsFramer(ModbusFramer):
|
||||
"""Modbus TLS Frame controller
|
||||
|
||||
No prefix MBAP header before decrypted PDU is used as a message frame for
|
||||
Modbus Security Application Protocol. It allows us to easily separate
|
||||
decrypted messages which is PDU as follows:
|
||||
|
||||
[ Function Code] [ Data ]
|
||||
1b Nb
|
||||
"""
|
||||
|
||||
method = "tls"
|
||||
|
||||
def __init__(self, decoder, client=None):
|
||||
"""Initialize a new instance of the framer.
|
||||
|
||||
:param decoder: The decoder factory implementation to use
|
||||
"""
|
||||
super().__init__(decoder, client)
|
||||
self._hsize = 0x0
|
||||
|
||||
# ----------------------------------------------------------------------- #
|
||||
# Private Helper Functions
|
||||
# ----------------------------------------------------------------------- #
|
||||
def checkFrame(self):
|
||||
"""Check and decode the next frame.
|
||||
|
||||
Return true if we were successful.
|
||||
"""
|
||||
if self.isFrameReady():
|
||||
# we have at least a complete message, continue
|
||||
if len(self._buffer) - self._hsize >= 1:
|
||||
return True
|
||||
# we don't have enough of a message yet, wait
|
||||
return False
|
||||
|
||||
def advanceFrame(self):
|
||||
"""Skip over the current framed message.
|
||||
|
||||
This allows us to skip over the current message after we have processed
|
||||
it or determined that it contains an error. It also has to reset the
|
||||
current frame header handle
|
||||
"""
|
||||
self._buffer = b""
|
||||
self._header = {}
|
||||
|
||||
def isFrameReady(self):
|
||||
"""Check if we should continue decode logic.
|
||||
|
||||
This is meant to be used in a while loop in the decoding phase to let
|
||||
the decoder factory know that there is still data in the buffer.
|
||||
|
||||
:returns: True if ready, False otherwise
|
||||
"""
|
||||
return len(self._buffer) > self._hsize
|
||||
|
||||
def getFrame(self):
|
||||
"""Return the next frame from the buffered data.
|
||||
|
||||
:returns: The next full frame buffer
|
||||
"""
|
||||
return self._buffer[self._hsize :]
|
||||
|
||||
# ----------------------------------------------------------------------- #
|
||||
# Public Member Functions
|
||||
# ----------------------------------------------------------------------- #
|
||||
def decode_data(self, data):
|
||||
"""Decode data."""
|
||||
if len(data) > self._hsize:
|
||||
(fcode,) = struct.unpack(TLS_FRAME_HEADER, data[0 : self._hsize + 1])
|
||||
return {"fcode": fcode}
|
||||
return {}
|
||||
|
||||
def frameProcessIncomingPacket(self, single, callback, slave, _tid=None, **kwargs):
|
||||
"""Process new packet pattern."""
|
||||
# no slave id for Modbus Security Application Protocol
|
||||
if not self.isFrameReady():
|
||||
return
|
||||
if not self.checkFrame():
|
||||
Log.debug("Frame check failed, ignoring!!")
|
||||
self.resetFrame()
|
||||
return
|
||||
if not self._validate_slave_id(slave, single):
|
||||
Log.debug("Not in valid slave id - {}, ignoring!!", slave)
|
||||
self.resetFrame()
|
||||
self._process(callback)
|
||||
|
||||
def _process(self, callback, error=False):
|
||||
"""Process incoming packets irrespective error condition."""
|
||||
data = self._buffer if error else self.getFrame()
|
||||
if (result := self.decoder.decode(data)) is None:
|
||||
raise ModbusIOException("Unable to decode request")
|
||||
if error and result.function_code < 0x80:
|
||||
raise InvalidMessageReceivedException(result)
|
||||
self.populateResult(result)
|
||||
self.advanceFrame()
|
||||
callback(result) # defer or push to a thread?
|
||||
|
||||
def buildPacket(self, message):
|
||||
"""Create a ready to send modbus packet.
|
||||
|
||||
:param message: The populated request/response to send
|
||||
"""
|
||||
data = message.encode()
|
||||
packet = struct.pack(TLS_FRAME_HEADER, message.function_code)
|
||||
packet += data
|
||||
return packet
|
||||
|
||||
|
||||
# __END__
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Pymodbus: Modbus Protocol Implementation.
|
||||
|
||||
Released under the the BSD license
|
||||
"""
|
||||
import logging
|
||||
from binascii import b2a_hex
|
||||
from logging import NullHandler as __null
|
||||
from typing import Union
|
||||
|
||||
from pymodbus.utilities import hexlify_packets
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------#
|
||||
# Block unhandled logging
|
||||
# ---------------------------------------------------------------------------#
|
||||
logging.getLogger("pymodbus_internal").addHandler(__null())
|
||||
|
||||
|
||||
def pymodbus_apply_logging_config(
|
||||
level: Union[str, int] = logging.DEBUG, log_file_name: str = None
|
||||
):
|
||||
"""Apply basic logging configuration used by default by Pymodbus maintainers.
|
||||
|
||||
:param level: (optional) set log level, if not set it is inherited.
|
||||
:param log_file_name: (optional) log additional to file
|
||||
|
||||
Please call this function to format logging appropriately when opening issues.
|
||||
"""
|
||||
if isinstance(level, str):
|
||||
level = level.upper()
|
||||
Log.apply_logging_config(level, log_file_name)
|
||||
|
||||
|
||||
class Log:
|
||||
"""Class to hide logging complexity.
|
||||
|
||||
:meta private:
|
||||
"""
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
@classmethod
|
||||
def apply_logging_config(cls, level, log_file_name):
|
||||
"""Apply basic logging configuration"""
|
||||
if level == logging.NOTSET:
|
||||
level = cls._logger.getEffectiveLevel()
|
||||
if isinstance(level, str):
|
||||
level = level.upper()
|
||||
log_stream_handler = logging.StreamHandler()
|
||||
log_formatter = logging.Formatter(
|
||||
"%(asctime)s %(levelname)-5s %(module)s:%(lineno)s %(message)s"
|
||||
)
|
||||
log_stream_handler.setFormatter(log_formatter)
|
||||
cls._logger.addHandler(log_stream_handler)
|
||||
if log_file_name:
|
||||
log_file_handler = logging.FileHandler(log_file_name)
|
||||
log_file_handler.setFormatter(log_formatter)
|
||||
cls._logger.addHandler(log_file_handler)
|
||||
cls.setLevel(level)
|
||||
|
||||
@classmethod
|
||||
def setLevel(cls, level):
|
||||
"""Apply basic logging level"""
|
||||
cls._logger.setLevel(level)
|
||||
|
||||
@classmethod
|
||||
def build_msg(cls, txt, *args):
|
||||
"""Build message."""
|
||||
string_args = []
|
||||
count_args = len(args) - 1
|
||||
skip = False
|
||||
for i in range(count_args + 1):
|
||||
if skip:
|
||||
skip = False
|
||||
continue
|
||||
if (
|
||||
i < count_args
|
||||
and isinstance(args[i + 1], str)
|
||||
and args[i + 1][0] == ":"
|
||||
):
|
||||
if args[i + 1] == ":hex":
|
||||
string_args.append(hexlify_packets(args[i]))
|
||||
elif args[i + 1] == ":str":
|
||||
string_args.append(str(args[i]))
|
||||
elif args[i + 1] == ":b2a":
|
||||
string_args.append(b2a_hex(args[i]))
|
||||
skip = True
|
||||
else:
|
||||
string_args.append(args[i])
|
||||
return txt.format(*string_args)
|
||||
|
||||
@classmethod
|
||||
def info(cls, txt, *args):
|
||||
"""Log info messagees."""
|
||||
if cls._logger.isEnabledFor(logging.INFO):
|
||||
cls._logger.info(cls.build_msg(txt, *args))
|
||||
|
||||
@classmethod
|
||||
def debug(cls, txt, *args):
|
||||
"""Log debug messagees."""
|
||||
if cls._logger.isEnabledFor(logging.DEBUG):
|
||||
cls._logger.debug(cls.build_msg(txt, *args))
|
||||
|
||||
@classmethod
|
||||
def warning(cls, txt, *args):
|
||||
"""Log warning messagees."""
|
||||
if cls._logger.isEnabledFor(logging.WARNING):
|
||||
cls._logger.warning(cls.build_msg(txt, *args))
|
||||
|
||||
@classmethod
|
||||
def error(cls, txt, *args):
|
||||
"""Log error messagees."""
|
||||
if cls._logger.isEnabledFor(logging.ERROR):
|
||||
cls._logger.error(cls.build_msg(txt, *args))
|
||||
|
||||
@classmethod
|
||||
def critical(cls, txt, *args):
|
||||
"""Log critical messagees."""
|
||||
if cls._logger.isEnabledFor(logging.CRITICAL):
|
||||
cls._logger.critical(cls.build_msg(txt, *args))
|
||||
@@ -0,0 +1,221 @@
|
||||
"""Encapsulated Interface (MEI) Transport Messages."""
|
||||
|
||||
__all__ = [
|
||||
"ReadDeviceInformationRequest",
|
||||
"ReadDeviceInformationResponse",
|
||||
]
|
||||
|
||||
# pylint: disable=missing-type-doc
|
||||
import struct
|
||||
|
||||
from pymodbus.constants import DeviceInformation, MoreData
|
||||
from pymodbus.device import DeviceInformationFactory, ModbusControlBlock
|
||||
from pymodbus.pdu import ModbusExceptions as merror
|
||||
from pymodbus.pdu import ModbusRequest, ModbusResponse
|
||||
|
||||
|
||||
_MCB = ModbusControlBlock()
|
||||
|
||||
|
||||
class _OutOfSpaceException(Exception):
|
||||
"""Internal out of space exception."""
|
||||
|
||||
# This exception exists here as a simple, local way to manage response
|
||||
# length control for the only MODBUS command which requires it under
|
||||
# standard, non-error conditions. It and the structures associated with
|
||||
# it should ideally be refactored and applied to all responses, however,
|
||||
# since a Client can make requests which result in disallowed conditions,
|
||||
# such as, for instance, requesting a register read of more registers
|
||||
# than will fit in a single PDU. As per the specification, the PDU is
|
||||
# restricted to 253 bytes, irrespective of the transport used.
|
||||
#
|
||||
# See Page 5/50 of MODBUS Application Protocol Specification V1.1b3.
|
||||
|
||||
def __init__(self, oid):
|
||||
self.oid = oid
|
||||
super().__init__()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------#
|
||||
# Read Device Information
|
||||
# ---------------------------------------------------------------------------#
|
||||
class ReadDeviceInformationRequest(ModbusRequest):
|
||||
"""Read device information.
|
||||
|
||||
This function code allows reading the identification and additional
|
||||
information relative to the physical and functional description of a
|
||||
remote device, only.
|
||||
|
||||
The Read Device Identification interface is modeled as an address space
|
||||
composed of a set of addressable data elements. The data elements are
|
||||
called objects and an object Id identifies them.
|
||||
"""
|
||||
|
||||
function_code = 0x2B
|
||||
sub_function_code = 0x0E
|
||||
function_code_name = "read_device_information"
|
||||
_rtu_frame_size = 7
|
||||
|
||||
def __init__(self, read_code=None, object_id=0x00, **kwargs):
|
||||
"""Initialize a new instance.
|
||||
|
||||
:param read_code: The device information read code
|
||||
:param object_id: The object to read from
|
||||
"""
|
||||
ModbusRequest.__init__(self, **kwargs)
|
||||
self.read_code = read_code or DeviceInformation.BASIC
|
||||
self.object_id = object_id
|
||||
|
||||
def encode(self):
|
||||
"""Encode the request packet.
|
||||
|
||||
:returns: The byte encoded packet
|
||||
"""
|
||||
packet = struct.pack(
|
||||
">BBB", self.sub_function_code, self.read_code, self.object_id
|
||||
)
|
||||
return packet
|
||||
|
||||
def decode(self, data):
|
||||
"""Decode data part of the message.
|
||||
|
||||
:param data: The incoming data
|
||||
"""
|
||||
params = struct.unpack(">BBB", data)
|
||||
self.sub_function_code, self.read_code, self.object_id = params
|
||||
|
||||
def execute(self, _context):
|
||||
"""Run a read exception status request against the store.
|
||||
|
||||
:returns: The populated response
|
||||
"""
|
||||
if not 0x00 <= self.object_id <= 0xFF:
|
||||
return self.doException(merror.IllegalValue)
|
||||
if not 0x00 <= self.read_code <= 0x04:
|
||||
return self.doException(merror.IllegalValue)
|
||||
|
||||
information = DeviceInformationFactory.get(_MCB, self.read_code, self.object_id)
|
||||
return ReadDeviceInformationResponse(self.read_code, information)
|
||||
|
||||
def __str__(self):
|
||||
"""Build a representation of the request.
|
||||
|
||||
:returns: The string representation of the request
|
||||
"""
|
||||
params = (self.read_code, self.object_id)
|
||||
return (
|
||||
"ReadDeviceInformationRequest(%d,%d)" # pylint: disable=consider-using-f-string
|
||||
% params
|
||||
)
|
||||
|
||||
|
||||
class ReadDeviceInformationResponse(ModbusResponse):
|
||||
"""Read device information response."""
|
||||
|
||||
function_code = 0x2B
|
||||
sub_function_code = 0x0E
|
||||
|
||||
@classmethod
|
||||
def calculateRtuFrameSize(cls, buffer):
|
||||
"""Calculate the size of the message
|
||||
|
||||
:param buffer: A buffer containing the data that have been received.
|
||||
:returns: The number of bytes in the response.
|
||||
"""
|
||||
size = 8 # skip the header information
|
||||
count = int(buffer[7])
|
||||
|
||||
try:
|
||||
while count > 0:
|
||||
_, object_length = struct.unpack(">BB", buffer[size : size + 2])
|
||||
size += object_length + 2
|
||||
count -= 1
|
||||
return size + 2
|
||||
except struct.error as exc:
|
||||
raise IndexError from exc
|
||||
|
||||
def __init__(self, read_code=None, information=None, **kwargs):
|
||||
"""Initialize a new instance.
|
||||
|
||||
:param read_code: The device information read code
|
||||
:param information: The requested information request
|
||||
"""
|
||||
ModbusResponse.__init__(self, **kwargs)
|
||||
self.read_code = read_code or DeviceInformation.BASIC
|
||||
self.information = information or {}
|
||||
self.number_of_objects = 0
|
||||
self.conformity = 0x83 # I support everything right now
|
||||
self.next_object_id = 0x00
|
||||
self.more_follows = MoreData.NOTHING
|
||||
self.space_left = None
|
||||
|
||||
def _encode_object(self, object_id, data):
|
||||
"""Encode object."""
|
||||
self.space_left -= 2 + len(data)
|
||||
if self.space_left <= 0:
|
||||
raise _OutOfSpaceException(object_id)
|
||||
encoded_obj = struct.pack(">BB", object_id, len(data))
|
||||
if isinstance(data, bytes):
|
||||
encoded_obj += data
|
||||
else:
|
||||
encoded_obj += data.encode()
|
||||
self.number_of_objects += 1
|
||||
return encoded_obj
|
||||
|
||||
def encode(self):
|
||||
"""Encode the response.
|
||||
|
||||
:returns: The byte encoded message
|
||||
"""
|
||||
packet = struct.pack(
|
||||
">BBB", self.sub_function_code, self.read_code, self.conformity
|
||||
)
|
||||
self.space_left = 253 - 6
|
||||
objects = b""
|
||||
try:
|
||||
for object_id, data in iter(self.information.items()):
|
||||
if isinstance(data, list):
|
||||
for item in data:
|
||||
objects += self._encode_object(object_id, item)
|
||||
else:
|
||||
objects += self._encode_object(object_id, data)
|
||||
except _OutOfSpaceException as exc:
|
||||
self.next_object_id = exc.oid
|
||||
self.more_follows = MoreData.KEEP_READING
|
||||
|
||||
packet += struct.pack(
|
||||
">BBB", self.more_follows, self.next_object_id, self.number_of_objects
|
||||
)
|
||||
packet += objects
|
||||
return packet
|
||||
|
||||
def decode(self, data):
|
||||
"""Decode a the response.
|
||||
|
||||
:param data: The packet data to decode
|
||||
"""
|
||||
params = struct.unpack(">BBBBBB", data[0:6])
|
||||
self.sub_function_code, self.read_code = params[0:2]
|
||||
self.conformity, self.more_follows = params[2:4]
|
||||
self.next_object_id, self.number_of_objects = params[4:6]
|
||||
self.information, count = {}, 6 # skip the header information
|
||||
|
||||
while count < len(data):
|
||||
object_id, object_length = struct.unpack(">BB", data[count : count + 2])
|
||||
count += object_length + 2
|
||||
if object_id not in self.information:
|
||||
self.information[object_id] = data[count - object_length : count]
|
||||
elif isinstance(self.information[object_id], list):
|
||||
self.information[object_id].append(data[count - object_length : count])
|
||||
else:
|
||||
self.information[object_id] = [
|
||||
self.information[object_id],
|
||||
data[count - object_length : count],
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
"""Build a representation of the response.
|
||||
|
||||
:returns: The string representation of the response
|
||||
"""
|
||||
return f"ReadDeviceInformationResponse({self.read_code})"
|
||||
@@ -0,0 +1,483 @@
|
||||
"""Diagnostic record read/write.
|
||||
|
||||
Currently not all implemented
|
||||
"""
|
||||
|
||||
__all__ = [
|
||||
"ReadExceptionStatusRequest",
|
||||
"ReadExceptionStatusResponse",
|
||||
"GetCommEventCounterRequest",
|
||||
"GetCommEventCounterResponse",
|
||||
"GetCommEventLogRequest",
|
||||
"GetCommEventLogResponse",
|
||||
"ReportSlaveIdRequest",
|
||||
"ReportSlaveIdResponse",
|
||||
]
|
||||
|
||||
# pylint: disable=missing-type-doc
|
||||
import struct
|
||||
|
||||
from pymodbus.constants import ModbusStatus
|
||||
from pymodbus.device import DeviceInformationFactory, ModbusControlBlock
|
||||
from pymodbus.pdu import ModbusRequest, ModbusResponse
|
||||
|
||||
|
||||
_MCB = ModbusControlBlock()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------#
|
||||
# TODO Make these only work on serial # pylint: disable=fixme
|
||||
# ---------------------------------------------------------------------------#
|
||||
class ReadExceptionStatusRequest(ModbusRequest):
|
||||
"""This function code is used to read the contents of eight Exception Status outputs in a remote device.
|
||||
|
||||
The function provides a simple method for
|
||||
accessing this information, because the Exception Output references are
|
||||
known (no output reference is needed in the function).
|
||||
"""
|
||||
|
||||
function_code = 0x07
|
||||
function_code_name = "read_exception_status"
|
||||
_rtu_frame_size = 4
|
||||
|
||||
def __init__(self, slave=None, **kwargs):
|
||||
"""Initialize a new instance."""
|
||||
ModbusRequest.__init__(self, slave=slave, **kwargs)
|
||||
|
||||
def encode(self):
|
||||
"""Encode the message."""
|
||||
return b""
|
||||
|
||||
def decode(self, data):
|
||||
"""Decode data part of the message.
|
||||
|
||||
:param data: The incoming data
|
||||
"""
|
||||
|
||||
def execute(self, _context=None):
|
||||
"""Run a read exception status request against the store.
|
||||
|
||||
:returns: The populated response
|
||||
"""
|
||||
status = _MCB.Counter.summary()
|
||||
return ReadExceptionStatusResponse(status)
|
||||
|
||||
def __str__(self):
|
||||
"""Build a representation of the request.
|
||||
|
||||
:returns: The string representation of the request
|
||||
"""
|
||||
return f"ReadExceptionStatusRequest({self.function_code})"
|
||||
|
||||
|
||||
class ReadExceptionStatusResponse(ModbusResponse):
|
||||
"""The normal response contains the status of the eight Exception Status outputs.
|
||||
|
||||
The outputs are packed into one data byte, with one bit
|
||||
per output. The status of the lowest output reference is contained
|
||||
in the least significant bit of the byte. The contents of the eight
|
||||
Exception Status outputs are device specific.
|
||||
"""
|
||||
|
||||
function_code = 0x07
|
||||
_rtu_frame_size = 5
|
||||
|
||||
def __init__(self, status=0x00, **kwargs):
|
||||
"""Initialize a new instance.
|
||||
|
||||
:param status: The status response to report
|
||||
"""
|
||||
ModbusResponse.__init__(self, **kwargs)
|
||||
self.status = status if status < 256 else 255
|
||||
|
||||
def encode(self):
|
||||
"""Encode the response.
|
||||
|
||||
:returns: The byte encoded message
|
||||
"""
|
||||
return struct.pack(">B", self.status)
|
||||
|
||||
def decode(self, data):
|
||||
"""Decode a the response.
|
||||
|
||||
:param data: The packet data to decode
|
||||
"""
|
||||
self.status = int(data[0])
|
||||
|
||||
def __str__(self):
|
||||
"""Build a representation of the response.
|
||||
|
||||
:returns: The string representation of the response
|
||||
"""
|
||||
arguments = (self.function_code, self.status)
|
||||
return (
|
||||
"ReadExceptionStatusResponse(%d, %s)" # pylint: disable=consider-using-f-string
|
||||
% arguments
|
||||
)
|
||||
|
||||
|
||||
# Encapsulate interface transport 43, 14
|
||||
# CANopen general reference 43, 13
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------#
|
||||
# TODO Make these only work on serial # pylint: disable=fixme
|
||||
# ---------------------------------------------------------------------------#
|
||||
class GetCommEventCounterRequest(ModbusRequest):
|
||||
"""This function code is used to get a status word.
|
||||
|
||||
And an event count from the remote device's communication event counter.
|
||||
|
||||
By fetching the current count before and after a series of messages, a
|
||||
client can determine whether the messages were handled normally by the
|
||||
remote device.
|
||||
|
||||
The device's event counter is incremented once for each successful
|
||||
message completion. It is not incremented for exception responses,
|
||||
poll commands, or fetch event counter commands.
|
||||
|
||||
The event counter can be reset by means of the Diagnostics function
|
||||
(code 08), with a subfunction of Restart Communications Option
|
||||
(code 00 01) or Clear Counters and Diagnostic Register (code 00 0A).
|
||||
"""
|
||||
|
||||
function_code = 0x0B
|
||||
function_code_name = "get_event_counter"
|
||||
_rtu_frame_size = 4
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
"""Initialize a new instance."""
|
||||
ModbusRequest.__init__(self, **kwargs)
|
||||
|
||||
def encode(self):
|
||||
"""Encode the message."""
|
||||
return b""
|
||||
|
||||
def decode(self, data):
|
||||
"""Decode data part of the message.
|
||||
|
||||
:param data: The incoming data
|
||||
"""
|
||||
|
||||
def execute(self, _context=None):
|
||||
"""Run a read exception status request against the store.
|
||||
|
||||
:returns: The populated response
|
||||
"""
|
||||
status = _MCB.Counter.Event
|
||||
return GetCommEventCounterResponse(status)
|
||||
|
||||
def __str__(self):
|
||||
"""Build a representation of the request.
|
||||
|
||||
:returns: The string representation of the request
|
||||
"""
|
||||
return f"GetCommEventCounterRequest({self.function_code})"
|
||||
|
||||
|
||||
class GetCommEventCounterResponse(ModbusResponse):
|
||||
"""Get comm event counter response.
|
||||
|
||||
The normal response contains a two-byte status word, and a two-byte
|
||||
event count. The status word will be all ones (FF FF hex) if a
|
||||
previously-issued program command is still being processed by the
|
||||
remote device (a busy condition exists). Otherwise, the status word
|
||||
will be all zeros.
|
||||
"""
|
||||
|
||||
function_code = 0x0B
|
||||
_rtu_frame_size = 8
|
||||
|
||||
def __init__(self, count=0x0000, **kwargs):
|
||||
"""Initialize a new instance.
|
||||
|
||||
:param count: The current event counter value
|
||||
"""
|
||||
ModbusResponse.__init__(self, **kwargs)
|
||||
self.count = count
|
||||
self.status = True # this means we are ready, not waiting
|
||||
|
||||
def encode(self):
|
||||
"""Encode the response.
|
||||
|
||||
:returns: The byte encoded message
|
||||
"""
|
||||
if self.status:
|
||||
ready = ModbusStatus.READY
|
||||
else:
|
||||
ready = ModbusStatus.WAITING
|
||||
return struct.pack(">HH", ready, self.count)
|
||||
|
||||
def decode(self, data):
|
||||
"""Decode a the response.
|
||||
|
||||
:param data: The packet data to decode
|
||||
"""
|
||||
ready, self.count = struct.unpack(">HH", data)
|
||||
self.status = ready == ModbusStatus.READY
|
||||
|
||||
def __str__(self):
|
||||
"""Build a representation of the response.
|
||||
|
||||
:returns: The string representation of the response
|
||||
"""
|
||||
arguments = (self.function_code, self.count, self.status)
|
||||
return (
|
||||
"GetCommEventCounterResponse(%d, %d, %d)" # pylint: disable=consider-using-f-string
|
||||
% arguments
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------#
|
||||
# TODO Make these only work on serial # pylint: disable=fixme
|
||||
# ---------------------------------------------------------------------------#
|
||||
class GetCommEventLogRequest(ModbusRequest):
|
||||
"""This function code is used to get a status word.
|
||||
|
||||
Event count, message count, and a field of event bytes from the remote device.
|
||||
|
||||
The status word and event counts are identical to that returned by
|
||||
the Get Communications Event Counter function (11, 0B hex).
|
||||
|
||||
The message counter contains the quantity of messages processed by the
|
||||
remote device since its last restart, clear counters operation, or
|
||||
power-up. This count is identical to that returned by the Diagnostic
|
||||
function (code 08), sub-function Return Bus Message Count (code 11,
|
||||
0B hex).
|
||||
|
||||
The event bytes field contains 0-64 bytes, with each byte corresponding
|
||||
to the status of one MODBUS send or receive operation for the remote
|
||||
device. The remote device enters the events into the field in
|
||||
chronological order. Byte 0 is the most recent event. Each new byte
|
||||
flushes the oldest byte from the field.
|
||||
"""
|
||||
|
||||
function_code = 0x0C
|
||||
function_code_name = "get_event_log"
|
||||
_rtu_frame_size = 4
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
"""Initialize a new instance."""
|
||||
ModbusRequest.__init__(self, **kwargs)
|
||||
|
||||
def encode(self):
|
||||
"""Encode the message."""
|
||||
return b""
|
||||
|
||||
def decode(self, data):
|
||||
"""Decode data part of the message.
|
||||
|
||||
:param data: The incoming data
|
||||
"""
|
||||
|
||||
def execute(self, _context=None):
|
||||
"""Run a read exception status request against the store.
|
||||
|
||||
:returns: The populated response
|
||||
"""
|
||||
results = {
|
||||
"status": True,
|
||||
"message_count": _MCB.Counter.BusMessage,
|
||||
"event_count": _MCB.Counter.Event,
|
||||
"events": _MCB.getEvents(),
|
||||
}
|
||||
return GetCommEventLogResponse(**results)
|
||||
|
||||
def __str__(self):
|
||||
"""Build a representation of the request.
|
||||
|
||||
:returns: The string representation of the request
|
||||
"""
|
||||
return f"GetCommEventLogRequest({self.function_code})"
|
||||
|
||||
|
||||
class GetCommEventLogResponse(ModbusResponse):
|
||||
"""Get Comm event log response.
|
||||
|
||||
The normal response contains a two-byte status word field,
|
||||
a two-byte event count field, a two-byte message count field,
|
||||
and a field containing 0-64 bytes of events. A byte count
|
||||
field defines the total length of the data in these four field
|
||||
"""
|
||||
|
||||
function_code = 0x0C
|
||||
_rtu_byte_count_pos = 2
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
"""Initialize a new instance.
|
||||
|
||||
:param status: The status response to report
|
||||
:param message_count: The current message count
|
||||
:param event_count: The current event count
|
||||
:param events: The collection of events to send
|
||||
"""
|
||||
ModbusResponse.__init__(self, **kwargs)
|
||||
self.status = kwargs.get("status", True)
|
||||
self.message_count = kwargs.get("message_count", 0)
|
||||
self.event_count = kwargs.get("event_count", 0)
|
||||
self.events = kwargs.get("events", [])
|
||||
|
||||
def encode(self):
|
||||
"""Encode the response.
|
||||
|
||||
:returns: The byte encoded message
|
||||
"""
|
||||
if self.status:
|
||||
ready = ModbusStatus.READY
|
||||
else:
|
||||
ready = ModbusStatus.WAITING
|
||||
packet = struct.pack(">B", 6 + len(self.events))
|
||||
packet += struct.pack(">H", ready)
|
||||
packet += struct.pack(">HH", self.event_count, self.message_count)
|
||||
packet += b"".join(struct.pack(">B", e) for e in self.events)
|
||||
return packet
|
||||
|
||||
def decode(self, data):
|
||||
"""Decode a the response.
|
||||
|
||||
:param data: The packet data to decode
|
||||
"""
|
||||
length = int(data[0])
|
||||
status = struct.unpack(">H", data[1:3])[0]
|
||||
self.status = status == ModbusStatus.READY
|
||||
self.event_count = struct.unpack(">H", data[3:5])[0]
|
||||
self.message_count = struct.unpack(">H", data[5:7])[0]
|
||||
|
||||
self.events = []
|
||||
for i in range(7, length + 1):
|
||||
self.events.append(int(data[i]))
|
||||
|
||||
def __str__(self):
|
||||
"""Build a representation of the response.
|
||||
|
||||
:returns: The string representation of the response
|
||||
"""
|
||||
arguments = (
|
||||
self.function_code,
|
||||
self.status,
|
||||
self.message_count,
|
||||
self.event_count,
|
||||
)
|
||||
return (
|
||||
"GetCommEventLogResponse(%d, %d, %d, %d)" # pylint: disable=consider-using-f-string
|
||||
% arguments
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------#
|
||||
# TODO Make these only work on serial # pylint: disable=fixme
|
||||
# ---------------------------------------------------------------------------#
|
||||
class ReportSlaveIdRequest(ModbusRequest):
|
||||
"""This function code is used to read the description of the type.
|
||||
|
||||
The current status, and other information specific to a remote device.
|
||||
"""
|
||||
|
||||
function_code = 0x11
|
||||
function_code_name = "report_slave_id"
|
||||
_rtu_frame_size = 4
|
||||
|
||||
def __init__(self, slave=0, **kwargs):
|
||||
"""Initialize a new instance.
|
||||
|
||||
:param slave: Modbus slave slave ID
|
||||
|
||||
"""
|
||||
ModbusRequest.__init__(self, slave, **kwargs)
|
||||
|
||||
def encode(self):
|
||||
"""Encode the message."""
|
||||
return b""
|
||||
|
||||
def decode(self, data):
|
||||
"""Decode data part of the message.
|
||||
|
||||
:param data: The incoming data
|
||||
"""
|
||||
|
||||
def execute(self, context=None):
|
||||
"""Run a report slave id request against the store.
|
||||
|
||||
:returns: The populated response
|
||||
"""
|
||||
report_slave_id_data = None
|
||||
if context:
|
||||
report_slave_id_data = getattr(context, "reportSlaveIdData", None)
|
||||
if not report_slave_id_data:
|
||||
information = DeviceInformationFactory.get(_MCB)
|
||||
|
||||
# Support identity values as bytes data and regular str data
|
||||
id_data = []
|
||||
for v_item in information.values():
|
||||
if isinstance(v_item, bytes):
|
||||
id_data.append(v_item)
|
||||
else:
|
||||
id_data.append(v_item.encode())
|
||||
|
||||
identifier = b"-".join(id_data)
|
||||
identifier = identifier or b"Pymodbus"
|
||||
report_slave_id_data = identifier
|
||||
return ReportSlaveIdResponse(report_slave_id_data)
|
||||
|
||||
def __str__(self):
|
||||
"""Build a representation of the request.
|
||||
|
||||
:returns: The string representation of the request
|
||||
"""
|
||||
return f"ReportSlaveIdRequest({self.function_code})"
|
||||
|
||||
|
||||
class ReportSlaveIdResponse(ModbusResponse):
|
||||
"""Show response.
|
||||
|
||||
The data contents are specific to each type of device.
|
||||
"""
|
||||
|
||||
function_code = 0x11
|
||||
_rtu_byte_count_pos = 2
|
||||
|
||||
def __init__(self, identifier=b"\x00", status=True, **kwargs):
|
||||
"""Initialize a new instance.
|
||||
|
||||
:param identifier: The identifier of the slave
|
||||
:param status: The status response to report
|
||||
"""
|
||||
ModbusResponse.__init__(self, **kwargs)
|
||||
self.identifier = identifier
|
||||
self.status = status
|
||||
self.byte_count = None
|
||||
|
||||
def encode(self):
|
||||
"""Encode the response.
|
||||
|
||||
:returns: The byte encoded message
|
||||
"""
|
||||
if self.status:
|
||||
status = ModbusStatus.SLAVE_ON
|
||||
else:
|
||||
status = ModbusStatus.SLAVE_OFF
|
||||
length = len(self.identifier) + 1
|
||||
packet = struct.pack(">B", length)
|
||||
packet += self.identifier # we assume it is already encoded
|
||||
packet += struct.pack(">B", status)
|
||||
return packet
|
||||
|
||||
def decode(self, data):
|
||||
"""Decode a the response.
|
||||
|
||||
Since the identifier is device dependent, we just return the
|
||||
raw value that a user can decode to whatever it should be.
|
||||
|
||||
:param data: The packet data to decode
|
||||
"""
|
||||
self.byte_count = int(data[0])
|
||||
self.identifier = data[1 : self.byte_count + 1]
|
||||
status = int(data[-1])
|
||||
self.status = status == ModbusStatus.SLAVE_ON
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Build a representation of the response.
|
||||
|
||||
:returns: The string representation of the response
|
||||
"""
|
||||
return f"ReportSlaveIdResponse({self.function_code}, {self.identifier}, {self.status})"
|
||||
@@ -0,0 +1,460 @@
|
||||
"""Modbus Payload Builders.
|
||||
|
||||
A collection of utilities for building and decoding
|
||||
modbus messages payloads.
|
||||
"""
|
||||
|
||||
__all__ = [
|
||||
"BinaryPayloadBuilder",
|
||||
"BinaryPayloadDecoder",
|
||||
]
|
||||
|
||||
# pylint: disable=missing-type-doc
|
||||
from struct import pack, unpack
|
||||
from typing import List
|
||||
|
||||
from pymodbus.constants import Endian
|
||||
from pymodbus.exceptions import ParameterException
|
||||
from pymodbus.logging import Log
|
||||
from pymodbus.utilities import (
|
||||
pack_bitstring,
|
||||
unpack_bitstring,
|
||||
)
|
||||
|
||||
|
||||
WC = {"b": 1, "h": 2, "e": 2, "i": 4, "l": 4, "q": 8, "f": 4, "d": 8}
|
||||
|
||||
|
||||
class BinaryPayloadBuilder:
|
||||
"""A utility that helps build payload messages to be written with the various modbus messages.
|
||||
|
||||
It really is just a simple wrapper around the struct module,
|
||||
however it saves time looking up the format strings.
|
||||
What follows is a simple example::
|
||||
|
||||
builder = BinaryPayloadBuilder(byteorder=Endian.Little)
|
||||
builder.add_8bit_uint(1)
|
||||
builder.add_16bit_uint(2)
|
||||
payload = builder.build()
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, payload=None, byteorder=Endian.LITTLE, wordorder=Endian.BIG, repack=False
|
||||
):
|
||||
"""Initialize a new instance of the payload builder.
|
||||
|
||||
:param payload: Raw binary payload data to initialize with
|
||||
:param byteorder: The endianness of the bytes in the words
|
||||
:param wordorder: The endianness of the word (when wordcount is >= 2)
|
||||
:param repack: Repack the provided payload based on BO
|
||||
"""
|
||||
self._payload = payload or []
|
||||
self._byteorder = byteorder
|
||||
self._wordorder = wordorder
|
||||
self._repack = repack
|
||||
|
||||
def _pack_words(self, fstring, value):
|
||||
"""Pack words based on the word order and byte order.
|
||||
|
||||
# ---------------------------------------------- #
|
||||
# pack in to network ordered value #
|
||||
# unpack in to network ordered unsigned integer #
|
||||
# Change Word order if little endian word order #
|
||||
# Pack values back based on correct byte order #
|
||||
# ---------------------------------------------- #
|
||||
|
||||
:param fstring:
|
||||
:param value: Value to be packed
|
||||
:return:
|
||||
"""
|
||||
value = pack(f"!{fstring}", value)
|
||||
wordorder = WC.get(fstring.lower()) // 2
|
||||
upperbyte = f"!{wordorder}H"
|
||||
payload = unpack(upperbyte, value)
|
||||
|
||||
if self._wordorder == Endian.LITTLE:
|
||||
payload = list(reversed(payload))
|
||||
|
||||
fstring = self._byteorder + "H"
|
||||
payload = [pack(fstring, word) for word in payload]
|
||||
payload = b"".join(payload)
|
||||
|
||||
return payload
|
||||
|
||||
def encode(self) -> bytes:
|
||||
"""Get the payload buffer encoded in bytes."""
|
||||
return b"".join(self._payload)
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Return the payload buffer as a string.
|
||||
|
||||
:returns: The payload buffer as a string
|
||||
"""
|
||||
return self.encode().decode("utf-8")
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset the payload buffer."""
|
||||
self._payload = []
|
||||
|
||||
def to_registers(self):
|
||||
"""Convert the payload buffer to register layout that can be used as a context block.
|
||||
|
||||
:returns: The register layout to use as a block
|
||||
"""
|
||||
# fstring = self._byteorder+"H"
|
||||
fstring = "!H"
|
||||
payload = self.build()
|
||||
if self._repack:
|
||||
payload = [unpack(self._byteorder + "H", value)[0] for value in payload]
|
||||
else:
|
||||
payload = [unpack(fstring, value)[0] for value in payload]
|
||||
Log.debug("{}", payload)
|
||||
return payload
|
||||
|
||||
def to_coils(self) -> List[bool]:
|
||||
"""Convert the payload buffer into a coil layout that can be used as a context block.
|
||||
|
||||
:returns: The coil layout to use as a block
|
||||
"""
|
||||
payload = self.to_registers()
|
||||
coils = [bool(int(bit)) for reg in payload for bit in format(reg, "016b")]
|
||||
return coils
|
||||
|
||||
def build(self) -> List[bytes]:
|
||||
"""Return the payload buffer as a list.
|
||||
|
||||
This list is two bytes per element and can
|
||||
thus be treated as a list of registers.
|
||||
|
||||
:returns: The payload buffer as a list
|
||||
"""
|
||||
buffer = self.encode()
|
||||
length = len(buffer)
|
||||
buffer += b"\x00" * (length % 2)
|
||||
return [buffer[i : i + 2] for i in range(0, length, 2)]
|
||||
|
||||
def add_bits(self, values: List[bool]) -> None:
|
||||
"""Add a collection of bits to be encoded.
|
||||
|
||||
If these are less than a multiple of eight,
|
||||
they will be left padded with 0 bits to make
|
||||
it so.
|
||||
|
||||
:param values: The value to add to the buffer
|
||||
"""
|
||||
value = pack_bitstring(values)
|
||||
self._payload.append(value)
|
||||
|
||||
def add_8bit_uint(self, value: int) -> None:
|
||||
"""Add a 8 bit unsigned int to the buffer.
|
||||
|
||||
:param value: The value to add to the buffer
|
||||
"""
|
||||
fstring = self._byteorder + "B"
|
||||
self._payload.append(pack(fstring, value))
|
||||
|
||||
def add_16bit_uint(self, value: int) -> None:
|
||||
"""Add a 16 bit unsigned int to the buffer.
|
||||
|
||||
:param value: The value to add to the buffer
|
||||
"""
|
||||
fstring = self._byteorder + "H"
|
||||
self._payload.append(pack(fstring, value))
|
||||
|
||||
def add_32bit_uint(self, value: int) -> None:
|
||||
"""Add a 32 bit unsigned int to the buffer.
|
||||
|
||||
:param value: The value to add to the buffer
|
||||
"""
|
||||
fstring = "I"
|
||||
# fstring = self._byteorder + "I"
|
||||
p_string = self._pack_words(fstring, value)
|
||||
self._payload.append(p_string)
|
||||
|
||||
def add_64bit_uint(self, value: int) -> None:
|
||||
"""Add a 64 bit unsigned int to the buffer.
|
||||
|
||||
:param value: The value to add to the buffer
|
||||
"""
|
||||
fstring = "Q"
|
||||
p_string = self._pack_words(fstring, value)
|
||||
self._payload.append(p_string)
|
||||
|
||||
def add_8bit_int(self, value: int) -> None:
|
||||
"""Add a 8 bit signed int to the buffer.
|
||||
|
||||
:param value: The value to add to the buffer
|
||||
"""
|
||||
fstring = self._byteorder + "b"
|
||||
self._payload.append(pack(fstring, value))
|
||||
|
||||
def add_16bit_int(self, value: int) -> None:
|
||||
"""Add a 16 bit signed int to the buffer.
|
||||
|
||||
:param value: The value to add to the buffer
|
||||
"""
|
||||
fstring = self._byteorder + "h"
|
||||
self._payload.append(pack(fstring, value))
|
||||
|
||||
def add_32bit_int(self, value: int) -> None:
|
||||
"""Add a 32 bit signed int to the buffer.
|
||||
|
||||
:param value: The value to add to the buffer
|
||||
"""
|
||||
fstring = "i"
|
||||
p_string = self._pack_words(fstring, value)
|
||||
self._payload.append(p_string)
|
||||
|
||||
def add_64bit_int(self, value: int) -> None:
|
||||
"""Add a 64 bit signed int to the buffer.
|
||||
|
||||
:param value: The value to add to the buffer
|
||||
"""
|
||||
fstring = "q"
|
||||
p_string = self._pack_words(fstring, value)
|
||||
self._payload.append(p_string)
|
||||
|
||||
def add_16bit_float(self, value: float) -> None:
|
||||
"""Add a 16 bit float to the buffer.
|
||||
|
||||
:param value: The value to add to the buffer
|
||||
"""
|
||||
fstring = "e"
|
||||
p_string = self._pack_words(fstring, value)
|
||||
self._payload.append(p_string)
|
||||
|
||||
def add_32bit_float(self, value: float) -> None:
|
||||
"""Add a 32 bit float to the buffer.
|
||||
|
||||
:param value: The value to add to the buffer
|
||||
"""
|
||||
fstring = "f"
|
||||
p_string = self._pack_words(fstring, value)
|
||||
self._payload.append(p_string)
|
||||
|
||||
def add_64bit_float(self, value: float) -> None:
|
||||
"""Add a 64 bit float(double) to the buffer.
|
||||
|
||||
:param value: The value to add to the buffer
|
||||
"""
|
||||
fstring = "d"
|
||||
p_string = self._pack_words(fstring, value)
|
||||
self._payload.append(p_string)
|
||||
|
||||
def add_string(self, value: str) -> None:
|
||||
"""Add a string to the buffer.
|
||||
|
||||
:param value: The value to add to the buffer
|
||||
"""
|
||||
fstring = self._byteorder + str(len(value)) + "s"
|
||||
self._payload.append(pack(fstring, value.encode()))
|
||||
|
||||
|
||||
class BinaryPayloadDecoder:
|
||||
"""A utility that helps decode payload messages from a modbus response message.
|
||||
|
||||
It really is just a simple wrapper around
|
||||
the struct module, however it saves time looking up the format
|
||||
strings. What follows is a simple example::
|
||||
|
||||
decoder = BinaryPayloadDecoder(payload)
|
||||
first = decoder.decode_8bit_uint()
|
||||
second = decoder.decode_16bit_uint()
|
||||
"""
|
||||
|
||||
def __init__(self, payload, byteorder=Endian.LITTLE, wordorder=Endian.BIG):
|
||||
"""Initialize a new payload decoder.
|
||||
|
||||
:param payload: The payload to decode with
|
||||
:param byteorder: The endianness of the payload
|
||||
:param wordorder: The endianness of the word (when wordcount is >= 2)
|
||||
"""
|
||||
self._payload = payload
|
||||
self._pointer = 0x00
|
||||
self._byteorder = byteorder
|
||||
self._wordorder = wordorder
|
||||
|
||||
@classmethod
|
||||
def fromRegisters(
|
||||
cls,
|
||||
registers,
|
||||
byteorder=Endian.LITTLE,
|
||||
wordorder=Endian.BIG,
|
||||
):
|
||||
"""Initialize a payload decoder.
|
||||
|
||||
With the result of reading a collection of registers from a modbus device.
|
||||
|
||||
The registers are treated as a list of 2 byte values.
|
||||
We have to do this because of how the data has already
|
||||
been decoded by the rest of the library.
|
||||
|
||||
:param registers: The register results to initialize with
|
||||
:param byteorder: The Byte order of each word
|
||||
:param wordorder: The endianness of the word (when wordcount is >= 2)
|
||||
:returns: An initialized PayloadDecoder
|
||||
:raises ParameterException:
|
||||
"""
|
||||
Log.debug("{}", registers)
|
||||
if isinstance(registers, list): # repack into flat binary
|
||||
payload = b"".join(pack("!H", x) for x in registers)
|
||||
return cls(payload, byteorder, wordorder)
|
||||
raise ParameterException("Invalid collection of registers supplied")
|
||||
|
||||
@classmethod
|
||||
def bit_chunks(cls, coils, size=8):
|
||||
"""Return bit chunks."""
|
||||
chunks = [coils[i : i + size] for i in range(0, len(coils), size)]
|
||||
return chunks
|
||||
|
||||
@classmethod
|
||||
def fromCoils(
|
||||
cls,
|
||||
coils,
|
||||
byteorder=Endian.LITTLE,
|
||||
_wordorder=Endian.BIG,
|
||||
):
|
||||
"""Initialize a payload decoder with the result of reading of coils."""
|
||||
if isinstance(coils, list):
|
||||
payload = b""
|
||||
if padding := len(coils) % 8: # Pad zeros
|
||||
extra = [False] * padding
|
||||
coils = extra + coils
|
||||
chunks = cls.bit_chunks(coils)
|
||||
for chunk in chunks:
|
||||
payload += pack_bitstring(chunk[::-1])
|
||||
return cls(payload, byteorder)
|
||||
raise ParameterException("Invalid collection of coils supplied")
|
||||
|
||||
def _unpack_words(self, fstring, handle):
|
||||
"""Unpack words based on the word order and byte order.
|
||||
|
||||
# ---------------------------------------------- #
|
||||
# Unpack in to network ordered unsigned integer #
|
||||
# Change Word order if little endian word order #
|
||||
# Pack values back based on correct byte order #
|
||||
# ---------------------------------------------- #
|
||||
:param fstring:
|
||||
:param handle: Value to be unpacked
|
||||
:return:
|
||||
"""
|
||||
wc_value = WC.get(fstring.lower()) // 2
|
||||
handle = unpack(f"!{wc_value}H", handle)
|
||||
if self._wordorder == Endian.LITTLE:
|
||||
handle = list(reversed(handle))
|
||||
|
||||
# Repack as unsigned Integer
|
||||
handle = [pack(self._byteorder + "H", p) for p in handle]
|
||||
Log.debug("handle: {}", handle)
|
||||
handle = b"".join(handle)
|
||||
return handle
|
||||
|
||||
def reset(self):
|
||||
"""Reset the decoder pointer back to the start."""
|
||||
self._pointer = 0x00
|
||||
|
||||
def decode_8bit_uint(self):
|
||||
"""Decode a 8 bit unsigned int from the buffer."""
|
||||
self._pointer += 1
|
||||
fstring = self._byteorder + "B"
|
||||
handle = self._payload[self._pointer - 1 : self._pointer]
|
||||
return unpack(fstring, handle)[0]
|
||||
|
||||
def decode_bits(self, package_len=1):
|
||||
"""Decode a byte worth of bits from the buffer."""
|
||||
self._pointer += package_len
|
||||
# fstring = self._endian + "B"
|
||||
handle = self._payload[self._pointer - 1 : self._pointer]
|
||||
return unpack_bitstring(handle)
|
||||
|
||||
def decode_16bit_uint(self):
|
||||
"""Decode a 16 bit unsigned int from the buffer."""
|
||||
self._pointer += 2
|
||||
fstring = self._byteorder + "H"
|
||||
handle = self._payload[self._pointer - 2 : self._pointer]
|
||||
return unpack(fstring, handle)[0]
|
||||
|
||||
def decode_32bit_uint(self):
|
||||
"""Decode a 32 bit unsigned int from the buffer."""
|
||||
self._pointer += 4
|
||||
fstring = "I"
|
||||
handle = self._payload[self._pointer - 4 : self._pointer]
|
||||
handle = self._unpack_words(fstring, handle)
|
||||
return unpack("!" + fstring, handle)[0]
|
||||
|
||||
def decode_64bit_uint(self):
|
||||
"""Decode a 64 bit unsigned int from the buffer."""
|
||||
self._pointer += 8
|
||||
fstring = "Q"
|
||||
handle = self._payload[self._pointer - 8 : self._pointer]
|
||||
handle = self._unpack_words(fstring, handle)
|
||||
return unpack("!" + fstring, handle)[0]
|
||||
|
||||
def decode_8bit_int(self):
|
||||
"""Decode a 8 bit signed int from the buffer."""
|
||||
self._pointer += 1
|
||||
fstring = self._byteorder + "b"
|
||||
handle = self._payload[self._pointer - 1 : self._pointer]
|
||||
return unpack(fstring, handle)[0]
|
||||
|
||||
def decode_16bit_int(self):
|
||||
"""Decode a 16 bit signed int from the buffer."""
|
||||
self._pointer += 2
|
||||
fstring = self._byteorder + "h"
|
||||
handle = self._payload[self._pointer - 2 : self._pointer]
|
||||
return unpack(fstring, handle)[0]
|
||||
|
||||
def decode_32bit_int(self):
|
||||
"""Decode a 32 bit signed int from the buffer."""
|
||||
self._pointer += 4
|
||||
fstring = "i"
|
||||
handle = self._payload[self._pointer - 4 : self._pointer]
|
||||
handle = self._unpack_words(fstring, handle)
|
||||
return unpack("!" + fstring, handle)[0]
|
||||
|
||||
def decode_64bit_int(self):
|
||||
"""Decode a 64 bit signed int from the buffer."""
|
||||
self._pointer += 8
|
||||
fstring = "q"
|
||||
handle = self._payload[self._pointer - 8 : self._pointer]
|
||||
handle = self._unpack_words(fstring, handle)
|
||||
return unpack("!" + fstring, handle)[0]
|
||||
|
||||
def decode_16bit_float(self):
|
||||
"""Decode a 16 bit float from the buffer."""
|
||||
self._pointer += 2
|
||||
fstring = "e"
|
||||
handle = self._payload[self._pointer - 2 : self._pointer]
|
||||
handle = self._unpack_words(fstring, handle)
|
||||
return unpack("!" + fstring, handle)[0]
|
||||
|
||||
def decode_32bit_float(self):
|
||||
"""Decode a 32 bit float from the buffer."""
|
||||
self._pointer += 4
|
||||
fstring = "f"
|
||||
handle = self._payload[self._pointer - 4 : self._pointer]
|
||||
handle = self._unpack_words(fstring, handle)
|
||||
return unpack("!" + fstring, handle)[0]
|
||||
|
||||
def decode_64bit_float(self):
|
||||
"""Decode a 64 bit float(double) from the buffer."""
|
||||
self._pointer += 8
|
||||
fstring = "d"
|
||||
handle = self._payload[self._pointer - 8 : self._pointer]
|
||||
handle = self._unpack_words(fstring, handle)
|
||||
return unpack("!" + fstring, handle)[0]
|
||||
|
||||
def decode_string(self, size=1):
|
||||
"""Decode a string from the buffer.
|
||||
|
||||
:param size: The size of the string to decode
|
||||
"""
|
||||
self._pointer += size
|
||||
return self._payload[self._pointer - size : self._pointer]
|
||||
|
||||
def skip_bytes(self, nbytes):
|
||||
"""Skip n bytes in the buffer.
|
||||
|
||||
:param nbytes: The number of bytes to skip
|
||||
"""
|
||||
self._pointer += nbytes
|
||||
+256
@@ -0,0 +1,256 @@
|
||||
"""Contains base classes for modbus request/response/error packets."""
|
||||
|
||||
__all__ = [
|
||||
"ModbusRequest",
|
||||
"ModbusResponse",
|
||||
"ModbusExceptions",
|
||||
"ExceptionResponse",
|
||||
"IllegalFunctionRequest",
|
||||
]
|
||||
|
||||
# pylint: disable=missing-type-doc
|
||||
import struct
|
||||
|
||||
from pymodbus.exceptions import NotImplementedException
|
||||
from pymodbus.logging import Log
|
||||
from pymodbus.utilities import rtuFrameSize
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Base PDUs
|
||||
# --------------------------------------------------------------------------- #
|
||||
class ModbusPDU:
|
||||
"""Base class for all Modbus messages.
|
||||
|
||||
.. attribute:: transaction_id
|
||||
|
||||
This value is used to uniquely identify a request
|
||||
response pair. It can be implemented as a simple counter
|
||||
|
||||
.. attribute:: protocol_id
|
||||
|
||||
This is a constant set at 0 to indicate Modbus. It is
|
||||
put here for ease of expansion.
|
||||
|
||||
.. attribute:: slave_id
|
||||
|
||||
This is used to route the request to the correct child. In
|
||||
the TCP modbus, it is used for routing (or not used at all. However,
|
||||
for the serial versions, it is used to specify which child to perform
|
||||
the requests against. The value 0x00 represents the broadcast address
|
||||
(also 0xff).
|
||||
|
||||
.. attribute:: check
|
||||
|
||||
This is used for LRC/CRC in the serial modbus protocols
|
||||
|
||||
.. attribute:: skip_encode
|
||||
|
||||
This is used when the message payload has already been encoded.
|
||||
Generally this will occur when the PayloadBuilder is being used
|
||||
to create a complicated message. By setting this to True, the
|
||||
request will pass the currently encoded message through instead
|
||||
of encoding it again.
|
||||
"""
|
||||
|
||||
def __init__(self, slave=0, **kwargs):
|
||||
"""Initialize the base data for a modbus request.
|
||||
|
||||
:param slave: Modbus slave slave ID
|
||||
|
||||
"""
|
||||
self.transaction_id = kwargs.get("transaction", 0)
|
||||
self.protocol_id = kwargs.get("protocol", 0)
|
||||
self.slave_id = slave
|
||||
self.skip_encode = kwargs.get("skip_encode", False)
|
||||
self.check = 0x0000
|
||||
|
||||
def encode(self):
|
||||
"""Encode the message.
|
||||
|
||||
:raises: A not implemented exception
|
||||
"""
|
||||
raise NotImplementedException()
|
||||
|
||||
def decode(self, data):
|
||||
"""Decode data part of the message.
|
||||
|
||||
:param data: is a string object
|
||||
:raises NotImplementedException:
|
||||
"""
|
||||
raise NotImplementedException()
|
||||
|
||||
@classmethod
|
||||
def calculateRtuFrameSize(cls, buffer):
|
||||
"""Calculate the size of a PDU.
|
||||
|
||||
:param buffer: A buffer containing the data that have been received.
|
||||
:returns: The number of bytes in the PDU.
|
||||
:raises NotImplementedException:
|
||||
"""
|
||||
if hasattr(cls, "_rtu_frame_size"):
|
||||
return cls._rtu_frame_size
|
||||
if hasattr(cls, "_rtu_byte_count_pos"):
|
||||
return rtuFrameSize(buffer, cls._rtu_byte_count_pos)
|
||||
raise NotImplementedException(
|
||||
f"Cannot determine RTU frame size for {cls.__name__}"
|
||||
)
|
||||
|
||||
|
||||
class ModbusRequest(ModbusPDU):
|
||||
"""Base class for a modbus request PDU."""
|
||||
|
||||
function_code = -1
|
||||
|
||||
def __init__(self, slave=0, **kwargs): # pylint: disable=useless-parent-delegation
|
||||
"""Proxy to the lower level initializer.
|
||||
|
||||
:param slave: Modbus slave slave ID
|
||||
"""
|
||||
super().__init__(slave, **kwargs)
|
||||
|
||||
def doException(self, exception):
|
||||
"""Build an error response based on the function.
|
||||
|
||||
:param exception: The exception to return
|
||||
:raises: An exception response
|
||||
"""
|
||||
exc = ExceptionResponse(self.function_code, exception)
|
||||
Log.error("Exception response {}", exc)
|
||||
return exc
|
||||
|
||||
|
||||
class ModbusResponse(ModbusPDU):
|
||||
"""Base class for a modbus response PDU.
|
||||
|
||||
.. attribute:: should_respond
|
||||
|
||||
A flag that indicates if this response returns a result back
|
||||
to the client issuing the request
|
||||
|
||||
.. attribute:: _rtu_frame_size
|
||||
|
||||
Indicates the size of the modbus rtu response used for
|
||||
calculating how much to read.
|
||||
"""
|
||||
|
||||
should_respond = True
|
||||
function_code = 0x00
|
||||
|
||||
def __init__(self, slave=0, **kwargs):
|
||||
"""Proxy the lower level initializer.
|
||||
|
||||
:param slave: Modbus slave slave ID
|
||||
|
||||
"""
|
||||
super().__init__(slave, **kwargs)
|
||||
self.bits = []
|
||||
self.registers = []
|
||||
|
||||
def isError(self) -> bool:
|
||||
"""Check if the error is a success or failure."""
|
||||
return self.function_code > 0x80
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Exception PDUs
|
||||
# --------------------------------------------------------------------------- #
|
||||
class ModbusExceptions: # pylint: disable=too-few-public-methods
|
||||
"""An enumeration of the valid modbus exceptions."""
|
||||
|
||||
IllegalFunction = 0x01
|
||||
IllegalAddress = 0x02
|
||||
IllegalValue = 0x03
|
||||
SlaveFailure = 0x04
|
||||
Acknowledge = 0x05
|
||||
SlaveBusy = 0x06
|
||||
MemoryParityError = 0x08
|
||||
GatewayPathUnavailable = 0x0A
|
||||
GatewayNoResponse = 0x0B
|
||||
|
||||
@classmethod
|
||||
def decode(cls, code):
|
||||
"""Give an error code, translate it to a string error name.
|
||||
|
||||
:param code: The code number to translate
|
||||
"""
|
||||
values = {
|
||||
v: k
|
||||
for k, v in iter(cls.__dict__.items())
|
||||
if not k.startswith("__") and not callable(v)
|
||||
}
|
||||
return values.get(code, None)
|
||||
|
||||
|
||||
class ExceptionResponse(ModbusResponse):
|
||||
"""Base class for a modbus exception PDU."""
|
||||
|
||||
ExceptionOffset = 0x80
|
||||
_rtu_frame_size = 5
|
||||
|
||||
def __init__(self, function_code, exception_code=None, **kwargs):
|
||||
"""Initialize the modbus exception response.
|
||||
|
||||
:param function_code: The function to build an exception response for
|
||||
:param exception_code: The specific modbus exception to return
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
self.original_code = function_code
|
||||
self.function_code = function_code | self.ExceptionOffset
|
||||
self.exception_code = exception_code
|
||||
|
||||
def encode(self):
|
||||
"""Encode a modbus exception response.
|
||||
|
||||
:returns: The encoded exception packet
|
||||
"""
|
||||
return struct.pack(">B", self.exception_code)
|
||||
|
||||
def decode(self, data):
|
||||
"""Decode a modbus exception response.
|
||||
|
||||
:param data: The packet data to decode
|
||||
"""
|
||||
self.exception_code = int(data[0])
|
||||
|
||||
def __str__(self):
|
||||
"""Build a representation of an exception response.
|
||||
|
||||
:returns: The string representation of an exception response
|
||||
"""
|
||||
message = ModbusExceptions.decode(self.exception_code)
|
||||
parameters = (self.function_code, self.original_code, message)
|
||||
return (
|
||||
"Exception Response(%d, %d, %s)" # pylint: disable=consider-using-f-string
|
||||
% parameters
|
||||
)
|
||||
|
||||
|
||||
class IllegalFunctionRequest(ModbusRequest):
|
||||
"""Define the Modbus slave exception type "Illegal Function".
|
||||
|
||||
This exception code is returned if the slave::
|
||||
|
||||
- does not implement the function code **or**
|
||||
- is not in a state that allows it to process the function
|
||||
"""
|
||||
|
||||
ErrorCode = 1
|
||||
|
||||
def __init__(self, function_code, **kwargs):
|
||||
"""Initialize a IllegalFunctionRequest.
|
||||
|
||||
:param function_code: The function we are erroring on
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
self.function_code = function_code
|
||||
|
||||
def decode(self, _data):
|
||||
"""Decode so this failure will run correctly."""
|
||||
|
||||
def execute(self, _context):
|
||||
"""Build an illegal function request error response.
|
||||
|
||||
:returns: The error response packet
|
||||
"""
|
||||
return ExceptionResponse(self.function_code, self.ErrorCode)
|
||||
@@ -0,0 +1,411 @@
|
||||
"""Register Reading Request/Response."""
|
||||
|
||||
__all__ = [
|
||||
"ReadHoldingRegistersRequest",
|
||||
"ReadHoldingRegistersResponse",
|
||||
"ReadInputRegistersRequest",
|
||||
"ReadInputRegistersResponse",
|
||||
"ReadRegistersResponseBase",
|
||||
"ReadWriteMultipleRegistersRequest",
|
||||
"ReadWriteMultipleRegistersResponse",
|
||||
]
|
||||
|
||||
# pylint: disable=missing-type-doc
|
||||
import struct
|
||||
|
||||
from pymodbus.pdu import ExceptionResponse, ModbusRequest, ModbusResponse
|
||||
from pymodbus.pdu import ModbusExceptions as merror
|
||||
|
||||
|
||||
class ReadRegistersRequestBase(ModbusRequest):
|
||||
"""Base class for reading a modbus register."""
|
||||
|
||||
_rtu_frame_size = 8
|
||||
|
||||
def __init__(self, address, count, slave=0, **kwargs):
|
||||
"""Initialize a new instance.
|
||||
|
||||
:param address: The address to start the read from
|
||||
:param count: The number of registers to read
|
||||
:param slave: Modbus slave slave ID
|
||||
"""
|
||||
super().__init__(slave, **kwargs)
|
||||
self.address = address
|
||||
self.count = count
|
||||
|
||||
def encode(self):
|
||||
"""Encode the request packet.
|
||||
|
||||
:return: The encoded packet
|
||||
"""
|
||||
return struct.pack(">HH", self.address, self.count)
|
||||
|
||||
def decode(self, data):
|
||||
"""Decode a register request packet.
|
||||
|
||||
:param data: The request to decode
|
||||
"""
|
||||
self.address, self.count = struct.unpack(">HH", data)
|
||||
|
||||
def get_response_pdu_size(self):
|
||||
"""Get response pdu size.
|
||||
|
||||
Func_code (1 byte) + Byte Count(1 byte) + 2 * Quantity of Coils (n Bytes).
|
||||
"""
|
||||
return 1 + 1 + 2 * self.count
|
||||
|
||||
def __str__(self):
|
||||
"""Return a string representation of the instance.
|
||||
|
||||
:returns: A string representation of the instance
|
||||
"""
|
||||
return f"{self.__class__.__name__} ({self.address},{self.count})"
|
||||
|
||||
|
||||
class ReadRegistersResponseBase(ModbusResponse):
|
||||
"""Base class for responding to a modbus register read.
|
||||
|
||||
The requested registers can be found in the .registers list.
|
||||
"""
|
||||
|
||||
_rtu_byte_count_pos = 2
|
||||
|
||||
def __init__(self, values, slave=0, **kwargs):
|
||||
"""Initialize a new instance.
|
||||
|
||||
:param values: The values to write to
|
||||
:param slave: Modbus slave slave ID
|
||||
"""
|
||||
super().__init__(slave, **kwargs)
|
||||
|
||||
#: A list of register values
|
||||
self.registers = values or []
|
||||
|
||||
def encode(self):
|
||||
"""Encode the response packet.
|
||||
|
||||
:returns: The encoded packet
|
||||
"""
|
||||
result = struct.pack(">B", len(self.registers) * 2)
|
||||
for register in self.registers:
|
||||
result += struct.pack(">H", register)
|
||||
return result
|
||||
|
||||
def decode(self, data):
|
||||
"""Decode a register response packet.
|
||||
|
||||
:param data: The request to decode
|
||||
"""
|
||||
byte_count = int(data[0])
|
||||
self.registers = []
|
||||
for i in range(1, byte_count + 1, 2):
|
||||
self.registers.append(struct.unpack(">H", data[i : i + 2])[0])
|
||||
|
||||
def getRegister(self, index):
|
||||
"""Get the requested register.
|
||||
|
||||
:param index: The indexed register to retrieve
|
||||
:returns: The request register
|
||||
"""
|
||||
return self.registers[index]
|
||||
|
||||
def __str__(self):
|
||||
"""Return a string representation of the instance.
|
||||
|
||||
:returns: A string representation of the instance
|
||||
"""
|
||||
return f"{self.__class__.__name__} ({len(self.registers)})"
|
||||
|
||||
|
||||
class ReadHoldingRegistersRequest(ReadRegistersRequestBase):
|
||||
"""Read holding registers.
|
||||
|
||||
This function code is used to read the contents of a contiguous block
|
||||
of holding registers in a remote device. The Request PDU specifies the
|
||||
starting register address and the number of registers. In the PDU
|
||||
Registers are addressed starting at zero. Therefore registers numbered
|
||||
1-16 are addressed as 0-15.
|
||||
"""
|
||||
|
||||
function_code = 3
|
||||
function_code_name = "read_holding_registers"
|
||||
|
||||
def __init__(self, address=None, count=None, slave=0, **kwargs):
|
||||
"""Initialize a new instance of the request.
|
||||
|
||||
:param address: The starting address to read from
|
||||
:param count: The number of registers to read from address
|
||||
:param slave: Modbus slave slave ID
|
||||
"""
|
||||
super().__init__(address, count, slave, **kwargs)
|
||||
|
||||
def execute(self, context):
|
||||
"""Run a read holding request against a datastore.
|
||||
|
||||
:param context: The datastore to request from
|
||||
:returns: An initialized :py:class:`~pymodbus.register_read_message.ReadHoldingRegistersResponse`, or an :py:class:`~pymodbus.pdu.ExceptionResponse` if an error occurred
|
||||
"""
|
||||
if not (1 <= self.count <= 0x7D):
|
||||
return self.doException(merror.IllegalValue)
|
||||
if not context.validate(self.function_code, self.address, self.count):
|
||||
return self.doException(merror.IllegalAddress)
|
||||
values = context.getValues(self.function_code, self.address, self.count)
|
||||
if isinstance(values, ExceptionResponse):
|
||||
return values
|
||||
|
||||
return ReadHoldingRegistersResponse(values)
|
||||
|
||||
|
||||
class ReadHoldingRegistersResponse(ReadRegistersResponseBase):
|
||||
"""Read holding registers.
|
||||
|
||||
This function code is used to read the contents of a contiguous block
|
||||
of holding registers in a remote device. The Request PDU specifies the
|
||||
starting register address and the number of registers. In the PDU
|
||||
Registers are addressed starting at zero. Therefore registers numbered
|
||||
1-16 are addressed as 0-15.
|
||||
|
||||
The requested registers can be found in the .registers list.
|
||||
"""
|
||||
|
||||
function_code = 3
|
||||
|
||||
def __init__(self, values=None, **kwargs):
|
||||
"""Initialize a new response instance.
|
||||
|
||||
:param values: The resulting register values
|
||||
"""
|
||||
super().__init__(values, **kwargs)
|
||||
|
||||
|
||||
class ReadInputRegistersRequest(ReadRegistersRequestBase):
|
||||
"""Read input registers.
|
||||
|
||||
This function code is used to read from 1 to approx. 125 contiguous
|
||||
input registers in a remote device. The Request PDU specifies the
|
||||
starting register address and the number of registers. In the PDU
|
||||
Registers are addressed starting at zero. Therefore input registers
|
||||
numbered 1-16 are addressed as 0-15.
|
||||
"""
|
||||
|
||||
function_code = 4
|
||||
function_code_name = "read_input_registers"
|
||||
|
||||
def __init__(self, address=None, count=None, slave=0, **kwargs):
|
||||
"""Initialize a new instance of the request.
|
||||
|
||||
:param address: The starting address to read from
|
||||
:param count: The number of registers to read from address
|
||||
:param slave: Modbus slave slave ID
|
||||
"""
|
||||
super().__init__(address, count, slave, **kwargs)
|
||||
|
||||
def execute(self, context):
|
||||
"""Run a read input request against a datastore.
|
||||
|
||||
:param context: The datastore to request from
|
||||
:returns: An initialized :py:class:`~pymodbus.register_read_message.ReadInputRegistersResponse`, or an :py:class:`~pymodbus.pdu.ExceptionResponse` if an error occurred
|
||||
"""
|
||||
if not (1 <= self.count <= 0x7D):
|
||||
return self.doException(merror.IllegalValue)
|
||||
if not context.validate(self.function_code, self.address, self.count):
|
||||
return self.doException(merror.IllegalAddress)
|
||||
values = context.getValues(self.function_code, self.address, self.count)
|
||||
if isinstance(values, ExceptionResponse):
|
||||
return values
|
||||
return ReadInputRegistersResponse(values)
|
||||
|
||||
|
||||
class ReadInputRegistersResponse(ReadRegistersResponseBase):
|
||||
"""Read/write input registers.
|
||||
|
||||
This function code is used to read from 1 to approx. 125 contiguous
|
||||
input registers in a remote device. The Request PDU specifies the
|
||||
starting register address and the number of registers. In the PDU
|
||||
Registers are addressed starting at zero. Therefore input registers
|
||||
numbered 1-16 are addressed as 0-15.
|
||||
|
||||
The requested registers can be found in the .registers list.
|
||||
"""
|
||||
|
||||
function_code = 4
|
||||
|
||||
def __init__(self, values=None, **kwargs):
|
||||
"""Initialize a new response instance.
|
||||
|
||||
:param values: The resulting register values
|
||||
"""
|
||||
super().__init__(values, **kwargs)
|
||||
|
||||
|
||||
class ReadWriteMultipleRegistersRequest(ModbusRequest):
|
||||
"""Read/write multiple registers.
|
||||
|
||||
This function code performs a combination of one read operation and one
|
||||
write operation in a single MODBUS transaction. The write
|
||||
operation is performed before the read.
|
||||
|
||||
Holding registers are addressed starting at zero. Therefore holding
|
||||
registers 1-16 are addressed in the PDU as 0-15.
|
||||
|
||||
The request specifies the starting address and number of holding
|
||||
registers to be read as well as the starting address, number of holding
|
||||
registers, and the data to be written. The byte count specifies the
|
||||
number of bytes to follow in the write data field."
|
||||
"""
|
||||
|
||||
function_code = 23
|
||||
function_code_name = "read_write_multiple_registers"
|
||||
_rtu_byte_count_pos = 10
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
"""Initialize a new request message.
|
||||
|
||||
:param read_address: The address to start reading from
|
||||
:param read_count: The number of registers to read from address
|
||||
:param write_address: The address to start writing to
|
||||
:param write_registers: The registers to write to the specified address
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
self.read_address = kwargs.get("read_address", 0x00)
|
||||
self.read_count = kwargs.get("read_count", 0)
|
||||
self.write_address = kwargs.get("write_address", 0x00)
|
||||
self.write_registers = kwargs.get("write_registers", None)
|
||||
if not hasattr(self.write_registers, "__iter__"):
|
||||
self.write_registers = [self.write_registers]
|
||||
self.write_count = len(self.write_registers)
|
||||
self.write_byte_count = self.write_count * 2
|
||||
|
||||
def encode(self):
|
||||
"""Encode the request packet.
|
||||
|
||||
:returns: The encoded packet
|
||||
"""
|
||||
result = struct.pack(
|
||||
">HHHHB",
|
||||
self.read_address,
|
||||
self.read_count,
|
||||
self.write_address,
|
||||
self.write_count,
|
||||
self.write_byte_count,
|
||||
)
|
||||
for register in self.write_registers:
|
||||
result += struct.pack(">H", register)
|
||||
return result
|
||||
|
||||
def decode(self, data):
|
||||
"""Decode the register request packet.
|
||||
|
||||
:param data: The request to decode
|
||||
"""
|
||||
(
|
||||
self.read_address,
|
||||
self.read_count,
|
||||
self.write_address,
|
||||
self.write_count,
|
||||
self.write_byte_count,
|
||||
) = struct.unpack(">HHHHB", data[:9])
|
||||
self.write_registers = []
|
||||
for i in range(9, self.write_byte_count + 9, 2):
|
||||
register = struct.unpack(">H", data[i : i + 2])[0]
|
||||
self.write_registers.append(register)
|
||||
|
||||
def execute(self, context):
|
||||
"""Run a write single register request against a datastore.
|
||||
|
||||
:param context: The datastore to request from
|
||||
:returns: An initialized :py:class:`~pymodbus.register_read_message.ReadWriteMultipleRegistersResponse`, or an :py:class:`~pymodbus.pdu.ExceptionResponse` if an error occurred
|
||||
"""
|
||||
if not (1 <= self.read_count <= 0x07D):
|
||||
return self.doException(merror.IllegalValue)
|
||||
if not 1 <= self.write_count <= 0x079:
|
||||
return self.doException(merror.IllegalValue)
|
||||
if self.write_byte_count != self.write_count * 2:
|
||||
return self.doException(merror.IllegalValue)
|
||||
if not context.validate(
|
||||
self.function_code, self.write_address, self.write_count
|
||||
):
|
||||
return self.doException(merror.IllegalAddress)
|
||||
if not context.validate(self.function_code, self.read_address, self.read_count):
|
||||
return self.doException(merror.IllegalAddress)
|
||||
result = context.setValues(
|
||||
self.function_code, self.write_address, self.write_registers
|
||||
)
|
||||
if isinstance(result, ExceptionResponse):
|
||||
return result
|
||||
registers = context.getValues(
|
||||
self.function_code, self.read_address, self.read_count
|
||||
)
|
||||
return ReadWriteMultipleRegistersResponse(registers)
|
||||
|
||||
def get_response_pdu_size(self):
|
||||
"""Get response pdu size.
|
||||
|
||||
Func_code (1 byte) + Byte Count(1 byte) + 2 * Quantity of Coils (n Bytes)
|
||||
:return:
|
||||
"""
|
||||
return 1 + 1 + 2 * self.read_count
|
||||
|
||||
def __str__(self):
|
||||
"""Return a string representation of the instance.
|
||||
|
||||
:returns: A string representation of the instance
|
||||
"""
|
||||
params = (
|
||||
self.read_address,
|
||||
self.read_count,
|
||||
self.write_address,
|
||||
self.write_count,
|
||||
)
|
||||
return (
|
||||
"ReadWriteNRegisterRequest R(%d,%d) W(%d,%d)" # pylint: disable=consider-using-f-string
|
||||
% params
|
||||
)
|
||||
|
||||
|
||||
class ReadWriteMultipleRegistersResponse(ModbusResponse):
|
||||
"""Read/write multiple registers.
|
||||
|
||||
The normal response contains the data from the group of registers that
|
||||
were read. The byte count field specifies the quantity of bytes to
|
||||
follow in the read data field.
|
||||
|
||||
The requested registers can be found in the .registers list.
|
||||
"""
|
||||
|
||||
function_code = 23
|
||||
_rtu_byte_count_pos = 2
|
||||
|
||||
def __init__(self, values=None, **kwargs):
|
||||
"""Initialize a new instance.
|
||||
|
||||
:param values: The register values to write
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
self.registers = values or []
|
||||
|
||||
def encode(self):
|
||||
"""Encode the response packet.
|
||||
|
||||
:returns: The encoded packet
|
||||
"""
|
||||
result = struct.pack(">B", len(self.registers) * 2)
|
||||
for register in self.registers:
|
||||
result += struct.pack(">H", register)
|
||||
return result
|
||||
|
||||
def decode(self, data):
|
||||
"""Decode the register response packet.
|
||||
|
||||
:param data: The response to decode
|
||||
"""
|
||||
bytecount = int(data[0])
|
||||
for i in range(1, bytecount, 2):
|
||||
self.registers.append(struct.unpack(">H", data[i : i + 2])[0])
|
||||
|
||||
def __str__(self):
|
||||
"""Return a string representation of the instance.
|
||||
|
||||
:returns: A string representation of the instance
|
||||
"""
|
||||
return f"ReadWriteNRegisterResponse ({len(self.registers)})"
|
||||
@@ -0,0 +1,379 @@
|
||||
"""Register Writing Request/Response Messages."""
|
||||
|
||||
__all__ = [
|
||||
"WriteSingleRegisterRequest",
|
||||
"WriteSingleRegisterResponse",
|
||||
"WriteMultipleRegistersRequest",
|
||||
"WriteMultipleRegistersResponse",
|
||||
"MaskWriteRegisterRequest",
|
||||
"MaskWriteRegisterResponse",
|
||||
]
|
||||
|
||||
# pylint: disable=missing-type-doc
|
||||
import struct
|
||||
|
||||
from pymodbus.pdu import ExceptionResponse, ModbusRequest, ModbusResponse
|
||||
from pymodbus.pdu import ModbusExceptions as merror
|
||||
|
||||
|
||||
class WriteSingleRegisterRequest(ModbusRequest):
|
||||
"""This function code is used to write a single holding register in a remote device.
|
||||
|
||||
The Request PDU specifies the address of the register to
|
||||
be written. Registers are addressed starting at zero. Therefore register
|
||||
numbered 1 is addressed as 0.
|
||||
"""
|
||||
|
||||
function_code = 6
|
||||
function_code_name = "write_register"
|
||||
_rtu_frame_size = 8
|
||||
|
||||
def __init__(self, address=None, value=None, slave=None, **kwargs):
|
||||
"""Initialize a new instance.
|
||||
|
||||
:param address: The address to start writing add
|
||||
:param value: The values to write
|
||||
"""
|
||||
super().__init__(slave=slave, **kwargs)
|
||||
self.address = address
|
||||
self.value = value
|
||||
|
||||
def encode(self):
|
||||
"""Encode a write single register packet packet request.
|
||||
|
||||
:returns: The encoded packet
|
||||
"""
|
||||
packet = struct.pack(">H", self.address)
|
||||
if self.skip_encode:
|
||||
packet += self.value
|
||||
else:
|
||||
packet += struct.pack(">H", self.value)
|
||||
return packet
|
||||
|
||||
def decode(self, data):
|
||||
"""Decode a write single register packet packet request.
|
||||
|
||||
:param data: The request to decode
|
||||
"""
|
||||
self.address, self.value = struct.unpack(">HH", data)
|
||||
|
||||
def execute(self, context):
|
||||
"""Run a write single register request against a datastore.
|
||||
|
||||
:param context: The datastore to request from
|
||||
:returns: An initialized response, exception message otherwise
|
||||
"""
|
||||
if not 0 <= self.value <= 0xFFFF:
|
||||
return self.doException(merror.IllegalValue)
|
||||
if not context.validate(self.function_code, self.address, 1):
|
||||
return self.doException(merror.IllegalAddress)
|
||||
|
||||
result = context.setValues(self.function_code, self.address, [self.value])
|
||||
if isinstance(result, ExceptionResponse):
|
||||
return result
|
||||
values = context.getValues(self.function_code, self.address, 1)
|
||||
return WriteSingleRegisterResponse(self.address, values[0])
|
||||
|
||||
def get_response_pdu_size(self):
|
||||
"""Get response pdu size.
|
||||
|
||||
Func_code (1 byte) + Register Address(2 byte) + Register Value (2 bytes)
|
||||
:return:
|
||||
"""
|
||||
return 1 + 2 + 2
|
||||
|
||||
def __str__(self):
|
||||
"""Return a string representation of the instance.
|
||||
|
||||
:returns: A string representation of the instance
|
||||
"""
|
||||
return f"WriteRegisterRequest {self.address}"
|
||||
|
||||
|
||||
class WriteSingleRegisterResponse(ModbusResponse):
|
||||
"""The normal response is an echo of the request.
|
||||
|
||||
Returned after the register contents have been written.
|
||||
"""
|
||||
|
||||
function_code = 6
|
||||
_rtu_frame_size = 8
|
||||
|
||||
def __init__(self, address=None, value=None, **kwargs):
|
||||
"""Initialize a new instance.
|
||||
|
||||
:param address: The address to start writing add
|
||||
:param value: The values to write
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
self.address = address
|
||||
self.value = value
|
||||
|
||||
def encode(self):
|
||||
"""Encode a write single register packet packet request.
|
||||
|
||||
:returns: The encoded packet
|
||||
"""
|
||||
return struct.pack(">HH", self.address, self.value)
|
||||
|
||||
def decode(self, data):
|
||||
"""Decode a write single register packet packet request.
|
||||
|
||||
:param data: The request to decode
|
||||
"""
|
||||
self.address, self.value = struct.unpack(">HH", data)
|
||||
|
||||
def get_response_pdu_size(self):
|
||||
"""Get response pdu size.
|
||||
|
||||
Func_code (1 byte) + Starting Address (2 byte) + And_mask (2 Bytes) + OrMask (2 Bytes)
|
||||
:return:
|
||||
"""
|
||||
return 1 + 2 + 2 + 2
|
||||
|
||||
def __str__(self):
|
||||
"""Return a string representation of the instance.
|
||||
|
||||
:returns: A string representation of the instance
|
||||
"""
|
||||
params = (self.address, self.value)
|
||||
return (
|
||||
"WriteRegisterResponse %d => %d" # pylint: disable=consider-using-f-string
|
||||
% params
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------#
|
||||
# Write Multiple Registers
|
||||
# ---------------------------------------------------------------------------#
|
||||
class WriteMultipleRegistersRequest(ModbusRequest):
|
||||
"""This function code is used to write a block.
|
||||
|
||||
Of contiguous registers (1 to approx. 120 registers) in a remote device.
|
||||
|
||||
The requested written values are specified in the request data field.
|
||||
Data is packed as two bytes per register.
|
||||
"""
|
||||
|
||||
function_code = 16
|
||||
function_code_name = "write_registers"
|
||||
_rtu_byte_count_pos = 6
|
||||
_pdu_length = 5 # func + adress1 + adress2 + outputQuant1 + outputQuant2
|
||||
|
||||
def __init__(self, address=None, values=None, slave=None, **kwargs):
|
||||
"""Initialize a new instance.
|
||||
|
||||
:param address: The address to start writing to
|
||||
:param values: The values to write
|
||||
"""
|
||||
super().__init__(slave=slave, **kwargs)
|
||||
self.address = address
|
||||
if values is None:
|
||||
values = []
|
||||
elif not hasattr(values, "__iter__"):
|
||||
values = [values]
|
||||
self.values = values
|
||||
self.count = len(self.values)
|
||||
self.byte_count = self.count * 2
|
||||
|
||||
def encode(self):
|
||||
"""Encode a write single register packet packet request.
|
||||
|
||||
:returns: The encoded packet
|
||||
"""
|
||||
packet = struct.pack(">HHB", self.address, self.count, self.byte_count)
|
||||
if self.skip_encode:
|
||||
return packet + b"".join(self.values)
|
||||
|
||||
for value in self.values:
|
||||
packet += struct.pack(">H", value)
|
||||
|
||||
return packet
|
||||
|
||||
def decode(self, data):
|
||||
"""Decode a write single register packet packet request.
|
||||
|
||||
:param data: The request to decode
|
||||
"""
|
||||
self.address, self.count, self.byte_count = struct.unpack(">HHB", data[:5])
|
||||
self.values = [] # reset
|
||||
for idx in range(5, (self.count * 2) + 5, 2):
|
||||
self.values.append(struct.unpack(">H", data[idx : idx + 2])[0])
|
||||
|
||||
def execute(self, context):
|
||||
"""Run a write single register request against a datastore.
|
||||
|
||||
:param context: The datastore to request from
|
||||
:returns: An initialized response, exception message otherwise
|
||||
"""
|
||||
if not 1 <= self.count <= 0x07B:
|
||||
return self.doException(merror.IllegalValue)
|
||||
if self.byte_count != self.count * 2:
|
||||
return self.doException(merror.IllegalValue)
|
||||
if not context.validate(self.function_code, self.address, self.count):
|
||||
return self.doException(merror.IllegalAddress)
|
||||
|
||||
result = context.setValues(self.function_code, self.address, self.values)
|
||||
if isinstance(result, ExceptionResponse):
|
||||
return result
|
||||
return WriteMultipleRegistersResponse(self.address, self.count)
|
||||
|
||||
def get_response_pdu_size(self):
|
||||
"""Get response pdu size.
|
||||
|
||||
Func_code (1 byte) + Starting Address (2 byte) + Quantity of Registers (2 Bytes)
|
||||
:return:
|
||||
"""
|
||||
return 1 + 2 + 2
|
||||
|
||||
def __str__(self):
|
||||
"""Return a string representation of the instance.
|
||||
|
||||
:returns: A string representation of the instance
|
||||
"""
|
||||
params = (self.address, self.count)
|
||||
return (
|
||||
"WriteMultipleRegisterRequest %d => %d" # pylint: disable=consider-using-f-string
|
||||
% params
|
||||
)
|
||||
|
||||
|
||||
class WriteMultipleRegistersResponse(ModbusResponse):
|
||||
"""The normal response returns the function code.
|
||||
|
||||
Starting address, and quantity of registers written.
|
||||
"""
|
||||
|
||||
function_code = 16
|
||||
_rtu_frame_size = 8
|
||||
|
||||
def __init__(self, address=None, count=None, **kwargs):
|
||||
"""Initialize a new instance.
|
||||
|
||||
:param address: The address to start writing to
|
||||
:param count: The number of registers to write to
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
self.address = address
|
||||
self.count = count
|
||||
|
||||
def encode(self):
|
||||
"""Encode a write single register packet packet request.
|
||||
|
||||
:returns: The encoded packet
|
||||
"""
|
||||
return struct.pack(">HH", self.address, self.count)
|
||||
|
||||
def decode(self, data):
|
||||
"""Decode a write single register packet packet request.
|
||||
|
||||
:param data: The request to decode
|
||||
"""
|
||||
self.address, self.count = struct.unpack(">HH", data)
|
||||
|
||||
def __str__(self):
|
||||
"""Return a string representation of the instance.
|
||||
|
||||
:returns: A string representation of the instance
|
||||
"""
|
||||
params = (self.address, self.count)
|
||||
return (
|
||||
"WriteMultipleRegisterResponse (%d,%d)" # pylint: disable=consider-using-f-string
|
||||
% params
|
||||
)
|
||||
|
||||
|
||||
class MaskWriteRegisterRequest(ModbusRequest):
|
||||
"""This function code is used to modify the contents.
|
||||
|
||||
Of a specified holding register using a combination of an AND mask,
|
||||
an OR mask, and the register's current contents.
|
||||
The function can be used to set or clear individual bits in the register.
|
||||
"""
|
||||
|
||||
function_code = 0x16
|
||||
function_code_name = "mask_write_register"
|
||||
_rtu_frame_size = 10
|
||||
|
||||
def __init__(self, address=0x0000, and_mask=0xFFFF, or_mask=0x0000, **kwargs):
|
||||
"""Initialize a new instance.
|
||||
|
||||
:param address: The mask pointer address (0x0000 to 0xffff)
|
||||
:param and_mask: The and bitmask to apply to the register address
|
||||
:param or_mask: The or bitmask to apply to the register address
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
self.address = address
|
||||
self.and_mask = and_mask
|
||||
self.or_mask = or_mask
|
||||
|
||||
def encode(self):
|
||||
"""Encode the request packet.
|
||||
|
||||
:returns: The byte encoded packet
|
||||
"""
|
||||
return struct.pack(">HHH", self.address, self.and_mask, self.or_mask)
|
||||
|
||||
def decode(self, data):
|
||||
"""Decode the incoming request.
|
||||
|
||||
:param data: The data to decode into the address
|
||||
"""
|
||||
self.address, self.and_mask, self.or_mask = struct.unpack(">HHH", data)
|
||||
|
||||
def execute(self, context):
|
||||
"""Run a mask write register request against the store.
|
||||
|
||||
:param context: The datastore to request from
|
||||
:returns: The populated response
|
||||
"""
|
||||
if not 0x0000 <= self.and_mask <= 0xFFFF:
|
||||
return self.doException(merror.IllegalValue)
|
||||
if not 0x0000 <= self.or_mask <= 0xFFFF:
|
||||
return self.doException(merror.IllegalValue)
|
||||
if not context.validate(self.function_code, self.address, 1):
|
||||
return self.doException(merror.IllegalAddress)
|
||||
values = context.getValues(self.function_code, self.address, 1)[0]
|
||||
if isinstance(values, ExceptionResponse):
|
||||
return values
|
||||
values = (values & self.and_mask) | (self.or_mask & ~self.and_mask)
|
||||
result = context.setValues(self.function_code, self.address, [values])
|
||||
if isinstance(result, ExceptionResponse):
|
||||
return result
|
||||
return MaskWriteRegisterResponse(self.address, self.and_mask, self.or_mask)
|
||||
|
||||
|
||||
class MaskWriteRegisterResponse(ModbusResponse):
|
||||
"""The normal response is an echo of the request.
|
||||
|
||||
The response is returned after the register has been written.
|
||||
"""
|
||||
|
||||
function_code = 0x16
|
||||
_rtu_frame_size = 10
|
||||
|
||||
def __init__(self, address=0x0000, and_mask=0xFFFF, or_mask=0x0000, **kwargs):
|
||||
"""Initialize new instance.
|
||||
|
||||
:param address: The mask pointer address (0x0000 to 0xffff)
|
||||
:param and_mask: The and bitmask applied to the register address
|
||||
:param or_mask: The or bitmask applied to the register address
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
self.address = address
|
||||
self.and_mask = and_mask
|
||||
self.or_mask = or_mask
|
||||
|
||||
def encode(self):
|
||||
"""Encode the response.
|
||||
|
||||
:returns: The byte encoded message
|
||||
"""
|
||||
return struct.pack(">HHH", self.address, self.and_mask, self.or_mask)
|
||||
|
||||
def decode(self, data):
|
||||
"""Decode a the response.
|
||||
|
||||
:param data: The packet data to decode
|
||||
"""
|
||||
self.address, self.and_mask, self.or_mask = struct.unpack(">HHH", data)
|
||||
@@ -0,0 +1 @@
|
||||
"""Pymodbus REPL Module."""
|
||||
BIN
Binary file not shown.
@@ -0,0 +1 @@
|
||||
"""Repl client."""
|
||||
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,143 @@
|
||||
"""Command Completion for pymodbus REPL."""
|
||||
from prompt_toolkit.application.current import get_app
|
||||
|
||||
# pylint: disable=missing-type-doc
|
||||
from prompt_toolkit.completion import Completer, Completion
|
||||
from prompt_toolkit.filters import Condition
|
||||
from prompt_toolkit.styles import Style
|
||||
|
||||
from pymodbus.repl.client.helper import get_commands
|
||||
|
||||
|
||||
@Condition
|
||||
def has_selected_completion():
|
||||
"""Check for selected completion."""
|
||||
complete_state = get_app().current_buffer.complete_state
|
||||
return complete_state is not None and complete_state.current_completion is not None
|
||||
|
||||
|
||||
style = Style.from_dict(
|
||||
{
|
||||
"completion-menu.completion": "bg:#008888 #ffffff",
|
||||
"completion-menu.completion.current": "bg:#00aaaa #000000",
|
||||
"scrollbar.background": "bg:#88aaaa",
|
||||
"scrollbar.button": "bg:#222222",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class CmdCompleter(Completer):
|
||||
"""Completer for Pymodbus REPL."""
|
||||
|
||||
def __init__(self, client=None, commands=None, ignore_case=True):
|
||||
"""Initialize.
|
||||
|
||||
:param client: Modbus Client
|
||||
:param commands: Commands to be added for Completion (list)
|
||||
:param ignore_case: Ignore Case while looking up for commands
|
||||
"""
|
||||
self._commands = commands or get_commands(client)
|
||||
self._commands["help"] = ""
|
||||
self._command_names = self._commands.keys()
|
||||
self.ignore_case = ignore_case
|
||||
|
||||
@property
|
||||
def commands(self):
|
||||
"""Return commands."""
|
||||
return self._commands
|
||||
|
||||
@property
|
||||
def command_names(self):
|
||||
"""Return command names."""
|
||||
return self._commands.keys()
|
||||
|
||||
def completing_command(self, words, word_before_cursor):
|
||||
"""Determine if we are dealing with supported command.
|
||||
|
||||
:param words: Input text broken in to word tokens.
|
||||
:param word_before_cursor: The current word before the cursor, \
|
||||
which might be one or more blank spaces.
|
||||
:return:
|
||||
"""
|
||||
return len(words) == 1 and len(word_before_cursor)
|
||||
|
||||
def completing_arg(self, words, word_before_cursor):
|
||||
"""Determine if we are currently completing an argument.
|
||||
|
||||
:param words: The input text broken into word tokens.
|
||||
:param word_before_cursor: The current word before the cursor, \
|
||||
which might be one or more blank spaces.
|
||||
:return: Specifies whether we are currently completing an arg.
|
||||
"""
|
||||
return len(words) > 1 and len(word_before_cursor)
|
||||
|
||||
def arg_completions(self, words, _word_before_cursor):
|
||||
"""Generate arguments completions based on the input."""
|
||||
cmd = words[0].strip()
|
||||
cmd = self._commands.get(cmd, None)
|
||||
return cmd if cmd else None
|
||||
|
||||
def _get_completions(self, word, word_before_cursor):
|
||||
"""Get completions."""
|
||||
if self.ignore_case:
|
||||
word_before_cursor = word_before_cursor.lower()
|
||||
return self.word_matches(word, word_before_cursor)
|
||||
|
||||
def word_matches(self, word, word_before_cursor):
|
||||
"""Match the word and word before cursor.
|
||||
|
||||
:param word: The input text broken into word tokens.
|
||||
:param word_before_cursor: The current word before the cursor, \
|
||||
which might be one or more blank spaces.
|
||||
:return: True if matched.
|
||||
|
||||
"""
|
||||
if self.ignore_case:
|
||||
word = word.lower()
|
||||
return word.startswith(word_before_cursor)
|
||||
|
||||
def get_completions(self, document, complete_event):
|
||||
"""Get completions for the current scope.
|
||||
|
||||
:param document: An instance of `prompt_toolkit.Document`.
|
||||
:param complete_event: (Unused).
|
||||
:return: Yields an instance of `prompt_toolkit.completion.Completion`.
|
||||
"""
|
||||
word_before_cursor = document.get_word_before_cursor(WORD=True)
|
||||
text = document.text_before_cursor.lstrip()
|
||||
words = document.text.strip().split()
|
||||
meta = None
|
||||
commands = []
|
||||
if not words:
|
||||
# yield commands
|
||||
pass
|
||||
if self.completing_command(words, word_before_cursor):
|
||||
commands = self._command_names
|
||||
c_meta = {
|
||||
k: v.help_text if not isinstance(v, str) else v
|
||||
for k, v in self._commands.items()
|
||||
}
|
||||
meta = lambda x: ( # pylint: disable=unnecessary-lambda-assignment
|
||||
x,
|
||||
c_meta.get(x, ""),
|
||||
)
|
||||
else:
|
||||
if not list(
|
||||
filter(lambda cmd: any(x == cmd for x in words), self._command_names)
|
||||
):
|
||||
# yield commands
|
||||
pass
|
||||
|
||||
if " " in text:
|
||||
command = self.arg_completions(words, word_before_cursor)
|
||||
commands = list(command.get_completion())
|
||||
commands = list(
|
||||
filter(lambda cmd: not (any(cmd in x for x in words)), commands)
|
||||
)
|
||||
meta = command.get_meta
|
||||
for command in commands:
|
||||
if self._get_completions(command, word_before_cursor):
|
||||
_, display_meta = meta(command) if meta else ("", "")
|
||||
yield Completion(
|
||||
command, -len(word_before_cursor), display_meta=display_meta
|
||||
)
|
||||
@@ -0,0 +1,312 @@
|
||||
"""Helper Module for REPL actions."""
|
||||
import inspect
|
||||
|
||||
# pylint: disable=missing-type-doc
|
||||
import json
|
||||
from collections import OrderedDict
|
||||
from typing import Any, Dict, List, Union
|
||||
|
||||
import pygments
|
||||
from prompt_toolkit import print_formatted_text
|
||||
from prompt_toolkit.formatted_text import HTML, PygmentsTokens
|
||||
from pygments.lexers.data import JsonLexer
|
||||
|
||||
from pymodbus.payload import BinaryPayloadDecoder, Endian
|
||||
|
||||
|
||||
predicate = inspect.isfunction
|
||||
argspec = inspect.signature
|
||||
|
||||
|
||||
FORMATTERS = {
|
||||
"int8": "decode_8bit_int",
|
||||
"int16": "decode_16bit_int",
|
||||
"int32": "decode_32bit_int",
|
||||
"int64": "decode_64bit_int",
|
||||
"uint8": "decode_8bit_uint",
|
||||
"uint16": "decode_16bit_uint",
|
||||
"uint32": "decode_32bit_uint",
|
||||
"uint64": "decode_64bit_int",
|
||||
"float16": "decode_16bit_float",
|
||||
"float32": "decode_32bit_float",
|
||||
"float64": "decode_64bit_float",
|
||||
}
|
||||
|
||||
|
||||
DEFAULT_KWARGS = {"slave": "Slave address"}
|
||||
|
||||
OTHER_COMMANDS = {
|
||||
"result.raw": "Show RAW Result",
|
||||
"result.decode": "Decode register response to known formats",
|
||||
}
|
||||
EXCLUDE = ["execute", "recv", "send", "trace", "set_debug"]
|
||||
CLIENT_METHODS = [
|
||||
"connect",
|
||||
"close",
|
||||
"idle_time",
|
||||
"is_socket_open",
|
||||
"get_port",
|
||||
"set_port",
|
||||
"get_stopbits",
|
||||
"set_stopbits",
|
||||
"get_bytesize",
|
||||
"set_bytesize",
|
||||
"get_parity",
|
||||
"set_parity",
|
||||
"get_baudrate",
|
||||
"set_baudrate",
|
||||
"get_timeout",
|
||||
"set_timeout",
|
||||
"get_serial_settings",
|
||||
]
|
||||
CLIENT_ATTRIBUTES: List[str] = []
|
||||
|
||||
|
||||
class Command:
|
||||
"""Class representing Commands to be consumed by Completer."""
|
||||
|
||||
def __init__(self, name, signature, doc, slave=False):
|
||||
"""Initialize.
|
||||
|
||||
:param name: Name of the command
|
||||
:param signature: inspect object
|
||||
:param doc: Doc string for the command
|
||||
:param slave: Use slave as additional argument in the command .
|
||||
"""
|
||||
self.name = name
|
||||
self.doc = doc.split("\n") if doc else " ".join(name.split("_"))
|
||||
self.help_text = self._create_help()
|
||||
self.param_help = self._create_arg_help()
|
||||
if signature:
|
||||
self._params = signature.parameters
|
||||
self.args = self.create_completion()
|
||||
else:
|
||||
self._params = ""
|
||||
|
||||
if self.name.startswith("client.") and slave:
|
||||
self.args.update(**DEFAULT_KWARGS)
|
||||
|
||||
def _create_help(self):
|
||||
"""Create help."""
|
||||
doc = filter(lambda d: d, self.doc)
|
||||
cmd_help = list(
|
||||
filter(
|
||||
lambda x: not x.startswith(":param") and not x.startswith(":return"),
|
||||
doc,
|
||||
)
|
||||
)
|
||||
return " ".join(cmd_help).strip()
|
||||
|
||||
def _create_arg_help(self):
|
||||
"""Create arg help."""
|
||||
param_dict = {}
|
||||
params = list(filter(lambda d: d.strip().startswith(":param"), self.doc))
|
||||
for param in params:
|
||||
param, param_help = param.split(":param")[1].strip().split(":")
|
||||
param_dict[param] = param_help
|
||||
return param_dict
|
||||
|
||||
def create_completion(self):
|
||||
"""Create command completion meta data.
|
||||
|
||||
:return:
|
||||
"""
|
||||
words = {}
|
||||
|
||||
def _create(entry, default):
|
||||
if entry not in ["self", "kwargs"]:
|
||||
if isinstance(default, (int, str)):
|
||||
entry += f"={default}"
|
||||
return entry
|
||||
return None
|
||||
|
||||
for arg in self._params.values():
|
||||
if entry := _create(arg.name, arg.default):
|
||||
entry, meta = self.get_meta(entry)
|
||||
words[entry] = meta
|
||||
|
||||
return words
|
||||
|
||||
def get_completion(self):
|
||||
"""Get a list of completions.
|
||||
|
||||
:return:
|
||||
"""
|
||||
return self.args.keys()
|
||||
|
||||
def get_meta(self, cmd):
|
||||
"""Get Meta info of a given command.
|
||||
|
||||
:param cmd: Name of command.
|
||||
:return: Dict containing meta info.
|
||||
"""
|
||||
cmd = cmd.strip()
|
||||
cmd = cmd.split("=")[0].strip()
|
||||
return cmd, self.param_help.get(cmd, "")
|
||||
|
||||
def __str__(self):
|
||||
"""Return string representation."""
|
||||
if self.doc:
|
||||
return f"Command {self.name:>50}{self.doc:<20}"
|
||||
return f"Command {self.name}"
|
||||
|
||||
|
||||
def _get_requests(members):
|
||||
"""Get requests."""
|
||||
commands = list(
|
||||
filter(
|
||||
lambda x: (
|
||||
x[0] not in EXCLUDE and x[0] not in CLIENT_METHODS and callable(x[1])
|
||||
),
|
||||
members,
|
||||
)
|
||||
)
|
||||
commands = {
|
||||
f"client.{c[0]}": Command(
|
||||
f"client.{c[0]}", argspec(c[1]), inspect.getdoc(c[1]), slave=False
|
||||
)
|
||||
for c in commands
|
||||
if not c[0].startswith("_")
|
||||
}
|
||||
return commands
|
||||
|
||||
|
||||
def _get_client_methods(members):
|
||||
"""Get client methods."""
|
||||
commands = list(
|
||||
filter(lambda x: (x[0] not in EXCLUDE and x[0] in CLIENT_METHODS), members)
|
||||
)
|
||||
commands = {
|
||||
f"client.{c[0]}": Command(
|
||||
f"client.{c[0]}", argspec(c[1]), inspect.getdoc(c[1]), slave=False
|
||||
)
|
||||
for c in commands
|
||||
if not c[0].startswith("_")
|
||||
}
|
||||
return commands
|
||||
|
||||
|
||||
def _get_client_properties(members):
|
||||
"""Get client properties."""
|
||||
global CLIENT_ATTRIBUTES # pylint: disable=global-variable-not-assigned
|
||||
commands = list(filter(lambda x: not callable(x[1]), members))
|
||||
commands = {
|
||||
f"client.{c[0]}": Command(f"client.{c[0]}", None, "Read Only!", slave=False)
|
||||
for c in commands
|
||||
if (not c[0].startswith("_") and isinstance(c[1], (str, int, float)))
|
||||
}
|
||||
CLIENT_ATTRIBUTES.extend(list(commands.keys()))
|
||||
return commands
|
||||
|
||||
|
||||
def get_commands(client):
|
||||
"""Retrieve all required methods and attributes.
|
||||
|
||||
Of a client object and convert it to commands.
|
||||
|
||||
:param client: Modbus Client object.
|
||||
:return:
|
||||
"""
|
||||
commands = {}
|
||||
members = inspect.getmembers(client)
|
||||
requests = _get_requests(members)
|
||||
client_methods = _get_client_methods(members)
|
||||
client_attr = _get_client_properties(members)
|
||||
|
||||
result_commands = inspect.getmembers(Result, predicate=predicate)
|
||||
result_commands = {
|
||||
f"result.{c[0]}": Command(f"result.{c[0]}", argspec(c[1]), inspect.getdoc(c[1]))
|
||||
for c in result_commands
|
||||
if (not c[0].startswith("_") and c[0] != "print_result")
|
||||
}
|
||||
commands.update(requests)
|
||||
commands.update(client_methods)
|
||||
commands.update(client_attr)
|
||||
commands.update(result_commands)
|
||||
return commands
|
||||
|
||||
|
||||
class Result:
|
||||
"""Represent result command."""
|
||||
|
||||
function_code: int = None
|
||||
data: Union[Dict[int, Any], Any] = None
|
||||
|
||||
def __init__(self, result):
|
||||
"""Initialize.
|
||||
|
||||
:param result: Response of a modbus command.
|
||||
"""
|
||||
if isinstance(result, dict): # Modbus response
|
||||
self.function_code = result.pop("function_code", None)
|
||||
self.data = dict(result)
|
||||
else:
|
||||
self.data = result
|
||||
|
||||
def decode(self, formatters, byte_order="big", word_order="big"):
|
||||
"""Decode the register response to known formatters.
|
||||
|
||||
:param formatters: int8/16/32/64, uint8/16/32/64, float32/64
|
||||
:param byte_order: little/big
|
||||
:param word_order: little/big
|
||||
"""
|
||||
# Read Holding Registers (3)
|
||||
# Read Input Registers (4)
|
||||
# Read Write Registers (23)
|
||||
if not isinstance(formatters, (list, tuple)):
|
||||
formatters = [formatters]
|
||||
|
||||
if self.function_code not in [3, 4, 23]:
|
||||
print_formatted_text(HTML("<red>Decoder works only for registers!!</red>"))
|
||||
return
|
||||
byte_order = (
|
||||
Endian.LITTLE if byte_order.strip().lower() == "little" else Endian.BIG
|
||||
)
|
||||
word_order = (
|
||||
Endian.LITTLE if word_order.strip().lower() == "little" else Endian.BIG
|
||||
)
|
||||
decoder = BinaryPayloadDecoder.fromRegisters(
|
||||
self.data.get("registers"), byteorder=byte_order, wordorder=word_order
|
||||
)
|
||||
for formatter in formatters:
|
||||
if not (formatter := FORMATTERS.get(formatter)):
|
||||
print_formatted_text(
|
||||
HTML(f"<red>Invalid Formatter - {formatter}!!</red>")
|
||||
)
|
||||
return
|
||||
decoded = getattr(decoder, formatter)()
|
||||
self.print_result(decoded)
|
||||
|
||||
def raw(self):
|
||||
"""Return raw result dict."""
|
||||
self.print_result()
|
||||
|
||||
def _process_dict(self, use_dict):
|
||||
"""Process dict."""
|
||||
new_dict = OrderedDict()
|
||||
for k, v_item in use_dict.items():
|
||||
if isinstance(v_item, bytes):
|
||||
v_item = v_item.decode("utf-8")
|
||||
elif isinstance(v_item, dict):
|
||||
v_item = self._process_dict(v_item)
|
||||
elif isinstance(v_item, (list, tuple)):
|
||||
v_item = [
|
||||
v1.decode("utf-8") if isinstance(v1, bytes) else v1 for v1 in v_item
|
||||
]
|
||||
new_dict[k] = v_item
|
||||
return new_dict
|
||||
|
||||
def print_result(self, data=None):
|
||||
"""Print result object pretty.
|
||||
|
||||
:param data: Data to be printed.
|
||||
"""
|
||||
data = data or self.data
|
||||
if isinstance(data, dict):
|
||||
data = self._process_dict(data)
|
||||
elif isinstance(data, (list, tuple)):
|
||||
data = [v.decode("utf-8") if isinstance(v, bytes) else v for v in data]
|
||||
elif isinstance(data, bytes):
|
||||
data = data.decode("utf-8")
|
||||
tokens = list(pygments.lex(json.dumps(data, indent=4), lexer=JsonLexer()))
|
||||
print_formatted_text(PygmentsTokens(tokens))
|
||||
@@ -0,0 +1,437 @@
|
||||
"""Pymodbus REPL Entry point."""
|
||||
import logging
|
||||
import pathlib
|
||||
|
||||
import click
|
||||
from prompt_toolkit import PromptSession, print_formatted_text
|
||||
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
|
||||
from prompt_toolkit.formatted_text import HTML
|
||||
from prompt_toolkit.history import FileHistory
|
||||
from prompt_toolkit.key_binding import KeyBindings
|
||||
from prompt_toolkit.lexers import PygmentsLexer
|
||||
from prompt_toolkit.styles import Style
|
||||
from pygments.lexers.python import PythonLexer
|
||||
|
||||
from pymodbus import __version__ as pymodbus_version
|
||||
from pymodbus.exceptions import ParameterException
|
||||
from pymodbus.repl.client.completer import (
|
||||
CmdCompleter,
|
||||
has_selected_completion,
|
||||
)
|
||||
from pymodbus.repl.client.helper import CLIENT_ATTRIBUTES, Result
|
||||
from pymodbus.repl.client.mclient import ModbusSerialClient, ModbusTcpClient
|
||||
from pymodbus.transaction import (
|
||||
ModbusAsciiFramer,
|
||||
ModbusBinaryFramer,
|
||||
ModbusRtuFramer,
|
||||
ModbusSocketFramer,
|
||||
)
|
||||
|
||||
|
||||
_logger = logging.getLogger()
|
||||
|
||||
TITLE = rf"""
|
||||
----------------------------------------------------------------------------
|
||||
__________ _____ .___ __________ .__
|
||||
\______ \___.__. / \ ____ __| _/ \______ \ ____ ______ | |
|
||||
| ___< | |/ \ / \ / _ \ / __ | | _// __ \\\____ \| |
|
||||
| | \___ / Y ( <_> ) /_/ | | | \ ___/| |_> > |__
|
||||
|____| / ____\____|__ /\____/\____ | /\ |____|_ /\___ > __/|____/
|
||||
\/ \/ \/ \/ \/ \/|__|
|
||||
v1.3.0 - {pymodbus_version}
|
||||
----------------------------------------------------------------------------
|
||||
"""
|
||||
|
||||
|
||||
style = Style.from_dict(
|
||||
{
|
||||
"completion-menu.completion": "bg:#008888 #ffffff",
|
||||
"completion-menu.completion.current": "bg:#00aaaa #000000",
|
||||
"scrollbar.background": "bg:#88aaaa",
|
||||
"scrollbar.button": "bg:#222222",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def bottom_toolbar():
|
||||
"""Do console toolbar.
|
||||
|
||||
:return:
|
||||
"""
|
||||
return HTML(
|
||||
'Press <b><style bg="ansired">CTRL+D or exit </style></b>'
|
||||
' to exit! Type "help" for list of available commands'
|
||||
)
|
||||
|
||||
|
||||
class CaseInsenstiveChoice(click.Choice):
|
||||
"""Do case Insensitive choice for click commands and options."""
|
||||
|
||||
def convert(self, value, param, ctx):
|
||||
"""Convert args to uppercase for evaluation."""
|
||||
if value is None:
|
||||
return None
|
||||
return super().convert(value.strip().upper(), param, ctx)
|
||||
|
||||
|
||||
class NumericChoice(click.Choice):
|
||||
"""Do numeric choice for click arguments and options."""
|
||||
|
||||
def __init__(self, choices, typ):
|
||||
"""Initialize."""
|
||||
self.typ = typ
|
||||
super().__init__(choices)
|
||||
|
||||
def convert(self, value, param, ctx):
|
||||
"""Convert."""
|
||||
# Exact match
|
||||
if value in self.choices:
|
||||
return self.typ(value)
|
||||
|
||||
if ctx is not None and ctx.token_normalize_func is not None:
|
||||
value = ctx.token_normalize_func(value)
|
||||
for choice in self.casted_choices: # pylint: disable=no-member
|
||||
if ctx.token_normalize_func(choice) == value:
|
||||
return choice
|
||||
|
||||
self.fail(
|
||||
f"invalid choice: {value}. (choose from {', '.join(self.choices)})",
|
||||
param,
|
||||
ctx,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _process_args(args: list, string: bool = True):
|
||||
"""Parse arguments provided on command line.
|
||||
|
||||
:param args: Array of argument values
|
||||
:param string: True if arguments values are strings, false if argument values are integers
|
||||
|
||||
:return Tuple, where the first member is hash of parsed values, and second is boolean flag
|
||||
indicating if parsing succeeded.
|
||||
"""
|
||||
kwargs = {}
|
||||
execute = True
|
||||
skip_index = None
|
||||
|
||||
def _parse_val(arg_name, val):
|
||||
if not string:
|
||||
if "," in val:
|
||||
val = val.split(",")
|
||||
val = [int(v, 0) for v in val]
|
||||
else:
|
||||
val = int(val, 0)
|
||||
kwargs[arg_name] = val
|
||||
|
||||
for i, arg in enumerate(args):
|
||||
if i == skip_index:
|
||||
continue
|
||||
arg = arg.strip()
|
||||
if "=" in arg:
|
||||
arg_name, val = arg.split("=")
|
||||
_parse_val(arg_name, val)
|
||||
else:
|
||||
arg_name, val = arg, args[i + 1]
|
||||
try:
|
||||
_parse_val(arg_name, val)
|
||||
skip_index = i + 1
|
||||
except TypeError:
|
||||
click.secho("Error parsing arguments!", fg="yellow")
|
||||
execute = False
|
||||
break
|
||||
except ValueError:
|
||||
click.secho("Error parsing argument", fg="yellow")
|
||||
execute = False
|
||||
break
|
||||
return kwargs, execute
|
||||
|
||||
|
||||
class CLI: # pylint: disable=too-few-public-methods
|
||||
"""Client definition."""
|
||||
|
||||
def __init__(self, client):
|
||||
"""Set up client and keybindings."""
|
||||
|
||||
use_keys = KeyBindings()
|
||||
history_file = pathlib.Path.home().joinpath(".pymodhis")
|
||||
self.client = client
|
||||
|
||||
@use_keys.add("c-space")
|
||||
def _(event):
|
||||
"""Initialize autocompletion, or select the next completion."""
|
||||
buff = event.app.current_buffer
|
||||
if buff.complete_state:
|
||||
buff.complete_next()
|
||||
else:
|
||||
buff.start_completion(select_first=False)
|
||||
|
||||
@use_keys.add("enter", filter=has_selected_completion)
|
||||
def _(event):
|
||||
"""Make the enter key work as the tab key only when showing the menu."""
|
||||
event.current_buffer.complete_state = None
|
||||
buffer = event.cli.current_buffer
|
||||
buffer.complete_state = None
|
||||
|
||||
self.session = PromptSession(
|
||||
lexer=PygmentsLexer(PythonLexer),
|
||||
completer=CmdCompleter(client),
|
||||
style=style,
|
||||
complete_while_typing=True,
|
||||
bottom_toolbar=bottom_toolbar,
|
||||
key_bindings=use_keys,
|
||||
history=FileHistory(history_file),
|
||||
auto_suggest=AutoSuggestFromHistory(),
|
||||
)
|
||||
click.secho(TITLE, fg="green")
|
||||
|
||||
def _print_command_help(self, commands):
|
||||
"""Print a list of commands with help text."""
|
||||
for cmd, obj in sorted(commands.items()):
|
||||
if cmd != "help":
|
||||
print_formatted_text(
|
||||
HTML(
|
||||
f"<skyblue>{cmd:45s}</skyblue>"
|
||||
f"<seagreen>{obj.help_text:100s}"
|
||||
"</seagreen>"
|
||||
)
|
||||
)
|
||||
|
||||
def _process_client(self, text, client) -> Result:
|
||||
"""Process client commands."""
|
||||
text = text.strip().split()
|
||||
cmd = text[0].split(".")[1]
|
||||
args = text[1:]
|
||||
kwargs, execute = _process_args(args, string=False)
|
||||
if execute:
|
||||
if text[0] in CLIENT_ATTRIBUTES:
|
||||
result = Result(getattr(client, cmd))
|
||||
else:
|
||||
result = Result(getattr(client, cmd)(**kwargs))
|
||||
result.print_result()
|
||||
return result
|
||||
|
||||
def _process_result(self, text, result):
|
||||
"""Process result commands."""
|
||||
words = text.split()
|
||||
if words[0] == "result.raw":
|
||||
result.raw()
|
||||
if words[0] == "result.decode":
|
||||
args = words[1:]
|
||||
kwargs, execute = _process_args(args)
|
||||
if execute:
|
||||
result.decode(**kwargs)
|
||||
|
||||
def run(self):
|
||||
"""Run the REPL."""
|
||||
result = None
|
||||
while True:
|
||||
try:
|
||||
text = self.session.prompt("> ", complete_while_typing=True)
|
||||
if text.strip().lower() == "help":
|
||||
print_formatted_text(HTML("<u>Available commands:</u>"))
|
||||
self._print_command_help(self.session.completer.commands)
|
||||
elif text.strip().lower() == "exit":
|
||||
raise EOFError()
|
||||
elif text.strip().lower().startswith("client."):
|
||||
result = self._process_client(text, self.client)
|
||||
elif text.strip().lower().startswith("result.") and result:
|
||||
self._process_result(text, result)
|
||||
except KeyboardInterrupt:
|
||||
continue # Control-C pressed. Try again.
|
||||
except EOFError:
|
||||
break # Control-D pressed.
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
click.secho(str(exc), fg="red")
|
||||
|
||||
click.secho("GoodBye!", fg="blue")
|
||||
|
||||
|
||||
@click.group("pymodbus-repl")
|
||||
@click.version_option(str(pymodbus_version), message=TITLE)
|
||||
@click.option("--verbose", is_flag=True, default=False, help="Verbose logs")
|
||||
@click.option(
|
||||
"--broadcast-support",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Support broadcast messages",
|
||||
)
|
||||
@click.option(
|
||||
"--retry-on-empty", is_flag=True, default=False, help="Retry on empty response"
|
||||
)
|
||||
@click.option(
|
||||
"--retry-on-error", is_flag=True, default=False, help="Retry on error response"
|
||||
)
|
||||
@click.option("--retries", default=3, help="Retry count")
|
||||
@click.pass_context
|
||||
def main(
|
||||
ctx,
|
||||
verbose,
|
||||
broadcast_support,
|
||||
retry_on_empty,
|
||||
retry_on_error,
|
||||
retries,
|
||||
):
|
||||
"""Run Main."""
|
||||
if verbose:
|
||||
use_format = (
|
||||
"%(asctime)-15s %(threadName)-15s "
|
||||
"%(levelname)-8s %(module)-15s:%(lineno)-8s %(message)s"
|
||||
)
|
||||
logging.basicConfig(format=use_format)
|
||||
_logger.setLevel(logging.DEBUG)
|
||||
ctx.obj = {
|
||||
"broadcast_enable": broadcast_support,
|
||||
"retry_on_empty": retry_on_empty,
|
||||
"retry_on_invalid": retry_on_error,
|
||||
"retries": retries,
|
||||
}
|
||||
|
||||
|
||||
@main.command("tcp")
|
||||
@click.pass_context
|
||||
@click.option("--host", default="localhost", help="Modbus TCP IP ")
|
||||
@click.option(
|
||||
"--port",
|
||||
default=502,
|
||||
type=int,
|
||||
help="Modbus TCP port",
|
||||
)
|
||||
@click.option(
|
||||
"--framer",
|
||||
default="tcp",
|
||||
type=str,
|
||||
help="Override the default packet framer tcp|rtu",
|
||||
)
|
||||
def tcp(ctx, host, port, framer):
|
||||
"""Define TCP."""
|
||||
kwargs = {"host": host, "port": port}
|
||||
kwargs.update(**ctx.obj)
|
||||
if framer == "rtu":
|
||||
kwargs["framer"] = ModbusRtuFramer
|
||||
client = ModbusTcpClient(**kwargs)
|
||||
cli = CLI(client)
|
||||
cli.run()
|
||||
|
||||
|
||||
@main.command("serial")
|
||||
@click.pass_context
|
||||
@click.option(
|
||||
"--method",
|
||||
default="rtu",
|
||||
type=str,
|
||||
help="Modbus Serial Mode (rtu/ascii)",
|
||||
)
|
||||
@click.option(
|
||||
"--port",
|
||||
default=None,
|
||||
type=str,
|
||||
help="Modbus RTU port",
|
||||
)
|
||||
@click.option(
|
||||
"--baudrate",
|
||||
help="Modbus RTU serial baudrate to use.",
|
||||
default=9600,
|
||||
type=int,
|
||||
)
|
||||
@click.option(
|
||||
"--bytesize",
|
||||
help="Modbus RTU serial Number of data bits. "
|
||||
"Possible values: FIVEBITS, SIXBITS, SEVENBITS, "
|
||||
"EIGHTBITS.",
|
||||
type=NumericChoice(["5", "6", "7", "8"], int),
|
||||
default="8",
|
||||
)
|
||||
@click.option(
|
||||
"--parity",
|
||||
help="Modbus RTU serial parity. "
|
||||
" Enable parity checking. Possible values: "
|
||||
"PARITY_NONE, PARITY_EVEN, PARITY_ODD PARITY_MARK, "
|
||||
'PARITY_SPACE. Default to "N"',
|
||||
default="N",
|
||||
type=CaseInsenstiveChoice(["N", "E", "O", "M", "S"]),
|
||||
)
|
||||
@click.option(
|
||||
"--stopbits",
|
||||
help="Modbus RTU serial stop bits. "
|
||||
"Number of stop bits. Possible values: STOPBITS_ONE, "
|
||||
'STOPBITS_ONE_POINT_FIVE, STOPBITS_TWO. Default to "1"',
|
||||
default="1",
|
||||
type=NumericChoice(["1", "1.5", "2"], float),
|
||||
)
|
||||
@click.option(
|
||||
"--xonxoff",
|
||||
help="Modbus RTU serial xonxoff. Enable software flow control.",
|
||||
default=0,
|
||||
type=int,
|
||||
)
|
||||
@click.option(
|
||||
"--rtscts",
|
||||
help="Modbus RTU serial rtscts. Enable hardware (RTS/CTS) flow " "control.",
|
||||
default=0,
|
||||
type=int,
|
||||
)
|
||||
@click.option(
|
||||
"--dsrdtr",
|
||||
help="Modbus RTU serial dsrdtr. Enable hardware (DSR/DTR) flow " "control.",
|
||||
default=0,
|
||||
type=int,
|
||||
)
|
||||
@click.option(
|
||||
"--timeout",
|
||||
help="Modbus RTU serial read timeout.",
|
||||
default=0.25,
|
||||
type=float,
|
||||
)
|
||||
@click.option(
|
||||
"--write-timeout",
|
||||
help="Modbus RTU serial write timeout.",
|
||||
default=2,
|
||||
type=float,
|
||||
)
|
||||
def serial( # pylint: disable=too-many-arguments
|
||||
ctx,
|
||||
method,
|
||||
port,
|
||||
baudrate,
|
||||
bytesize,
|
||||
parity,
|
||||
stopbits,
|
||||
xonxoff,
|
||||
rtscts,
|
||||
dsrdtr,
|
||||
timeout,
|
||||
write_timeout,
|
||||
):
|
||||
"""Define serial communication."""
|
||||
method = method.lower()
|
||||
if method == "ascii":
|
||||
framer = ModbusAsciiFramer
|
||||
elif method == "rtu":
|
||||
framer = ModbusRtuFramer
|
||||
elif method == "binary":
|
||||
framer = ModbusBinaryFramer
|
||||
elif method == "socket":
|
||||
framer = ModbusSocketFramer
|
||||
else:
|
||||
raise ParameterException("Invalid framer method requested")
|
||||
client = ModbusSerialClient(
|
||||
framer=framer,
|
||||
port=port,
|
||||
baudrate=baudrate,
|
||||
bytesize=bytesize,
|
||||
parity=parity,
|
||||
stopbits=stopbits,
|
||||
xonxoff=xonxoff,
|
||||
rtscts=rtscts,
|
||||
dsrdtr=dsrdtr,
|
||||
timeout=timeout,
|
||||
write_timeout=write_timeout,
|
||||
**ctx.obj,
|
||||
)
|
||||
cli = CLI(client)
|
||||
cli.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main() # pylint: disable=no-value-for-parameter
|
||||
@@ -0,0 +1,683 @@
|
||||
"""Modbus Clients to be used with REPL."""
|
||||
# pylint: disable=missing-type-doc
|
||||
import functools
|
||||
|
||||
from pymodbus.client import ModbusSerialClient as _ModbusSerialClient
|
||||
from pymodbus.client import ModbusTcpClient as _ModbusTcpClient
|
||||
from pymodbus.diag_message import (
|
||||
ChangeAsciiInputDelimiterRequest,
|
||||
ClearCountersRequest,
|
||||
ClearOverrunCountRequest,
|
||||
ForceListenOnlyModeRequest,
|
||||
GetClearModbusPlusRequest,
|
||||
RestartCommunicationsOptionRequest,
|
||||
ReturnBusCommunicationErrorCountRequest,
|
||||
ReturnBusExceptionErrorCountRequest,
|
||||
ReturnBusMessageCountRequest,
|
||||
ReturnDiagnosticRegisterRequest,
|
||||
ReturnIopOverrunCountRequest,
|
||||
ReturnQueryDataRequest,
|
||||
ReturnSlaveBusCharacterOverrunCountRequest,
|
||||
ReturnSlaveBusyCountRequest,
|
||||
ReturnSlaveMessageCountRequest,
|
||||
ReturnSlaveNAKCountRequest,
|
||||
ReturnSlaveNoResponseCountRequest,
|
||||
)
|
||||
from pymodbus.exceptions import ModbusIOException
|
||||
from pymodbus.mei_message import ReadDeviceInformationRequest
|
||||
from pymodbus.other_message import (
|
||||
GetCommEventCounterRequest,
|
||||
GetCommEventLogRequest,
|
||||
ReadExceptionStatusRequest,
|
||||
ReportSlaveIdRequest,
|
||||
)
|
||||
from pymodbus.pdu import ExceptionResponse, ModbusExceptions
|
||||
|
||||
|
||||
def make_response_dict(resp):
|
||||
"""Make response dict."""
|
||||
resp_dict = {"function_code": resp.function_code, "address": resp.address}
|
||||
if hasattr(resp, "value"):
|
||||
resp_dict["value"] = resp.value
|
||||
elif hasattr(resp, "values"):
|
||||
resp_dict["values"] = resp.values
|
||||
elif hasattr(resp, "count"):
|
||||
resp_dict["count"] = resp.count
|
||||
return resp_dict
|
||||
|
||||
|
||||
def handle_brodcast(func):
|
||||
"""Handle broadcast."""
|
||||
|
||||
@functools.wraps(func)
|
||||
def _wrapper(*args, **kwargs):
|
||||
self = args[0]
|
||||
resp = func(*args, **kwargs)
|
||||
if not kwargs.get("slave") and self.params.broadcast_enable:
|
||||
return {"broadcasted": True}
|
||||
if not resp.isError():
|
||||
return make_response_dict(resp)
|
||||
return ExtendedRequestSupport._process_exception( # pylint: disable=protected-access
|
||||
resp, **kwargs
|
||||
)
|
||||
|
||||
return _wrapper
|
||||
|
||||
|
||||
class ExtendedRequestSupport: # pylint: disable=(too-many-public-methods
|
||||
"""Extended request support."""
|
||||
|
||||
@staticmethod
|
||||
def _process_exception(resp, **kwargs):
|
||||
"""Set internal process exception."""
|
||||
if "slave" not in kwargs:
|
||||
err = {"message": "Broadcast message, ignoring errors!!!"}
|
||||
else:
|
||||
if isinstance(resp, ExceptionResponse): # pylint: disable=else-if-used
|
||||
err = {
|
||||
"original_function_code": f"{resp.original_code} ({hex(resp.original_code)})",
|
||||
"error_function_code": f"{resp.function_code} ({hex(resp.function_code)})",
|
||||
"exception code": resp.exception_code,
|
||||
"message": ModbusExceptions.decode(resp.exception_code),
|
||||
}
|
||||
elif isinstance(resp, ModbusIOException):
|
||||
err = {
|
||||
"original_function_code": f"{resp.fcode} ({hex(resp.fcode)})",
|
||||
"error": resp.message,
|
||||
}
|
||||
else:
|
||||
err = {"error": str(resp)}
|
||||
return err
|
||||
|
||||
def read_coils(self, address, count=1, slave=0, **kwargs):
|
||||
"""Read `count` coils from a given slave starting at `address`.
|
||||
|
||||
:param address: The starting address to read from
|
||||
:param count: The number of coils to read
|
||||
:param slave: Modbus slave slave ID
|
||||
:param kwargs:
|
||||
:returns: List of register values
|
||||
"""
|
||||
resp = super().read_coils( # pylint: disable=no-member
|
||||
address, count, slave, **kwargs
|
||||
)
|
||||
if not resp.isError():
|
||||
return {"function_code": resp.function_code, "bits": resp.bits}
|
||||
return ExtendedRequestSupport._process_exception(resp, slave=slave)
|
||||
|
||||
def read_discrete_inputs(self, address, count=1, slave=0, **kwargs):
|
||||
"""Read `count` number of discrete inputs starting at offset `address`.
|
||||
|
||||
:param address: The starting address to read from
|
||||
:param count: The number of coils to read
|
||||
:param slave: Modbus slave slave ID
|
||||
:param kwargs:
|
||||
:return: List of bits
|
||||
"""
|
||||
resp = super().read_discrete_inputs( # pylint: disable=no-member
|
||||
address, count, slave, **kwargs
|
||||
)
|
||||
if not resp.isError():
|
||||
return {"function_code": resp.function_code, "bits": resp.bits}
|
||||
return ExtendedRequestSupport._process_exception(resp, slave=slave)
|
||||
|
||||
@handle_brodcast
|
||||
def write_coil(self, address, value, slave=0, **kwargs):
|
||||
"""Write `value` to coil at `address`.
|
||||
|
||||
:param address: coil offset to write to
|
||||
:param value: bit value to write
|
||||
:param slave: Modbus slave slave ID
|
||||
:param kwargs:
|
||||
:return:
|
||||
"""
|
||||
resp = super().write_coil( # pylint: disable=no-member
|
||||
address, value, slave, **kwargs
|
||||
)
|
||||
return resp
|
||||
|
||||
@handle_brodcast
|
||||
def write_coils(self, address, values, slave=0, **kwargs):
|
||||
"""Write `value` to coil at `address`.
|
||||
|
||||
:param address: coil offset to write to
|
||||
:param values: list of bit values to write (comma separated)
|
||||
:param slave: Modbus slave slave ID
|
||||
:param kwargs:
|
||||
:return:
|
||||
"""
|
||||
resp = super().write_coils( # pylint: disable=no-member
|
||||
address, values, slave, **kwargs
|
||||
)
|
||||
return resp
|
||||
|
||||
@handle_brodcast
|
||||
def write_register(self, address, value, slave=0, **kwargs):
|
||||
"""Write `value` to register at `address`.
|
||||
|
||||
:param address: register offset to write to
|
||||
:param value: register value to write
|
||||
:param slave: Modbus slave slave ID
|
||||
:param kwargs:
|
||||
:return:
|
||||
"""
|
||||
resp = super().write_register( # pylint: disable=no-member
|
||||
address, value, slave, **kwargs
|
||||
)
|
||||
return resp
|
||||
|
||||
@handle_brodcast
|
||||
def write_registers(self, address, values, slave=0, **kwargs):
|
||||
"""Write list of `values` to registers starting at `address`.
|
||||
|
||||
:param address: register offset to write to
|
||||
:param values: list of register value to write (comma separated)
|
||||
:param slave: Modbus slave slave ID
|
||||
:param kwargs:
|
||||
:return:
|
||||
"""
|
||||
resp = super().write_registers( # pylint: disable=no-member
|
||||
address, values, slave, **kwargs
|
||||
)
|
||||
return resp
|
||||
|
||||
def read_holding_registers(self, address, count=1, slave=0, **kwargs):
|
||||
"""Read `count` number of holding registers starting at `address`.
|
||||
|
||||
:param address: starting register offset to read from
|
||||
:param count: Number of registers to read
|
||||
:param slave: Modbus slave slave ID
|
||||
:param kwargs:
|
||||
:return:
|
||||
"""
|
||||
resp = super().read_holding_registers( # pylint: disable=no-member
|
||||
address, count, slave, **kwargs
|
||||
)
|
||||
if not resp.isError():
|
||||
return {"function_code": resp.function_code, "registers": resp.registers}
|
||||
return ExtendedRequestSupport._process_exception(resp, slave=slave)
|
||||
|
||||
def read_input_registers(self, address, count=1, slave=0, **kwargs):
|
||||
"""Read `count` number of input registers starting at `address`.
|
||||
|
||||
:param address: starting register offset to read from to
|
||||
:param count: Number of registers to read
|
||||
:param slave: Modbus slave slave ID
|
||||
:param kwargs:
|
||||
:return:
|
||||
"""
|
||||
resp = super().read_input_registers( # pylint: disable=no-member
|
||||
address, count, slave, **kwargs
|
||||
)
|
||||
if not resp.isError():
|
||||
return {"function_code": resp.function_code, "registers": resp.registers}
|
||||
return ExtendedRequestSupport._process_exception(resp, slave=slave)
|
||||
|
||||
def readwrite_registers(
|
||||
self,
|
||||
read_address=0,
|
||||
read_count=0,
|
||||
write_address=0,
|
||||
values=0,
|
||||
slave=0,
|
||||
**kwargs,
|
||||
):
|
||||
"""Read `read_count` number of holding registers.
|
||||
|
||||
Starting at `read_address`
|
||||
and write `write_registers` starting at `write_address`.
|
||||
|
||||
:param read_address: register offset to read from
|
||||
:param read_count: Number of registers to read
|
||||
:param write_address: register offset to write to
|
||||
:param values: List of register values to write (comma separated)
|
||||
:param slave: Modbus slave slave ID
|
||||
:param kwargs:
|
||||
:return:
|
||||
"""
|
||||
resp = super().readwrite_registers( # pylint: disable=no-member
|
||||
read_address=read_address,
|
||||
read_count=read_count,
|
||||
write_address=write_address,
|
||||
values=values,
|
||||
slave=slave,
|
||||
**kwargs,
|
||||
)
|
||||
if not resp.isError():
|
||||
return {"function_code": resp.function_code, "registers": resp.registers}
|
||||
return ExtendedRequestSupport._process_exception(resp, slave=slave)
|
||||
|
||||
def mask_write_register(
|
||||
self,
|
||||
address=0x0000,
|
||||
and_mask=0xFFFF,
|
||||
or_mask=0x0000,
|
||||
slave=0,
|
||||
**kwargs,
|
||||
):
|
||||
"""Mask content of holding register at `address` with `and_mask` and `or_mask`.
|
||||
|
||||
:param address: Reference address of register
|
||||
:param and_mask: And Mask
|
||||
:param or_mask: OR Mask
|
||||
:param slave: Modbus slave slave ID
|
||||
:param kwargs:
|
||||
:return:
|
||||
"""
|
||||
resp = super().mask_write_register( # pylint: disable=no-member
|
||||
address=address, and_mask=and_mask, or_mask=or_mask, slave=slave, **kwargs
|
||||
)
|
||||
if not resp.isError():
|
||||
return {
|
||||
"function_code": resp.function_code,
|
||||
"address": resp.address,
|
||||
"and mask": resp.and_mask,
|
||||
"or mask": resp.or_mask,
|
||||
}
|
||||
return ExtendedRequestSupport._process_exception(resp, slave=slave)
|
||||
|
||||
def read_device_information(self, read_code=None, object_id=0x00, **kwargs):
|
||||
"""Read the identification and additional information of remote slave.
|
||||
|
||||
:param read_code: Read Device ID code (0x01/0x02/0x03/0x04)
|
||||
:param object_id: Identification of the first object to obtain.
|
||||
:param kwargs:
|
||||
:return:
|
||||
"""
|
||||
request = ReadDeviceInformationRequest(read_code, object_id, **kwargs)
|
||||
resp = self.execute(request) # pylint: disable=no-member
|
||||
if not resp.isError():
|
||||
return {
|
||||
"function_code": resp.function_code,
|
||||
"information": resp.information,
|
||||
"object count": resp.number_of_objects,
|
||||
"conformity": resp.conformity,
|
||||
"next object id": resp.next_object_id,
|
||||
"more follows": resp.more_follows,
|
||||
"space left": resp.space_left,
|
||||
}
|
||||
return ExtendedRequestSupport._process_exception(resp, slave=request.slave_id)
|
||||
|
||||
def report_slave_id(self, slave=0, **kwargs):
|
||||
"""Report information about remote slave ID.
|
||||
|
||||
:param slave: Modbus slave ID
|
||||
:param kwargs:
|
||||
:return:
|
||||
"""
|
||||
request = ReportSlaveIdRequest(slave, **kwargs)
|
||||
resp = self.execute(request) # pylint: disable=no-member
|
||||
if not resp.isError():
|
||||
return {
|
||||
"function_code": resp.function_code,
|
||||
"identifier": resp.identifier.decode("cp1252"),
|
||||
"status": resp.status,
|
||||
"byte count": resp.byte_count,
|
||||
}
|
||||
return ExtendedRequestSupport._process_exception(resp, slave=slave)
|
||||
|
||||
def read_exception_status(self, slave=0, **kwargs):
|
||||
"""Read contents of eight Exception Status output in a remote device.
|
||||
|
||||
:param slave: Modbus slave ID
|
||||
:param kwargs:
|
||||
:return:
|
||||
"""
|
||||
request = ReadExceptionStatusRequest(slave, **kwargs)
|
||||
resp = self.execute(request) # pylint: disable=no-member
|
||||
if not resp.isError():
|
||||
return {"function_code": resp.function_code, "status": resp.status}
|
||||
return ExtendedRequestSupport._process_exception(resp, slave=request.slave_id)
|
||||
|
||||
def get_com_event_counter(self, **kwargs):
|
||||
"""Read status word and an event count.
|
||||
|
||||
From the remote device's communication event counter.
|
||||
|
||||
:param kwargs:
|
||||
:return:
|
||||
"""
|
||||
request = GetCommEventCounterRequest(**kwargs)
|
||||
resp = self.execute(request) # pylint: disable=no-member
|
||||
if not resp.isError():
|
||||
return {
|
||||
"function_code": resp.function_code,
|
||||
"status": resp.status,
|
||||
"count": resp.count,
|
||||
}
|
||||
return ExtendedRequestSupport._process_exception(resp, slave=request.slave_id)
|
||||
|
||||
def get_com_event_log(self, **kwargs):
|
||||
"""Read status word.
|
||||
|
||||
Event count, message count, and a field of event
|
||||
bytes from the remote device.
|
||||
|
||||
:param kwargs:
|
||||
:return:
|
||||
"""
|
||||
request = GetCommEventLogRequest(**kwargs)
|
||||
resp = self.execute(request) # pylint: disable=no-member
|
||||
if not resp.isError():
|
||||
return {
|
||||
"function_code": resp.function_code,
|
||||
"status": resp.status,
|
||||
"message count": resp.message_count,
|
||||
"event count": resp.event_count,
|
||||
"events": resp.events,
|
||||
}
|
||||
return ExtendedRequestSupport._process_exception(resp, slave=request.slave_id)
|
||||
|
||||
def _execute_diagnostic_request(self, request):
|
||||
"""Execute diagnostic request."""
|
||||
resp = self.execute(request) # pylint: disable=no-member
|
||||
if not resp.isError():
|
||||
return {
|
||||
"function code": resp.function_code,
|
||||
"sub function code": resp.sub_function_code,
|
||||
"message": resp.message,
|
||||
}
|
||||
return ExtendedRequestSupport._process_exception(resp, slave=request.slave_id)
|
||||
|
||||
def return_query_data(self, message=0, **kwargs):
|
||||
"""Loop back data sent in response.
|
||||
|
||||
:param message: Message to be looped back
|
||||
:param kwargs:
|
||||
:return:
|
||||
"""
|
||||
request = ReturnQueryDataRequest(message, **kwargs)
|
||||
return self._execute_diagnostic_request(request)
|
||||
|
||||
def restart_comm_option(self, toggle=False, **kwargs):
|
||||
"""Initialize and restart remote devices.
|
||||
|
||||
Serial interface and clear all of its communications event counters.
|
||||
|
||||
:param toggle: Toggle Status [ON(0xff00)/OFF(0x0000]
|
||||
:param kwargs:
|
||||
:return:
|
||||
"""
|
||||
request = RestartCommunicationsOptionRequest(toggle, **kwargs)
|
||||
return self._execute_diagnostic_request(request)
|
||||
|
||||
def return_diagnostic_register(self, data=0, **kwargs):
|
||||
"""Read 16-bit diagnostic register.
|
||||
|
||||
:param data: Data field (0x0000)
|
||||
:param kwargs:
|
||||
:return:
|
||||
"""
|
||||
request = ReturnDiagnosticRegisterRequest(data, **kwargs)
|
||||
return self._execute_diagnostic_request(request)
|
||||
|
||||
def change_ascii_input_delimiter(self, data=0, **kwargs):
|
||||
"""Change message delimiter for future requests.
|
||||
|
||||
:param data: New delimiter character
|
||||
:param kwargs:
|
||||
:return:
|
||||
"""
|
||||
request = ChangeAsciiInputDelimiterRequest(data, **kwargs)
|
||||
return self._execute_diagnostic_request(request)
|
||||
|
||||
def force_listen_only_mode(self, data=0, **kwargs):
|
||||
"""Force addressed remote device to its Listen Only Mode.
|
||||
|
||||
:param data: Data field (0x0000)
|
||||
:param kwargs:
|
||||
:return:
|
||||
"""
|
||||
request = ForceListenOnlyModeRequest(data, **kwargs)
|
||||
return self._execute_diagnostic_request(request)
|
||||
|
||||
def clear_counters(self, data=0, **kwargs):
|
||||
"""Clear all counters and diag registers.
|
||||
|
||||
:param data: Data field (0x0000)
|
||||
:param kwargs:
|
||||
:return:
|
||||
"""
|
||||
request = ClearCountersRequest(data, **kwargs)
|
||||
return self._execute_diagnostic_request(request)
|
||||
|
||||
def return_bus_message_count(self, data=0, **kwargs):
|
||||
"""Return count of message detected on bus by remote slave.
|
||||
|
||||
:param data: Data field (0x0000)
|
||||
:param kwargs:
|
||||
:return:
|
||||
"""
|
||||
request = ReturnBusMessageCountRequest(data, **kwargs)
|
||||
return self._execute_diagnostic_request(request)
|
||||
|
||||
def return_bus_com_error_count(self, data=0, **kwargs):
|
||||
"""Return count of CRC errors received by remote slave.
|
||||
|
||||
:param data: Data field (0x0000)
|
||||
:param kwargs:
|
||||
:return:
|
||||
"""
|
||||
request = ReturnBusCommunicationErrorCountRequest(data, **kwargs)
|
||||
return self._execute_diagnostic_request(request)
|
||||
|
||||
def return_bus_exception_error_count(self, data=0, **kwargs):
|
||||
"""Return count of Modbus exceptions returned by remote slave.
|
||||
|
||||
:param data: Data field (0x0000)
|
||||
:param kwargs:
|
||||
:return:
|
||||
"""
|
||||
request = ReturnBusExceptionErrorCountRequest(data, **kwargs)
|
||||
return self._execute_diagnostic_request(request)
|
||||
|
||||
def return_slave_message_count(self, data=0, **kwargs):
|
||||
"""Return count of messages addressed to remote slave.
|
||||
|
||||
:param data: Data field (0x0000)
|
||||
:param kwargs:
|
||||
:return:
|
||||
"""
|
||||
request = ReturnSlaveMessageCountRequest(data, **kwargs)
|
||||
return self._execute_diagnostic_request(request)
|
||||
|
||||
def return_slave_no_response_count(self, data=0, **kwargs):
|
||||
"""Return count of No responses by remote slave.
|
||||
|
||||
:param data: Data field (0x0000)
|
||||
:param kwargs:
|
||||
:return:
|
||||
"""
|
||||
request = ReturnSlaveNoResponseCountRequest(data, **kwargs)
|
||||
return self._execute_diagnostic_request(request)
|
||||
|
||||
def return_slave_no_ack_count(self, data=0, **kwargs):
|
||||
"""Return count of NO ACK exceptions sent by remote slave.
|
||||
|
||||
:param data: Data field (0x0000)
|
||||
:param kwargs:
|
||||
:return:
|
||||
"""
|
||||
request = ReturnSlaveNAKCountRequest(data, **kwargs)
|
||||
return self._execute_diagnostic_request(request)
|
||||
|
||||
def return_slave_busy_count(self, data=0, **kwargs):
|
||||
"""Return count of server busy exceptions sent by remote slave.
|
||||
|
||||
:param data: Data field (0x0000)
|
||||
:param kwargs:
|
||||
:return:
|
||||
"""
|
||||
request = ReturnSlaveBusyCountRequest(data, **kwargs)
|
||||
return self._execute_diagnostic_request(request)
|
||||
|
||||
def return_slave_bus_char_overrun_count(self, data=0, **kwargs):
|
||||
"""Return count of messages not handled.
|
||||
|
||||
By remote slave due to character overrun condition.
|
||||
|
||||
:param data: Data field (0x0000)
|
||||
:param kwargs:
|
||||
:return:
|
||||
"""
|
||||
request = ReturnSlaveBusCharacterOverrunCountRequest(data, **kwargs)
|
||||
return self._execute_diagnostic_request(request)
|
||||
|
||||
def return_iop_overrun_count(self, data=0, **kwargs):
|
||||
"""Return count of iop overrun errors by remote slave.
|
||||
|
||||
:param data: Data field (0x0000)
|
||||
:param kwargs:
|
||||
:return:
|
||||
"""
|
||||
request = ReturnIopOverrunCountRequest(data, **kwargs)
|
||||
return self._execute_diagnostic_request(request)
|
||||
|
||||
def clear_overrun_count(self, data=0, **kwargs):
|
||||
"""Clear over run counter.
|
||||
|
||||
:param data: Data field (0x0000)
|
||||
:param kwargs:
|
||||
:return:
|
||||
"""
|
||||
request = ClearOverrunCountRequest(data, **kwargs)
|
||||
return self._execute_diagnostic_request(request)
|
||||
|
||||
def get_clear_modbus_plus(self, data=0, **kwargs):
|
||||
"""Get/clear stats of remote modbus plus device.
|
||||
|
||||
:param data: Data field (0x0000)
|
||||
:param kwargs:
|
||||
:return:
|
||||
"""
|
||||
request = GetClearModbusPlusRequest(data, **kwargs)
|
||||
return self._execute_diagnostic_request(request)
|
||||
|
||||
|
||||
class ModbusSerialClient(ExtendedRequestSupport, _ModbusSerialClient):
|
||||
"""Modbus serial client."""
|
||||
|
||||
def __init__(self, framer, **kwargs):
|
||||
"""Initialize."""
|
||||
super().__init__(framer=framer, **kwargs)
|
||||
|
||||
def get_port(self):
|
||||
"""Get serial Port.
|
||||
|
||||
:return: Current Serial port
|
||||
"""
|
||||
return self.comm_params.port
|
||||
|
||||
def set_port(self, value):
|
||||
"""Set serial Port setter.
|
||||
|
||||
:param value: New port
|
||||
"""
|
||||
self.comm_params.port = value
|
||||
if self.is_socket_open():
|
||||
self.close()
|
||||
|
||||
def get_stopbits(self):
|
||||
"""Get number of stop bits.
|
||||
|
||||
:return: Current Stop bits
|
||||
"""
|
||||
return self.params.stopbits
|
||||
|
||||
def set_stopbits(self, value):
|
||||
"""Set stop bit.
|
||||
|
||||
:param value: Possible values (1, 1.5, 2)
|
||||
"""
|
||||
self.params.stopbits = float(value)
|
||||
if self.is_socket_open():
|
||||
self.close()
|
||||
|
||||
def get_bytesize(self):
|
||||
"""Get number of data bits.
|
||||
|
||||
:return: Current bytesize
|
||||
"""
|
||||
return self.comm_params.bytesize
|
||||
|
||||
def set_bytesize(self, value):
|
||||
"""Set Byte size.
|
||||
|
||||
:param value: Possible values (5, 6, 7, 8)
|
||||
|
||||
"""
|
||||
self.comm_params.bytesize = int(value)
|
||||
if self.is_socket_open():
|
||||
self.close()
|
||||
|
||||
def get_parity(self):
|
||||
"""Enable Parity Checking.
|
||||
|
||||
:return: Current parity setting
|
||||
"""
|
||||
return self.params.parity
|
||||
|
||||
def set_parity(self, value):
|
||||
"""Set parity Setter.
|
||||
|
||||
:param value: Possible values ("N", "E", "O", "M", "S")
|
||||
"""
|
||||
self.params.parity = value
|
||||
if self.is_socket_open():
|
||||
self.close()
|
||||
|
||||
def get_baudrate(self):
|
||||
"""Get serial Port baudrate.
|
||||
|
||||
:return: Current baudrate
|
||||
"""
|
||||
return self.comm_params.baudrate
|
||||
|
||||
def set_baudrate(self, value):
|
||||
"""Set baudrate setter.
|
||||
|
||||
:param value: <supported baudrate>
|
||||
"""
|
||||
self.comm_params.baudrate = int(value)
|
||||
if self.is_socket_open():
|
||||
self.close()
|
||||
|
||||
def get_timeout(self):
|
||||
"""Get serial Port Read timeout.
|
||||
|
||||
:return: Current read imeout.
|
||||
"""
|
||||
return self.comm_params.timeout_connect
|
||||
|
||||
def set_timeout(self, value):
|
||||
"""Read timeout setter.
|
||||
|
||||
:param value: Read Timeout in seconds
|
||||
"""
|
||||
self.comm_params.timeout_connect = float(value)
|
||||
if self.is_socket_open():
|
||||
self.close()
|
||||
|
||||
def get_serial_settings(self):
|
||||
"""Get Current Serial port settings.
|
||||
|
||||
:return: Current Serial settings as dict.
|
||||
"""
|
||||
return {
|
||||
"baudrate": self.comm_params.baudrate,
|
||||
"port": self.comm_params.port,
|
||||
"parity": self.comm_params.parity,
|
||||
"stopbits": self.comm_params.stopbits,
|
||||
"bytesize": self.comm_params.bytesize,
|
||||
"read timeout": self.comm_params.timeout_connect,
|
||||
"t1.5": self.inter_char_timeout,
|
||||
"t3.5": self.silent_interval,
|
||||
}
|
||||
|
||||
|
||||
class ModbusTcpClient(ExtendedRequestSupport, _ModbusTcpClient):
|
||||
"""TCP client."""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
"""Initialize."""
|
||||
super().__init__(**kwargs)
|
||||
@@ -0,0 +1 @@
|
||||
"""Repl server."""
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,211 @@
|
||||
"""Repl server cli."""
|
||||
import shutil
|
||||
|
||||
import click
|
||||
from prompt_toolkit import PromptSession, print_formatted_text
|
||||
from prompt_toolkit.completion import NestedCompleter
|
||||
from prompt_toolkit.formatted_text import HTML
|
||||
from prompt_toolkit.shortcuts import clear
|
||||
from prompt_toolkit.shortcuts.progress_bar import formatters
|
||||
from prompt_toolkit.styles import Style
|
||||
|
||||
|
||||
TITLE = r"""
|
||||
__________ .______. _________
|
||||
\______ \___.__. _____ ____ __| _/\_ |__ __ __ ______ / _____/ ______________ __ ___________
|
||||
| ___< | |/ \ / _ \ / __ | | __ \| | \/ ___/ \_____ \_/ __ \_ __ \ \/ // __ \_ __ \\
|
||||
| | \___ | Y Y ( <_> ) /_/ | | \_\ \ | /\___ \ / \ ___/| | \/\ /\ ___/| | \/
|
||||
|____| / ____|__|_| /\____/\____ | |___ /____//____ > /_______ /\___ >__| \_/ \___ >__|
|
||||
\/ \/ \/ \/ \/ \/ \/ \/
|
||||
"""
|
||||
|
||||
|
||||
SMALL_TITLE = "Pymodbus server..."
|
||||
BOTTOM_TOOLBAR = HTML(
|
||||
'(MODBUS SERVER) <b><style bg="ansired">Press Ctrl+C or '
|
||||
'type "exit" to quit</style></b> Type "help" '
|
||||
"for list of available commands"
|
||||
)
|
||||
COMMAND_ARGS = ["response_type", "error_code", "delay_by", "clear_after", "data_len"]
|
||||
RESPONSE_TYPES = ["normal", "error", "delayed", "empty", "stray"]
|
||||
COMMANDS = {
|
||||
"manipulator": {
|
||||
"response_type": None,
|
||||
"error_code": None,
|
||||
"delay_by": None,
|
||||
"clear_after": None,
|
||||
},
|
||||
"exit": None,
|
||||
"help": None,
|
||||
"clear": None,
|
||||
}
|
||||
USAGE = (
|
||||
"manipulator response_type=|normal|error|delayed|empty|stray \n"
|
||||
"\tAdditional parameters\n"
|
||||
"\t\terror_code=<int> \n\t\tdelay_by=<in seconds> \n\t\t"
|
||||
"clear_after=<clear after n messages int>"
|
||||
"\n\t\tdata_len=<length of stray data (int)>\n"
|
||||
"\n\tExample usage: \n\t"
|
||||
"1. Send error response 3 for 4 requests\n\t"
|
||||
" <ansiblue>manipulator response_type=error error_code=3 clear_after=4</ansiblue>\n\t"
|
||||
"2. Delay outgoing response by 5 seconds indefinitely\n\t"
|
||||
" <ansiblue>manipulator response_type=delayed delay_by=5</ansiblue>\n\t"
|
||||
"3. Send empty response\n\t"
|
||||
" <ansiblue>manipulator response_type=empty</ansiblue>\n\t"
|
||||
"4. Send stray response of length 12 and revert to normal after 2 responses\n\t"
|
||||
" <ansiblue>manipulator response_type=stray data_len=11 clear_after=2</ansiblue>\n\t"
|
||||
"5. To disable response manipulation\n\t"
|
||||
" <ansiblue>manipulator response_type=normal</ansiblue>"
|
||||
)
|
||||
COMMAND_HELPS = {
|
||||
"manipulator": f"Manipulate response from server.\nUsage: {USAGE}",
|
||||
"clear": "Clears screen",
|
||||
}
|
||||
|
||||
|
||||
STYLE = Style.from_dict({"": "cyan"})
|
||||
CUSTOM_FORMATTERS = [
|
||||
formatters.Label(suffix=": "),
|
||||
formatters.Bar(start="|", end="|", sym_a="#", sym_b="#", sym_c="-"),
|
||||
formatters.Text(" "),
|
||||
formatters.Text(" "),
|
||||
formatters.TimeElapsed(),
|
||||
formatters.Text(" "),
|
||||
]
|
||||
|
||||
|
||||
def info(message):
|
||||
"""Show info."""
|
||||
if not isinstance(message, str):
|
||||
message = str(message)
|
||||
click.secho(message, fg="green")
|
||||
|
||||
|
||||
def warning(message):
|
||||
"""Show warning."""
|
||||
click.secho(str(message), fg="yellow")
|
||||
|
||||
|
||||
def error(message):
|
||||
"""Show error."""
|
||||
click.secho(str(message), fg="red")
|
||||
|
||||
|
||||
def get_terminal_width():
|
||||
"""Get terminal width."""
|
||||
return shutil.get_terminal_size()[0]
|
||||
|
||||
|
||||
def print_help():
|
||||
"""Print help."""
|
||||
print_formatted_text(HTML("<u>Available commands:</u>"))
|
||||
for cmd, hlp in sorted(COMMAND_HELPS.items()):
|
||||
print_formatted_text(
|
||||
HTML(f"<skyblue>{cmd:45s}</skyblue><seagreen>{hlp:100s}</seagreen>")
|
||||
)
|
||||
|
||||
|
||||
def print_title():
|
||||
"""Print title - large if there are sufficient columns, otherwise small."""
|
||||
col = get_terminal_width()
|
||||
max_len = max( # pylint: disable=consider-using-generator
|
||||
[len(t) for t in TITLE.split("\n")]
|
||||
)
|
||||
if col > max_len:
|
||||
info(TITLE)
|
||||
else:
|
||||
print_formatted_text(
|
||||
HTML(f'<u><b><style color="green">{SMALL_TITLE}</style></b></u>')
|
||||
)
|
||||
|
||||
|
||||
async def interactive_shell(server):
|
||||
"""Run CLI interactive shell."""
|
||||
print_title()
|
||||
info("")
|
||||
completer = NestedCompleter.from_nested_dict(COMMANDS)
|
||||
session = PromptSession(
|
||||
"SERVER > ", completer=completer, bottom_toolbar=BOTTOM_TOOLBAR
|
||||
)
|
||||
|
||||
# Run echo loop. Read text from stdin, and reply it back.
|
||||
while True:
|
||||
try:
|
||||
result = await session.prompt_async()
|
||||
if result == "exit":
|
||||
await server.web_app.shutdown()
|
||||
break
|
||||
if result == "help":
|
||||
print_help()
|
||||
continue
|
||||
if result == "clear":
|
||||
clear()
|
||||
continue
|
||||
if command := result.split():
|
||||
if command[0] not in COMMANDS:
|
||||
warning(f"Invalid command or invalid usage of command - {command}")
|
||||
continue
|
||||
if len(command) == 1:
|
||||
warning(f'Usage: "{USAGE}"')
|
||||
else:
|
||||
val_dict = _process_args(command[1:])
|
||||
if val_dict: # pylint: disable=consider-using-assignment-expr
|
||||
server.update_manipulator_config(val_dict)
|
||||
# server.manipulator_config = val_dict
|
||||
# result = await run_command(tester, *command)
|
||||
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
return
|
||||
|
||||
|
||||
def _process_args(args) -> dict:
|
||||
"""Process arguments passed to CLI."""
|
||||
skip_next = False
|
||||
val_dict = {}
|
||||
for index, arg in enumerate(args):
|
||||
if skip_next:
|
||||
skip_next = False
|
||||
continue
|
||||
if "=" in arg:
|
||||
arg, value = arg.split("=")
|
||||
elif arg in COMMAND_ARGS:
|
||||
try:
|
||||
value = args[index + 1]
|
||||
skip_next = True
|
||||
except IndexError:
|
||||
error(f"Missing value for argument - {arg}")
|
||||
warning('Usage: "{USAGE}"')
|
||||
break
|
||||
if arg == "response_type":
|
||||
if value not in RESPONSE_TYPES:
|
||||
warning(f"Invalid response type request - {value}")
|
||||
warning(f"Choose from {RESPONSE_TYPES}")
|
||||
continue
|
||||
elif arg in { # pylint: disable=confusing-consecutive-elif
|
||||
"error_code",
|
||||
"delay_by",
|
||||
"clear_after",
|
||||
"data_len",
|
||||
}:
|
||||
try:
|
||||
value = int(value)
|
||||
except ValueError:
|
||||
warning(f"Expected integer value for {arg}")
|
||||
continue
|
||||
val_dict[arg] = value
|
||||
return val_dict
|
||||
|
||||
|
||||
async def main(server):
|
||||
"""Run main."""
|
||||
# with patch_stdout():
|
||||
try:
|
||||
await interactive_shell(server)
|
||||
finally:
|
||||
pass
|
||||
warning("Bye Bye!!!")
|
||||
|
||||
|
||||
async def run_repl(server):
|
||||
"""Run repl server."""
|
||||
await main(server)
|
||||
@@ -0,0 +1,209 @@
|
||||
"""Repl server main."""
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
import typer
|
||||
|
||||
from pymodbus import pymodbus_apply_logging_config
|
||||
from pymodbus.framer.socket_framer import ModbusSocketFramer
|
||||
from pymodbus.logging import Log
|
||||
from pymodbus.repl.server.cli import run_repl
|
||||
from pymodbus.server.reactive.default_config import DEFAULT_CONFIG
|
||||
from pymodbus.server.reactive.main import (
|
||||
DEFAULT_FRAMER,
|
||||
ReactiveServer,
|
||||
)
|
||||
|
||||
|
||||
CANCELLED_ERROR = asyncio.exceptions.CancelledError
|
||||
CONTEXT_SETTING = {"allow_extra_args": True, "ignore_unknown_options": True}
|
||||
|
||||
|
||||
# TBD class ModbusServerConfig:
|
||||
|
||||
|
||||
class ModbusServerTypes(str, Enum):
|
||||
"""Server types."""
|
||||
|
||||
# ["tcp", "serial", "tls", "udp"]
|
||||
tcp = "tcp" # pylint: disable=invalid-name
|
||||
serial = "serial" # pylint: disable=invalid-name
|
||||
tls = "tls" # pylint: disable=invalid-name
|
||||
udp = "udp" # pylint: disable=invalid-name
|
||||
|
||||
|
||||
class ModbusFramerTypes(str, Enum):
|
||||
"""Framer types."""
|
||||
|
||||
# ["socket", "rtu", "tls", "ascii", "binary"]
|
||||
socket = "socket" # pylint: disable=invalid-name
|
||||
rtu = "rtu" # pylint: disable=invalid-name
|
||||
tls = "tls" # pylint: disable=invalid-name
|
||||
ascii = "ascii" # pylint: disable=invalid-name
|
||||
binary = "binary" # pylint: disable=invalid-name
|
||||
|
||||
|
||||
def _completer(incomplete: str, valid_values: List[str]) -> List[str]:
|
||||
"""Complete value."""
|
||||
completion = []
|
||||
for name in valid_values:
|
||||
if name.startswith(incomplete):
|
||||
completion.append(name)
|
||||
return completion
|
||||
|
||||
|
||||
def framers(incomplete: str) -> List[str]:
|
||||
"""Return an autocompleted list of supported clouds."""
|
||||
_framers = ["socket", "rtu", "tls", "ascii", "binary"]
|
||||
return _completer(incomplete, _framers)
|
||||
|
||||
|
||||
def servers(incomplete: str) -> List[str]:
|
||||
"""Return an autocompleted list of supported clouds."""
|
||||
_servers = ["tcp", "serial", "tls", "udp"]
|
||||
return _completer(incomplete, _servers)
|
||||
|
||||
|
||||
def process_extra_args(extra_args: List[str], modbus_config: dict) -> dict:
|
||||
"""Process extra args passed to server."""
|
||||
options_stripped = [x.strip().replace("--", "") for x in extra_args[::2]]
|
||||
extra_args_dict = dict(list(zip(options_stripped, extra_args[1::2])))
|
||||
for option, value in extra_args_dict.items():
|
||||
if option in modbus_config:
|
||||
try:
|
||||
modbus_config[option] = type(modbus_config[option])(value)
|
||||
except ValueError as err:
|
||||
Log.error(
|
||||
"Error parsing extra arg {} with value '{}'. {}", option, value, err
|
||||
)
|
||||
sys.exit(1)
|
||||
return modbus_config
|
||||
|
||||
|
||||
app = typer.Typer(
|
||||
no_args_is_help=True,
|
||||
context_settings=CONTEXT_SETTING,
|
||||
help="Reactive Modbus server",
|
||||
)
|
||||
|
||||
|
||||
@app.callback()
|
||||
def server(
|
||||
ctx: typer.Context,
|
||||
host: str = typer.Option("localhost", "--host", help="Host address"),
|
||||
web_port: int = typer.Option(8080, "--web-port", help="Web app port"),
|
||||
broadcast_support: bool = typer.Option(
|
||||
False, "-b", help="Support broadcast messages"
|
||||
),
|
||||
repl: bool = typer.Option(True, help="Enable/Disable repl for server"),
|
||||
verbose: bool = typer.Option(
|
||||
False, help="Run with debug logs enabled for pymodbus"
|
||||
),
|
||||
):
|
||||
"""Run server code."""
|
||||
log_level = logging.DEBUG if verbose else logging.ERROR
|
||||
pymodbus_apply_logging_config(log_level)
|
||||
|
||||
ctx.obj = {
|
||||
"repl": repl,
|
||||
"host": host,
|
||||
"web_port": web_port,
|
||||
"broadcast_enable": broadcast_support,
|
||||
}
|
||||
|
||||
|
||||
@app.command("run", context_settings=CONTEXT_SETTING)
|
||||
def run(
|
||||
ctx: typer.Context,
|
||||
modbus_server: str = typer.Option(
|
||||
ModbusServerTypes.tcp.value,
|
||||
"--modbus-server",
|
||||
"-s",
|
||||
case_sensitive=False,
|
||||
autocompletion=servers,
|
||||
help="Modbus Server",
|
||||
),
|
||||
modbus_framer: str = typer.Option(
|
||||
ModbusFramerTypes.socket.value,
|
||||
"--framer",
|
||||
"-f",
|
||||
case_sensitive=False,
|
||||
autocompletion=framers,
|
||||
help="Modbus framer to use",
|
||||
),
|
||||
modbus_port: str = typer.Option("5020", "--modbus-port", "-p", help="Modbus port"),
|
||||
modbus_slave_id: List[int] = typer.Option(
|
||||
[1], "--slave-id", "-u", help="Supported Modbus slave id's"
|
||||
),
|
||||
modbus_config_path: Path = typer.Option(
|
||||
None, help="Path to additional modbus server config"
|
||||
),
|
||||
randomize: int = typer.Option(
|
||||
0,
|
||||
"--random",
|
||||
"-r",
|
||||
help="Randomize every `r` reads. 0=never, 1=always,2=every-second-read"
|
||||
", and so on. Applicable IR and DI.",
|
||||
),
|
||||
change_rate: int = typer.Option(
|
||||
0,
|
||||
"--change-rate",
|
||||
"-c",
|
||||
help="Rate in % registers to change. 0=none, 100=all, 12=12% of registers"
|
||||
", and so on. Applicable IR and DI.",
|
||||
),
|
||||
):
|
||||
"""Run Reactive Modbus server.
|
||||
|
||||
Exposing REST endpoint for response manipulation.
|
||||
"""
|
||||
repl = ctx.obj.pop("repl")
|
||||
# TBD extra_args = ctx.args
|
||||
web_app_config = ctx.obj
|
||||
loop = asyncio.get_event_loop()
|
||||
framer = DEFAULT_FRAMER.get(modbus_framer, ModbusSocketFramer)
|
||||
if modbus_config_path:
|
||||
with open(modbus_config_path, encoding="utf-8") as my_file:
|
||||
modbus_config = json.load(my_file)
|
||||
else:
|
||||
modbus_config = DEFAULT_CONFIG
|
||||
|
||||
extra_args = ctx.args
|
||||
data_block_settings = modbus_config.pop("data_block_settings", {})
|
||||
modbus_config = modbus_config.get(modbus_server, {})
|
||||
modbus_config = process_extra_args(extra_args, modbus_config)
|
||||
|
||||
modbus_config["randomize"] = randomize
|
||||
modbus_config["change_rate"] = change_rate
|
||||
|
||||
async def _wrapper():
|
||||
app = ReactiveServer.factory(
|
||||
modbus_server,
|
||||
framer,
|
||||
modbus_port=modbus_port,
|
||||
slave=modbus_slave_id,
|
||||
single=False,
|
||||
data_block_settings=data_block_settings,
|
||||
**web_app_config,
|
||||
**modbus_config,
|
||||
)
|
||||
await app.run_async(repl)
|
||||
return app
|
||||
|
||||
app = loop.run_until_complete(_wrapper())
|
||||
if repl:
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
loop.run_until_complete(run_repl(app))
|
||||
|
||||
else:
|
||||
loop.run_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Server.
|
||||
|
||||
import external classes, to make them easier to use:
|
||||
"""
|
||||
|
||||
__all__ = [
|
||||
"get_simulator_commandline",
|
||||
"ModbusSerialServer",
|
||||
"ModbusSimulatorServer",
|
||||
"ModbusTcpServer",
|
||||
"ModbusTlsServer",
|
||||
"ModbusUdpServer",
|
||||
"ServerAsyncStop",
|
||||
"ServerStop",
|
||||
"StartAsyncSerialServer",
|
||||
"StartAsyncTcpServer",
|
||||
"StartAsyncTlsServer",
|
||||
"StartAsyncUdpServer",
|
||||
"StartSerialServer",
|
||||
"StartTcpServer",
|
||||
"StartTlsServer",
|
||||
"StartUdpServer",
|
||||
]
|
||||
|
||||
from pymodbus.server.async_io import (
|
||||
ModbusSerialServer,
|
||||
ModbusTcpServer,
|
||||
ModbusTlsServer,
|
||||
ModbusUdpServer,
|
||||
ServerAsyncStop,
|
||||
ServerStop,
|
||||
StartAsyncSerialServer,
|
||||
StartAsyncTcpServer,
|
||||
StartAsyncTlsServer,
|
||||
StartAsyncUdpServer,
|
||||
StartSerialServer,
|
||||
StartTcpServer,
|
||||
StartTlsServer,
|
||||
StartUdpServer,
|
||||
)
|
||||
from pymodbus.server.simulator.http_server import ModbusSimulatorServer
|
||||
from pymodbus.server.simulator.main import get_commandline as get_simulator_commandline
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,742 @@
|
||||
"""Implementation of a Threaded Modbus Server."""
|
||||
# pylint: disable=missing-type-doc
|
||||
import asyncio
|
||||
import os
|
||||
import time
|
||||
import traceback
|
||||
from contextlib import suppress
|
||||
from typing import Union
|
||||
|
||||
from pymodbus.datastore import ModbusServerContext
|
||||
from pymodbus.device import ModbusControlBlock, ModbusDeviceIdentification
|
||||
from pymodbus.exceptions import NoSuchSlaveException
|
||||
from pymodbus.factory import ServerDecoder
|
||||
from pymodbus.framer import ModbusFramer
|
||||
from pymodbus.logging import Log
|
||||
from pymodbus.pdu import ModbusExceptions as merror
|
||||
from pymodbus.transaction import (
|
||||
ModbusAsciiFramer,
|
||||
ModbusRtuFramer,
|
||||
ModbusSocketFramer,
|
||||
ModbusTlsFramer,
|
||||
)
|
||||
from pymodbus.transport import CommParams, CommType, ModbusProtocol
|
||||
|
||||
|
||||
with suppress(ImportError):
|
||||
pass
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Protocol Handlers
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class ModbusServerRequestHandler(ModbusProtocol):
|
||||
"""Implements modbus slave wire protocol.
|
||||
|
||||
This uses the asyncio.Protocol to implement the server protocol.
|
||||
|
||||
When a connection is established, a callback is called.
|
||||
This callback will setup the connection and
|
||||
create and schedule an asyncio.Task and assign it to running_task.
|
||||
"""
|
||||
|
||||
def __init__(self, owner):
|
||||
"""Initialize."""
|
||||
params = CommParams(
|
||||
comm_name="server",
|
||||
comm_type=owner.comm_params.comm_type,
|
||||
reconnect_delay=0.0,
|
||||
reconnect_delay_max=0.0,
|
||||
timeout_connect=0.0,
|
||||
host=owner.comm_params.source_address[0],
|
||||
port=owner.comm_params.source_address[1],
|
||||
)
|
||||
super().__init__(params, False)
|
||||
self.server = owner
|
||||
self.running = False
|
||||
self.receive_queue = asyncio.Queue()
|
||||
self.handler_task = None # coroutine to be run on asyncio loop
|
||||
self.framer: ModbusFramer = None
|
||||
|
||||
def _log_exception(self):
|
||||
"""Show log exception."""
|
||||
Log.debug(
|
||||
"Handler for stream [{}] has been canceled", self.comm_params.comm_name
|
||||
)
|
||||
|
||||
def callback_connected(self) -> None:
|
||||
"""Call when connection is succcesfull."""
|
||||
try:
|
||||
self.running = True
|
||||
self.framer = self.server.framer(
|
||||
self.server.decoder,
|
||||
client=None,
|
||||
)
|
||||
|
||||
# schedule the connection handler on the event loop
|
||||
self.handler_task = asyncio.create_task(self.handle())
|
||||
except Exception as exc: # pragma: no cover pylint: disable=broad-except
|
||||
Log.error(
|
||||
"Server callback_connected exception: {}; {}",
|
||||
exc,
|
||||
traceback.format_exc(),
|
||||
)
|
||||
|
||||
def callback_disconnected(self, call_exc: Exception) -> None:
|
||||
"""Call when connection is lost."""
|
||||
try:
|
||||
if self.handler_task:
|
||||
self.handler_task.cancel()
|
||||
if hasattr(self.server, "on_connection_lost"):
|
||||
self.server.on_connection_lost()
|
||||
if call_exc is None:
|
||||
self._log_exception()
|
||||
else:
|
||||
Log.debug(
|
||||
"Client Disconnection {} due to {}",
|
||||
self.comm_params.comm_name,
|
||||
call_exc,
|
||||
)
|
||||
self.running = False
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
Log.error(
|
||||
"Datastore unable to fulfill request: {}; {}",
|
||||
exc,
|
||||
traceback.format_exc(),
|
||||
)
|
||||
|
||||
async def inner_handle(self):
|
||||
"""Handle handler."""
|
||||
slaves = self.server.context.slaves()
|
||||
# this is an asyncio.Queue await, it will never fail
|
||||
data = await self._recv_()
|
||||
if isinstance(data, tuple):
|
||||
# addr is populated when talking over UDP
|
||||
data, *addr = data
|
||||
else:
|
||||
addr = (None,) # empty tuple
|
||||
|
||||
# if broadcast is enabled make sure to
|
||||
# process requests to address 0
|
||||
if self.server.broadcast_enable: # pragma: no cover
|
||||
if 0 not in slaves:
|
||||
slaves.append(0)
|
||||
|
||||
Log.debug("Handling data: {}", data, ":hex")
|
||||
|
||||
single = self.server.context.single
|
||||
self.framer.processIncomingPacket(
|
||||
data=data,
|
||||
callback=lambda x: self.execute(x, *addr),
|
||||
slave=slaves,
|
||||
single=single,
|
||||
)
|
||||
|
||||
async def handle(self):
|
||||
"""Return Asyncio coroutine which represents a single conversation.
|
||||
|
||||
between the modbus slave and master
|
||||
|
||||
Once the client connection is established, the data chunks will be
|
||||
fed to this coroutine via the asyncio.Queue object which is fed by
|
||||
the ModbusServerRequestHandler class's callback Future.
|
||||
|
||||
This callback future gets data from either
|
||||
|
||||
This function will execute without blocking in the while-loop and
|
||||
yield to the asyncio event loop when the frame is exhausted.
|
||||
As a result, multiple clients can be interleaved without any
|
||||
interference between them.
|
||||
"""
|
||||
reset_frame = False
|
||||
while self.running:
|
||||
try:
|
||||
await self.inner_handle()
|
||||
except asyncio.CancelledError:
|
||||
# catch and ignore cancellation errors
|
||||
if self.running:
|
||||
self._log_exception()
|
||||
self.running = False
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
# force TCP socket termination as processIncomingPacket
|
||||
# should handle application layer errors
|
||||
# for UDP sockets, simply reset the frame
|
||||
if isinstance(self, ModbusServerRequestHandler):
|
||||
Log.error(
|
||||
'Unknown exception "{}" on stream {} forcing disconnect',
|
||||
exc,
|
||||
self.comm_params.comm_name,
|
||||
)
|
||||
self.transport_close()
|
||||
self.callback_disconnected(exc)
|
||||
else:
|
||||
Log.error("Unknown error occurred {}", exc)
|
||||
reset_frame = True # graceful recovery
|
||||
finally:
|
||||
if reset_frame:
|
||||
self.framer.resetFrame()
|
||||
reset_frame = False
|
||||
|
||||
def execute(self, request, *addr):
|
||||
"""Call with the resulting message.
|
||||
|
||||
:param request: The decoded request message
|
||||
:param addr: the address
|
||||
"""
|
||||
if self.server.request_tracer:
|
||||
self.server.request_tracer(request, *addr)
|
||||
|
||||
broadcast = False
|
||||
try:
|
||||
if self.server.broadcast_enable and not request.slave_id:
|
||||
broadcast = True
|
||||
# if broadcasting then execute on all slave contexts,
|
||||
# note response will be ignored
|
||||
for slave_id in self.server.context.slaves():
|
||||
response = request.execute(self.server.context[slave_id])
|
||||
else:
|
||||
context = self.server.context[request.slave_id]
|
||||
response = request.execute(context)
|
||||
except NoSuchSlaveException:
|
||||
Log.error("requested slave does not exist: {}", request.slave_id)
|
||||
if self.server.ignore_missing_slaves:
|
||||
return # the client will simply timeout waiting for a response
|
||||
response = request.doException(merror.GatewayNoResponse)
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
Log.error(
|
||||
"Datastore unable to fulfill request: {}; {}",
|
||||
exc,
|
||||
traceback.format_exc(),
|
||||
)
|
||||
response = request.doException(merror.SlaveFailure)
|
||||
# no response when broadcasting
|
||||
if not broadcast:
|
||||
response.transaction_id = request.transaction_id
|
||||
response.slave_id = request.slave_id
|
||||
skip_encoding = False
|
||||
if self.server.response_manipulator:
|
||||
response, skip_encoding = self.server.response_manipulator(response)
|
||||
self.send(response, *addr, skip_encoding=skip_encoding)
|
||||
|
||||
def send(self, message, addr, **kwargs):
|
||||
"""Send message."""
|
||||
if kwargs.get("skip_encoding", False):
|
||||
self.transport_send(message, addr=addr)
|
||||
elif message.should_respond:
|
||||
pdu = self.framer.buildPacket(message)
|
||||
self.transport_send(pdu, addr=addr)
|
||||
else:
|
||||
Log.debug("Skipping sending response!!")
|
||||
|
||||
async def _recv_(self): # pragma: no cover
|
||||
"""Receive data from the network."""
|
||||
try:
|
||||
result = await self.receive_queue.get()
|
||||
except RuntimeError:
|
||||
Log.error("Event loop is closed")
|
||||
result = None
|
||||
return result
|
||||
|
||||
def callback_data(self, data: bytes, addr: tuple = None) -> int:
|
||||
"""Handle received data."""
|
||||
if addr:
|
||||
self.receive_queue.put_nowait((data, addr))
|
||||
else:
|
||||
self.receive_queue.put_nowait(data)
|
||||
return len(data)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Server Implementations
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class ModbusBaseServer(ModbusProtocol):
|
||||
"""Common functionality for all server classes"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
params: CommParams,
|
||||
context,
|
||||
ignore_missing_slaves,
|
||||
broadcast_enable,
|
||||
response_manipulator,
|
||||
request_tracer,
|
||||
identity,
|
||||
framer,
|
||||
) -> None:
|
||||
"""Initialize base server."""
|
||||
super().__init__(
|
||||
params,
|
||||
True,
|
||||
)
|
||||
self.loop = asyncio.get_running_loop()
|
||||
self.decoder = ServerDecoder()
|
||||
self.context = context or ModbusServerContext()
|
||||
self.control = ModbusControlBlock()
|
||||
self.ignore_missing_slaves = ignore_missing_slaves
|
||||
self.broadcast_enable = broadcast_enable
|
||||
self.response_manipulator = response_manipulator
|
||||
self.request_tracer = request_tracer
|
||||
self.handle_local_echo = False
|
||||
if isinstance(identity, ModbusDeviceIdentification):
|
||||
self.control.Identity.update(identity)
|
||||
self.framer = framer
|
||||
self.serving: asyncio.Future = asyncio.Future()
|
||||
|
||||
def callback_new_connection(self):
|
||||
"""Handle incoming connect."""
|
||||
return ModbusServerRequestHandler(self)
|
||||
|
||||
async def shutdown(self):
|
||||
"""Shutdown server."""
|
||||
await self.server_close()
|
||||
|
||||
async def server_close(self):
|
||||
"""Close server."""
|
||||
if not self.serving.done():
|
||||
self.serving.set_result(True)
|
||||
self.transport_close()
|
||||
|
||||
async def serve_forever(self):
|
||||
"""Start endless loop."""
|
||||
if self.transport:
|
||||
raise RuntimeError(
|
||||
"Can't call serve_forever on an already running server object"
|
||||
)
|
||||
await self.transport_listen()
|
||||
Log.info("Server listening.")
|
||||
await self.serving
|
||||
Log.info("Server graceful shutdown.")
|
||||
|
||||
|
||||
class ModbusTcpServer(ModbusBaseServer):
|
||||
"""A modbus threaded tcp socket server.
|
||||
|
||||
We inherit and overload the socket server so that we
|
||||
can control the client threads as well as have a single
|
||||
server context instance.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
context,
|
||||
framer=ModbusSocketFramer,
|
||||
identity=None,
|
||||
address=("", 502),
|
||||
ignore_missing_slaves=False,
|
||||
broadcast_enable=False,
|
||||
response_manipulator=None,
|
||||
request_tracer=None,
|
||||
):
|
||||
"""Initialize the socket server.
|
||||
|
||||
If the identify structure is not passed in, the ModbusControlBlock
|
||||
uses its own empty structure.
|
||||
|
||||
:param context: The ModbusServerContext datastore
|
||||
:param framer: The framer strategy to use
|
||||
:param identity: An optional identify structure
|
||||
:param address: An optional (interface, port) to bind to.
|
||||
:param ignore_missing_slaves: True to not send errors on a request
|
||||
to a missing slave
|
||||
:param broadcast_enable: True to treat slave_id 0 as broadcast address,
|
||||
False to treat 0 as any other slave_id
|
||||
:param response_manipulator: Callback method for manipulating the
|
||||
response
|
||||
:param request_tracer: Callback method for tracing
|
||||
"""
|
||||
params = getattr(
|
||||
self,
|
||||
"tls_setup",
|
||||
CommParams(
|
||||
comm_type=CommType.TCP,
|
||||
comm_name="server_listener",
|
||||
reconnect_delay=0.0,
|
||||
reconnect_delay_max=0.0,
|
||||
timeout_connect=0.0,
|
||||
),
|
||||
)
|
||||
params.source_address = address
|
||||
super().__init__(
|
||||
params,
|
||||
context,
|
||||
ignore_missing_slaves,
|
||||
broadcast_enable,
|
||||
response_manipulator,
|
||||
request_tracer,
|
||||
identity,
|
||||
framer,
|
||||
)
|
||||
|
||||
|
||||
class ModbusTlsServer(ModbusTcpServer):
|
||||
"""A modbus threaded tls socket server.
|
||||
|
||||
We inherit and overload the socket server so that we
|
||||
can control the client threads as well as have a single
|
||||
server context instance.
|
||||
"""
|
||||
|
||||
def __init__( # pylint: disable=too-many-arguments
|
||||
self,
|
||||
context,
|
||||
framer=ModbusTlsFramer,
|
||||
identity=None,
|
||||
address=("", 502),
|
||||
sslctx=None,
|
||||
certfile=None,
|
||||
keyfile=None,
|
||||
password=None,
|
||||
ignore_missing_slaves=False,
|
||||
broadcast_enable=False,
|
||||
response_manipulator=None,
|
||||
request_tracer=None,
|
||||
):
|
||||
"""Overloaded initializer for the socket server.
|
||||
|
||||
If the identify structure is not passed in, the ModbusControlBlock
|
||||
uses its own empty structure.
|
||||
|
||||
:param context: The ModbusServerContext datastore
|
||||
:param framer: The framer strategy to use
|
||||
:param identity: An optional identify structure
|
||||
:param address: An optional (interface, port) to bind to.
|
||||
:param sslctx: The SSLContext to use for TLS (default None and auto
|
||||
create)
|
||||
:param certfile: The cert file path for TLS (used if sslctx is None)
|
||||
:param keyfile: The key file path for TLS (used if sslctx is None)
|
||||
:param password: The password for for decrypting the private key file
|
||||
:param ignore_missing_slaves: True to not send errors on a request
|
||||
to a missing slave
|
||||
:param broadcast_enable: True to treat slave_id 0 as broadcast address,
|
||||
False to treat 0 as any other slave_id
|
||||
:param response_manipulator: Callback method for
|
||||
manipulating the response
|
||||
"""
|
||||
self.tls_setup = CommParams(
|
||||
comm_type=CommType.TLS,
|
||||
comm_name="server_listener",
|
||||
reconnect_delay=0.0,
|
||||
reconnect_delay_max=0.0,
|
||||
timeout_connect=0.0,
|
||||
sslctx=CommParams.generate_ssl(
|
||||
True, certfile, keyfile, password, sslctx=sslctx
|
||||
),
|
||||
)
|
||||
super().__init__(
|
||||
context,
|
||||
framer=framer,
|
||||
identity=identity,
|
||||
address=address,
|
||||
ignore_missing_slaves=ignore_missing_slaves,
|
||||
broadcast_enable=broadcast_enable,
|
||||
response_manipulator=response_manipulator,
|
||||
request_tracer=request_tracer,
|
||||
)
|
||||
|
||||
|
||||
class ModbusUdpServer(ModbusBaseServer):
|
||||
"""A modbus threaded udp socket server.
|
||||
|
||||
We inherit and overload the socket server so that we
|
||||
can control the client threads as well as have a single
|
||||
server context instance.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
context,
|
||||
framer=ModbusSocketFramer,
|
||||
identity=None,
|
||||
address=("", 502),
|
||||
ignore_missing_slaves=False,
|
||||
broadcast_enable=False,
|
||||
response_manipulator=None,
|
||||
request_tracer=None,
|
||||
):
|
||||
"""Overloaded initializer for the socket server.
|
||||
|
||||
If the identify structure is not passed in, the ModbusControlBlock
|
||||
uses its own empty structure.
|
||||
|
||||
:param context: The ModbusServerContext datastore
|
||||
:param framer: The framer strategy to use
|
||||
:param identity: An optional identify structure
|
||||
:param address: An optional (interface, port) to bind to.
|
||||
:param ignore_missing_slaves: True to not send errors on a request
|
||||
to a missing slave
|
||||
:param broadcast_enable: True to treat slave_id 0 as broadcast address,
|
||||
False to treat 0 as any other slave_id
|
||||
:param response_manipulator: Callback method for
|
||||
manipulating the response
|
||||
:param request_tracer: Callback method for tracing
|
||||
"""
|
||||
# ----------------
|
||||
super().__init__(
|
||||
CommParams(
|
||||
comm_type=CommType.UDP,
|
||||
comm_name="server_listener",
|
||||
source_address=address,
|
||||
reconnect_delay=0.0,
|
||||
reconnect_delay_max=0.0,
|
||||
timeout_connect=0.0,
|
||||
),
|
||||
context,
|
||||
ignore_missing_slaves,
|
||||
broadcast_enable,
|
||||
response_manipulator,
|
||||
request_tracer,
|
||||
identity,
|
||||
framer,
|
||||
)
|
||||
|
||||
|
||||
class ModbusSerialServer(ModbusBaseServer):
|
||||
"""A modbus threaded serial socket server.
|
||||
|
||||
We inherit and overload the socket server so that we
|
||||
can control the client threads as well as have a single
|
||||
server context instance.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, context, framer=ModbusRtuFramer, identity=None, **kwargs
|
||||
): # pragma: no cover
|
||||
"""Initialize the socket server.
|
||||
|
||||
If the identity structure is not passed in, the ModbusControlBlock
|
||||
uses its own empty structure.
|
||||
:param context: The ModbusServerContext datastore
|
||||
:param framer: The framer strategy to use, default ModbusRtuFramer
|
||||
:param identity: An optional identify structure
|
||||
:param port: The serial port to attach to
|
||||
:param stopbits: The number of stop bits to use
|
||||
:param bytesize: The bytesize of the serial messages
|
||||
:param parity: Which kind of parity to use
|
||||
:param baudrate: The baud rate to use for the serial device
|
||||
:param timeout: The timeout to use for the serial device
|
||||
:param handle_local_echo: (optional) Discard local echo from dongle.
|
||||
:param ignore_missing_slaves: True to not send errors on a request
|
||||
to a missing slave
|
||||
:param broadcast_enable: True to treat slave_id 0 as broadcast address,
|
||||
False to treat 0 as any other slave_id
|
||||
:param reconnect_delay: reconnect delay in seconds
|
||||
:param response_manipulator: Callback method for
|
||||
manipulating the response
|
||||
:param request_tracer: Callback method for tracing
|
||||
"""
|
||||
super().__init__(
|
||||
CommParams(
|
||||
comm_type=CommType.SERIAL,
|
||||
comm_name="server_listener",
|
||||
reconnect_delay=kwargs.get("reconnect_delay", 2),
|
||||
reconnect_delay_max=0.0,
|
||||
timeout_connect=kwargs.get("timeout", 3),
|
||||
source_address=(kwargs.get("port", 0), 0),
|
||||
bytesize=kwargs.get("bytesize", 8),
|
||||
parity=kwargs.get("parity", "N"),
|
||||
baudrate=kwargs.get("baudrate", 19200),
|
||||
stopbits=kwargs.get("stopbits", 1),
|
||||
),
|
||||
context,
|
||||
kwargs.get("ignore_missing_slaves", False),
|
||||
kwargs.get("broadcast_enable", False),
|
||||
kwargs.get("request_tracer", None),
|
||||
kwargs.get("response_manipulator", None),
|
||||
kwargs.get("identity", None),
|
||||
framer,
|
||||
)
|
||||
self.handle_local_echo = kwargs.get("handle_local_echo", False)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Creation Factories
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class _serverList:
|
||||
"""Maintains information about the active server.
|
||||
|
||||
:meta private:
|
||||
"""
|
||||
|
||||
active_server: Union[ModbusTcpServer, ModbusUdpServer, ModbusSerialServer] = None
|
||||
|
||||
def __init__(self, server):
|
||||
"""Register new server."""
|
||||
self.server = server
|
||||
self.loop = asyncio.get_event_loop()
|
||||
|
||||
@classmethod
|
||||
async def run(cls, server, custom_functions):
|
||||
"""Help starting/stopping server."""
|
||||
for func in custom_functions:
|
||||
server.decoder.register(func)
|
||||
cls.active_server = _serverList(server)
|
||||
with suppress(asyncio.exceptions.CancelledError):
|
||||
await server.serve_forever()
|
||||
|
||||
@classmethod
|
||||
async def async_stop(cls):
|
||||
"""Wait for server stop."""
|
||||
if not cls.active_server:
|
||||
raise RuntimeError("ServerAsyncStop called without server task active.")
|
||||
await cls.active_server.server.shutdown()
|
||||
if os.name == "nt":
|
||||
await asyncio.sleep(1)
|
||||
else:
|
||||
await asyncio.sleep(0.1)
|
||||
cls.active_server = None
|
||||
|
||||
@classmethod
|
||||
def stop(cls):
|
||||
"""Wait for server stop."""
|
||||
if not cls.active_server:
|
||||
Log.info("ServerStop called without server task active.")
|
||||
return
|
||||
if not cls.active_server.loop.is_running():
|
||||
Log.info("ServerStop called with loop stopped.")
|
||||
return
|
||||
asyncio.run_coroutine_threadsafe(cls.async_stop(), cls.active_server.loop)
|
||||
if os.name == "nt":
|
||||
time.sleep(10)
|
||||
else:
|
||||
time.sleep(0.5)
|
||||
|
||||
|
||||
async def StartAsyncTcpServer( # pylint: disable=invalid-name,dangerous-default-value
|
||||
context=None,
|
||||
identity=None,
|
||||
address=None,
|
||||
custom_functions=[],
|
||||
**kwargs,
|
||||
):
|
||||
"""Start and run a tcp modbus server.
|
||||
|
||||
:param context: The ModbusServerContext datastore
|
||||
:param identity: An optional identify structure
|
||||
:param address: An optional (interface, port) to bind to.
|
||||
:param custom_functions: An optional list of custom function classes
|
||||
supported by server instance.
|
||||
:param kwargs: The rest
|
||||
"""
|
||||
kwargs.pop("host", None)
|
||||
server = ModbusTcpServer(
|
||||
context, kwargs.pop("framer", ModbusSocketFramer), identity, address, **kwargs
|
||||
)
|
||||
await _serverList.run(server, custom_functions)
|
||||
|
||||
|
||||
async def StartAsyncTlsServer( # pylint: disable=invalid-name,dangerous-default-value
|
||||
context=None,
|
||||
identity=None,
|
||||
address=None,
|
||||
sslctx=None,
|
||||
certfile=None,
|
||||
keyfile=None,
|
||||
password=None,
|
||||
custom_functions=[],
|
||||
**kwargs,
|
||||
):
|
||||
"""Start and run a tls modbus server.
|
||||
|
||||
:param context: The ModbusServerContext datastore
|
||||
:param identity: An optional identify structure
|
||||
:param address: An optional (interface, port) to bind to.
|
||||
:param sslctx: The SSLContext to use for TLS (default None and auto create)
|
||||
:param certfile: The cert file path for TLS (used if sslctx is None)
|
||||
:param keyfile: The key file path for TLS (used if sslctx is None)
|
||||
:param password: The password for for decrypting the private key file
|
||||
:param custom_functions: An optional list of custom function classes
|
||||
supported by server instance.
|
||||
:param kwargs: The rest
|
||||
"""
|
||||
kwargs.pop("host", None)
|
||||
server = ModbusTlsServer(
|
||||
context,
|
||||
kwargs.pop("framer", ModbusTlsFramer),
|
||||
identity,
|
||||
address,
|
||||
sslctx,
|
||||
certfile,
|
||||
keyfile,
|
||||
password,
|
||||
**kwargs,
|
||||
)
|
||||
await _serverList.run(server, custom_functions)
|
||||
|
||||
|
||||
async def StartAsyncUdpServer( # pylint: disable=invalid-name,dangerous-default-value
|
||||
context=None,
|
||||
identity=None,
|
||||
address=None,
|
||||
custom_functions=[],
|
||||
**kwargs,
|
||||
):
|
||||
"""Start and run a udp modbus server.
|
||||
|
||||
:param context: The ModbusServerContext datastore
|
||||
:param identity: An optional identify structure
|
||||
:param address: An optional (interface, port) to bind to.
|
||||
:param custom_functions: An optional list of custom function classes
|
||||
supported by server instance.
|
||||
:param kwargs:
|
||||
"""
|
||||
kwargs.pop("host", None)
|
||||
server = ModbusUdpServer(
|
||||
context, kwargs.pop("framer", ModbusSocketFramer), identity, address, **kwargs
|
||||
)
|
||||
await _serverList.run(server, custom_functions)
|
||||
|
||||
|
||||
async def StartAsyncSerialServer( # pylint: disable=invalid-name,dangerous-default-value
|
||||
context=None,
|
||||
identity=None,
|
||||
custom_functions=[],
|
||||
**kwargs,
|
||||
): # pragma: no cover
|
||||
"""Start and run a serial modbus server.
|
||||
|
||||
:param context: The ModbusServerContext datastore
|
||||
:param identity: An optional identify structure
|
||||
:param custom_functions: An optional list of custom function classes
|
||||
supported by server instance.
|
||||
:param kwargs: The rest
|
||||
"""
|
||||
server = ModbusSerialServer(
|
||||
context, kwargs.pop("framer", ModbusAsciiFramer), identity=identity, **kwargs
|
||||
)
|
||||
await _serverList.run(server, custom_functions)
|
||||
|
||||
|
||||
def StartSerialServer(**kwargs): # pylint: disable=invalid-name
|
||||
"""Start and run a serial modbus server."""
|
||||
return asyncio.run(StartAsyncSerialServer(**kwargs))
|
||||
|
||||
|
||||
def StartTcpServer(**kwargs): # pylint: disable=invalid-name
|
||||
"""Start and run a serial modbus server."""
|
||||
return asyncio.run(StartAsyncTcpServer(**kwargs))
|
||||
|
||||
|
||||
def StartTlsServer(**kwargs): # pylint: disable=invalid-name
|
||||
"""Start and run a serial modbus server."""
|
||||
return asyncio.run(StartAsyncTlsServer(**kwargs))
|
||||
|
||||
|
||||
def StartUdpServer(**kwargs): # pylint: disable=invalid-name
|
||||
"""Start and run a serial modbus server."""
|
||||
return asyncio.run(StartAsyncUdpServer(**kwargs))
|
||||
|
||||
|
||||
async def ServerAsyncStop(): # pylint: disable=invalid-name
|
||||
"""Terminate server."""
|
||||
await _serverList.async_stop()
|
||||
|
||||
|
||||
def ServerStop(): # pylint: disable=invalid-name
|
||||
"""Terminate server."""
|
||||
_serverList.stop()
|
||||
@@ -0,0 +1 @@
|
||||
"""Initialize."""
|
||||
Vendored
BIN
Binary file not shown.
env/lib/python3.11/site-packages/pymodbus/server/reactive/__pycache__/default_config.cpython-311.pyc
Vendored
BIN
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user