Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions docs/usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 1 addition & 1 deletion modpoll/arg_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
100 changes: 82 additions & 18 deletions modpoll/modbus_task.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)


Expand All @@ -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]
92 changes: 92 additions & 0 deletions tests/test_framer_validation.py
Original file line number Diff line number Diff line change
@@ -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