This commit is contained in:
2025-01-03 15:06:21 +01:00
parent 68a7c9cb0f
commit d91714829d
3441 changed files with 615211 additions and 0 deletions

View File

@@ -0,0 +1 @@
"""Repl client."""

View File

@@ -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
)

View File

@@ -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))

View File

@@ -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

View File

@@ -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)