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 @@
"""Pymodbus REPL Module."""

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)

View File

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

View File

@@ -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=&lt;int&gt; \n\t\tdelay_by=&lt;in seconds&gt; \n\t\t"
"clear_after=&lt;clear after n messages int&gt;"
"\n\t\tdata_len=&lt;length of stray data (int)&gt;\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)

View File

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