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