diff --git a/README.md b/README.md index 01b19a5..82b38b1 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,11 @@ modpoll --once \ > the modsim code is also available [here](https://github.com/gavinying/modsim) +### Modbus ASCII and framers + +- Serial transports: use `--rtu PORT --framer ascii`; pyserial URLs such as `socket://host:port` and `rfc2217://host:port` work for serial-over-TCP tunnels. +- Serial supports framers `ascii`, `rtu`, and `binary` (pymodbus defaults to RTU if `--framer default`). TCP/UDP use the `socket` framer (default when `--framer default`); other framers are rejected. + ### Prepare Modbus configure file The reason we can magically poll data from the online device *modsim* is because we have already provided the [Modbus configure file](https://raw.githubusercontent.com/gavinying/modpoll/master/examples/modsim.csv) for *modsim* device as following, diff --git a/docs/usage.rst b/docs/usage.rst index 4dd3026..820ca5b 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -34,3 +34,9 @@ Commandline Usage .. code-block:: shell modpoll --tcp modsim.topmaker.net --config https://raw.githubusercontent.com/gavinying/modpoll/master/examples/modsim.csv --export data.csv + +Framers and transports +---------------------- + +- Serial (`--rtu`) supports framers `rtu`, `ascii`, and `binary` (e.g., `--rtu ... --framer ascii`). If `--framer default` is used, pymodbus defaults to RTU framer. +- TCP/UDP (`--tcp`/`--udp`) use the `socket` framer; other framers are rejected. If `--framer default` is used, pymodbus defaults to socket framer. diff --git a/modpoll/arg_parser.py b/modpoll/arg_parser.py index 3887fe9..f2e5cb1 100644 --- a/modpoll/arg_parser.py +++ b/modpoll/arg_parser.py @@ -182,6 +182,6 @@ def get_parser(): "--framer", default="default", choices=["default", "ascii", "binary", "rtu", "socket"], - help="The type of framer for modbus message. Use default framer if not specified.", + help="The type of framer for Modbus messages. Serial supports ascii/binary/rtu; TCP/UDP use socket.", ) return parser diff --git a/modpoll/modbus_task.py b/modpoll/modbus_task.py index 0ab2908..f524a6c 100644 --- a/modpoll/modbus_task.py +++ b/modpoll/modbus_task.py @@ -9,6 +9,12 @@ from pymodbus.client import ModbusSerialClient, ModbusTcpClient, ModbusUdpClient from pymodbus.constants import Endian from pymodbus.exceptions import ModbusException +from pymodbus.framer import ( + ModbusAsciiFramer, + ModbusBinaryFramer, + ModbusRtuFramer, + ModbusSocketFramer, +) from pymodbus.payload import BinaryPayloadDecoder from .utils import on_threading_event, delay_thread @@ -609,50 +615,59 @@ def setup_modbus_handlers(args, mqtt_handler: Optional[MqttHandler] = None): def _create_modbus_client(args): - if args.rtu: - return _create_rtu_client(args) - elif args.tcp: - return _create_tcp_client(args) - elif args.udp: - return _create_udp_client(args) - else: - raise ValueError("No communication method specified.") + transport = _determine_transport(args) + + if transport == "rtu": + framer = _resolve_framer("serial", args.framer) + return _create_serial_client(args, args.rtu, framer) + + if transport == "tcp": + framer = _resolve_framer("tcp", args.framer) + return _create_tcp_client(args, framer) + + if transport == "udp": + framer = _resolve_framer("udp", args.framer) + return _create_udp_client(args, framer) + raise ValueError("No communication method specified.") -def _create_rtu_client(args): + +def _create_serial_client(args, port, framer): + if not port: + raise ValueError("Serial port/URL must be provided for serial transports.") parity = _get_parity(args.rtu_parity) client_args = { - "port": args.rtu, + "port": port, "baudrate": int(args.rtu_baud), "bytesize": 8, "parity": parity, "stopbits": 1, "timeout": args.timeout, } - if args.framer != "default": - client_args["framer"] = args.framer + if framer: + client_args["framer"] = framer return ModbusSerialClient(**client_args) -def _create_tcp_client(args): +def _create_tcp_client(args, framer): client_args = { "host": args.tcp, "port": args.tcp_port, "timeout": args.timeout, } - if args.framer != "default": - client_args["framer"] = args.framer + if framer: + client_args["framer"] = framer return ModbusTcpClient(**client_args) -def _create_udp_client(args): +def _create_udp_client(args, framer): client_args = { "host": args.udp, "port": args.udp_port, "timeout": args.timeout, } - if args.framer != "default": - client_args["framer"] = args.framer + if framer: + client_args["framer"] = framer return ModbusUdpClient(**client_args) @@ -663,3 +678,52 @@ def _get_parity(rtu_parity): return "E" else: return "N" + + +def _determine_transport(args): + transports = [] + if args.rtu: + transports.append("rtu") + if args.tcp: + transports.append("tcp") + if args.udp: + transports.append("udp") + + if not transports: + raise ValueError("No communication method specified.") + if len(transports) > 1: + raise ValueError( + "Multiple communication methods specified; pick one of --rtu/--tcp/--udp." + ) + return transports[0] + + +def _resolve_framer(transport, framer_name): + if framer_name == "default": + # Let pymodbus choose its transport defaults: + # Serial -> ModbusRtuFramer; TCP/UDP -> ModbusSocketFramer. + return None + + framer_map = { + "rtu": ModbusRtuFramer, + "ascii": ModbusAsciiFramer, + "binary": ModbusBinaryFramer, + "socket": ModbusSocketFramer, + } + allowed = { + "serial": {"rtu", "ascii", "binary"}, + "tcp": {"socket"}, + "udp": {"socket"}, + } + + if transport in ("tcp", "udp"): + transport_key = transport + else: + transport_key = "serial" + + if framer_name not in allowed[transport_key]: + raise ValueError( + f"Framer '{framer_name}' is not valid for transport '{transport_key}'." + ) + + return framer_map[framer_name] diff --git a/tests/test_framer_validation.py b/tests/test_framer_validation.py new file mode 100644 index 0000000..f2d2c5c --- /dev/null +++ b/tests/test_framer_validation.py @@ -0,0 +1,92 @@ +import pytest + +from modpoll.arg_parser import get_parser +from modpoll import modbus_task as mt + + +def _fake_args(argv): + parser = get_parser() + return parser.parse_args(argv) + + +def test_rtu_with_ascii_framer_supported(monkeypatch): + captured = {} + + def fake_serial_client(**kwargs): + captured.update(kwargs) + return "serial-client" + + monkeypatch.setattr(mt, "ModbusSerialClient", fake_serial_client) + + args = _fake_args( + [ + "--config", + "dummy.csv", + "--rtu", + "/dev/ttyUSB0", + "--framer", + "ascii", + ] + ) + + mt._create_modbus_client(args) + + assert captured["framer"] is mt.ModbusAsciiFramer + + +def test_tcp_with_ascii_framer_rejected(): + args = _fake_args( + [ + "--config", + "dummy.csv", + "--tcp", + "localhost", + "--framer", + "ascii", + ] + ) + + with pytest.raises(ValueError): + mt._create_modbus_client(args) + + +def test_multiple_transports_rejected(): + args = _fake_args( + [ + "--config", + "dummy.csv", + "--rtu", + "/dev/ttyUSB0", + "--tcp", + "localhost", + ] + ) + + with pytest.raises(ValueError): + mt._create_modbus_client(args) + + +def test_tcp_socket_framer_is_applied(monkeypatch): + captured = {} + + def fake_tcp_client(**kwargs): + captured.update(kwargs) + return "tcp-client" + + monkeypatch.setattr(mt, "ModbusTcpClient", fake_tcp_client) + + args = _fake_args( + [ + "--config", + "dummy.csv", + "--tcp", + "localhost", + "--framer", + "socket", + ] + ) + + client = mt._create_modbus_client(args) + + assert client == "tcp-client" + assert captured["framer"] is mt.ModbusSocketFramer